8.0 KiB

Volunteer Shifts Page

Overview

File Path: admin/src/pages/volunteer/VolunteerShiftsPage.tsx (312 lines)

Route: /volunteer/assignments

Role Requirements: Authenticated users (USER or TEMP role)

Purpose: Volunteer-facing shift management showing upcoming shifts and personal signups with signup/cancel functionality.

Key Features:

  • Segmented tabs: "Upcoming Shifts" / "My Signups"
  • Responsive shift cards grid (xs=1, sm=2 columns)
  • Progress bar showing volunteer capacity
  • Signup confirmation modal
  • Cancel signup modal (danger button)
  • "Signed Up" badge + cancel link on signed-up shifts
  • Empty states for no shifts/signups
  • Dark theme (VolunteerLayout)

Layout: Uses VolunteerLayout with top navigation


Features

1. Segmented Tabs

<Segmented
  value={activeTab}
  onChange={setActiveTab}
  options={[
    { label: 'Upcoming Shifts', value: 'upcoming' },
    { label: 'My Signups', value: 'signups' }
  ]}
  size="large"
  block
/>

2. Shift Cards

Upcoming Shifts Tab:

  • Shows all available shifts
  • "Sign Up" button (primary)
  • "Signed Up" badge if user already signed up
  • "Cancel Signup" link if signed up

My Signups Tab:

  • Shows only user's signups
  • "Cancel Signup" button (danger)
  • Shift details emphasized

3. Capacity Progress Bar

const percentage = (shift.currentSignups / shift.maxVolunteers) * 100;
const color = percentage < 70 ? 'success' : percentage < 90 ? 'warning' : 'exception';

<Progress percent={percentage} status={color} />
<Text type="secondary">
  {shift.currentSignups} of {shift.maxVolunteers} volunteers
</Text>

4. Signup Confirmation Modal

<Modal
  title="Confirm Signup"
  open={signupModalVisible}
  onOk={handleSignup}
  onCancel={() => setSignupModalVisible(false)}
>
  <Text>Are you sure you want to sign up for:</Text>
  <div style={{ marginTop: 16, padding: 16, background: '#f5f5f5' }}>
    <Text strong>{selectedShift?.title}</Text>
    <br />
    <Text>{dayjs(selectedShift?.date).format('MMMM D, YYYY')}</Text>
    <br />
    <Text>{selectedShift?.startTime} - {selectedShift?.endTime}</Text>
  </div>
</Modal>

5. Cancel Signup Modal

<Modal
  title="Cancel Signup"
  open={cancelModalVisible}
  onOk={handleCancel}
  onCancel={() => setCancelModalVisible(false)}
  okText="Yes, Cancel Signup"
  okButtonProps={{ danger: true }}
>
  <Text>Are you sure you want to cancel your signup for this shift?</Text>
  <Alert
    type="warning"
    message="This action cannot be undone"
    style={{ marginTop: 16 }}
  />
</Modal>

State Management

const [activeTab, setActiveTab] = useState<'upcoming' | 'signups'>('upcoming');
const [shifts, setShifts] = useState<Shift[]>([]);
const [mySignups, setMySignups] = useState<Shift[]>([]);
const [loading, setLoading] = useState(true);
const [signupModalVisible, setSignupModalVisible] = useState(false);
const [cancelModalVisible, setCancelModalVisible] = useState(false);
const [selectedShift, setSelectedShift] = useState<Shift | null>(null);

API Integration

Endpoints

1. Get Upcoming Shifts

GET /api/map/shifts/upcoming
Authorization: Bearer {token}

Response:

[
  {
    "id": "cm1abc123",
    "title": "Weekend Canvass",
    "date": "2025-02-15",
    "startTime": "10:00",
    "endTime": "14:00",
    "location": "Campaign Office",
    "maxVolunteers": 10,
    "currentSignups": 7,
    "cutId": "cm2cut123",
    "cutName": "Downtown",
    "isSignedUp": false
  }
]

2. Get My Signups

GET /api/map/shifts/my-signups
Authorization: Bearer {token}

3. Sign Up

POST /api/map/shifts/:id/signup
Authorization: Bearer {token}

Response:

{
  "success": true,
  "signupId": "cm3signup456",
  "message": "Successfully signed up for shift"
}

4. Cancel Signup

