Implement Issue #2: Scheduling & Publishing #10
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user