8.0 KiB
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
- Parallel Fetches: Upcoming shifts and signups fetched simultaneously
- Optimistic Updates: Signup/cancel updates UI immediately
- Tab State: No refetch when switching tabs (cached)
- 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);
}
};