From e86d86377315126d65221119c5975c81bbb1f837 Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Wed, 12 Aug 2026 11:41:26 +0200 Subject: [PATCH] feat: add admin schedule template and exceptions page Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH --- src/pages/admin/AdminSchedule.module.css | 55 ++++++ src/pages/admin/AdminSchedule.test.tsx | 90 +++++++++ src/pages/admin/AdminSchedule.tsx | 229 +++++++++++++++++++++++ 3 files changed, 374 insertions(+) create mode 100644 src/pages/admin/AdminSchedule.module.css create mode 100644 src/pages/admin/AdminSchedule.test.tsx create mode 100644 src/pages/admin/AdminSchedule.tsx diff --git a/src/pages/admin/AdminSchedule.module.css b/src/pages/admin/AdminSchedule.module.css new file mode 100644 index 0000000..caa2a15 --- /dev/null +++ b/src/pages/admin/AdminSchedule.module.css @@ -0,0 +1,55 @@ +.page { + display: flex; + flex-direction: column; + gap: var(--space-xl); +} + +.section { + background: white; + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-sm); + max-width: 480px; +} + +.days { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.days label { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.form label, +.section label { + display: flex; + flex-direction: column; + gap: 0.25rem; + color: var(--color-charcoal); +} + +.form input { + padding: var(--space-sm); + border: 1px solid var(--color-gold); +} + +.exceptionsTable { + width: 100%; + border-collapse: collapse; +} + +.exceptionsTable th, +.exceptionsTable td { + text-align: left; + padding: var(--space-sm); + border-bottom: 1px solid var(--color-cream-alt); +} + +.error { + color: #a33; +} diff --git a/src/pages/admin/AdminSchedule.test.tsx b/src/pages/admin/AdminSchedule.test.tsx new file mode 100644 index 0000000..a5b2043 --- /dev/null +++ b/src/pages/admin/AdminSchedule.test.tsx @@ -0,0 +1,90 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const templateSingleMock = vi.fn() +const templateEqUpdateMock = vi.fn() +const templateUpdateMock = vi.fn() +const exceptionsOrderMock = vi.fn() +const exceptionsInsertMock = vi.fn() +const exceptionsDeleteEqMock = vi.fn() +const exceptionsDeleteMock = vi.fn() +const fromMock = vi.fn() + +vi.mock('../../lib/supabase', () => ({ + supabase: { from: (...args: unknown[]) => fromMock(...args) }, +})) + +const TEMPLATE = { + id: 'default', + working_days: [1, 2, 3, 4, 5, 6], + start_time: '09:00:00', + end_time: '18:00:00', + slot_minutes: 60, +} + +const EXCEPTION = { date: '2026-12-25', closed: true, note: 'Christmas' } + +import AdminSchedule from './AdminSchedule' + +describe('AdminSchedule', () => { + beforeEach(() => { + templateSingleMock.mockReset().mockResolvedValue({ data: TEMPLATE, error: null }) + templateEqUpdateMock.mockReset().mockResolvedValue({ error: null }) + templateUpdateMock.mockReset().mockReturnValue({ eq: templateEqUpdateMock }) + exceptionsOrderMock.mockReset().mockResolvedValue({ data: [EXCEPTION], error: null }) + exceptionsInsertMock.mockReset().mockResolvedValue({ error: null }) + exceptionsDeleteEqMock.mockReset().mockResolvedValue({ error: null }) + exceptionsDeleteMock.mockReset().mockReturnValue({ eq: exceptionsDeleteEqMock }) + fromMock.mockReset() + + fromMock.mockImplementation((table: string) => { + if (table === 'schedule_template') { + return { + select: () => ({ eq: () => ({ single: templateSingleMock }) }), + update: templateUpdateMock, + } + } + if (table === 'schedule_exceptions') { + return { + select: () => ({ order: exceptionsOrderMock }), + insert: exceptionsInsertMock, + delete: exceptionsDeleteMock, + } + } + throw new Error(`unexpected table: ${table}`) + }) + }) + + it('loads and displays the weekly template and exceptions', async () => { + render() + expect(await screen.findByDisplayValue('09:00:00')).toBeInTheDocument() + expect(screen.getByText('2026-12-25')).toBeInTheDocument() + expect(screen.getByText('Christmas')).toBeInTheDocument() + }) + + it('saves the weekly template', async () => { + render() + await screen.findByDisplayValue('09:00:00') + + await userEvent.click(screen.getByRole('button', { name: /save schedule/i })) + + await waitFor(() => expect(templateUpdateMock).toHaveBeenCalled()) + expect(templateEqUpdateMock).toHaveBeenCalledWith('id', 'default') + }) + + it('adds a new exception', async () => { + render() + await screen.findByDisplayValue('09:00:00') + + await userEvent.type(screen.getByLabelText(/exception date/i), '2026-11-01') + await userEvent.type(screen.getByLabelText(/^note$/i), 'Closed for training') + await userEvent.click(screen.getByRole('button', { name: /add exception/i })) + + await waitFor(() => + expect(exceptionsInsertMock).toHaveBeenCalledWith( + expect.objectContaining({ date: '2026-11-01', note: 'Closed for training' }), + ), + ) + }) +}) diff --git a/src/pages/admin/AdminSchedule.tsx b/src/pages/admin/AdminSchedule.tsx new file mode 100644 index 0000000..5e84af9 --- /dev/null +++ b/src/pages/admin/AdminSchedule.tsx @@ -0,0 +1,229 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { supabase } from '../../lib/supabase' +import styles from './AdminSchedule.module.css' + +interface ScheduleTemplate { + id: string + working_days: number[] + start_time: string + end_time: string + slot_minutes: number +} + +interface ScheduleException { + date: string + closed: boolean + note: string | null +} + +const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +function AdminSchedule() { + const [template, setTemplate] = useState(null) + const [exceptions, setExceptions] = useState([]) + const [newException, setNewException] = useState({ date: '', closed: true, note: '' }) + const [error, setError] = useState('') + const [saving, setSaving] = useState(false) + + async function loadExceptions() { + const { data, error: fetchError } = await supabase.from('schedule_exceptions').select('*').order('date') + if (fetchError) { + setError(fetchError.message) + return + } + setExceptions((data ?? []) as ScheduleException[]) + } + + useEffect(() => { + async function load() { + const { data, error: fetchError } = await supabase + .from('schedule_template') + .select('*') + .eq('id', 'default') + .single() + if (fetchError) { + setError(fetchError.message) + return + } + setTemplate(data as ScheduleTemplate) + } + void load() + }, []) + + useEffect(() => { + async function load() { + const { data, error: fetchError } = await supabase.from('schedule_exceptions').select('*').order('date') + if (fetchError) { + setError(fetchError.message) + return + } + setExceptions((data ?? []) as ScheduleException[]) + } + void load() + }, []) + + function toggleDay(day: number) { + if (!template) return + const working_days = template.working_days.includes(day) + ? template.working_days.filter((d) => d !== day) + : [...template.working_days, day].sort() + setTemplate({ ...template, working_days }) + } + + async function handleSaveTemplate(event: FormEvent) { + event.preventDefault() + if (!template) return + setSaving(true) + setError('') + const { error: updateError } = await supabase + .from('schedule_template') + .update({ + working_days: template.working_days, + start_time: template.start_time, + end_time: template.end_time, + slot_minutes: template.slot_minutes, + }) + .eq('id', 'default') + setSaving(false) + if (updateError) setError(updateError.message) + } + + async function handleAddException(event: FormEvent) { + event.preventDefault() + setError('') + const { error: insertError } = await supabase.from('schedule_exceptions').insert({ + date: newException.date, + closed: newException.closed, + note: newException.note || null, + }) + if (insertError) { + setError(insertError.message) + return + } + setNewException({ date: '', closed: true, note: '' }) + await loadExceptions() + } + + async function handleRemoveException(date: string) { + const { error: deleteError } = await supabase.from('schedule_exceptions').delete().eq('date', date) + if (deleteError) { + setError(deleteError.message) + return + } + await loadExceptions() + } + + return ( +
+

Schedule

+ + {template && ( +
+

Weekly hours

+
+ {DAY_LABELS.map((label, day) => ( + + ))} +
+ + + + +
+ )} + +
+

Exceptions

+ + + + + + + + + + + {exceptions.map((exception) => ( + + + + + + + ))} + +
DateClosedNote
{exception.date}{exception.closed ? 'Yes' : 'No'}{exception.note} + +
+ +
+ + + + +
+
+ + {error &&

{error}

} +
+ ) +} + +export default AdminSchedule