diff --git a/admin/src/pages/ShiftsPage.tsx b/admin/src/pages/ShiftsPage.tsx index f1a11c41..2ea4d652 100644 --- a/admin/src/pages/ShiftsPage.tsx +++ b/admin/src/pages/ShiftsPage.tsx @@ -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 = { + CANVASS: , + TRAINING: , + EVENT_STAFFING: , + PHONE_BANK: , + OTHER: , +}; + 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>(undefined); const [statusFilter, setStatusFilter] = useState(); + const [kindFilter, setKindFilter] = useState(); const [stats, setStats] = useState(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 ( + + {SHIFT_KIND_LABELS[k]} + + ); + }, + 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() { )} + + ({ + value: opt.value, + label: ( + + {KIND_ICONS[opt.value]} + {opt.label} + + ), + }))} + value={kindFilter} + onChange={setKindFilter} + allowClear + style={{ width: '100%' }} + /> + {/* 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.' :
No shifts yet.
- + } + menu={{ items: createMenuItems }} + onClick={() => openCreateDrawer('CANVASS')} + > + Create Shift +
}} /> @@ -955,13 +1058,14 @@ export default function ShiftsPage() { }, ]} tabBarExtraContent={ - + } /> diff --git a/admin/src/types/api.ts b/admin/src/types/api.ts index 639212fa..8673ea1e 100644 --- a/admin/src/types/api.ts +++ b/admin/src/types/api.ts @@ -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 = { + CANVASS: 'Canvass', + TRAINING: 'Training', + EVENT_STAFFING: 'Event Staffing', + PHONE_BANK: 'Phone Bank', + OTHER: 'Other', +}; + +export const SHIFT_KIND_COLORS: Record = { + 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'; diff --git a/api/src/modules/map/shifts/shift-series.schemas.ts b/api/src/modules/map/shifts/shift-series.schemas.ts index cbbc0b96..8a5df99c 100644 --- a/api/src/modules/map/shifts/shift-series.schemas.ts +++ b/api/src/modules/map/shifts/shift-series.schemas.ts @@ -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), diff --git a/api/src/modules/map/shifts/shift-series.service.ts b/api/src/modules/map/shifts/shift-series.service.ts index 2092c604..fdeb6e69 100644 --- a/api/src/modules/map/shifts/shift-series.service.ts +++ b/api/src/modules/map/shifts/shift-series.service.ts @@ -101,6 +101,7 @@ export class ShiftSeriesService { maxVolunteers: input.maxVolunteers, isPublic: input.isPublic ?? false, cutId: input.cutId, + kind: input.kind, seriesId: series.id, createdBy, }, diff --git a/api/src/modules/map/shifts/shifts.schemas.ts b/api/src/modules/map/shifts/shifts.schemas.ts index 881760ec..6279c6eb 100644 --- a/api/src/modules/map/shifts/shifts.schemas.ts +++ b/api/src/modules/map/shifts/shifts.schemas.ts @@ -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'), diff --git a/api/src/modules/map/shifts/shifts.service.ts b/api/src/modules/map/shifts/shifts.service.ts index 54cefaaa..08c0e58b 100644 --- a/api/src/modules/map/shifts/shifts.service.ts +++ b/api/src/modules/map/shifts/shifts.service.ts @@ -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, }, });