diff --git a/CLAUDE.md b/CLAUDE.md index 5b621b4..cf69dfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,6 +290,33 @@ på dette: (API'ets maksimum). Rigtig pagination er udskudt til Fase 2, hvis listen en dag vokser forbi 500. +**11b del 2** (runder + vinliste) bygger videre på samme +infrastruktur — ingen backend-ændringer var nødvendige, al CRUD +fandtes allerede (opgave 6): +- **`RoundsPage`** (liste + opret) og **`RoundDetailPage`** + (`/runder/:roundId` — redigér alle felter, status-overgange, slet, + "kopiér denne runde", samt en nestet vinliste-sektion: + `GET /wine-offerings` + `GET /wine-categories` joines client-side + for kategorinavn, da `WineOfferingPublic` kun har `category_id`). + `409`-fejl fra rundens dato-constraint (påkrævet så snart status + ikke er `draft`) vises direkte som API'ets fejlbesked — ingen + duplikeret validering i frontend. +- **`dateUtils.ts`** — konverterer mellem backend'ens ISO-datoer og + ``/`type="datetime-local">`. `Åbner` og + `Bestillingsfrist` bruger kun dato (ingen tidspunkt — fundet under + brugertest); `Afhentning` beholder tidspunkt (afhentning sker på et + konkret klokkeslæt). +- **Lukkede runder kan nu også slettes fra UI'et**, når eleveret + (fundet under brugertest — kun kladde-sletning var understøttet + først): samme mønster som deltager-hård-sletning i del 1 + (`elevatedToken` fra `AuthContext` i stedet for det almindelige + token). Åbne runder kan aldrig slettes (håndhævet server-side, + ingen UI-vej udenom). +- **Bevidst udeladt** (matcher 8/11-opdelingen): annoncér-knap og + mail-skabeloner (del 3); link til opgave 10's afhentningsliste + (autentificeret HTML-endpoint — kræver en særskilt løsning, da et + almindeligt `` ikke sender `Authorization`-headeren). + ## Mail-events 1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret** (opgave 7a-c): skabelon pr. rute (`MailTemplate`, @@ -322,7 +349,7 @@ logges/advares om i stedet. ## Fase 1 — nuværende scope -**Færdige opgaver (1-10, 11a, 11b del 1):** +**Færdige opgaver (1-10, 11a, 11b del 1-2):** 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding 2. ✅ Datamodellerne (Organization/Route-hierarki) 3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, @@ -375,8 +402,8 @@ logges/advares om i stedet. "Admin-UI-arkitektur" ovenfor. - **11b**: Deltagere, runder/vinliste, annoncering — delt i tre (se "Admin-UI-arkitektur" ovenfor): **del 1** ✅ - (frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder + - vinliste, ikke bygget), **del 3** (mail-skabelon-editor + + (frontend-infrastruktur + Deltagere-CRUD), **del 2** ✅ + (runder + vinliste), **del 3** (mail-skabelon-editor + annoncér-knap, ikke bygget). - **11c**: Ordreoversigt/markér betalt — ikke bygget. diff --git a/admin-ui/src/App.css b/admin-ui/src/App.css index 9e8b335..a0cf3aa 100644 --- a/admin-ui/src/App.css +++ b/admin-ui/src/App.css @@ -132,3 +132,35 @@ td { padding: 6px 12px; cursor: pointer; } + +.stacked-form { + max-width: 480px; + margin: 16px 0; +} + +.stacked-form label { + display: block; + font-weight: 600; + margin: 12px 0 4px; +} + +.stacked-form input, +.stacked-form select, +.stacked-form textarea { + display: block; + width: 100%; + padding: 8px; + font-size: 1rem; + box-sizing: border-box; + font-family: inherit; +} + +.stacked-form textarea { + min-height: 80px; +} + +.stacked-form button { + margin: 16px 8px 0 0; + padding: 8px 16px; + cursor: pointer; +} diff --git a/admin-ui/src/App.tsx b/admin-ui/src/App.tsx index b0d842b..ef5a41b 100644 --- a/admin-ui/src/App.tsx +++ b/admin-ui/src/App.tsx @@ -3,6 +3,8 @@ import { AuthProvider, useAuth } from './AuthContext' import { Layout } from './Layout' import { LoginPage } from './pages/LoginPage' import { ParticipantsPage } from './pages/ParticipantsPage' +import { RoundDetailPage } from './pages/RoundDetailPage' +import { RoundsPage } from './pages/RoundsPage' import './App.css' function AppRoutes() { @@ -15,6 +17,8 @@ function AppRoutes() { }> } /> + } /> + } /> } /> diff --git a/admin-ui/src/Layout.tsx b/admin-ui/src/Layout.tsx index ca96be0..376d50b 100644 --- a/admin-ui/src/Layout.tsx +++ b/admin-ui/src/Layout.tsx @@ -22,6 +22,7 @@ export function Layout() { Fælles Vinindkøb — Admin
Deltagere + Runder {currentUser?.is_superadmin && (isElevated ? ( diff --git a/admin-ui/src/dateUtils.ts b/admin-ui/src/dateUtils.ts new file mode 100644 index 0000000..fce3822 --- /dev/null +++ b/admin-ui/src/dateUtils.ts @@ -0,0 +1,29 @@ +/** Konverterer en ISO-dato (UTC, fra backend) til værdien et + * forventer, i browserens lokale tid. */ +export function toDatetimeLocalValue(iso: string | null | undefined): string { + if (!iso) return '' + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +/** Konverterer en -værdi (browserens lokale tid) + * til en ISO-streng (UTC) API'et kan bruge, eller null hvis feltet er tomt. */ +export function fromDatetimeLocalValue(value: string): string | null { + return value ? new Date(value).toISOString() : null +} + +/** Samme som toDatetimeLocalValue, men kun dato (til felter uden brug for et tidspunkt). */ +export function toDateValue(iso: string | null | undefined): string { + if (!iso) return '' + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +/** Konverterer en -værdi (lokal dato, midnat) til en ISO-streng (UTC). */ +export function fromDateValue(value: string): string | null { + if (!value) return null + const [year, month, day] = value.split('-').map(Number) + return new Date(year, month - 1, day).toISOString() +} diff --git a/admin-ui/src/pages/RoundDetailPage.tsx b/admin-ui/src/pages/RoundDetailPage.tsx new file mode 100644 index 0000000..b66f201 --- /dev/null +++ b/admin-ui/src/pages/RoundDetailPage.tsx @@ -0,0 +1,420 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { apiFetch, ApiError } from '../api' +import { useAuth } from '../AuthContext' +import { fromDateValue, fromDatetimeLocalValue, toDateValue, toDatetimeLocalValue } from '../dateUtils' + +interface PurchaseRound { + id: number + name: string + status: 'draft' | 'open' | 'closed' + opens_at: string | null + order_deadline_at: string | null + pickup_at: string | null + eur_dkk_rate: string | null + intro_text: string | null + pickup_info_text: string | null + route_id: number +} + +interface WineCategory { + id: number + name: string + sort_order: number +} + +interface WineOffering { + id: number + name: string + price: string + is_organic: boolean + category_id: number + purchase_round_id: number +} + +interface RoundFormState { + name: string + status: PurchaseRound['status'] + opens_at: string + order_deadline_at: string + pickup_at: string + eur_dkk_rate: string + intro_text: string + pickup_info_text: string +} + +function toFormState(round: PurchaseRound): RoundFormState { + return { + name: round.name, + status: round.status, + opens_at: toDateValue(round.opens_at), + order_deadline_at: toDateValue(round.order_deadline_at), + pickup_at: toDatetimeLocalValue(round.pickup_at), + eur_dkk_rate: round.eur_dkk_rate ?? '', + intro_text: round.intro_text ?? '', + pickup_info_text: round.pickup_info_text ?? '', + } +} + +interface NewWineForm { + name: string + price: string + is_organic: boolean + category_id: string +} + +const emptyWineForm: NewWineForm = { name: '', price: '', is_organic: false, category_id: '' } + +export function RoundDetailPage() { + const { roundId } = useParams<{ roundId: string }>() + const navigate = useNavigate() + const { currentUser, isElevated, elevatedToken } = useAuth() + const [round, setRound] = useState(null) + const [form, setForm] = useState(null) + const [categories, setCategories] = useState([]) + const [wines, setWines] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [newWine, setNewWine] = useState(emptyWineForm) + const [editingWineId, setEditingWineId] = useState(null) + const [editWineForm, setEditWineForm] = useState(emptyWineForm) + + async function loadWines(forRoundId: string) { + const data = await apiFetch(`/wine-offerings?purchase_round_id=${forRoundId}&limit=500`) + setWines(data) + } + + async function loadAll() { + if (!roundId) return + setLoading(true) + setError(null) + try { + const [roundData, categoryData] = await Promise.all([ + apiFetch(`/purchase-rounds/${roundId}`), + apiFetch('/wine-categories'), + ]) + setRound(roundData) + setForm(toFormState(roundData)) + setCategories(categoryData) + await loadWines(roundId) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke hente runden') + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadAll() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roundId]) + + async function handleSave(event: FormEvent) { + event.preventDefault() + if (!roundId || !form) return + setError(null) + try { + const updated = await apiFetch(`/purchase-rounds/${roundId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: form.name, + status: form.status, + opens_at: fromDateValue(form.opens_at), + order_deadline_at: fromDateValue(form.order_deadline_at), + pickup_at: fromDatetimeLocalValue(form.pickup_at), + eur_dkk_rate: form.eur_dkk_rate ? form.eur_dkk_rate : null, + intro_text: form.intro_text || null, + pickup_info_text: form.pickup_info_text || null, + }), + }) + setRound(updated) + setForm(toFormState(updated)) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme runden') + } + } + + async function handleDelete() { + if (!roundId || !round) return + if (!window.confirm(`Slet "${round.name}"?`)) return + setError(null) + try { + const token = round.status === 'closed' ? elevatedToken : undefined + await apiFetch(`/purchase-rounds/${roundId}`, { method: 'DELETE' }, token) + navigate('/runder') + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke slette runden') + } + } + + async function handleCopy() { + if (!roundId) return + setError(null) + try { + const copy = await apiFetch(`/purchase-rounds/${roundId}/copy`, { method: 'POST' }) + navigate(`/runder/${copy.id}`) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke kopiere runden') + } + } + + async function handleCreateWine(event: FormEvent) { + event.preventDefault() + if (!roundId) return + setError(null) + try { + await apiFetch('/wine-offerings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newWine.name, + price: newWine.price, + is_organic: newWine.is_organic, + category_id: Number(newWine.category_id), + purchase_round_id: Number(roundId), + }), + }) + setNewWine(emptyWineForm) + await loadWines(roundId) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke oprette vin') + } + } + + function startEditWine(wine: WineOffering) { + setEditingWineId(wine.id) + setEditWineForm({ + name: wine.name, + price: wine.price, + is_organic: wine.is_organic, + category_id: String(wine.category_id), + }) + } + + async function handleSaveWine(event: FormEvent) { + event.preventDefault() + if (!roundId || editingWineId === null) return + setError(null) + try { + await apiFetch(`/wine-offerings/${editingWineId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: editWineForm.name, + price: editWineForm.price, + is_organic: editWineForm.is_organic, + category_id: Number(editWineForm.category_id), + }), + }) + setEditingWineId(null) + await loadWines(roundId) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme vin') + } + } + + async function handleDeleteWine(wine: WineOffering) { + if (!roundId) return + if (!window.confirm(`Slet "${wine.name}"?`)) return + setError(null) + try { + await apiFetch(`/wine-offerings/${wine.id}`, { method: 'DELETE' }) + await loadWines(roundId) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke slette vin') + } + } + + if (loading) return

Indlæser...

+ if (!round || !form) return

{error ?? 'Runden blev ikke fundet'}

+ + const categoryName = (categoryId: number) => categories.find((c) => c.id === categoryId)?.name ?? '—' + + return ( +
+

{round.name}

+ {error &&

{error}

} + +
+ + + + + + +