DELETE /api/map/shifts/:id/cancel
Authorization: Bearer {token}

Code Examples

Shift Card (Upcoming Tab)

<Card hoverable={!shift.isSignedUp}>
  {shift.isSignedUp && (
    <Tag color="green" style={{ position: 'absolute', top: 16, right: 16 }}>
      Signed Up
    </Tag>
  )}

  <Title level={4}>{shift.title}</Title>

  <Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 16 }}>
    <Text><CalendarOutlined /> {dayjs(shift.date).format('MMMM D, YYYY')}</Text>
    <Text><ClockCircleOutlined /> {shift.startTime} - {shift.endTime}</Text>
    <Text><EnvironmentOutlined /> {shift.location}</Text>
    {shift.cutName && <Tag color="blue">{shift.cutName}</Tag>}
  </Space>

  <Progress
    percent={(shift.currentSignups / shift.maxVolunteers) * 100}
    strokeColor={shift.currentSignups < shift.maxVolunteers ? '#52c41a' : '#f5222d'}
    showInfo={false}
    style={{ marginBottom: 16 }}
  />

  {shift.isSignedUp ? (
    <Button
      danger
      block
      onClick={() => {
        setSelectedShift(shift);
        setCancelModalVisible(true);
      }}
    >
      Cancel Signup
    </Button>
  ) : (
    <Button
      type="primary"
      block
      disabled={shift.currentSignups >= shift.maxVolunteers}
      onClick={() => {
        setSelectedShift(shift);
        setSignupModalVisible(true);
      }}
    >
      {shift.currentSignups >= shift.maxVolunteers ? 'Full' : 'Sign Up'}
    </Button>
  )}
</Card>

My Signups Tab

{activeTab === 'signups' && (
  <>
    {mySignups.length === 0 ? (
      <Empty
        description="You haven't signed up for any shifts yet"
        image={Empty.PRESENTED_IMAGE_SIMPLE}
      />
    ) : (
      <Row gutter={[16, 16]}>
        {mySignups.map(shift => (
          <Col xs={24} sm={12} key={shift.id}>
            <Card>
              <Title level={4}>{shift.title}</Title>
              {/* Shift details */}
              <Button
                danger
                block
                onClick={() => handleCancelClick(shift)}
              >
                Cancel Signup
              </Button>
            </Card>
          </Col>
        ))}
      </Row>
    )}
  </>
)}

Performance Considerations

  1. Parallel Fetches: Upcoming shifts and signups fetched simultaneously
  2. Optimistic Updates: Signup/cancel updates UI immediately
  3. Tab State: No refetch when switching tabs (cached)
  4. Debounced Modals: Prevent double-submission with loading state

Responsive Design

  • Mobile: Single column cards (xs=24)
  • Tablet: Two column grid (sm=12)
  • Desktop: Two column grid maintained (not 3+ for readability)
  • Segmented Tabs: Full-width on mobile, auto-width on desktop

Accessibility

  • Tab Navigation: Segmented component keyboard accessible
  • Button Labels: Clear action labels ("Sign Up", "Cancel Signup")
  • Modal Focus: Auto-focus on OK button
  • Screen Reader: Empty states announce "No shifts available"

Troubleshooting

Issue: Signed Up Badge Not Showing

Cause: isSignedUp field not populated by API

Solution:

// Backend: Include isSignedUp in shift query
const shifts = await prisma.shift.findMany({
  where: { date: { gte: new Date() } },
  include: {
    signups: {
      where: { userId: req.user!.id },
      select: { id: true }
    }
  }
});

// Map to include isSignedUp
return shifts.map(shift => ({
  ...shift,
  isSignedUp: shift.signups.length > 0
}));

Issue: Cancel Not Refreshing List

Solution:

const handleCancel = async () => {
  try {
    await api.delete(`/api/map/shifts/${selectedShift.id}/cancel`);
    message.success('Signup cancelled');
    
    // Refresh both lists
    const [upcomingRes, signupsRes] = await Promise.all([
      api.get('/api/map/shifts/upcoming'),
      api.get('/api/map/shifts/my-signups')
    ]);
    
    setShifts(upcomingRes.data);
    setMySignups(signupsRes.data);
    
  } catch (error) {
    message.error('Failed to cancel signup');
  } finally {
    setCancelModalVisible(false);
  }
};