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
3 changed files with 72 additions and 42 deletions
Showing only changes of commit 9473ce24bc - Show all commits

View File

@@ -36,6 +36,7 @@ function ProtectedLayout() {
<main> <main>
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/schedules/templates" element={<Schedules />} />
<Route path="/schedules" element={<Schedules />} /> <Route path="/schedules" element={<Schedules />} />
<Route path="/timeoff" element={<TimeOff />} /> <Route path="/timeoff" element={<TimeOff />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Schedules from './Schedules'; import Schedules from './Schedules';
import { api, ShiftInstance, ShiftTemplate } from '../api'; import { api, ShiftInstance, ShiftTemplate } from '../api';
@@ -62,6 +63,14 @@ const mockTemplate: ShiftTemplate = {
updated_at: '2026-04-01T00:00:00Z', updated_at: '2026-04-01T00:00:00Z',
}; };
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Schedules />
</MemoryRouter>
);
}
describe('Schedules (volunteer view)', () => { describe('Schedules (volunteer view)', () => {
beforeEach(() => { beforeEach(() => {
useAuth.mockReturnValue({ role: 'volunteer', volunteerID: 10 }); useAuth.mockReturnValue({ role: 'volunteer', volunteerID: 10 });
@@ -69,7 +78,7 @@ describe('Schedules (volunteer view)', () => {
}); });
it('renders published shifts for a volunteer', async () => { it('renders published shifts for a volunteer', async () => {
render(<Schedules />); renderAt('/schedules');
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Morning Shift')).toBeInTheDocument(); expect(screen.getByText('Morning Shift')).toBeInTheDocument();
}); });
@@ -77,7 +86,7 @@ describe('Schedules (volunteer view)', () => {
}); });
it('does not show admin controls', async () => { it('does not show admin controls', async () => {
render(<Schedules />); renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
expect(screen.queryByText('Generate')).not.toBeInTheDocument(); expect(screen.queryByText('Generate')).not.toBeInTheDocument();
expect(screen.queryByText('Publish')).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(() => { beforeEach(() => {
useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 }); useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 });
(api.listShifts as jest.Mock).mockResolvedValue([mockDraftInstance]); (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 () => { it('shows Generate and Publish buttons when drafts exist', async () => {
render(<Schedules />); renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
expect(screen.getByText('Generate')).toBeInTheDocument(); expect(screen.getByText('Generate')).toBeInTheDocument();
expect(screen.getByText('Publish')).toBeInTheDocument(); expect(screen.getByText('Publish')).toBeInTheDocument();
@@ -101,7 +110,7 @@ describe('Schedules (admin view)', () => {
it('calls generateShifts on Generate click', async () => { it('calls generateShifts on Generate click', async () => {
(api.generateShifts as jest.Mock).mockResolvedValue([mockDraftInstance]); (api.generateShifts as jest.Mock).mockResolvedValue([mockDraftInstance]);
render(<Schedules />); renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Generate')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Generate')).toBeInTheDocument());
fireEvent.click(screen.getByText('Generate')); fireEvent.click(screen.getByText('Generate'));
expect(api.generateShifts).toHaveBeenCalled(); expect(api.generateShifts).toHaveBeenCalled();
@@ -113,7 +122,7 @@ describe('Schedules (admin view)', () => {
.mockResolvedValueOnce([mockDraftInstance]) .mockResolvedValueOnce([mockDraftInstance])
.mockResolvedValue([{ ...mockDraftInstance, status: 'published' }]); .mockResolvedValue([{ ...mockDraftInstance, status: 'published' }]);
render(<Schedules />); renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Publish')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Publish')).toBeInTheDocument());
fireEvent.click(screen.getByText('Publish')); fireEvent.click(screen.getByText('Publish'));
expect(api.publishShifts).toHaveBeenCalled(); expect(api.publishShifts).toHaveBeenCalled();
@@ -121,35 +130,43 @@ describe('Schedules (admin view)', () => {
it('shows Unpublish button when all shifts are published', async () => { it('shows Unpublish button when all shifts are published', async () => {
(api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]); (api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]);
render(<Schedules />); renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument());
}); });
it('shows Edit button on each shift row', async () => { it('shows Edit button on each shift row', async () => {
render(<Schedules />); renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
expect(screen.getByText('Edit')).toBeInTheDocument(); expect(screen.getByText('Edit')).toBeInTheDocument();
}); });
it('opens edit form when Edit is clicked', async () => { it('opens edit form when Edit is clicked', async () => {
render(<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'));
expect(screen.getByText(/Edit Shift/)).toBeInTheDocument(); expect(screen.getByText(/Edit Shift/)).toBeInTheDocument();
}); });
it('switches to templates view', async () => { it('reads year and month from search params', async () => {
render(<Schedules />); renderAt('/schedules?year=2025&month=12');
await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument()); await waitFor(() => expect(api.listShifts).toHaveBeenCalledWith(2025, 12));
fireEvent.click(screen.getByText('Manage Templates')); });
});
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()); await waitFor(() => expect(screen.getByText('Shift Templates')).toBeInTheDocument());
expect(screen.getByText('Morning Shift')).toBeInTheDocument(); expect(screen.getByText('Morning Shift')).toBeInTheDocument();
}); });
it('shows template form when New Template is clicked', async () => { it('shows template form when New Template is clicked', async () => {
render(<Schedules />); renderAt('/schedules/templates');
await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument());
fireEvent.click(screen.getByText('Manage Templates'));
await waitFor(() => expect(screen.getByText('+ New Template')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('+ New Template')).toBeInTheDocument());
fireEvent.click(screen.getByText('+ New Template')); fireEvent.click(screen.getByText('+ New Template'));
expect(screen.getByText('New Template')).toBeInTheDocument(); expect(screen.getByText('New Template')).toBeInTheDocument();
@@ -158,9 +175,7 @@ describe('Schedules (admin view)', () => {
it('deletes a template', async () => { it('deletes a template', async () => {
(api.deleteShiftTemplate as jest.Mock).mockResolvedValue(undefined); (api.deleteShiftTemplate as jest.Mock).mockResolvedValue(undefined);
window.confirm = jest.fn().mockReturnValue(true); window.confirm = jest.fn().mockReturnValue(true);
render(<Schedules />); renderAt('/schedules/templates');
await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument());
fireEvent.click(screen.getByText('Manage Templates'));
await waitFor(() => expect(screen.getAllByText('Delete')[0]).toBeInTheDocument()); await waitFor(() => expect(screen.getAllByText('Delete')[0]).toBeInTheDocument());
fireEvent.click(screen.getAllByText('Delete')[0]); fireEvent.click(screen.getAllByText('Delete')[0]);
expect(api.deleteShiftTemplate).toHaveBeenCalledWith(1); expect(api.deleteShiftTemplate).toHaveBeenCalledWith(1);

View File

@@ -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 { import {
api, api,
ShiftTemplate, ShiftTemplate,
@@ -145,16 +146,19 @@ function TemplateForm({ initial, onSave, onCancel, title }: TemplateFormProps) {
// Main page // Main page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type View = 'shifts' | 'templates';
export default function Schedules() { export default function Schedules() {
const { role } = useAuth(); const { role } = useAuth();
const [view, setView] = useState<View>('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 init = currentYearMonth();
const [year, setYear] = useState(init.year); const year = Number(searchParams.get('year')) || init.year;
const [month, setMonth] = useState(init.month); const month = Number(searchParams.get('month')) || init.month;
// Data // Data
const [instances, setInstances] = useState<ShiftInstance[]>([]); const [instances, setInstances] = useState<ShiftInstance[]>([]);
@@ -169,26 +173,37 @@ export default function Schedules() {
const [editMinCap, setEditMinCap] = useState(''); const [editMinCap, setEditMinCap] = useState('');
const [editMaxCap, setEditMaxCap] = 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(() => { useEffect(() => {
if (view === 'shifts') { if (isTemplatesView) {
api.listShifts(year, month)
.then(setInstances)
.catch(() => setError('Could not load shifts.'));
} else {
api.listShiftTemplates() api.listShiftTemplates()
.then(setTemplates) .then(setTemplates)
.catch(() => setError('Could not load templates.')); .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() { function prevMonth() {
if (month === 1) { setYear(y => y - 1); setMonth(12); } const m = month === 1 ? 12 : month - 1;
else setMonth(m => m - 1); const y = month === 1 ? year - 1 : year;
goToShifts(y, m);
} }
function nextMonth() { function nextMonth() {
if (month === 12) { setYear(y => y + 1); setMonth(1); } const m = month === 12 ? 1 : month + 1;
else setMonth(m => m + 1); const y = month === 12 ? year + 1 : year;
goToShifts(y, m);
} }
async function handleGenerate() { async function handleGenerate() {
@@ -295,8 +310,8 @@ export default function Schedules() {
<h2>Schedules</h2> <h2>Schedules</h2>
{role === 'admin' && ( {role === 'admin' && (
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem' }}>
<button onClick={() => setView(v => v === 'shifts' ? 'templates' : 'shifts')}> <button onClick={() => isTemplatesView ? goToShifts(year, month) : goToTemplates()}>
{view === 'shifts' ? 'Manage Templates' : 'View Shifts'} {isTemplatesView ? 'View Shifts' : 'Manage Templates'}
</button> </button>
</div> </div>
)} )}
@@ -305,7 +320,7 @@ export default function Schedules() {
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{/* ---- Templates view ---- */} {/* ---- Templates view ---- */}
{view === 'templates' && role === 'admin' && ( {isTemplatesView && role === 'admin' && (
<> <>
<div className="page-header" style={{ marginBottom: '1rem' }}> <div className="page-header" style={{ marginBottom: '1rem' }}>
<h3>Shift Templates</h3> <h3>Shift Templates</h3>
@@ -367,7 +382,7 @@ export default function Schedules() {
)} )}
{/* ---- Shifts view ---- */} {/* ---- Shifts view ---- */}
{view === 'shifts' && ( {!isTemplatesView && (
<> <>
{/* Month navigation */} {/* Month navigation */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
@@ -428,7 +443,6 @@ export default function Schedules() {
</thead> </thead>
<tbody> <tbody>
{instances.map(inst => { {instances.map(inst => {
const myAssignment = inst.volunteers.find(() => true); // for volunteers, API already filters
const confirmed = inst.volunteers.some(v => v.confirmed); const confirmed = inst.volunteers.some(v => v.confirmed);
return ( return (
<tr key={inst.id}> <tr key={inst.id}>
@@ -454,7 +468,7 @@ export default function Schedules() {
<button className="btn-small" onClick={() => openEditInstance(inst)}>Edit</button> <button className="btn-small" onClick={() => openEditInstance(inst)}>Edit</button>
</td> </td>
)} )}
{role !== 'admin' && inst.status === 'published' && !confirmed && myAssignment && ( {role !== 'admin' && inst.status === 'published' && !confirmed && (
<td> <td>
<button className="btn-small" onClick={() => handleConfirm(inst.id)}>Confirm</button> <button className="btn-small" onClick={() => handleConfirm(inst.id)}>Confirm</button>
</td> </td>