diff --git a/CLAUDE.md b/CLAUDE.md index cf69dfb..570dacb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,12 +203,11 @@ offentlige side, egen `Jinja2Templates`-instans i Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a** (scaffold + hosting-mekanisme, ✅). **11b** viste sig under afklaring at være for stort til én omgang og er selv delt i tre dele, alle -stadig under "11b" (ingen ny bogstav-opdeling): **del 1** (delt -frontend-infrastruktur + Deltagere-CRUD, ✅, se nedenfor), **del 2** -(runder + vinliste, ikke bygget) og **del 3** (mail-skabelon-editor + -annoncér-knap, ikke bygget — "annoncér" er reelt ubrugelig uden en -måde at oprette en `round_announced`-skabelon på). **11c** -(ordreoversigt/markér betalt) er heller ikke bygget endnu. +stadig under "11b" (ingen ny bogstav-opdeling), **alle tre nu ✅**: +**del 1** (delt frontend-infrastruktur + Deltagere-CRUD), **del 2** +(runder + vinliste), **del 3** (mail-skabelon-editor + +annoncér-knap). **11c** (ordreoversigt/markér betalt) er ikke bygget +endnu. **Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript, almindelig CSS — ingen framework) serveres af **samme FastAPI-app** @@ -313,9 +312,34 @@ fandtes allerede (opgave 6): 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-skabeloner (del 3, se nedenfor); link til opgave 10's + afhentningsliste (autentificeret HTML-endpoint — kræver en særskilt + løsning, da et almindeligt `` ikke sender + `Authorization`-headeren). + +**11b del 3** (mail-skabeloner + annoncér) afslutter 11b. Ingen +backend-ændringer nødvendige — opgave 7a's `MailTemplate`-CRUD og +7b's `/announce`-endpoint fandtes allerede, blot uden noget UI: +- **`TemplatesPage`** (`/skabeloner`) — én sektion pr. `event_type` + (Runde åbnet/Ordre bekræftet/Betaling bekræftet), hver med Emne + + Indhold (rå HTML i en ``, bevidst ingen rich-text-editor- + afhængighed) og et hint om hvilke `{{variabler}}` der er + tilgængelige for netop den event-type (genlæst fra + `send_order_event_mail`/`announce_purchase_round`). Opret/gem/slet + pr. sektion — ingen elevation nødvendig (backend kræver kun + `CurrentUser`). +- **"Annoncér runde"-knap** på `RoundDetailPage`, vist når + `status === 'open'`. Sender rigtig mail til **alle aktive + deltagere på ruten** og er ikke idempotent — derfor et eksplicit + `window.confirm(...)` først. Viser resultatet + (sendt/fejlet/deltagere i alt) direkte på siden. +- **Rute 4 har nu rigtige mail-skabeloner for alle tre event-typer** + (oprettet af brugeren under browser-test af editoren — det tidligere + udskudte punkt er dermed løst som en sideeffekt). "Annoncér" er + fortsat kun afprøvet i en fuldstændig isoleret sandbox (en + midlertidig separat rute, ikke rute 4) for at undgå at sende en + utilsigtet rigtig annoncering til de 308 rigtige deltagere under + udvikling. ## Mail-events 1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret** @@ -349,7 +373,7 @@ logges/advares om i stedet. ## Fase 1 — nuværende scope -**Færdige opgaver (1-10, 11a, 11b del 1-2):** +**Færdige opgaver (1-10, 11a, 11b):** 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding 2. ✅ Datamodellerne (Organization/Route-hierarki) 3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, @@ -400,11 +424,10 @@ logges/advares om i stedet. 11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre: - **11a**: ✅ Scaffold + domæne-baseret hosting-mekanisme — se "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), **del 3** (mail-skabelon-editor + - annoncér-knap, ikke bygget). + - **11b**: ✅ Deltagere, runder/vinliste, annoncering — delt i tre + (se "Admin-UI-arkitektur" ovenfor), alle færdige: **del 1** + (frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder + + vinliste), **del 3** (mail-skabelon-editor + annoncér-knap). - **11c**: Ordreoversigt/markér betalt — ikke bygget. **Resterende opgaver:** diff --git a/admin-ui/src/App.tsx b/admin-ui/src/App.tsx index ef5a41b..5b548cc 100644 --- a/admin-ui/src/App.tsx +++ b/admin-ui/src/App.tsx @@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage' import { ParticipantsPage } from './pages/ParticipantsPage' import { RoundDetailPage } from './pages/RoundDetailPage' import { RoundsPage } from './pages/RoundsPage' +import { TemplatesPage } from './pages/TemplatesPage' import './App.css' function AppRoutes() { @@ -19,6 +20,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/admin-ui/src/Layout.tsx b/admin-ui/src/Layout.tsx index 376d50b..9a370eb 100644 --- a/admin-ui/src/Layout.tsx +++ b/admin-ui/src/Layout.tsx @@ -23,6 +23,7 @@ export function Layout() { Deltagere Runder + Skabeloner {currentUser?.is_superadmin && (isElevated ? ( diff --git a/admin-ui/src/pages/RoundDetailPage.tsx b/admin-ui/src/pages/RoundDetailPage.tsx index b66f201..2e6b0d9 100644 --- a/admin-ui/src/pages/RoundDetailPage.tsx +++ b/admin-ui/src/pages/RoundDetailPage.tsx @@ -65,6 +65,12 @@ interface NewWineForm { const emptyWineForm: NewWineForm = { name: '', price: '', is_organic: false, category_id: '' } +interface AnnounceResult { + sent: number + failed: number + total_participants: number +} + export function RoundDetailPage() { const { roundId } = useParams<{ roundId: string }>() const navigate = useNavigate() @@ -75,6 +81,7 @@ export function RoundDetailPage() { const [wines, setWines] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [announceResult, setAnnounceResult] = useState(null) const [newWine, setNewWine] = useState(emptyWineForm) const [editingWineId, setEditingWineId] = useState(null) const [editWineForm, setEditWineForm] = useState(emptyWineForm) @@ -159,6 +166,24 @@ export function RoundDetailPage() { } } + async function handleAnnounce() { + if (!roundId) return + if ( + !window.confirm( + 'Send "runde åbnet"-mail til alle aktive deltagere på ruten? Dette kan ikke fortrydes, og sender igen til alle hvis du klikker mere end én gang.', + ) + ) + return + setError(null) + setAnnounceResult(null) + try { + const result = await apiFetch(`/purchase-rounds/${roundId}/announce`, { method: 'POST' }) + setAnnounceResult(result) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke annoncere runden') + } + } + async function handleCreateWine(event: FormEvent) { event.preventDefault() if (!roundId) return @@ -296,6 +321,11 @@ export function RoundDetailPage() { Kopiér denne runde + {round.status === 'open' && ( + + Annoncér runde + + )} {(round.status === 'draft' || (round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && ( Slet @@ -305,6 +335,12 @@ export function RoundDetailPage() { Forhøj dine rettigheder (knap øverst) for at kunne slette en lukket runde. )} + {announceResult && ( + + Sendt: {announceResult.sent}, fejlet: {announceResult.failed}, deltagere i alt:{' '} + {announceResult.total_participants} + + )} Vinliste diff --git a/admin-ui/src/pages/TemplatesPage.tsx b/admin-ui/src/pages/TemplatesPage.tsx new file mode 100644 index 0000000..0154d9e --- /dev/null +++ b/admin-ui/src/pages/TemplatesPage.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { apiFetch, ApiError } from '../api' + +interface Route { + id: number + name: string +} + +type EventType = 'round_announced' | 'order_confirmed' | 'payment_confirmed' + +interface MailTemplate { + id: number + event_type: EventType + subject: string + body_html: string + route_id: number +} + +const ORDER_MAIL_VARIABLES = [ + 'participant_name', + 'route_name', + 'round_name', + 'order_number', + 'order_deadline_at', + 'pickup_at', + 'pickup_info_text', + 'order_lines_html', + 'order_total_eur', + 'order_total_dkk', +] + +const EVENT_TYPES: { value: EventType; label: string; variables: string[] }[] = [ + { + value: 'round_announced', + label: 'Runde åbnet', + variables: [ + 'participant_name', + 'route_name', + 'round_name', + 'opens_at', + 'order_deadline_at', + 'pickup_at', + 'intro_text', + 'pickup_info_text', + ], + }, + { value: 'order_confirmed', label: 'Ordre bekræftet', variables: ORDER_MAIL_VARIABLES }, + { value: 'payment_confirmed', label: 'Betaling bekræftet', variables: ORDER_MAIL_VARIABLES }, +] + +function TemplateSection({ + eventType, + label, + variables, + template, + routeId, + onChange, +}: { + eventType: EventType + label: string + variables: string[] + template: MailTemplate | undefined + routeId: number + onChange: () => Promise +}) { + const [subject, setSubject] = useState(template?.subject ?? '') + const [bodyHtml, setBodyHtml] = useState(template?.body_html ?? '') + const [error, setError] = useState(null) + + useEffect(() => { + setSubject(template?.subject ?? '') + setBodyHtml(template?.body_html ?? '') + }, [template]) + + async function handleSave(event: FormEvent) { + event.preventDefault() + setError(null) + try { + if (template) { + await apiFetch(`/mail-templates/${template.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subject, body_html: bodyHtml }), + }) + } else { + await apiFetch('/mail-templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event_type: eventType, subject, body_html: bodyHtml, route_id: routeId }), + }) + } + await onChange() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme skabelonen') + } + } + + async function handleDelete() { + if (!template) return + if (!window.confirm(`Slet skabelonen for "${label}"?`)) return + setError(null) + try { + await apiFetch(`/mail-templates/${template.id}`, { method: 'DELETE' }) + await onChange() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke slette skabelonen') + } + } + + return ( + + {label} + Tilgængelige variabler: {variables.map((v) => `{{${v}}}`).join(', ')} + + + Emne + setSubject(e.target.value)} required /> + + + Indhold (HTML) + setBodyHtml(e.target.value)} rows={8} required /> + + {template ? 'Gem' : 'Opret'} + {template && ( + + Slet + + )} + + {error && {error}} + + ) +} + +export function TemplatesPage() { + const [routeId, setRouteId] = useState(null) + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + async function loadTemplates(forRouteId: number) { + const data = await apiFetch(`/mail-templates?route_id=${forRouteId}`) + setTemplates(data) + } + + useEffect(() => { + apiFetch('/routes') + .then(async (routes) => { + if (routes.length === 0) { + setError('Ingen ruter fundet for din organisation') + setLoading(false) + return + } + setRouteId(routes[0].id) + await loadTemplates(routes[0].id) + }) + .catch((err) => { + setError(err instanceof ApiError ? err.message : 'Kunne ikke hente skabeloner') + }) + .finally(() => setLoading(false)) + }, []) + + if (loading) return Indlæser... + if (routeId === null) return {error} + + return ( + + Mail-skabeloner + {error && {error}} + {EVENT_TYPES.map(({ value, label, variables }) => ( + t.event_type === value)} + routeId={routeId} + onChange={() => loadTemplates(routeId)} + /> + ))} + + ) +}
Forhøj dine rettigheder (knap øverst) for at kunne slette en lukket runde.
+ Sendt: {announceResult.sent}, fejlet: {announceResult.failed}, deltagere i alt:{' '} + {announceResult.total_participants} +
Tilgængelige variabler: {variables.map((v) => `{{${v}}}`).join(', ')}
{error}
Indlæser...