1108 lines
32 KiB
Markdown

# 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 `/shifts` page
- **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
1. **OPEN** — Shift created, accepting signups
2. **FULL** — Max volunteers reached (currentVolunteers >= maxVolunteers)
3. **CANCELLED** — Shift cancelled by admin
4. **COMPLETED** — Shift date passed (auto-marked by backend)
## User Workflow
### Viewing Shifts List
1. Navigate to `/app/map/shifts`
2. 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
3. Table shows first 20 shifts (paginated)
4. 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)
5. Click any row to open signups drawer
### Creating a Shift
1. Click **"Create Shift"** button in page header
2. Modal opens (560px width) with vertical form
3. 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)
4. 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)
5. Click **"Create"** button
6. Success message: "Shift created"
7. Modal closes, table refreshes to page 1, stats refresh
8. New shift appears with status OPEN
### Editing a Shift
1. Locate shift in table
2. Click **Edit** icon button (EditOutlined) in Actions column
3. Drawer opens on right side (520px width) with vertical form
4. Modify any fields (same as create, plus **Status** dropdown):
- Status options: OPEN, FULL, CANCELLED, COMPLETED
5. Click **"Save"** button in drawer header
6. Success message: "Shift updated"
7. Drawer closes, table refreshes, stats refresh
### Publishing a Shift to Public Page
1. Open shift in edit drawer
2. Toggle **"Public"** switch to ON
3. Click **"Save"**
4. Shift now visible on public `/shifts` page
5. Users can self-signup via public page
6. Signups source tracked as PUBLIC
### Assigning a Cut (Area) to Shift
1. Open shift in edit drawer
2. Click **"Area (Cut)"** dropdown
3. Search for cut by name
4. Select cut from list
5. Click **"Save"**
6. **Volunteer portal integration:**
- Volunteers assigned to this shift now see it in `/volunteer/assignments` page
- Shift with cut enables volunteer canvassing workflow
- No cut = general shift (no canvass area)
### Viewing Shift Signups
1. Click any shift row in table
2. **Signups drawer** opens on right side (640px width)
3. Drawer header shows:
- TeamOutlined icon + "Signups — {Shift Title}"
- **"Email All"** button in header (disabled if no confirmed volunteers)
4. Info card at top displays shift summary:
- Date
- Time (start — end)
- Volunteers (current / max)
5. 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)
6. **Add volunteer section** at bottom:
- Email input (required)
- Name input (optional)
- **"Add"** button (disabled if email empty)
### Manually Adding a Volunteer
1. Open signups drawer for any shift
2. Scroll to bottom "Add volunteer" section
3. Enter **email** (required)
4. Enter **name** (optional)
5. Click **"Add"** button
6. Backend logic:
- If user exists: Create ShiftSignup record
- If user doesn't exist: Create temp User + ShiftSignup
- Signup source: ADMIN
- Signup status: CONFIRMED
7. Success message: "Volunteer added"
8. Table refreshes with new volunteer
9. Email and name inputs clear
10. 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
1. Open signups drawer
2. Locate volunteer in table
3. Click **Delete** icon button (red, last column)
4. Popconfirm: "Remove this volunteer?"
5. Click **"OK"**
6. Success message: "Volunteer removed"
7. Table refreshes (volunteer row disappears)
8. Main shifts table progress bar updates
9. Shift status may change from FULL to OPEN if capacity now available
### Emailing All Volunteers
1. Open signups drawer with confirmed volunteers
2. Click **"Email All"** button in drawer header
3. 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>")
4. Success message: "Emailed N volunteer(s)" (or "N sent, M failed" if failures)
5. Email uses SMTP settings from Settings page
### Searching and Filtering
1. **Search bar** (top left):
- Type title or location keywords
- 300ms debounce (waits for typing pause)
- Search resets pagination to page 1
2. **Status filter** dropdown (top right):
- Select OPEN, FULL, CANCELLED, or COMPLETED
- Filter resets pagination to page 1
- Clear to show all shifts
3. Filters persist during pagination
### Deleting a Shift
1. Locate shift in table
2. Click **Delete** icon button (DeleteOutlined) in Actions column
3. Popconfirm: "Delete this shift?"
4. Click **"OK"** to confirm
5. Success message: "Shift deleted"
6. Table refreshes, stats refresh
7. **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)
```typescript
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.signups` aggregation from Prisma (confirmed volunteers count)
- `responsive` array hides columns on smaller screens
- Progress bar shows visual capacity indicator (turns red when full)
- `onRow` prop makes entire row clickable to open signups drawer
### Signups Table Columns
```typescript
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:
```typescript
<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />
```
### Status Colors
```typescript
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
```typescript
export const SIGNUP_SOURCE_COLORS = {
PUBLIC: 'blue', // User signed up via public /shifts page
ADMIN: 'purple', // Admin added manually
};
```
### Form Fields
```typescript
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
```typescript
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
```typescript
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:**
```typescript
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:**
```json
{
"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:**
```typescript
const { data } = await api.get<ShiftStats>('/map/shifts/stats');
```
**Response:**
```json
{
"total": 47,
"open": 23,
"full": 8,
"cancelled": 2,
"completed": 14,
"upcoming": 31,
"totalSignups": 287
}
```
**Upcoming calculation:** Future shifts (date >= today)
### Create Shift
**Request:**
```typescript
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:**
```json
{
"id": "shift-999",
"title": "Phone Banking",
"status": "OPEN",
"currentVolunteers": 0,
"createdAt": "2026-02-11T10:00:00.000Z",
// ... all other fields
}
```
**Default values:**
- `status` — OPEN
- `currentVolunteers` — 0
- `isPublic` — false (if not specified)
### Update Shift
**Request:**
```typescript
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:**
```typescript
await api.post(`/map/shifts/${shiftId}/signups`, {
userEmail: 'volunteer@example.com',
userName: 'Jane Doe', // Optional
});
```
**Response:**
```json
{
"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.com` exists → link to existing user
- If doesn't exist → create temp user:
```json
{
"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:**
```typescript
const { data } = await api.post<{ sent: number; failed: number }>(
`/map/shifts/${shiftId}/email-details`
);
```
**Response:**
```json
{
"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
```typescript
<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
```typescript
{
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
```typescript
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
```typescript
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
```typescript
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
```typescript
{ 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
```typescript
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 `title` attribute
- **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:
```typescript
if (currentVolunteers >= maxVolunteers) {
await prisma.shift.update({
where: { id: shiftId },
data: { status: 'FULL' },
});
}
```
**Common Issues:**
1. **Backend logic not running:**
- Check API logs: `docker compose logs api | grep "shift status"`
- Verify signup endpoint includes auto-status update
2. **Race condition:**
- Multiple signups at same time (public + admin)
- Solution: Use Prisma transaction for atomic updates
3. **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:
1. Navigate to Settings → Email tab
2. Verify active provider: Production (not MailHog)
3. Click "Test Connection" → Should show success
**Common Issues:**
1. **MailHog active (dev mode):**
- Switch to Production provider
- Save settings
2. **SMTP credentials invalid:**
- Test connection fails
- Update credentials
- Re-test before emailing
3. **No confirmed volunteers:**
- Email All button disabled if 0 confirmed
- Check signups drawer table (only CONFIRMED shown)
**Solution:**
1. Fix SMTP settings
2. Test connection
3. 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:
```typescript
<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />
```
Cancelled signups hidden.
**Common Issues:**
1. **Volunteer added but immediately cancelled:**
- Check backend logs for cancellation endpoint calls
- Verify signup status in database
2. **Wrong shift:**
- Added to different shift
- Verify shift ID in URL when opening drawer
3. **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:
```bash
curl http://localhost:4000/api/map/cuts
```
**Common Issues:**
1. **No cuts created yet:**
- Navigate to `/app/map/cuts`
- Create at least one cut (polygon boundary)
- Return to shifts page
2. **Cuts API failing:**
- Check API logs: `docker compose logs api | grep "cuts"`
- Verify database connection
3. **Cuts fetch not called:**
- Check browser console for errors
- Verify `fetchCuts()` called in `useEffect`
**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:**
1. **Shift date in past:**
- Past shifts hidden from public page
- Edit shift, update date to future
2. **Status CANCELLED:**
- Cancelled shifts hidden from public page
- Change status to OPEN
3. **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)](/v2/backend/modules/shifts.md) — API implementation, schemas, service functions
- [Shift Signups](/v2/backend/modules/shifts.md#signups) — Signup creation, temp users, email logic
- [Cuts Module](/v2/backend/modules/cuts.md) — Polygon boundaries for shift areas
- [Public Shifts Page](/v2/frontend/pages/public/shifts-page.md) — Public shift signup page
- [Volunteer Shifts Page](/v2/frontend/pages/volunteer/volunteer-shifts-page.md) — Volunteer portal assignments
- [Shifts API Reference](/v2/api-reference/shifts.md) — Complete endpoint documentation
- [Map Feature Guide](/v2/features/map/shifts.md) — End-to-end shift workflow
- [User Guide: Volunteer Coordination](/v2/user-guides/map-organizer-guide.md) — Shift scheduling best practices
- [Troubleshooting: Shift Issues](/v2/troubleshooting/shift-issues.md) — Shift debugging