Implement Issue #2: Scheduling & Publishing #10
@@ -36,6 +36,7 @@ function ProtectedLayout() {
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/schedules/templates" element={<Schedules />} />
|
||||
<Route path="/schedules" element={<Schedules />} />
|
||||
<Route path="/timeoff" element={<TimeOff />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Schedules />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
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(<Schedules />);
|
||||
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(<Schedules />);
|
||||
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(<Schedules />);
|
||||
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(<Schedules />);
|
||||
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(<Schedules />);
|
||||
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(<Schedules />);
|
||||
renderAt('/schedules');
|
||||
await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows Edit button on each shift row', async () => {
|
||||
render(<Schedules />);
|
||||
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(<Schedules />);
|
||||
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(<Schedules />);
|
||||
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(<Schedules />);
|
||||
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(<Schedules />);
|
||||
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);
|
||||
|
||||
@@ -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<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 [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<ShiftInstance[]>([]);
|
||||
@@ -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() {
|
||||
<h2>Schedules</h2>
|
||||
{role === 'admin' && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button onClick={() => setView(v => v === 'shifts' ? 'templates' : 'shifts')}>
|
||||
{view === 'shifts' ? 'Manage Templates' : 'View Shifts'}
|
||||
<button onClick={() => isTemplatesView ? goToShifts(year, month) : goToTemplates()}>
|
||||
{isTemplatesView ? 'View Shifts' : 'Manage Templates'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -305,7 +320,7 @@ export default function Schedules() {
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{/* ---- Templates view ---- */}
|
||||
{view === 'templates' && role === 'admin' && (
|
||||
{isTemplatesView && role === 'admin' && (
|
||||
<>
|
||||
<div className="page-header" style={{ marginBottom: '1rem' }}>
|
||||
<h3>Shift Templates</h3>
|
||||
@@ -367,7 +382,7 @@ export default function Schedules() {
|
||||
)}
|
||||
|
||||
{/* ---- Shifts view ---- */}
|
||||
{view === 'shifts' && (
|
||||
{!isTemplatesView && (
|
||||
<>
|
||||
{/* Month navigation */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||
@@ -428,7 +443,6 @@ export default function Schedules() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{instances.map(inst => {
|
||||
const myAssignment = inst.volunteers.find(() => true); // for volunteers, API already filters
|
||||
const confirmed = inst.volunteers.some(v => v.confirmed);
|
||||
return (
|
||||
<tr key={inst.id}>
|
||||
@@ -454,7 +468,7 @@ export default function Schedules() {
|
||||
<button className="btn-small" onClick={() => openEditInstance(inst)}>Edit</button>
|
||||
</td>
|
||||
)}
|
||||
{role !== 'admin' && inst.status === 'published' && !confirmed && myAssignment && (
|
||||
{role !== 'admin' && inst.status === 'published' && !confirmed && (
|
||||
<td>
|
||||
<button className="btn-small" onClick={() => handleConfirm(inst.id)}>Confirm</button>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user