361 lines
8.0 KiB
Markdown
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)
|