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:
bunker-admin 2026-04-11 11:09:23 -06:00
parent 76fd3c7065
commit 96ff2a85d6
6 changed files with 145 additions and 13 deletions

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { import {
Table, Table,
Button, Button,
Dropdown,
Input, Input,
Select, Select,
Tag, Tag,
@ -40,7 +41,13 @@ import {
TeamOutlined, TeamOutlined,
VideoCameraOutlined, VideoCameraOutlined,
LinkOutlined, LinkOutlined,
EnvironmentOutlined,
ReadOutlined,
CustomerServiceOutlined,
PhoneOutlined,
AppstoreOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useOutletContext, useNavigate } from 'react-router-dom'; import { useOutletContext, useNavigate } from 'react-router-dom';
@ -54,13 +61,20 @@ import type {
ShiftsListParams, ShiftsListParams,
ShiftStats, ShiftStats,
ShiftStatus, ShiftStatus,
ShiftKind,
Cut, Cut,
CreateShiftSeriesInput, CreateShiftSeriesInput,
CalendarData, CalendarData,
EditMode, EditMode,
RecurrenceFrequency, RecurrenceFrequency,
} from '@/types/api'; } from '@/types/api';
import { SHIFT_STATUS_COLORS, SHIFT_STATUS_LABELS, SIGNUP_SOURCE_COLORS } from '@/types/api'; import {
SHIFT_STATUS_COLORS,
SHIFT_STATUS_LABELS,
SIGNUP_SOURCE_COLORS,
SHIFT_KIND_LABELS,
SHIFT_KIND_COLORS,
} from '@/types/api';
import EditModeModal from '@/components/shifts/EditModeModal'; import EditModeModal from '@/components/shifts/EditModeModal';
import { getErrorMessage } from '@/utils/getErrorMessage'; import { getErrorMessage } from '@/utils/getErrorMessage';
import ShiftsCalendar from '@/components/shifts/ShiftsCalendar'; import ShiftsCalendar from '@/components/shifts/ShiftsCalendar';
@ -72,6 +86,18 @@ const statusOptions = Object.entries(SHIFT_STATUS_LABELS).map(([value, label]) =
label, label,
})); }));
const kindOptions = (Object.entries(SHIFT_KIND_LABELS) as [ShiftKind, string][]).map(
([value, label]) => ({ value, label })
);
const KIND_ICONS: Record<ShiftKind, React.ReactNode> = {
CANVASS: <EnvironmentOutlined />,
TRAINING: <ReadOutlined />,
EVENT_STAFFING: <CustomerServiceOutlined />,
PHONE_BANK: <PhoneOutlined />,
OTHER: <AppstoreOutlined />,
};
const DAYS_OF_WEEK = [ const DAYS_OF_WEEK = [
{ label: 'Sun', value: 0 }, { label: 'Sun', value: 0 },
{ label: 'Mon', value: 1 }, { label: 'Mon', value: 1 },
@ -92,6 +118,7 @@ export default function ShiftsPage() {
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>(); const [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();
const [kindFilter, setKindFilter] = useState<ShiftKind | undefined>();
const [stats, setStats] = useState<ShiftStats | null>(null); const [stats, setStats] = useState<ShiftStats | null>(null);
// Create drawer // Create drawer
@ -208,6 +235,7 @@ export default function ShiftsPage() {
limit: params?.limit ?? pagination.limit, limit: params?.limit ?? pagination.limit,
search: params?.search ?? (debouncedSearch || undefined), search: params?.search ?? (debouncedSearch || undefined),
status: params?.status ?? statusFilter, status: params?.status ?? statusFilter,
kind: params?.kind ?? kindFilter,
}, },
}); });
setShifts(data.shifts); setShifts(data.shifts);
@ -217,13 +245,13 @@ export default function ShiftsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]); }, [pagination.page, pagination.limit, debouncedSearch, statusFilter, kindFilter]);
useEffect(() => { useEffect(() => {
fetchShifts({ page: 1 }); fetchShifts({ page: 1 });
fetchStats(); fetchStats();
fetchCuts(); fetchCuts();
}, [debouncedSearch, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps }, [debouncedSearch, statusFilter, kindFilter]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
if (activeTab === 'calendar') { if (activeTab === 'calendar') {
@ -250,9 +278,10 @@ export default function ShiftsPage() {
maxVolunteers: values.maxVolunteers, maxVolunteers: values.maxVolunteers,
isPublic: values.isPublic || false, isPublic: values.isPublic || false,
cutId: values.cutId || undefined, cutId: values.cutId || undefined,
kind: (values.kind as ShiftKind) || 'CANVASS',
}; };
await api.post('/map/shifts', payload); await api.post('/map/shifts', payload);
message.success('Shift created'); message.success(`${SHIFT_KIND_LABELS[payload.kind]} shift created`);
} else { } else {
// Series creation // Series creation
const seriesPayload: CreateShiftSeriesInput = { const seriesPayload: CreateShiftSeriesInput = {
@ -264,13 +293,14 @@ export default function ShiftsPage() {
maxVolunteers: values.maxVolunteers as number, maxVolunteers: values.maxVolunteers as number,
isPublic: (values.isPublic as boolean) ?? false, isPublic: (values.isPublic as boolean) ?? false,
cutId: values.cutId as string | undefined, cutId: values.cutId as string | undefined,
kind: (values.kind as ShiftKind) || 'CANVASS',
frequency: values.frequency as 'DAILY' | 'WEEKLY' | 'MONTHLY', frequency: values.frequency as 'DAILY' | 'WEEKLY' | 'MONTHLY',
daysOfWeek: values.frequency === 'WEEKLY' ? (values.daysOfWeek as number[]) : undefined, daysOfWeek: values.frequency === 'WEEKLY' ? (values.daysOfWeek as number[]) : undefined,
startDate: dayjs(values.startDate as string).format('YYYY-MM-DD'), startDate: dayjs(values.startDate as string).format('YYYY-MM-DD'),
endDate: values.endDate ? dayjs(values.endDate as string).format('YYYY-MM-DD') : undefined, endDate: values.endDate ? dayjs(values.endDate as string).format('YYYY-MM-DD') : undefined,
}; };
await api.post('/map/shifts/series', seriesPayload); await api.post('/map/shifts/series', seriesPayload);
message.success('Shift series created'); message.success(`${SHIFT_KIND_LABELS[seriesPayload.kind!]} series created`);
} }
setCreateDrawerOpen(false); setCreateDrawerOpen(false);
@ -305,6 +335,7 @@ export default function ShiftsPage() {
if (values.isPublic !== undefined) payload.isPublic = values.isPublic; if (values.isPublic !== undefined) payload.isPublic = values.isPublic;
if (values.status !== undefined) payload.status = values.status; if (values.status !== undefined) payload.status = values.status;
if (values.cutId !== undefined) payload.cutId = values.cutId || null; if (values.cutId !== undefined) payload.cutId = values.cutId || null;
if (values.kind !== undefined) payload.kind = values.kind;
await api.put(`/map/shifts/${editingShift.id}`, payload); await api.put(`/map/shifts/${editingShift.id}`, payload);
message.success('Shift updated'); message.success('Shift updated');
@ -382,6 +413,7 @@ export default function ShiftsPage() {
isPublic: shift.isPublic, isPublic: shift.isPublic,
status: shift.status, status: shift.status,
cutId: shift.cutId, cutId: shift.cutId,
kind: shift.kind || 'CANVASS',
}); });
setEditDrawerOpen(true); setEditDrawerOpen(true);
}; };
@ -502,6 +534,21 @@ export default function ShiftsPage() {
); );
}, },
}, },
{
title: 'Kind',
dataIndex: 'kind',
key: 'kind',
width: 130,
render: (kind: ShiftKind) => {
const k = kind || 'CANVASS';
return (
<Tag color={SHIFT_KIND_COLORS[k]} icon={KIND_ICONS[k]}>
{SHIFT_KIND_LABELS[k]}
</Tag>
);
},
responsive: ['md'],
},
{ {
title: 'Status', title: 'Status',
dataIndex: 'status', dataIndex: 'status',
@ -620,6 +667,19 @@ export default function ShiftsPage() {
const cutOptions = cuts.map((c) => ({ value: c.id, label: c.name })); const cutOptions = cuts.map((c) => ({ value: c.id, label: c.name }));
const openCreateDrawer = useCallback((kind: ShiftKind = 'CANVASS') => {
createForm.resetFields();
createForm.setFieldValue('kind', kind);
setCreateDrawerOpen(true);
}, [createForm]);
const createMenuItems: MenuProps['items'] = kindOptions.map((opt) => ({
key: opt.value,
label: opt.label,
icon: KIND_ICONS[opt.value],
onClick: () => openCreateDrawer(opt.value),
}));
const shiftFormFields = (isEdit = false) => ( const shiftFormFields = (isEdit = false) => (
<> <>
{/* Mode Selector (only for create, not edit) */} {/* Mode Selector (only for create, not edit) */}
@ -637,6 +697,24 @@ export default function ShiftsPage() {
</Form.Item> </Form.Item>
)} )}
<Form.Item
name="kind"
label="Kind"
initialValue="CANVASS"
rules={[{ required: true, message: 'Kind is required' }]}
>
<Select
options={kindOptions.map((opt) => ({
value: opt.value,
label: (
<Space>
{KIND_ICONS[opt.value]}
{opt.label}
</Space>
),
}))}
/>
</Form.Item>
<Form.Item <Form.Item
name="title" name="title"
label="Title" label="Title"
@ -897,6 +975,24 @@ export default function ShiftsPage() {
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</Col> </Col>
<Col xs={12} sm={6} md={4}>
<Select
placeholder="Kind"
options={kindOptions.map((opt) => ({
value: opt.value,
label: (
<Space>
{KIND_ICONS[opt.value]}
{opt.label}
</Space>
),
}))}
value={kindFilter}
onChange={setKindFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row> </Row>
{/* Table */} {/* Table */}
@ -918,11 +1014,18 @@ export default function ShiftsPage() {
style: { cursor: 'pointer' }, style: { cursor: 'pointer' },
})} })}
scroll={{ x: 'max-content' }} scroll={{ x: 'max-content' }}
locale={{ emptyText: (debouncedSearch || statusFilter) locale={{ emptyText: (debouncedSearch || statusFilter || kindFilter)
? 'No shifts match your filters.' ? 'No shifts match your filters.'
: <div style={{ padding: 16 }}> : <div style={{ padding: 16 }}>
<div style={{ marginBottom: 8, color: 'rgba(255,255,255,0.45)' }}>No shifts yet.</div> <div style={{ marginBottom: 8, color: 'rgba(255,255,255,0.45)' }}>No shifts yet.</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); setCreateDrawerOpen(true); }}>Create Shift</Button> <Dropdown.Button
type="primary"
icon={<PlusOutlined />}
menu={{ items: createMenuItems }}
onClick={() => openCreateDrawer('CANVASS')}
>
Create Shift
</Dropdown.Button>
</div> </div>
}} }}
/> />
@ -955,13 +1058,14 @@ export default function ShiftsPage() {
}, },
]} ]}
tabBarExtraContent={ tabBarExtraContent={
<Button <Dropdown.Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => setCreateDrawerOpen(true)} menu={{ items: createMenuItems }}
onClick={() => openCreateDrawer('CANVASS')}
> >
Create Shift Create Shift
</Button> </Dropdown.Button>
} }
/> />
</div> </div>

