361 lines
8.0 KiB
Markdown

# 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
```tsx
<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
```tsx
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
```tsx
<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
```tsx
<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
```tsx
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
```http
GET /api/map/shifts/upcoming
Authorization: Bearer {token}
```
Response:
```json
[
{
"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
```http
GET /api/map/shifts/my-signups
Authorization: Bearer {token}
```
#### 3. Sign Up
```http
POST /api/map/shifts/:id/signup
Authorization: Bearer {token}
```
Response:
```json
{
"success": true,
"signupId": "cm3signup456",
"message": "Successfully signed up for shift"
}
```
#### 4. Cancel Signup
```http
DELETE /api/map/shifts/:id/cancel
Authorization: Bearer {token}
```
---
## Code Examples
### Shift Card (Upcoming Tab)
```tsx
<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
```tsx
{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:**
```typescript
// 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:**
```tsx
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);
}
};
```
---
## Related Documentation
- [Public Shifts Page](../public/shifts-page.md)
- [Admin Shifts Page](../admin/shifts-page.md)
- [Volunteer Map Page](./volunteer-map-page.md)
- [VolunteerLayout](../../components/volunteer-layout.md)