From 6575ce8f440f894a0c366d04978ff2053abaa034 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 8 Apr 2026 19:59:05 -0300 Subject: [PATCH] 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 --- web/src/pages/Schedules.test.tsx | 21 +++++++++++++++++++++ web/src/pages/Schedules.tsx | 30 +++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/web/src/pages/Schedules.test.tsx b/web/src/pages/Schedules.test.tsx index 49fe025..ae0aaf7 100644 --- a/web/src/pages/Schedules.test.tsx +++ b/web/src/pages/Schedules.test.tsx @@ -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)); diff --git a/web/src/pages/Schedules.tsx b/web/src/pages/Schedules.tsx index c857751..3ef54c0 100644 --- a/web/src/pages/Schedules.tsx +++ b/web/src/pages/Schedules.tsx @@ -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([]); const [templateRoles, setTemplateRoles] = useState([]); + const [unavailableIds, setUnavailableIds] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>( new Set(instance.volunteers.map(v => v.volunteer_id)) ); @@ -169,6 +171,20 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm useEffect(() => { const loads: Promise[] = [ 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(); + 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(); @@ -287,6 +304,13 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm })} + {onTimeOff.length > 0 && ( +

+ On approved time off:{' '} + {onTimeOff.map(v => v.name).join(', ')} +

+ )} +