View File

@ -813,6 +813,24 @@ export type SignupSource = 'AUTHENTICATED' | 'PUBLIC' | 'ADMIN';
export type RecurrenceFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY'; export type RecurrenceFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY';
export type ShiftKind = 'CANVASS' | 'TRAINING' | 'EVENT_STAFFING' | 'PHONE_BANK' | 'OTHER';
export const SHIFT_KIND_LABELS: Record<ShiftKind, string> = {
CANVASS: 'Canvass',
TRAINING: 'Training',
EVENT_STAFFING: 'Event Staffing',
PHONE_BANK: 'Phone Bank',
OTHER: 'Other',
};
export const SHIFT_KIND_COLORS: Record<ShiftKind, string> = {
CANVASS: 'blue',
TRAINING: 'green',
EVENT_STAFFING: 'purple',
PHONE_BANK: 'orange',
OTHER: 'default',
};
export interface Shift { export interface Shift {
id: string; id: string;
title: string; title: string;
@ -824,6 +842,7 @@ export interface Shift {
maxVolunteers: number; maxVolunteers: number;
currentVolunteers: number; currentVolunteers: number;
status: ShiftStatus; status: ShiftStatus;
kind: ShiftKind;
isPublic: boolean; isPublic: boolean;
cutId: string | null; cutId: string | null;
cut?: { id: string; name: string } | null; cut?: { id: string; name: string } | null;
@ -890,6 +909,7 @@ export interface CreateShiftSeriesInput {
maxVolunteers: number; maxVolunteers: number;
isPublic?: boolean; isPublic?: boolean;
cutId?: string; cutId?: string;
kind?: ShiftKind;
frequency: RecurrenceFrequency; frequency: RecurrenceFrequency;
daysOfWeek?: number[]; daysOfWeek?: number[];
startDate: string; startDate: string;
@ -927,6 +947,7 @@ export interface ShiftsListParams {
limit?: number; limit?: number;
search?: string; search?: string;
status?: ShiftStatus; status?: ShiftStatus;
kind?: ShiftKind;
upcoming?: boolean; upcoming?: boolean;
sortBy?: 'date' | 'createdAt' | 'title'; sortBy?: 'date' | 'createdAt' | 'title';
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';

View File

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

View File

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

View File

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

View File

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