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:
parent
76fd3c7065
commit
96ff2a85d6
@ -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>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -101,6 +101,7 @@ export class ShiftSeriesService {
|
||||
maxVolunteers: input.maxVolunteers,
|
||||
isPublic: input.isPublic ?? false,
|
||||
cutId: input.cutId,
|
||||
kind: input.kind,
|
||||
seriesId: series.id,
|
||||
createdBy,
|
||||
},
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user