diff --git a/web/src/App.tsx b/web/src/App.tsx index be150ba..0b10b09 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -36,6 +36,7 @@ function ProtectedLayout() {
} /> + } /> } /> } /> } /> diff --git a/web/src/pages/Schedules.test.tsx b/web/src/pages/Schedules.test.tsx index 7e22bee..4f7edd8 100644 --- a/web/src/pages/Schedules.test.tsx +++ b/web/src/pages/Schedules.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import Schedules from './Schedules'; import { api, ShiftInstance, ShiftTemplate } from '../api'; @@ -62,6 +63,14 @@ const mockTemplate: ShiftTemplate = { updated_at: '2026-04-01T00:00:00Z', }; +function renderAt(path: string) { + return render( + + + + ); +} + describe('Schedules (volunteer view)', () => { beforeEach(() => { useAuth.mockReturnValue({ role: 'volunteer', volunteerID: 10 }); @@ -69,7 +78,7 @@ describe('Schedules (volunteer view)', () => { }); it('renders published shifts for a volunteer', async () => { - render(); + renderAt('/schedules'); await waitFor(() => { expect(screen.getByText('Morning Shift')).toBeInTheDocument(); }); @@ -77,7 +86,7 @@ describe('Schedules (volunteer view)', () => { }); it('does not show admin controls', async () => { - render(); + renderAt('/schedules'); await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); expect(screen.queryByText('Generate')).not.toBeInTheDocument(); expect(screen.queryByText('Publish')).not.toBeInTheDocument(); @@ -85,7 +94,7 @@ describe('Schedules (volunteer view)', () => { }); }); -describe('Schedules (admin view)', () => { +describe('Schedules (admin shifts view)', () => { beforeEach(() => { useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 }); (api.listShifts as jest.Mock).mockResolvedValue([mockDraftInstance]); @@ -93,7 +102,7 @@ describe('Schedules (admin view)', () => { }); it('shows Generate and Publish buttons when drafts exist', async () => { - render(); + renderAt('/schedules'); await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); expect(screen.getByText('Generate')).toBeInTheDocument(); expect(screen.getByText('Publish')).toBeInTheDocument(); @@ -101,7 +110,7 @@ describe('Schedules (admin view)', () => { it('calls generateShifts on Generate click', async () => { (api.generateShifts as jest.Mock).mockResolvedValue([mockDraftInstance]); - render(); + renderAt('/schedules'); await waitFor(() => expect(screen.getByText('Generate')).toBeInTheDocument()); fireEvent.click(screen.getByText('Generate')); expect(api.generateShifts).toHaveBeenCalled(); @@ -113,7 +122,7 @@ describe('Schedules (admin view)', () => { .mockResolvedValueOnce([mockDraftInstance]) .mockResolvedValue([{ ...mockDraftInstance, status: 'published' }]); - render(); + renderAt('/schedules'); await waitFor(() => expect(screen.getByText('Publish')).toBeInTheDocument()); fireEvent.click(screen.getByText('Publish')); expect(api.publishShifts).toHaveBeenCalled(); @@ -121,35 +130,43 @@ describe('Schedules (admin view)', () => { it('shows Unpublish button when all shifts are published', async () => { (api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]); - render(); + renderAt('/schedules'); await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument()); }); it('shows Edit button on each shift row', async () => { - render(); + renderAt('/schedules'); await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); expect(screen.getByText('Edit')).toBeInTheDocument(); }); it('opens edit form when Edit is clicked', async () => { - render(); + renderAt('/schedules'); await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument()); fireEvent.click(screen.getByText('Edit')); expect(screen.getByText(/Edit Shift/)).toBeInTheDocument(); }); - it('switches to templates view', async () => { - render(); - await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument()); - fireEvent.click(screen.getByText('Manage Templates')); + it('reads year and month from search params', async () => { + renderAt('/schedules?year=2025&month=12'); + await waitFor(() => expect(api.listShifts).toHaveBeenCalledWith(2025, 12)); + }); +}); + +describe('Schedules (admin templates view)', () => { + beforeEach(() => { + useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 }); + (api.listShiftTemplates as jest.Mock).mockResolvedValue([mockTemplate]); + }); + + it('renders templates at /schedules/templates', async () => { + renderAt('/schedules/templates'); await waitFor(() => expect(screen.getByText('Shift Templates')).toBeInTheDocument()); expect(screen.getByText('Morning Shift')).toBeInTheDocument(); }); it('shows template form when New Template is clicked', async () => { - render(); - await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument()); - fireEvent.click(screen.getByText('Manage Templates')); + renderAt('/schedules/templates'); await waitFor(() => expect(screen.getByText('+ New Template')).toBeInTheDocument()); fireEvent.click(screen.getByText('+ New Template')); expect(screen.getByText('New Template')).toBeInTheDocument(); @@ -158,9 +175,7 @@ describe('Schedules (admin view)', () => { it('deletes a template', async () => { (api.deleteShiftTemplate as jest.Mock).mockResolvedValue(undefined); window.confirm = jest.fn().mockReturnValue(true); - render(); - await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument()); - fireEvent.click(screen.getByText('Manage Templates')); + renderAt('/schedules/templates'); await waitFor(() => expect(screen.getAllByText('Delete')[0]).toBeInTheDocument()); fireEvent.click(screen.getAllByText('Delete')[0]); expect(api.deleteShiftTemplate).toHaveBeenCalledWith(1); diff --git a/web/src/pages/Schedules.tsx b/web/src/pages/Schedules.tsx index 053338f..5233a06 100644 --- a/web/src/pages/Schedules.tsx +++ b/web/src/pages/Schedules.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useState, FormEvent } from 'react'; +import React, { useEffect, useState, FormEvent, useCallback } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { api, ShiftTemplate, @@ -145,16 +146,19 @@ function TemplateForm({ initial, onSave, onCancel, title }: TemplateFormProps) { // Main page // --------------------------------------------------------------------------- -type View = 'shifts' | 'templates'; - export default function Schedules() { const { role } = useAuth(); - const [view, setView] = useState('shifts'); + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); - // Month navigation + // Derive view from URL path + const isTemplatesView = location.pathname === '/schedules/templates'; + + // Derive year/month from search params (shifts view only) const init = currentYearMonth(); - const [year, setYear] = useState(init.year); - const [month, setMonth] = useState(init.month); + const year = Number(searchParams.get('year')) || init.year; + const month = Number(searchParams.get('month')) || init.month; // Data const [instances, setInstances] = useState([]); @@ -169,26 +173,37 @@ export default function Schedules() { const [editMinCap, setEditMinCap] = useState(''); const [editMaxCap, setEditMaxCap] = useState(''); + // Navigation helpers + const goToShifts = useCallback((y: number, m: number) => { + navigate(`/schedules?year=${y}&month=${m}`); + }, [navigate]); + + const goToTemplates = useCallback(() => { + navigate('/schedules/templates'); + }, [navigate]); + useEffect(() => { - if (view === 'shifts') { - api.listShifts(year, month) - .then(setInstances) - .catch(() => setError('Could not load shifts.')); - } else { + if (isTemplatesView) { api.listShiftTemplates() .then(setTemplates) .catch(() => setError('Could not load templates.')); + } else { + api.listShifts(year, month) + .then(setInstances) + .catch(() => setError('Could not load shifts.')); } - }, [view, year, month]); + }, [isTemplatesView, year, month]); function prevMonth() { - if (month === 1) { setYear(y => y - 1); setMonth(12); } - else setMonth(m => m - 1); + const m = month === 1 ? 12 : month - 1; + const y = month === 1 ? year - 1 : year; + goToShifts(y, m); } function nextMonth() { - if (month === 12) { setYear(y => y + 1); setMonth(1); } - else setMonth(m => m + 1); + const m = month === 12 ? 1 : month + 1; + const y = month === 12 ? year + 1 : year; + goToShifts(y, m); } async function handleGenerate() { @@ -295,8 +310,8 @@ export default function Schedules() {

Schedules

{role === 'admin' && (
-
)} @@ -305,7 +320,7 @@ export default function Schedules() { {error &&

{error}

} {/* ---- Templates view ---- */} - {view === 'templates' && role === 'admin' && ( + {isTemplatesView && role === 'admin' && ( <>

Shift Templates

@@ -367,7 +382,7 @@ export default function Schedules() { )} {/* ---- Shifts view ---- */} - {view === 'shifts' && ( + {!isTemplatesView && ( <> {/* Month navigation */}
@@ -428,7 +443,6 @@ export default function Schedules() { {instances.map(inst => { - const myAssignment = inst.volunteers.find(() => true); // for volunteers, API already filters const confirmed = inst.volunteers.some(v => v.confirmed); return ( @@ -454,7 +468,7 @@ export default function Schedules() { )} - {role !== 'admin' && inst.status === 'published' && !confirmed && myAssignment && ( + {role !== 'admin' && inst.status === 'published' && !confirmed && (