Implement Issue #2: Scheduling & Publishing #10

Merged
thatguygriff merged 10 commits from feature/issue-2-scheduling into main 2026-04-08 23:02:18 +00:00
2 changed files with 48 additions and 3 deletions
Showing only changes of commit 6575ce8f44 - Show all commits

View File

@@ -10,6 +10,7 @@ jest.mock('../api', () => ({
listShifts: jest.fn(), listShifts: jest.fn(),
listShiftTemplates: jest.fn(), listShiftTemplates: jest.fn(),
listVolunteers: jest.fn(), listVolunteers: jest.fn(),
listTimeOff: jest.fn(),
generateShifts: jest.fn(), generateShifts: jest.fn(),
publishShifts: jest.fn(), publishShifts: jest.fn(),
unpublishShifts: 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: 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 }, { id: 6, name: 'Bob', active: true, operational_roles: 'Behaviour Team', is_trainee: false },
]); ]);
(api.listTimeOff as jest.Mock).mockResolvedValue([]);
renderAt('/schedules'); renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument());
fireEvent.click(screen.getByText('Edit')); fireEvent.click(screen.getByText('Edit'));
@@ -154,6 +156,25 @@ describe('Schedules (admin shifts view)', () => {
expect(screen.getByText(/Edit Shift/)).toBeInTheDocument(); 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 () => { it('reads year and month from search params', async () => {
renderAt('/schedules?year=2025&month=12'); renderAt('/schedules?year=2025&month=12');
await waitFor(() => expect(api.listShifts).toHaveBeenCalledWith(2025, 12)); await waitFor(() => expect(api.listShifts).toHaveBeenCalledWith(2025, 12));

View File

@@ -5,6 +5,7 @@ import {
ShiftTemplate, ShiftTemplate,
ShiftInstance, ShiftInstance,
Volunteer, Volunteer,
TimeOffRequest,
CreateShiftTemplateInput, CreateShiftTemplateInput,
TemplateRole, TemplateRole,
OPERATIONAL_ROLES, OPERATIONAL_ROLES,
@@ -157,6 +158,7 @@ interface ShiftEditFormProps {
function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditFormProps) { function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditFormProps) {
const [volunteers, setVolunteers] = useState<Volunteer[]>([]); const [volunteers, setVolunteers] = useState<Volunteer[]>([]);
const [templateRoles, setTemplateRoles] = useState<TemplateRole[]>([]); const [templateRoles, setTemplateRoles] = useState<TemplateRole[]>([]);
const [unavailableIds, setUnavailableIds] = useState<Set<number>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<number>>( const [selectedIds, setSelectedIds] = useState<Set<number>>(
new Set(instance.volunteers.map(v => v.volunteer_id)) new Set(instance.volunteers.map(v => v.volunteer_id))
); );
@@ -169,6 +171,20 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm
useEffect(() => { useEffect(() => {
const loads: Promise<void>[] = [ const loads: Promise<void>[] = [
api.listVolunteers().then((vols) => setVolunteers(vols as Volunteer[])), 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) { if (templateId) {
loads.push( loads.push(
@@ -179,7 +195,7 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm
); );
} }
Promise.all(loads).finally(() => setLoading(false)); Promise.all(loads).finally(() => setLoading(false));
}, [templateId]); }, [templateId, instance.date]);
function toggleVolunteer(id: number) { function toggleVolunteer(id: number) {
setSelectedIds(prev => { setSelectedIds(prev => {
@@ -208,8 +224,9 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm
} }
} }
// Group active volunteers by their operational roles for display // Group active, available volunteers by their operational roles for display
const activeVolunteers = volunteers.filter(v => v.active); 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 // Build a map of role → volunteers who hold that role
const byRole = new Map<string, Volunteer[]>(); const byRole = new Map<string, Volunteer[]>();
@@ -287,6 +304,13 @@ function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditForm
})} })}
</fieldset> </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' }}> <div style={{ display: 'flex', gap: '1rem', marginTop: '0.5rem' }}>
<label> <label>
Min Capacity Min Capacity