feat: add admin schedule template and exceptions page
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
55
src/pages/admin/AdminSchedule.module.css
Normal file
55
src/pages/admin/AdminSchedule.module.css
Normal file
@@ -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;
|
||||
}
|
||||
90
src/pages/admin/AdminSchedule.test.tsx
Normal file
90
src/pages/admin/AdminSchedule.test.tsx
Normal file
@@ -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(<AdminSchedule />)
|
||||
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(<AdminSchedule />)
|
||||
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(<AdminSchedule />)
|
||||
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' }),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
229
src/pages/admin/AdminSchedule.tsx
Normal file
229
src/pages/admin/AdminSchedule.tsx
Normal file
@@ -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<ScheduleTemplate | null>(null)
|
||||
const [exceptions, setExceptions] = useState<ScheduleException[]>([])
|
||||
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 (
|
||||
<div className={styles.page}>
|
||||
<h1>Schedule</h1>
|
||||
|
||||
{template && (
|
||||
<form className={styles.section} onSubmit={handleSaveTemplate}>
|
||||
<h2>Weekly hours</h2>
|
||||
<div className={styles.days}>
|
||||
{DAY_LABELS.map((label, day) => (
|
||||
<label key={day}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={template.working_days.includes(day)}
|
||||
onChange={() => toggleDay(day)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label htmlFor="schedule-start">
|
||||
Start time
|
||||
<input
|
||||
id="schedule-start"
|
||||
type="time"
|
||||
step="1"
|
||||
value={template.start_time}
|
||||
onChange={(e) => setTemplate({ ...template, start_time: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor="schedule-end">
|
||||
End time
|
||||
<input
|
||||
id="schedule-end"
|
||||
type="time"
|
||||
step="1"
|
||||
value={template.end_time}
|
||||
onChange={(e) => setTemplate({ ...template, end_time: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor="schedule-slot-minutes">
|
||||
Slot length (minutes)
|
||||
<input
|
||||
id="schedule-slot-minutes"
|
||||
type="number"
|
||||
value={template.slot_minutes}
|
||||
onChange={(e) => setTemplate({ ...template, slot_minutes: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={saving}>Save schedule</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className={styles.section}>
|
||||
<h2>Exceptions</h2>
|
||||
<table className={styles.exceptionsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Closed</th>
|
||||
<th>Note</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exceptions.map((exception) => (
|
||||
<tr key={exception.date}>
|
||||
<td>{exception.date}</td>
|
||||
<td>{exception.closed ? 'Yes' : 'No'}</td>
|
||||
<td>{exception.note}</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => handleRemoveException(exception.date)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form onSubmit={handleAddException} className={styles.form}>
|
||||
<label htmlFor="exception-date">
|
||||
Exception date
|
||||
<input
|
||||
id="exception-date"
|
||||
type="date"
|
||||
value={newException.date}
|
||||
onChange={(e) => setNewException((f) => ({ ...f, date: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newException.closed}
|
||||
onChange={(e) => setNewException((f) => ({ ...f, closed: e.target.checked }))}
|
||||
/>
|
||||
Closed
|
||||
</label>
|
||||
<label htmlFor="exception-note">
|
||||
Note
|
||||
<input
|
||||
id="exception-note"
|
||||
value={newException.note}
|
||||
onChange={(e) => setNewException((f) => ({ ...f, note: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">Add exception</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminSchedule
|
||||
Reference in New Issue
Block a user