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 {
Table,
Button,
Dropdown,
Input,
Select,
Tag,
@ -40,7 +41,13 @@ import {
TeamOutlined,
VideoCameraOutlined,
LinkOutlined,
EnvironmentOutlined,
ReadOutlined,
CustomerServiceOutlined,
PhoneOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useOutletContext, useNavigate } from 'react-router-dom';
@ -54,13 +61,20 @@ import type {
ShiftsListParams,
ShiftStats,
ShiftStatus,
ShiftKind,
Cut,
CreateShiftSeriesInput,
CalendarData,
EditMode,
RecurrenceFrequency,
} 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 { getErrorMessage } from '@/utils/getErrorMessage';
import ShiftsCalendar from '@/components/shifts/ShiftsCalendar';
@ -72,6 +86,18 @@ const statusOptions = Object.entries(SHIFT_STATUS_LABELS).map(([value, 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 = [
{ label: 'Sun', value: 0 },
{ label: 'Mon', value: 1 },
@ -92,6 +118,7 @@ export default function ShiftsPage() {
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();
const [kindFilter, setKindFilter] = useState<ShiftKind | undefined>();
const [stats, setStats] = useState<ShiftStats | null>(null);
// Create drawer
@ -208,6 +235,7 @@ export default function ShiftsPage() {
limit: params?.limit ?? pagination.limit,
search: params?.search ?? (debouncedSearch || undefined),
status: params?.status ?? statusFilter,
kind: params?.kind ?? kindFilter,
},
});
setShifts(data.shifts);
@ -217,13 +245,13 @@ export default function ShiftsPage() {
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter, kindFilter]);
useEffect(() => {
fetchShifts({ page: 1 });
fetchStats();
fetchCuts();
}, [debouncedSearch, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
}, [debouncedSearch, statusFilter, kindFilter]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (activeTab === 'calendar') {
@ -250,9 +278,10 @@ export default function ShiftsPage() {
maxVolunteers: values.maxVolunteers,
isPublic: values.isPublic || false,
cutId: values.cutId || undefined,
kind: (values.kind as ShiftKind) || 'CANVASS',
};
await api.post('/map/shifts', payload);
message.success('Shift created');
message.success(`${SHIFT_KIND_LABELS[payload.kind]} shift created`);
} else {
// Series creation
const seriesPayload: CreateShiftSeriesInput = {
@ -264,13 +293,14 @@ export default function ShiftsPage() {
maxVolunteers: values.maxVolunteers as number,
isPublic: (values.isPublic as boolean) ?? false,
cutId: values.cutId as string | undefined,
kind: (values.kind as ShiftKind) || 'CANVASS',
frequency: values.frequency as 'DAILY' | 'WEEKLY' | 'MONTHLY',
daysOfWeek: values.frequency === 'WEEKLY' ? (values.daysOfWeek as number[]) : undefined,
startDate: dayjs(values.startDate as string).format('YYYY-MM-DD'),
endDate: values.endDate ? dayjs(values.endDate as string).format('YYYY-MM-DD') : undefined,
};
await api.post('/map/shifts/series', seriesPayload);
message.success('Shift series created');
message.success(`${SHIFT_KIND_LABELS[seriesPayload.kind!]} series created`);
}
setCreateDrawerOpen(false);
@ -305,6 +335,7 @@ export default function ShiftsPage() {
if (values.isPublic !== undefined) payload.isPublic = values.isPublic;
if (values.status !== undefined) payload.status = values.status;
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);
message.success('Shift updated');
@ -382,6 +413,7 @@ export default function ShiftsPage() {
isPublic: shift.isPublic,
status: shift.status,
cutId: shift.cutId,
kind: shift.kind || 'CANVASS',
});
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',
dataIndex: 'status',
@ -620,6 +667,19 @@ export default function ShiftsPage() {
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) => (
<>
{/* Mode Selector (only for create, not edit) */}
@ -637,6 +697,24 @@ export default function ShiftsPage() {
</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
name="title"
label="Title"
@ -897,6 +975,24 @@ export default function ShiftsPage() {
style={{ width: '100%' }}
/>
</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>
{/* Table */}
@ -918,11 +1014,18 @@ export default function ShiftsPage() {
style: { cursor: 'pointer' },
})}
scroll={{ x: 'max-content' }}
locale={{ emptyText: (debouncedSearch || statusFilter)
locale={{ emptyText: (debouncedSearch || statusFilter || kindFilter)
? 'No shifts match your filters.'
: <div style={{ padding: 16 }}>
<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>
}}
/>
@ -955,13 +1058,14 @@ export default function ShiftsPage() {
},
]}
tabBarExtraContent={
<Button
<Dropdown.Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateDrawerOpen(true)}
menu={{ items: createMenuItems }}
onClick={() => openCreateDrawer('CANVASS')}
>
Create Shift
</Button>
</Dropdown.Button>
}
/>
</div>

View File

@ -813,6 +813,24 @@ export type SignupSource = 'AUTHENTICATED' | 'PUBLIC' | 'ADMIN';
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 {
id: string;
title: string;
@ -824,6 +842,7 @@ export interface Shift {
maxVolunteers: number;
currentVolunteers: number;
status: ShiftStatus;
kind: ShiftKind;
isPublic: boolean;
cutId: string | null;
cut?: { id: string; name: string } | null;
@ -890,6 +909,7 @@ export interface CreateShiftSeriesInput {
maxVolunteers: number;
isPublic?: boolean;
cutId?: string;
kind?: ShiftKind;
frequency: RecurrenceFrequency;
daysOfWeek?: number[];
startDate: string;
@ -927,6 +947,7 @@ export interface ShiftsListParams {
limit?: number;
search?: string;
status?: ShiftStatus;
kind?: ShiftKind;
upcoming?: boolean;
sortBy?: 'date' | 'createdAt' | 'title';
sortOrder?: 'asc' | 'desc';

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,
},
});