Expose ShiftKind in admin panel with Dropdown.Button picker

Shift.kind existed in the schema and on the volunteer dashboard's
training filter but there was no way to create or edit a shift's
kind from the admin UI — every shift landed as the default CANVASS.
This wires the full loop:

Backend: createShiftSchema / updateShiftSchema / listShiftsSchema
and their series counterparts now accept a kind field. The shifts
service passes it through on create and filters by it on list.
Series shift templates propagate kind to every generated shift
instance so a training series produces training shifts.

Admin UI: the Create Shift button becomes a Dropdown.Button. The
main action creates a Canvass shift (default); the menu offers
Training, Event Staffing, Phone Bank, and Other. Each menu item
pre-fills the form's kind field. A kind Select appears at the top
of the form so admins can change it mid-creation or on edit. The
shifts table gets a color-coded Kind column and the toolbar gets
a kind filter.

Bunker Admin
This commit is contained in:
2026-04-11 11:09:23 -06:00
parent 76fd3c7065
commit 96ff2a85d6
6 changed files with 145 additions and 13 deletions

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { RecurrenceFrequency } from '@prisma/client';
import { RecurrenceFrequency, ShiftKind } from '@prisma/client';
export const createShiftSeriesSchema = z.object({
title: z.string().min(1, 'Title is required'),
@@ -10,6 +10,7 @@ export const createShiftSeriesSchema = z.object({
maxVolunteers: z.number().int().min(1, 'Must have at least 1 volunteer spot'),
isPublic: z.boolean().optional().default(false),
cutId: z.string().optional(),
kind: z.nativeEnum(ShiftKind).optional().default(ShiftKind.CANVASS),
// Recurrence
frequency: z.nativeEnum(RecurrenceFrequency),

View File

@@ -101,6 +101,7 @@ export class ShiftSeriesService {
maxVolunteers: input.maxVolunteers,
isPublic: input.isPublic ?? false,
cutId: input.cutId,
kind: input.kind,
seriesId: series.id,
createdBy,
},

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { ShiftStatus } from '@prisma/client';
import { ShiftStatus, ShiftKind } from '@prisma/client';
export const createShiftSchema = z.object({
title: z.string().min(1, 'Title is required'),
@@ -11,6 +11,7 @@ export const createShiftSchema = z.object({
maxVolunteers: z.number().int().min(1, 'Must have at least 1 volunteer spot'),
isPublic: z.boolean().optional().default(false),
cutId: z.string().optional(),
kind: z.nativeEnum(ShiftKind).optional().default(ShiftKind.CANVASS),
});
export const updateShiftSchema = z.object({
@@ -24,6 +25,7 @@ export const updateShiftSchema = z.object({
isPublic: z.boolean().optional(),
status: z.nativeEnum(ShiftStatus).optional(),
cutId: z.string().nullable().optional(),
kind: z.nativeEnum(ShiftKind).optional(),
});
export const listShiftsSchema = z.object({
@@ -31,6 +33,7 @@ export const listShiftsSchema = z.object({
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.nativeEnum(ShiftStatus).optional(),
kind: z.nativeEnum(ShiftKind).optional(),
upcoming: z.coerce.boolean().optional(),
sortBy: z.enum(['date', 'createdAt', 'title']).optional().default('date'),
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),

View File

@@ -39,7 +39,7 @@ const meetingSelect = {
export const shiftsService = {
async findAll(filters: ListShiftsInput) {
const { page, limit, search, status, upcoming, sortBy, sortOrder } = filters;
const { page, limit, search, status, kind, upcoming, sortBy, sortOrder } = filters;
const skip = (page - 1) * limit;
const where: Prisma.ShiftWhereInput = {};
@@ -52,6 +52,7 @@ export const shiftsService = {
}
if (status) where.status = status;
if (kind) where.kind = kind;
if (upcoming) {
where.date = { gte: new Date() };
@@ -132,6 +133,7 @@ export const shiftsService = {
maxVolunteers: data.maxVolunteers,
isPublic: data.isPublic,
cutId: data.cutId,
kind: data.kind,
createdBy: userId,
},
});