32 KiB
ShiftsPage
Overview
The ShiftsPage provides complete volunteer shift management for the Map module, enabling administrators to schedule canvassing shifts, manage volunteer signups, and coordinate field operations. Features include date/time scheduling, volunteer capacity tracking, public shift publishing, area (cut) assignment, signup management, and bulk email notifications to confirmed volunteers.
Route: /app/map/shifts
Component: admin/src/pages/ShiftsPage.tsx (757 lines)
Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles)
Layout: AppLayout
Screenshot
[Screenshot: Shifts page with 6 statistics cards at top (Total, Open, Full, Cancelled, Upcoming, Signups) showing counts with colored icons. Below stats are search bar + status filter dropdown. Main table shows columns: Title, Date, Time (start — end), Location, Area (cut name), Volunteers (progress bar showing X/Y), Status (colored tags), Public (checkmark icon if true), Actions (edit + delete). Rows clickable to open signups drawer. Page header has "Create Shift" button.]
Features
Core Features
- Full CRUD operations — Create, read, update, delete shifts
- Advanced search — 300ms debounced search by title or location
- Status filtering — Filter by OPEN, FULL, CANCELLED, COMPLETED
- Statistics dashboard — 6 cards showing total, open, full, cancelled, upcoming, total signups
- Date/time scheduling — Date picker + time pickers with 5-minute intervals
- Volunteer capacity — Max volunteers setting with progress bar visualization
- Area assignment — Assign shift to a canvass cut (area)
- Public publishing — Toggle to show shift on public
/shiftspage - Clickable rows — Click any row to open signups drawer
- Responsive table — Columns hide on smaller screens (Time: md+, Location: lg+, Area: md+)
Signup Management
- Signups drawer — Click shift row to view all confirmed volunteers
- Manual signup — Add volunteer by email + name (creates temp user if needed)
- Signup source tracking — PUBLIC (self-signup), ADMIN (added by admin)
- Remove volunteers — Delete button for each confirmed signup
- Email all volunteers — Bulk email button in drawer header
- Signup stats — Progress bar in table shows current/max volunteers
- Auto-status management — Shift status auto-updates to FULL when capacity reached
Shift Status Workflow
- OPEN — Shift created, accepting signups
- FULL — Max volunteers reached (currentVolunteers >= maxVolunteers)
- CANCELLED — Shift cancelled by admin
- COMPLETED — Shift date passed (auto-marked by backend)
User Workflow
Viewing Shifts List
- Navigate to
/app/map/shifts - Page loads with statistics cards at top:
- Total: All shifts count
- Open: Shifts accepting signups (green)
- Full: Shifts at capacity (orange)
- Cancelled: Admin-cancelled shifts (red)
- Upcoming: Future shifts (blue)
- Signups: Total confirmed volunteers across all shifts
- Table shows first 20 shifts (paginated)
- View shift details:
- Title (bold)
- Date (YYYY-MM-DD)
- Time (HH:mm — HH:mm format)
- Location (e.g., "Campaign HQ, 123 Main St")
- Area (cut name if assigned)
- Volunteers (progress bar X/Y)
- Status tag (color-coded)
- Public checkmark icon (if published)
- Actions (edit, delete)
- Click any row to open signups drawer
Creating a Shift
- Click "Create Shift" button in page header
- Modal opens (560px width) with vertical form
- Fill required fields:
- Title — Shift name (e.g., "Door Knocking", "Phone Banking")
- Date — Date picker (calendar popup)
- Start Time — Time picker (HH:mm, 5-minute intervals)
- End Time — Time picker (HH:mm, 5-minute intervals)
- Max Volunteers — Number input (min: 1)
- Fill optional fields:
- Description — Multi-line text (shift details + instructions)
- Location — Text (e.g., "Campaign HQ, 123 Main St")
- Area (Cut) — Dropdown (select canvass area, searchable)
- Public — Switch toggle (default: false)
- Click "Create" button
- Success message: "Shift created"
- Modal closes, table refreshes to page 1, stats refresh
- New shift appears with status OPEN
Editing a Shift
- Locate shift in table
- Click Edit icon button (EditOutlined) in Actions column
- Drawer opens on right side (520px width) with vertical form
- Modify any fields (same as create, plus Status dropdown):
- Status options: OPEN, FULL, CANCELLED, COMPLETED
- Click "Save" button in drawer header
- Success message: "Shift updated"
- Drawer closes, table refreshes, stats refresh
Publishing a Shift to Public Page
- Open shift in edit drawer
- Toggle "Public" switch to ON
- Click "Save"
- Shift now visible on public
/shiftspage - Users can self-signup via public page
- Signups source tracked as PUBLIC
Assigning a Cut (Area) to Shift
- Open shift in edit drawer
- Click "Area (Cut)" dropdown
- Search for cut by name
- Select cut from list
- Click "Save"
- Volunteer portal integration:
- Volunteers assigned to this shift now see it in
/volunteer/assignmentspage - Shift with cut enables volunteer canvassing workflow
- No cut = general shift (no canvass area)
- Volunteers assigned to this shift now see it in
Viewing Shift Signups
- Click any shift row in table
- Signups drawer opens on right side (640px width)
- Drawer header shows:
- TeamOutlined icon + "Signups — {Shift Title}"
- "Email All" button in header (disabled if no confirmed volunteers)
- Info card at top displays shift summary:
- Date
- Time (start — end)
- Volunteers (current / max)
- Table shows confirmed volunteers:
- Columns: Email, Name, Phone, Source (PUBLIC/ADMIN tag), Date, Remove button
- Pagination: 20 per page (if > 20 signups)
- Cancelled signups hidden (filtered out)
- Add volunteer section at bottom:
- Email input (required)
- Name input (optional)
- "Add" button (disabled if email empty)
Manually Adding a Volunteer
- Open signups drawer for any shift
- Scroll to bottom "Add volunteer" section
- Enter email (required)
- Enter name (optional)
- Click "Add" button
- Backend logic:
- If user exists: Create ShiftSignup record
- If user doesn't exist: Create temp User + ShiftSignup
- Signup source: ADMIN
- Signup status: CONFIRMED
- Success message: "Volunteer added"
- Table refreshes with new volunteer
- Email and name inputs clear
- Main shifts table progress bar updates
Temp user creation:
- Role: TEMP
- Email: provided email
- Password: Readable format (e.g., "BlueEagle42")
- Expires: shift date + 1 day
- Used for public signups without account
Removing a Volunteer
- Open signups drawer
- Locate volunteer in table
- Click Delete icon button (red, last column)
- Popconfirm: "Remove this volunteer?"
- Click "OK"
- Success message: "Volunteer removed"
- Table refreshes (volunteer row disappears)
- Main shifts table progress bar updates
- Shift status may change from FULL to OPEN if capacity now available
Emailing All Volunteers
- Open signups drawer with confirmed volunteers
- Click "Email All" button in drawer header
- Backend sends email to all confirmed volunteers:
- Email template: Shift details (title, date, time, location, description)
- Subject: "Shift Reminder: {Shift Title}"
- From: Site settings sender (e.g., "Changemaker Lite noreply@cmlite.org")
- Success message: "Emailed N volunteer(s)" (or "N sent, M failed" if failures)
- Email uses SMTP settings from Settings page
Searching and Filtering
- Search bar (top left):
- Type title or location keywords
- 300ms debounce (waits for typing pause)
- Search resets pagination to page 1
- Status filter dropdown (top right):
- Select OPEN, FULL, CANCELLED, or COMPLETED
- Filter resets pagination to page 1
- Clear to show all shifts
- Filters persist during pagination
Deleting a Shift
- Locate shift in table
- Click Delete icon button (DeleteOutlined) in Actions column
- Popconfirm: "Delete this shift?"
- Click "OK" to confirm
- Success message: "Shift deleted"
- Table refreshes, stats refresh
- Cascade behavior: All ShiftSignup records also deleted (Prisma cascade)
Component Breakdown
Ant Design Components Used
- Table — Main shifts list + signups table in drawer
- Button — Create (primary), edit, delete, add volunteer, email all
- Input — Search bar, email input, name input (signups drawer)
- Select — Status filter dropdown, area (cut) dropdown
- Tag — Status tags (color-coded), signup source tags
- Space — Action button grouping, drawer header
- Card — Statistics cards (6 cards), shift summary card in signups drawer
- Statistic — Numeric displays with icons + prefixes
- Progress — Volunteer capacity progress bar (in Volunteers column)
- Modal — Create shift form
- Drawer — Edit shift (520px), signups drawer (640px)
- Form — Create/edit shift forms
- Form.Item — Field wrappers with labels + rules
- Input.TextArea — Description field (multi-line)
- DatePicker — Date selection with calendar popup
- TimePicker — Time selection with hour/minute dropdowns (5-minute steps)
- InputNumber — Max volunteers numeric input (min: 1)
- Switch — Public toggle (valuePropName="checked")
- Row, Col — Responsive grid for stats cards, date/time fields
- Popconfirm — Delete confirmation (shift + volunteer removal)
- Typography.Text — Labels, descriptions
Table Columns (Main Shifts Table)
const columns: ColumnsType<Shift> = [
{
title: 'Title',
dataIndex: 'title',
render: (title) => <span style={{ fontWeight: 500 }}>{title}</span>,
},
{
title: 'Date',
dataIndex: 'date',
render: (date) => dayjs(date).format('YYYY-MM-DD'),
},
{
title: 'Time',
render: (_, record) => `${record.startTime} — ${record.endTime}`,
responsive: ['md'],
},
{
title: 'Location',
dataIndex: 'location',
render: (loc) => loc || '--',
responsive: ['lg'],
},
{
title: 'Area',
render: (_, record) => record.cut?.name || '--',
responsive: ['md'],
},
{
title: 'Volunteers',
width: 140,
render: (_, record) => {
const confirmed = record._count?.signups ?? record.currentVolunteers;
const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;
return (
<Progress
percent={pct}
size="small"
status={pct >= 100 ? 'exception' : 'active'}
format={() => `${confirmed}/${record.maxVolunteers}`}
/>
);
},
},
{
title: 'Status',
dataIndex: 'status',
width: 100,
render: (status) => <Tag color={SHIFT_STATUS_COLORS[status]}>{SHIFT_STATUS_LABELS[status]}</Tag>,
},
{
title: 'Public',
dataIndex: 'isPublic',
width: 70,
render: (isPublic) => isPublic ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : null,
},
{
title: 'Actions',
width: 120,
render: (_, record) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title="Delete this shift?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
Key patterns:
_count.signupsaggregation from Prisma (confirmed volunteers count)responsivearray hides columns on smaller screens- Progress bar shows visual capacity indicator (turns red when full)
onRowprop makes entire row clickable to open signups drawer
Signups Table Columns
const signupColumns: ColumnsType<ShiftSignup> = [
{
title: 'Email',
dataIndex: 'userEmail',
},
{
title: 'Name',
dataIndex: 'userName',
render: (name) => name || '--',
},
{
title: 'Phone',
render: (_, record) => record.userPhone || record.user?.phone || '--',
responsive: ['md'],
},
{
title: 'Source',
dataIndex: 'signupSource',
width: 100,
render: (source) => <Tag color={SIGNUP_SOURCE_COLORS[source]}>{source}</Tag>,
},
{
title: 'Date',
dataIndex: 'signupDate',
render: (date) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['lg'],
},
{
title: '',
width: 60,
render: (_, record) =>
record.status === 'CONFIRMED' ? (
<Popconfirm title="Remove this volunteer?" onConfirm={() => handleRemoveSignup(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
) : (
<Tag color="red">Cancelled</Tag>
),
},
];
Filter: Table only shows CONFIRMED signups:
<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />
Status Colors
export const SHIFT_STATUS_COLORS: Record<ShiftStatus, string> = {
OPEN: 'green',
FULL: 'orange',
CANCELLED: 'red',
COMPLETED: 'default',
};
export const SHIFT_STATUS_LABELS: Record<ShiftStatus, string> = {
OPEN: 'Open',
FULL: 'Full',
CANCELLED: 'Cancelled',
COMPLETED: 'Completed',
};
Signup Source Colors
export const SIGNUP_SOURCE_COLORS = {
PUBLIC: 'blue', // User signed up via public /shifts page
ADMIN: 'purple', // Admin added manually
};
Form Fields
const shiftFormFields = (isEdit = false) => (
<>
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
<Input placeholder="e.g. Door Knocking, Phone Banking" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} placeholder="Shift details and instructions" />
</Form.Item>
<Row gutter={12}>
<Col xs={24} sm={8}>
<Form.Item name="date" label="Date" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="startTime" label="Start Time" rules={[{ required: true }]}>
<TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="endTime" label="End Time" rules={[{ required: true }]}>
<TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
</Form.Item>
</Col>
</Row>
<Form.Item name="location" label="Location">
<Input placeholder="e.g. Campaign HQ, 123 Main St" />
</Form.Item>
<Form.Item name="cutId" label="Area (Cut)">
<Select
options={cutOptions}
placeholder="Assign a canvass area..."
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Row gutter={12}>
<Col xs={12} sm={8}>
<Form.Item name="maxVolunteers" label="Max Volunteers" rules={[{ required: true }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="isPublic" label="Public" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
{isEdit && (
<Col xs={12} sm={8}>
<Form.Item name="status" label="Status">
<Select options={statusOptions} />
</Form.Item>
</Col>
)}
</Row>
</>
);
Reusable pattern: Same form fields for create + edit, with conditional Status field in edit mode.
State Management
Zustand Stores Used
None — Shifts fetched from API on each interaction. No global state required.
Local State
const [shifts, setShifts] = useState<Shift[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();
const [stats, setStats] = useState<ShiftStats | null>(null);
// Create modal
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createForm] = Form.useForm();
// Edit drawer
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
const [editingShift, setEditingShift] = useState<Shift | null>(null);
const [editForm] = Form.useForm();
// Signups drawer
const [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);
const [signupsShift, setSignupsShift] = useState<Shift | null>(null);
const [signups, setSignups] = useState<ShiftSignup[]>([]);
const [signupsLoading, setSignupsLoading] = useState(false);
const [addEmail, setAddEmail] = useState('');
const [addName, setAddName] = useState('');
// Cuts for area dropdown
const [cuts, setCuts] = useState<Cut[]>([]);
Debounced Search
const handleSearchChange = (value: string) => {
setSearch(value); // Update input immediately
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
};
useEffect(() => {
return () => clearTimeout(searchTimerRef.current); // Cleanup on unmount
}, []);
useEffect(() => {
fetchShifts({ page: 1 });
fetchStats();
fetchCuts();
}, [debouncedSearch, statusFilter]); // Re-fetch when search or filter changes
Why 300ms? Same pattern as other pages — prevents API spam while typing.
API Integration
Endpoints Used
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/map/shifts |
List shifts (paginated, filtered) |
| GET | /api/map/shifts/stats |
Fetch statistics (counts by status) |
| GET | /api/map/shifts/:id |
Fetch single shift with signups |
| POST | /api/map/shifts |
Create shift |
| PUT | /api/map/shifts/:id |
Update shift |
| DELETE | /api/map/shifts/:id |
Delete shift (cascade signups) |
| POST | /api/map/shifts/:id/signups |
Add volunteer manually (admin) |
| DELETE | /api/map/shifts/:id/signups/:signupId |
Remove volunteer |
| POST | /api/map/shifts/:id/email-details |
Email all confirmed volunteers |
List Shifts
Request:
const { data } = await api.get<ShiftsListResponse>('/map/shifts', {
params: {
page: 1,
limit: 20,
search: 'door knocking', // Optional: search title/location
status: 'OPEN', // Optional: filter by status
},
});
Response:
{
"shifts": [
{
"id": "shift-123",
"title": "Door Knocking — Downtown",
"description": "Focus on high-density residential areas. Bring campaign materials.",
"date": "2026-02-15",
"startTime": "10:00",
"endTime": "14:00",
"location": "Campaign HQ, 123 Main St",
"maxVolunteers": 15,
"currentVolunteers": 8,
"status": "OPEN",
"isPublic": true,
"cutId": "cut-456",
"cut": {
"id": "cut-456",
"name": "Downtown Core"
},
"createdAt": "2026-01-10T09:00:00.000Z",
"updatedAt": "2026-01-15T14:30:00.000Z",
"_count": {
"signups": 8
}
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 47,
"totalPages": 3
}
}
Key fields:
cutId— Foreign key to Cut (polygon area)cut— Nested cut object (if assigned)currentVolunteers— Confirmed signups count_count.signups— Prisma aggregation (confirmed signups count)startTime,endTime— 24-hour format strings (HH:mm)
Fetch Shift Statistics
Request:
const { data } = await api.get<ShiftStats>('/map/shifts/stats');
Response:
{
"total": 47,
"open": 23,
"full": 8,
"cancelled": 2,
"completed": 14,
"upcoming": 31,
"totalSignups": 287
}
Upcoming calculation: Future shifts (date >= today)
Create Shift
Request:
const payload = {
title: "Phone Banking",
description: "Call voters to discuss campaign issues",
date: "2026-02-20",
startTime: "18:00",
endTime: "21:00",
location: "Campaign HQ",
maxVolunteers: 10,
isPublic: true,
cutId: "cut-789", // Optional
};
await api.post('/map/shifts', payload);
Response:
{
"id": "shift-999",
"title": "Phone Banking",
"status": "OPEN",
"currentVolunteers": 0,
"createdAt": "2026-02-11T10:00:00.000Z",
// ... all other fields
}
Default values:
status— OPENcurrentVolunteers— 0isPublic— false (if not specified)
Update Shift
Request:
const payload = {
status: "CANCELLED",
description: "Cancelled due to weather",
};
await api.put(`/map/shifts/${shiftId}`, payload);
Partial updates: Only send changed fields.
Add Volunteer (Manual Signup)
Request:
await api.post(`/map/shifts/${shiftId}/signups`, {
userEmail: 'volunteer@example.com',
userName: 'Jane Doe', // Optional
});
Response:
{
"id": "signup-456",
"shiftId": "shift-123",
"userId": "user-789", // Existing or newly created temp user
"userEmail": "volunteer@example.com",
"userName": "Jane Doe",
"signupSource": "ADMIN",
"status": "CONFIRMED",
"signupDate": "2026-02-11T10:30:00.000Z"
}
Temp user creation logic:
- If
volunteer@example.comexists → link to existing user - If doesn't exist → create temp user:
{ "email": "volunteer@example.com", "name": "Jane Doe", "role": "TEMP", "password": "BlueEagle42", // Readable password "tempUserExpiresAt": "2026-02-21T00:00:00.000Z" // shift date + 1 day }
Email All Volunteers
Request:
const { data } = await api.post<{ sent: number; failed: number }>(
`/map/shifts/${shiftId}/email-details`
);
Response:
{
"sent": 8,
"failed": 0
}
Email template:
Subject: Shift Reminder: {Shift Title}
Hi {Volunteer Name},
This is a reminder about your upcoming volunteer shift:
Title: {Shift Title}
Date: {Date}
Time: {Start Time} — {End Time}
Location: {Location}
Description:
{Shift Description}
Thank you for volunteering!
Changemaker Lite
SMTP: Uses site settings (Settings page → Email tab)
Code Examples
Clickable Table Rows
<Table
columns={columns}
dataSource={shifts}
rowKey="id"
onRow={(record) => ({
onClick: () => openSignups(record), // Click row → open signups drawer
style: { cursor: 'pointer' }, // Show pointer cursor on hover
})}
/>
Pattern: Entire row clickable except action buttons (edit/delete use stopPropagation).
Progress Bar for Volunteer Capacity
{
title: 'Volunteers',
width: 140,
render: (_, record) => {
const confirmed = record._count?.signups ?? record.currentVolunteers;
const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;
return (
<Progress
percent={pct}
size="small"
status={pct >= 100 ? 'exception' : 'active'}
format={() => `${confirmed}/${record.maxVolunteers}`}
/>
);
},
}
Visual feedback:
- Green progress bar: < 100%
- Red progress bar: = 100% (full, status "exception")
- Format shows: "8/15" (8 confirmed out of 15 max)
Date/Time Payload Formatting
const handleCreate = async (values: Record<string, unknown>) => {
const payload = {
title: values.title,
date: dayjs(values.date as string).format('YYYY-MM-DD'), // DatePicker → string
startTime: dayjs(values.startTime as string).format('HH:mm'), // TimePicker → string
endTime: dayjs(values.endTime as string).format('HH:mm'), // TimePicker → string
maxVolunteers: values.maxVolunteers,
// ... other fields
};
await api.post('/map/shifts', payload);
};
Why format? DatePicker and TimePicker return Dayjs objects. Backend expects ISO date string + HH:mm time strings.
Edit Form Pre-Fill
const openEdit = (shift: Shift) => {
setEditingShift(shift);
editForm.setFieldsValue({
title: shift.title,
description: shift.description,
date: dayjs(shift.date), // String → Dayjs object
startTime: dayjs(shift.startTime, 'HH:mm'), // HH:mm string → Dayjs object with format
endTime: dayjs(shift.endTime, 'HH:mm'),
location: shift.location,
maxVolunteers: shift.maxVolunteers,
isPublic: shift.isPublic,
status: shift.status,
cutId: shift.cutId,
});
setEditDrawerOpen(true);
};
Why dayjs(shift.startTime, 'HH:mm')? TimePicker needs Dayjs object with specific format. Backend stores as "10:00" string, convert to Dayjs with HH:mm format.
Conditional Status Field
const shiftFormFields = (isEdit = false) => (
<>
{/* ... other fields */}
<Row gutter={12}>
{/* ... maxVolunteers, isPublic */}
{isEdit && (
<Col xs={12} sm={8}>
<Form.Item name="status" label="Status">
<Select options={statusOptions} />
</Form.Item>
</Col>
)}
</Row>
</>
);
Why conditional? Status dropdown only shown in edit mode. Create form defaults to OPEN (set by backend).
Performance Considerations
Debounced Search
Same 300ms debounce pattern as other pages:
- Prevents API spam while typing
- Only fires after user pauses
- Cleanup on unmount
Responsive Column Hiding
{ title: 'Time', responsive: ['md'] } // Hide on < 768px
{ title: 'Location', responsive: ['lg'] } // Hide on < 992px
{ title: 'Area', responsive: ['md'] } // Hide on < 768px
Mobile users see: Title, Date, Volunteers, Status, Public, Actions
useCallback Optimization
const fetchShifts = useCallback(async (params?: ShiftsListParams) => {
// ... fetch logic
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);
const fetchStats = useCallback(async () => {
// ... fetch logic
}, []);
const fetchCuts = useCallback(async () => {
// ... fetch logic
}, []);
Memoized functions prevent unnecessary re-renders.
Responsive Design
Mobile (< 576px)
- Stats cards: 2 columns (xs={12})
- Search bar: Full width
- Status filter: Full width below search
- Table: Minimal columns (Title, Date, Volunteers, Status, Actions)
- Signups drawer: Full screen overlay (width: 100%)
Tablet (576px - 992px)
- Stats cards: 3-4 columns (sm={4} or sm={6})
- Search bar: Half width (sm={12})
- Status filter: Quarter width (sm={6})
- Table: Time + Area columns visible
Desktop (≥ 992px)
- Stats cards: 6 columns (md={4})
- Filters: Compact (search 1/3, filter 1/6)
- Table: All columns visible (Location, Date)
- Signups drawer: 640px overlay (right side)
Accessibility
- Keyboard navigation: All buttons, inputs, selects focusable via Tab
- ARIA labels: Icon buttons have
titleattribute - Form validation: Required fields marked, inline error messages
- Color contrast: Status tags use Ant Design defaults (WCAG AA compliant)
- Screen reader support: Form labels properly associated
- Focus management: Modals/drawers auto-focus first input on open
Troubleshooting
Shift Status Not Auto-Updating to FULL
Problem: Shift reaches max volunteers (8/8), but status stays OPEN instead of changing to FULL.
Diagnosis:
Backend auto-status logic should run on every signup:
if (currentVolunteers >= maxVolunteers) {
await prisma.shift.update({
where: { id: shiftId },
data: { status: 'FULL' },
});
}
Common Issues:
-
Backend logic not running:
- Check API logs:
docker compose logs api | grep "shift status" - Verify signup endpoint includes auto-status update
- Check API logs:
-
Race condition:
- Multiple signups at same time (public + admin)
- Solution: Use Prisma transaction for atomic updates
-
Status manually set:
- Admin changed status to OPEN in edit drawer
- Solution: Status field warning: "Auto-updates to FULL when capacity reached"
Solution:
Refresh page to see latest status. Backend should auto-update on next signup/removal.
Email All Volunteers Fails
Problem: Click "Email All" button → Error: "Failed to email volunteers"
Diagnosis:
Check SMTP configuration:
- Navigate to Settings → Email tab
- Verify active provider: Production (not MailHog)
- Click "Test Connection" → Should show success
Common Issues:
-
MailHog active (dev mode):
- Switch to Production provider
- Save settings
-
SMTP credentials invalid:
- Test connection fails
- Update credentials
- Re-test before emailing
-
No confirmed volunteers:
- Email All button disabled if 0 confirmed
- Check signups drawer table (only CONFIRMED shown)
Solution:
- Fix SMTP settings
- Test connection
- Retry Email All
Volunteer Not Appearing in Signups
Problem: Add volunteer by email → Success message → Volunteer not in signups table
Diagnosis:
Check signups drawer filter:
<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />
Cancelled signups hidden.
Common Issues:
-
Volunteer added but immediately cancelled:
- Check backend logs for cancellation endpoint calls
- Verify signup status in database
-
Wrong shift:
- Added to different shift
- Verify shift ID in URL when opening drawer
-
Duplicate email:
- Volunteer already signed up
- Backend returns 400: "User already signed up for this shift"
- Check error message
Solution:
Refresh drawer: Close and re-open signups drawer to fetch latest data.
Area (Cut) Dropdown Empty
Problem: Create/edit shift → Area dropdown shows no options
Diagnosis:
Check cuts API endpoint:
curl http://localhost:4000/api/map/cuts
Common Issues:
-
No cuts created yet:
- Navigate to
/app/map/cuts - Create at least one cut (polygon boundary)
- Return to shifts page
- Navigate to
-
Cuts API failing:
- Check API logs:
docker compose logs api | grep "cuts" - Verify database connection
- Check API logs:
-
Cuts fetch not called:
- Check browser console for errors
- Verify
fetchCuts()called inuseEffect
Solution:
Create at least one cut in CutsPage before assigning to shifts.
Public Shift Not Showing on Public Page
Problem: Set isPublic to true, save shift → Public /shifts page doesn't show it
Diagnosis:
Check shift criteria for public page:
- Status: OPEN or FULL (not CANCELLED or COMPLETED)
- isPublic: true
- Date: Future (not past)
Common Issues:
-
Shift date in past:
- Past shifts hidden from public page
- Edit shift, update date to future
-
Status CANCELLED:
- Cancelled shifts hidden from public page
- Change status to OPEN
-
Browser cache:
- Hard refresh public page (Ctrl+Shift+R)
Solution:
Verify all 3 criteria met: OPEN/FULL status, isPublic true, future date.
Related Documentation
- Shifts Module (Backend) — API implementation, schemas, service functions
- Shift Signups — Signup creation, temp users, email logic
- Cuts Module — Polygon boundaries for shift areas
- Public Shifts Page — Public shift signup page
- Volunteer Shifts Page — Volunteer portal assignments
- Shifts API Reference — Complete endpoint documentation
- Map Feature Guide — End-to-end shift workflow
- User Guide: Volunteer Coordination — Shift scheduling best practices
- Troubleshooting: Shift Issues — Shift debugging