Filter out volunteers with approved time off from shift edit picker
The edit form now fetches approved time-off requests and excludes volunteers whose time off overlaps the shift date from the selectable list. Unavailable volunteers are shown below the picker in a "On approved time off" note. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #10.
This commit is contained in:
@@ -10,6 +10,7 @@ jest.mock('../api', () => ({
|
||||
listShifts: jest.fn(),
|
||||
listShiftTemplates: jest.fn(),
|
||||
listVolunteers: jest.fn(),
|
||||
listTimeOff: jest.fn(),
|
||||
generateShifts: jest.fn(),
|
||||
publishShifts: jest.fn(),
|
||||
unpublishShifts: jest.fn(),
|
||||
@@ -146,6 +147,7 @@ describe('Schedules (admin shifts view)', () => {
|
||||
{ id: 5, name: 'Alice', active: true, operational_roles: 'Dog Shelter Volunteer', is_trainee: false },
|
||||
{ id: 6, name: 'Bob', active: true, operational_roles: 'Behaviour Team', is_trainee: false },
|
||||
]);
|
||||
(api.listTimeOff as jest.Mock).mockResolvedValue([]);
|
||||
renderAt('/schedules');
|
||||
await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
@@ -154,6 +156,25 @@ describe('Schedules (admin shifts view)', () => {
|
||||
expect(screen.getByText(/Edit Shift/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides volunteers with approved time off on the shift date', async () => {
|
||||
(api.listVolunteers as jest.Mock).mockResolvedValue([
|
||||
{ id: 5, name: 'Alice', active: true, operational_roles: 'Dog Shelter Volunteer', is_trainee: false },
|
||||
{ id: 6, name: 'Bob', active: true, operational_roles: 'Behaviour Team', is_trainee: false },
|
||||
]);
|
||||
(api.listTimeOff as jest.Mock).mockResolvedValue([
|
||||
{ id: 1, volunteer_id: 6, starts_at: '2026-04-06T00:00:00Z', ends_at: '2026-04-07T00:00:00Z', status: 'approved' },
|
||||
]);
|
||||
renderAt('/schedules');
|
||||
await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument());
|
||||
// Bob should not appear as a selectable volunteer
|
||||
expect(screen.queryByLabelText('Bob')).not.toBeInTheDocument();
|
||||
// But should be listed as on time off
|
||||
expect(screen.getByText(/On approved time off/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Bob/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads year and month from search params', async () => {
|
||||
renderAt('/schedules?year=2025&month=12');
|
||||
await waitFor(() => expect(api.listShifts).toHaveBeenCalledWith(2025, 12));
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ShiftTemplate,
|
||||
ShiftInstance,
|
||||
Volunteer,
|
||||
TimeOffRequest,
|
||||
CreateShiftTemplateInput,
|
||||
TemplateRole,
|
||||
OPERATIONAL_ROLES,
|
||||
@@ -157,6 +158,7 @@ interface ShiftEditFormProps {
|
||||
function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditFormProps) {
|
||||
const [volunteers, setVolunteers] = useState<Volunteer[]>([]);
|
||||
const [templateRoles, setTemplateRoles] = useState<TemplateRole[]>([]);
|
||||
const [unavailableIds, setUnavailableIds] = useState<Set<number>>(new Set());
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(
|
||||
new Set(instance.volunteers.map(v => v.volunteer_id))
|
||||
);
|
||||
@@ -169,6 +171,20 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm
|
||||
useEffect(() => {
|
||||
const loads: Promise<void>[] = [
|
||||
api.listVolunteers().then((vols) => setVolunteers(vols as Volunteer[])),
|
||||
api.listTimeOff().then((requests: TimeOffRequest[]) => {
|
||||
const shiftDate = new Date(instance.date + 'T00:00:00');
|
||||
const off = new Set<number>();
|
||||
for (const r of requests) {
|
||||
if (r.status !== 'approved') continue;
|
||||
const start = new Date(r.starts_at);
|
||||
const end = new Date(r.ends_at);
|
||||
// Shift date falls within the approved time-off range
|
||||
if (shiftDate >= new Date(start.toDateString()) && shiftDate <= new Date(end.toDateString())) {
|
||||
off.add(r.volunteer_id);
|
||||
}
|
||||
}
|
||||
setUnavailableIds(off);
|
||||
}),
|
||||
];
|
||||
if (templateId) {
|
||||
loads.push(
|
||||
@@ -179,7 +195,7 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm
|
||||
);
|
||||
}
|
||||
Promise.all(loads).finally(() => setLoading(false));
|
||||
}, [templateId]);
|
||||
}, [templateId, instance.date]);
|
||||
|
||||
function toggleVolunteer(id: number) {
|
||||
setSelectedIds(prev => {
|
||||
@@ -208,8 +224,9 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm
|
||||
}
|
||||
}
|
||||
|
||||
// Group active volunteers by their operational roles for display
|
||||
const activeVolunteers = volunteers.filter(v => v.active);
|
||||
// Group active, available volunteers by their operational roles for display
|
||||
const activeVolunteers = volunteers.filter(v => v.active && !unavailableIds.has(v.id));
|
||||
const onTimeOff = volunteers.filter(v => v.active && unavailableIds.has(v.id));
|
||||
|
||||
// Build a map of role → volunteers who hold that role
|
||||
const byRole = new Map<string, Volunteer[]>();
|
||||
@@ -287,6 +304,13 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
{onTimeOff.length > 0 && (
|
||||
<p style={{ marginTop: '0.5rem', color: '#999', fontSize: '0.9em' }}>
|
||||
<strong>On approved time off:</strong>{' '}
|
||||
{onTimeOff.map(v => v.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '0.5rem' }}>
|
||||
<label>
|
||||
Min Capacity
|
||||
|
||||
Reference in New Issue
Block a user