1108 lines
32 KiB
Markdown
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
|