mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
feat(vacay): add manual school holiday calendars
This commit is contained in:
@@ -192,7 +192,7 @@ export default function CategoryManager() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
|
||||
@@ -156,7 +156,7 @@ export default function PackingTemplateManager() {
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { within } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import SchoolHolidayCatalog from './SchoolHolidayCatalog'
|
||||
import SchoolHolidayRegionEditor from './SchoolHolidayRegionEditor'
|
||||
import { fetchSchoolHolidayRegionOptions } from '../Vacay/holidayRegions'
|
||||
import { useSchoolHolidayCountries } from '../Vacay/useSchoolHolidayCountries'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import type { SchoolHolidayRegionDetail } from '@trek/shared'
|
||||
|
||||
const region: SchoolHolidayRegionDetail = { id: 7, code: 'US-MANUAL-7', country: 'US', name: 'Seattle schools', revision: 1, holidays: [{ name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' }] }
|
||||
const catalog = { countries: [{ code: 'US', name: 'USA' }], regions: [region] }
|
||||
beforeEach(() => resetAllStores())
|
||||
|
||||
function enterDate(label: string, value: string) {
|
||||
const picker = within(screen.getByRole('group', { name: label }))
|
||||
fireEvent.click(picker.getByRole('button', { name: /enter.*manually/i }))
|
||||
const input = picker.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value } })
|
||||
fireEvent.blur(input)
|
||||
}
|
||||
|
||||
describe('manual school holiday administration', () => {
|
||||
it('creates a country, a district and its named periods', async () => {
|
||||
let created = false
|
||||
const saves: unknown[] = []
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: created ? catalog.countries : [], regions: [] })),
|
||||
http.post('/api/school-holiday-catalog/countries', async ({ request }) => { expect(await request.json()).toEqual({ code: 'US', name: 'USA' }); created = true; return HttpResponse.json(catalog.countries[0]) }),
|
||||
http.post('/api/school-holiday-catalog/countries/US/regions', async ({ request }) => { saves.push(await request.json()); return HttpResponse.json(region) }),
|
||||
)
|
||||
render(<SchoolHolidayCatalog />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add country' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add country' }))
|
||||
fireEvent.change(screen.getByLabelText('Country'), { target: { value: 'USA' } })
|
||||
fireEvent.change(screen.getByLabelText('Country code (e.g. US)'), { target: { value: 'us' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add region' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add region' }))
|
||||
fireEvent.change(screen.getByLabelText('Region or school district'), { target: { value: 'Seattle schools' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add holiday period' }))
|
||||
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'Winter break' } })
|
||||
enterDate('Start date', '2026-12-20')
|
||||
enterDate('End date', '2027-01-06')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(saves).toEqual([{ name: region.name, revision: 0, holidays: region.holidays }]))
|
||||
await waitFor(() => expect(screen.queryByLabelText('Holiday name')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('opens existing periods and keeps edits after a failed save', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
|
||||
http.get('/api/school-holiday-catalog/regions/7', () => HttpResponse.json(region)),
|
||||
http.put('/api/school-holiday-catalog/regions/7', () => HttpResponse.json({ error: 'This region changed. Reopen it before saving again.' }, { status: 409 })),
|
||||
)
|
||||
render(<SchoolHolidayCatalog />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Seattle schools' }))
|
||||
await screen.findByDisplayValue('Winter break')
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
|
||||
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'New name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('This region changed')
|
||||
expect(screen.getByLabelText('Holiday name')).toHaveValue('New name')
|
||||
})
|
||||
|
||||
it('validates names and confirms discarding changes', async () => {
|
||||
const save = vi.fn()
|
||||
const close = vi.fn()
|
||||
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={close} onSave={save} />)
|
||||
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: ' ' } })
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save' }).closest('form')!)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Check the names and dates')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByText('Discard unsaved changes?')).toBeInTheDocument()
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes a period and saves an existing region without changing its revision token', () => {
|
||||
const save = vi.fn()
|
||||
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={vi.fn()} onSave={save} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Winter break' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(save).toHaveBeenCalledWith({ name: region.name, revision: 1, holidays: [] })
|
||||
})
|
||||
|
||||
it('blocks offline edits but still lets the dialog close', () => {
|
||||
const close = vi.fn()
|
||||
render(<SchoolHolidayRegionEditor region={region} busy={false} offline error="Offline" onClose={close} onSave={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps deletion conflicts visible and allows retrying the catalog', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
|
||||
http.delete('/api/school-holiday-catalog/regions/7', ({ request }) => {
|
||||
expect(new URL(request.url).searchParams.get('revision')).toBe('1')
|
||||
return HttpResponse.json({ error: 'Region is in use' }, { status: 409 })
|
||||
}),
|
||||
)
|
||||
render(<SchoolHolidayCatalog />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete Seattle schools' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Region is in use')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: 'Seattle schools' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps national API calendars selectable alongside manual regions', async () => {
|
||||
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'IE', name: 'Ireland' }], regions: [{ ...region, country: 'IE', code: 'IE-MANUAL-7' }] })))
|
||||
expect(await fetchSchoolHolidayRegionOptions('IE')).toEqual([{ value: 'IE', label: 'IE' }, { value: 'IE-MANUAL-7', label: region.name }])
|
||||
})
|
||||
|
||||
it('keeps API regions available if loading the manual catalog fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json({}, { status: 502 })),
|
||||
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({ subdivisions: [{ code: 'DE-BY', name: [{ language: 'EN', text: 'Bavaria' }] }] })),
|
||||
)
|
||||
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-BY', label: 'Bavaria' }])
|
||||
await expect(fetchSchoolHolidayRegionOptions('US')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('offers USA independently of provider countries and fetches only local regions', async () => {
|
||||
const provider = vi.fn(() => HttpResponse.json([]))
|
||||
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)), http.get('/api/addons/vacay/school-holidays/regions/US', provider))
|
||||
function Countries() { const { countries } = useSchoolHolidayCountries(); return <>{countries.map(country => <span key={country.value}>{country.label}</span>)}</> }
|
||||
render(<Countries />)
|
||||
expect(await screen.findByText('USA')).toBeInTheDocument()
|
||||
expect(await fetchSchoolHolidayRegionOptions('US')).toEqual([{ value: 'US-MANUAL-7', label: 'Seattle schools' }])
|
||||
expect(provider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps manual regions usable when a supported country provider fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'DE', name: 'Germany' }], regions: [{ ...region, country: 'DE', code: 'DE-MANUAL-7' }] })),
|
||||
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({}, { status: 502 })),
|
||||
)
|
||||
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-MANUAL-7', label: region.name }])
|
||||
})
|
||||
|
||||
it('renders manual dates with inclusive boundaries without touching leave entries', async () => {
|
||||
server.use(http.get('/api/school-holiday-catalog/regions/7/holidays/2026', () => HttpResponse.json(region.holidays)))
|
||||
useVacayStore.setState({
|
||||
selectedYear: 2026,
|
||||
plan: { id: 1, school_holidays_enabled: true, holidays_enabled: false, holidays_region: null, block_weekends: true, company_holidays_enabled: true, carry_over_enabled: false, holiday_calendars: [{ id: 1, plan_id: 1, region: region.code, label: 'School', color: '#a5f3fc', sort_order: 0, type: 'school_holiday' }] },
|
||||
entries: [{ plan_id: 1, user_id: 1, date: '2026-12-23', fraction: 1, kind: 'vacation' }],
|
||||
})
|
||||
const entries = useVacayStore.getState().entries
|
||||
await useVacayStore.getState().loadHolidays(2026)
|
||||
expect(useVacayStore.getState().holidays['2026-12-20']).toBeDefined()
|
||||
expect(useVacayStore.getState().holidays['2026-12-31']).toBeDefined()
|
||||
expect(useVacayStore.getState().entries).toBe(entries)
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSchoolHolidayCatalog } from './useSchoolHolidayCatalog'
|
||||
import SchoolHolidayRegionEditor, { holidayButtonClass, holidayInputClass } from './SchoolHolidayRegionEditor'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export default function SchoolHolidayCatalog() {
|
||||
return <ErrorBoundary boundaryId="school-holiday-catalog"><Catalog /></ErrorBoundary>
|
||||
}
|
||||
|
||||
function Catalog() {
|
||||
const { t } = useTranslation()
|
||||
const state = useSchoolHolidayCatalog()
|
||||
const [country, setCountry] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [code, setCode] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null)
|
||||
const selectedCountry = state.catalog.countries.find(item => item.code === country)?.code || state.catalog.countries[0]?.code || ''
|
||||
const regions = state.catalog.regions.filter(region => region.country === selectedCountry)
|
||||
const disabled = state.busy || state.loading || state.offline
|
||||
const addRegion = () => state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
|
||||
|
||||
return <section className="space-y-4 rounded-2xl border border-edge bg-surface-card p-4 sm:p-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('schoolCatalog.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('schoolCatalog.hint')}</p>
|
||||
</div>
|
||||
{state.offline && <p role="status" className="text-body text-warning">{t('schoolCatalog.offline')}</p>}
|
||||
{state.loading && <p role="status" className="text-body text-content-muted">{t('common.loading')}</p>}
|
||||
{state.error && !state.editor && <div role="alert" className="space-y-2 text-body text-danger">{state.error}<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={`${holidayButtonClass} block`}>{t('schoolCatalog.retry')}</button></div>}
|
||||
<fieldset disabled={disabled} className="space-y-4">
|
||||
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1 text-body"><span>{t('schoolCatalog.country')}</span>
|
||||
<CustomSelect value={selectedCountry} onChange={value => setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} />
|
||||
</div>
|
||||
<button type="button" disabled={adding} title={t('schoolCatalog.addCountry')} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`}><Plus size={18} /></button>
|
||||
<button type="button" disabled={regions.length > 0} title={t('schoolCatalog.deleteHint')} className={holidayButtonClass} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={18} /></button>
|
||||
</div>}
|
||||
{!adding && state.catalog.countries.length === 0 && <button type="button" onClick={() => setAdding(true)} className={`${holidayButtonClass} flex items-center gap-2`}><Plus size={16} />{t('schoolCatalog.addCountry')}</button>}
|
||||
{adding && <form className="space-y-3 rounded-xl border border-edge p-3" onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') }
|
||||
}}>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.country')}</span><input autoFocus required maxLength={100} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} /></label>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.code')}</span><input required pattern="[A-Z]{2}" maxLength={2} className={holidayInputClass} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" /></label>
|
||||
<div className="flex gap-2"><button type="submit" className={holidayButtonClass}>{t('common.save')}</button><button type="button" onClick={() => setAdding(false)} className={holidayButtonClass}>{t('common.cancel')}</button></div>
|
||||
</form>}
|
||||
{selectedCountry && <>
|
||||
{regions.length === 0 && <p className="text-body text-content-muted">{t('schoolCatalog.empty')}</p>}
|
||||
<ul className="divide-y divide-edge">{regions.map(region => <li key={region.id} className="flex items-center gap-2 py-2">
|
||||
<button type="button" className={`${holidayButtonClass} min-w-0 flex-1 text-left`} onClick={() => void state.openRegion(region.id)}>{region.name}</button>
|
||||
<button type="button" title={t('schoolCatalog.addRegion')} aria-label={t('schoolCatalog.addRegion')} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`} onClick={addRegion}><Plus size={18} /></button>
|
||||
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={holidayButtonClass} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={18} /></button>
|
||||
</li>)}</ul>
|
||||
{regions.length === 0 && <button type="button" className={`${holidayButtonClass} flex items-center gap-2`} onClick={addRegion}><Plus size={16} />{t('schoolCatalog.addRegion')}</button>}
|
||||
</>}
|
||||
</fieldset>
|
||||
{state.editor && <SchoolHolidayRegionEditor key={state.editor.id} region={state.editor} busy={state.busy} offline={state.offline} error={state.offline ? t('schoolCatalog.offline') : state.error} onClose={() => state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />}
|
||||
<ConfirmDialog isOpen={Boolean(remove)} onClose={() => setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} />
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Modal from '../shared/Modal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
|
||||
export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content'
|
||||
export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50'
|
||||
|
||||
export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: {
|
||||
region: SchoolHolidayRegionDetail
|
||||
busy: boolean
|
||||
offline?: boolean
|
||||
error: string
|
||||
onClose: () => void
|
||||
onSave: (body: SchoolHolidayRegionRequest) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const fieldId = useId()
|
||||
const [name, setName] = useState(region.name)
|
||||
const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index })))
|
||||
const [nextKey, setNextKey] = useState(holidays.length)
|
||||
const [discard, setDiscard] = useState(false)
|
||||
const [invalid, setInvalid] = useState(false)
|
||||
const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) }
|
||||
const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays)
|
||||
const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } }
|
||||
|
||||
return <>
|
||||
<Modal isOpen onClose={close} title={t('schoolCatalog.region')} size="xl">
|
||||
<form className="space-y-4" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
const parsed = schoolHolidayRegionRequestSchema.safeParse(body)
|
||||
setInvalid(!parsed.success)
|
||||
if (parsed.success) onSave(parsed.data)
|
||||
}}>
|
||||
<fieldset disabled={busy || offline} className="space-y-4">
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.region')}</span>
|
||||
<input autoFocus required maxLength={150} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<p className="text-caption text-content-muted">{t('schoolCatalog.periodHint')}</p>
|
||||
{holidays.map((holiday, index) => <div key={holiday.key} className="space-y-2 rounded-xl border border-edge p-3">
|
||||
<div className="space-y-1 text-body">
|
||||
<label htmlFor={`${fieldId}-holiday-${holiday.key}`}>{t('schoolCatalog.name')}</label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${holidayInputClass} min-w-0 flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
|
||||
<button type="button" className="flex shrink-0 items-center justify-center rounded-[10px] border border-edge px-[9px] text-content-faint transition-colors hover:border-[color:var(--text-faint)] hover:text-content disabled:opacity-50" aria-label={`${t('common.delete')} ${holiday.name || index + 1}`} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.start')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
|
||||
</div>
|
||||
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.end')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
<button type="button" disabled={holidays.length >= 500} className={`${holidayButtonClass} flex items-center gap-2`} onClick={() => {
|
||||
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
|
||||
}}><Plus size={16} />{t('schoolCatalog.addPeriod')}</button>
|
||||
{invalid && <p role="alert" className="text-body text-danger">{t('schoolCatalog.invalid')}</p>}
|
||||
{error && <p role="alert" className="text-body text-danger">{error}</p>}
|
||||
</fieldset>
|
||||
<div className="flex justify-end gap-2 border-t border-edge pt-4">
|
||||
<button type="button" className={holidayButtonClass} onClick={close}>{t('common.cancel')}</button>
|
||||
<button type="submit" disabled={busy || offline} aria-busy={busy} className="rounded-lg bg-accent px-4 py-2 text-body font-semibold text-accent-text disabled:opacity-50">{busy ? t('common.loading') : t('common.save')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<ConfirmDialog isOpen={discard} onClose={() => setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} />
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import { schoolHolidayCatalogRepo as catalogRepo } from '../../repo/schoolHolidayCatalogRepo'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
|
||||
export function useSchoolHolidayCatalog() {
|
||||
const { t } = useTranslation()
|
||||
const { offline } = useNetworkMode()
|
||||
const [catalog, setCatalog] = useState<SchoolHolidayCatalog>({ countries: [], regions: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [editor, setEditor] = useState<SchoolHolidayRegionDetail | null>(null)
|
||||
const mounted = useRef(true)
|
||||
const pending = useRef(false)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
catalogRepo.requireOnline()
|
||||
const catalog = await catalogRepo.catalog()
|
||||
if (mounted.current) setCatalog(catalog)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (offline) { setLoading(false); return }
|
||||
setLoading(true)
|
||||
catalogRepo.catalog().then(catalog => {
|
||||
if (!cancelled) { setCatalog(catalog); setError('') }
|
||||
}).catch(() => { if (!cancelled) setError(t('common.error')) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [offline, t])
|
||||
|
||||
async function run(action: () => Promise<unknown>, after?: () => void) {
|
||||
if (pending.current) return false
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
catalogRepo.requireOnline()
|
||||
await action()
|
||||
if (mounted.current) after?.()
|
||||
try {
|
||||
await reload()
|
||||
} catch {
|
||||
if (mounted.current) setError(t('schoolCatalog.refreshError'))
|
||||
}
|
||||
return true
|
||||
} catch (failure) {
|
||||
if (mounted.current) {
|
||||
const message = failure instanceof Error ? failure.message : ''
|
||||
const http = failure as { response?: { data?: { error?: string } } }
|
||||
setError(http.response?.data?.error || message || t('common.error'))
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
pending.current = false
|
||||
if (mounted.current) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
catalog, loading, busy, error, offline, editor, setEditor,
|
||||
refresh: () => run(async () => {}),
|
||||
createCountry: (country: SchoolHolidayCountryRequest) => run(() => catalogRepo.createCountry(country)),
|
||||
deleteCountry: (code: string) => run(() => catalogRepo.deleteCountry(code)),
|
||||
openRegion: (id: number) => run(async () => {
|
||||
const region = await catalogRepo.region(id)
|
||||
if (mounted.current) setEditor(region)
|
||||
}),
|
||||
saveRegion: (country: string, id: number, body: SchoolHolidayRegionRequest) => run(
|
||||
() => id ? catalogRepo.updateRegion(id, body) : catalogRepo.createRegion(country, body),
|
||||
() => setEditor(null),
|
||||
),
|
||||
deleteRegion: (id: number, revision: number) => run(() => catalogRepo.deleteRegion(id, revision)),
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import apiClient from '../../api/client'
|
||||
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions'
|
||||
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
|
||||
import { useSchoolHolidayCountries } from './useSchoolHolidayCountries'
|
||||
import { windowMonths } from '../../vacay/yearWindow'
|
||||
import type { VacayHolidayCalendar, VacayYearSettings } from '../../types'
|
||||
|
||||
@@ -38,12 +38,13 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
}).catch(() => {})
|
||||
}, [language])
|
||||
|
||||
const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries()
|
||||
|
||||
if (!plan) return null
|
||||
|
||||
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
|
||||
const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday')
|
||||
const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday')
|
||||
const schoolHolidayCountries = countries.filter(country => country.value in SCHOOL_HOLIDAY_COUNTRY_CONFIG)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -216,6 +217,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
value={plan.school_holidays_enabled}
|
||||
onChange={() => toggle('school_holidays_enabled')}
|
||||
/>
|
||||
{schoolCountryError && <p role="alert" className="text-caption text-danger">{schoolCountryError}</p>}
|
||||
{plan.school_holidays_enabled && (
|
||||
<div className="ml-7 mt-2 space-y-2">
|
||||
{schoolHolidayCalendars.length === 0 && (
|
||||
@@ -444,18 +446,18 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
|
||||
* apart from "this country has no regions"; on the list alone both are empty.
|
||||
*/
|
||||
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
|
||||
const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[] }>({ country: '', options: [] })
|
||||
const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[]; failed?: boolean }>({ country: '', options: [] })
|
||||
|
||||
useEffect(() => {
|
||||
if (!country) return
|
||||
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
|
||||
let stale = false
|
||||
load(country).then(options => { if (!stale) setLoaded({ country, options }) })
|
||||
load(country).then(options => { if (!stale) setLoaded({ country, options }) }).catch(() => { if (!stale) setLoaded({ country, options: [], failed: true }) })
|
||||
return () => { stale = true }
|
||||
}, [calendarType, country])
|
||||
|
||||
const ready = loaded.country === country
|
||||
return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready }
|
||||
return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready }
|
||||
}
|
||||
|
||||
// ── Existing calendar row (inline edit) ──────────────────────────────────────
|
||||
@@ -474,7 +476,7 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
|
||||
const [baseRegion] = cal.region.split('|')
|
||||
const selectedCountry = baseRegion.split('-')[0]
|
||||
const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
|
||||
const { regions } = useRegionOptions(selectedCountry, calendarType)
|
||||
const { regions, regionError } = useRegionOptions(selectedCountry, calendarType)
|
||||
|
||||
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
|
||||
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
|
||||
@@ -516,9 +518,10 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
|
||||
placeholder={t('vacay.selectCountry')}
|
||||
searchable
|
||||
/>
|
||||
{regionError && <p role="alert" className="text-caption text-danger">{t('schoolCatalog.loadError')}</p>}
|
||||
{regions.length > 0 && (
|
||||
<CustomSelect
|
||||
value={selectedRegion}
|
||||
value={selectedRegion || (regions.some(option => option.value === selectedCountry) ? selectedCountry : '')}
|
||||
onChange={v => onUpdate({ region: String(v) })}
|
||||
options={regions}
|
||||
placeholder={t('vacay.selectRegion')}
|
||||
@@ -555,12 +558,12 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
|
||||
const [baseRegion] = region.split('|')
|
||||
const selectedCountry = baseRegion.split('-')[0] || ''
|
||||
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
|
||||
const { regions, loadingRegions } = useRegionOptions(selectedCountry, calendarType)
|
||||
const { regions, loadingRegions, regionError } = useRegionOptions(selectedCountry, calendarType)
|
||||
|
||||
// Adding is blocked while the region list is on its way: an empty list would
|
||||
// otherwise pass as "this country needs no region" and create a calendar that
|
||||
// draws nothing (#1813). The button shows a spinner so the wait is visible.
|
||||
const canAdd = Boolean(selectedCountry) && !loadingRegions && (regions.length === 0 || selectedRegion !== '')
|
||||
const canAdd = Boolean(selectedCountry) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region))
|
||||
|
||||
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
@@ -597,9 +600,10 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
|
||||
placeholder={t('vacay.selectCountry')}
|
||||
searchable
|
||||
/>
|
||||
{regionError && <p role="alert" className="text-caption text-danger">{t('schoolCatalog.loadError')}</p>}
|
||||
{regions.length > 0 && (
|
||||
<CustomSelect
|
||||
value={selectedRegion}
|
||||
value={selectedRegion || (regions.some(option => option.value === selectedCountry) ? selectedCountry : '')}
|
||||
onChange={v => setRegion(String(v))}
|
||||
options={regions}
|
||||
placeholder={t('vacay.selectRegion')}
|
||||
|
||||
@@ -112,10 +112,10 @@ describe('fetchSchoolHolidayRegionOptions', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-COMP-VCYREG-008: a failing region request degrades to no regions', async () => {
|
||||
it('FE-COMP-VCYREG-008: a failing request cannot masquerade as a country without regions', async () => {
|
||||
server.use(http.get('/api/addons/vacay/school-holidays/regions/:country', () =>
|
||||
HttpResponse.json({ error: 'down' }, { status: 500 })))
|
||||
|
||||
expect(await fetchSchoolHolidayRegionOptions('NL')).toEqual([])
|
||||
await expect(fetchSchoolHolidayRegionOptions('NL')).rejects.toThrow('could not be loaded')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo'
|
||||
import apiClient from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
|
||||
@@ -77,23 +78,32 @@ function activeLanguage(): string {
|
||||
|
||||
export async function fetchSchoolHolidayRegionOptions(country: string, lang = activeLanguage()): Promise<{ value: string; label: string }[]> {
|
||||
const config = SCHOOL_HOLIDAY_COUNTRY_CONFIG[country]
|
||||
if (!config || config.strategy === 'country') return []
|
||||
const catalog = await schoolHolidayCatalogRepo.catalog().catch(error => {
|
||||
if (!config) throw error
|
||||
return { countries: [], regions: [] }
|
||||
})
|
||||
const manual = catalog.regions.filter(region => region.country === country).map(region => ({ value: region.code, label: region.name }))
|
||||
if (!config) return manual
|
||||
if (config.strategy === 'country') {
|
||||
return manual.length ? [{ value: country, label: country }, ...manual] : []
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`)
|
||||
if (config.strategy === 'groups') {
|
||||
return flattenOptions(r.data.groups, lang)
|
||||
return [...manual, ...flattenOptions(r.data.groups, lang)
|
||||
.map(opt => ({
|
||||
value: `${country}|group:${opt.value}`,
|
||||
label: opt.label,
|
||||
}))
|
||||
}))]
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
return flattenOptions(r.data.subdivisions, lang)
|
||||
.map(opt => ({ value: opt.value, label: opt.label }))
|
||||
return [...manual, ...flattenOptions(r.data.subdivisions, lang)
|
||||
.map(opt => ({ value: opt.value, label: opt.label }))]
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
} catch {
|
||||
return []
|
||||
if (manual.length) return manual
|
||||
throw new Error('School holiday regions could not be loaded')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo'
|
||||
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
|
||||
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||
|
||||
export function useSchoolHolidayCountries(enabled = true) {
|
||||
const { language, t } = useTranslation()
|
||||
const [manual, setManual] = useState<{ code: string; name: string }[]>([])
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
let stale = false
|
||||
schoolHolidayCatalogRepo.catalog().then(catalog => {
|
||||
if (!stale) {
|
||||
setManual(catalog.countries.filter(country => catalog.regions.some(region => region.country === country.code)))
|
||||
setError('')
|
||||
}
|
||||
}).catch(() => { if (!stale) setError(t('schoolCatalog.loadError')) })
|
||||
return () => { stale = true }
|
||||
}, [enabled, t])
|
||||
const names = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
|
||||
const countries = new Map(Object.keys(SCHOOL_HOLIDAY_COUNTRY_CONFIG).map(code => [code, names.of(code) || code]))
|
||||
for (const country of manual) if (!countries.has(country.code)) countries.set(country.code, country.name)
|
||||
return { countries: [...countries].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label)), error }
|
||||
}
|
||||
Reference in New Issue
Block a user