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 /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)

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

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[]>([]);
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 — OPEN
  • currentVolunteers — 0
  • isPublic — 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.com exists → 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 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:

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:

<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:

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.