OrdersPage (/runder/:roundId/ordrer, linket fra RoundsPage og
RoundDetailPage) — en to-niveaus harmonika designet af brugeren:
øverste fold viser rundens flasketotal, total og udestående beløb
(sum af ordrer der endnu ikke er betalt), foldet ud til rundens
samlede, kategori-grupperede vinliste. Derunder én fold pr. ordre
(sorteret efter order_number, rød/grøn kant for betalt/ubetalt-status),
foldet ud til "Markér betalt" (POST /orders/{id}/mark-paid) + ordrens
egen kategori-grupperede vinliste. Ingen backend-ændringer nødvendige
— alt data kommer fra opgave 9's eksisterende GET /orders, al
aggregering sker client-side.
Verificeret i en fuldstændig isoleret sandbox (midlertidig separat
rute) for slet ikke at røre de 308 rigtige deltageres data.
To fund fra brugertest, begge rettet:
1. Beløb blev vist i EUR — vises nu i DKK via rundens eur_dkk_rate
(falder tilbage til EUR hvis runden ikke har en kurs), samme
omregningsprincip som den offentlige side/kvitteringsmailen.
2. Vinlisterne manglede kategori-gruppering (kun den samlede liste
havde det først) og viste antal efter vinnavn i stedet for før —
begge lister er nu kategori-grupperede med antal først.
Dette afslutter opgave 11 (hele admin-UI'et).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TemplatesPage (/skabeloner) — én sektion pr. event_type
(round_announced/order_confirmed/payment_confirmed), hver med
opret/gem/slet og et hint om hvilke {{variabler}} der er tilgængelige
for netop den mailtype. Ingen backend-ændringer nødvendige, al CRUD
fandtes allerede (opgave 7a).
"Annoncér runde"-knap på RoundDetailPage (kun ved status "open"), med
et eksplicit confirm() da handlingen sender rigtig mail til alle
aktive deltagere på ruten og ikke er idempotent. Kalder det
eksisterende /announce-endpoint (opgave 7b) og viser resultatet.
Verificeret i en fuldstændig isoleret sandbox (midlertidig separat
rute) for at undgå en utilsigtet rigtig annoncering til de 308
rigtige deltagere under udvikling. Brugeren oprettede under
browser-test rigtige mail-skabeloner for alle tre event-typer på
rute 4 — det tidligere udskudte punkt er dermed løst.
Dette afslutter opgave 11b (alle tre dele).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RoundsPage (liste + opret) og RoundDetailPage (/runder/:roundId —
redigér, status-overgange, slet, "kopiér denne runde", nestet
vinliste-CRUD). Ingen backend-ændringer nødvendige, al CRUD fandtes
allerede (opgave 6). 409-fejl fra rundens dato-constraint vises
direkte som API'ets fejlbesked i stedet for at duplikere reglen i
frontend.
To fund fra brugertest, begge rettet:
1. Åbningsdato og bestillingsfrist krævede et tidspunkt
(datetime-local) uden reelt behov — skiftet til rene datofelter
(dateUtils.ts). Afhentningstidspunktet beholder klokkeslæt.
2. Kun kladde-runder kunne slettes fra UI'et. Lukkede runder kan nu
også slettes, når eleveret — samme elevations-mønster som
deltager-hård-sletning i del 1 (elevatedToken fra AuthContext).
Åbne runder kan fortsat aldrig slettes (håndhævet server-side).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deler 11b op i tre dele under afklaring (runder/vinliste og
mail-skabeloner/annoncering er egne skærme). Denne del bygger den
delte infrastruktur alle senere admin-skærme skal bruge:
- api.ts: autentificeret fetch-wrapper + elevate()/decodeJwtExpiry().
- AuthContext.tsx: session overlever nu en sideopdatering (GET
/auth/me ved appstart). Holder en eksplicit "eleveret
session"-tilstand — react-router-dom + Layout (navbar) +
ParticipantsPage (liste/opret/redigér/hård-sletning).
- Ny GET /routes (minimal, kun læsning, org-scoped) — UI'et skal
kende rutens id for at oprette deltagere; ikke en rute-
indstillings-skærm (den er bevidst udskudt).
To fund fra brugertest, begge rettet:
1. "Slet permanent" krævede kun et klik + en browser-confirm(), og
elevered sig selv usynligt i baggrunden ved hvert klik — det
underminerede hele pointen med en bevidst elevations-handling.
Elevation er nu en synlig, separat handling ("Forhøj
rettigheder"-knap i navbaren); destruktive knapper vises kun mens
en eleveret session er aktiv (udløber automatisk efter ~5 min).
2. GET /participants uden et eksplicit limit ramte API'ets default på
100 — med 308 rigtige deltagere (sorteret efter stigende id)
kunne hverken alle deltagere eller nyoprettede deltagere ses.
ParticipantsPage sender nu limit=500 (API'ets maksimum).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scaffolder admin-ui/ (Vite + React + TypeScript, almindelig CSS) med
en minimal login-side (POST /auth/login + GET /auth/me) der beviser
hele kæden virker. Vites build skriver direkte til app/admin_dist/.
Admin-UI'et serveres af samme FastAPI-app som API'et og den
offentlige side, men på sit eget dedikerede domæne (ADMIN_DOMAIN,
default admin.localhost) via en ny AdminDomainDispatch-middleware
(app/admin_site.py) der genbruger Host-header-teknikken fra 8b: den
offentlige side ejer allerede roden "/" domæneopløst, så admin-UI'et
kan ikke dele det domæne uden at kollidere. Samme trick undgår CORS
helt, da admin-UI'ets fetch-kald går til samme origin. API-kald
(/auth, /participants, ...) passerer uændret gennem til det
almindelige API uanset domæne; alt andet på admin-domænet serveres
fra den byggede SPA med index.html-fallback for client-side routing.
Et sikkerhedsnet deaktiverer dispatch'en stille hvis admin_dist/
mangler (frisk clone uden frontend-build), i stedet for at crashe
hele appen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Order.order_number tildeles atomisk i POST /public/orders via en
SELECT ... FOR UPDATE-lås på rundens række + MAX+1, så samtidige
bestillinger lige efter en runde åbner ikke kan kollidere. Nummeret
starter på 1 for hver runde (UNIQUE(purchase_round_id, order_number))
og er nu tilgængeligt som {{order_number}} i mail-skabeloner.
GET /purchase-rounds/{id}/pickup-list genskaber den gamle systems
afhentningsliste (delt af brugeren som reference) mod den nye
datamodel: én printside pr. ordre, sorteret efter order_number (vist
stort i øverste højre hjørne), kontaktinfo, flaske-/kasseantal
(pænt afrundet — originalen viste et urundet float), og
vinlinjer grupperet pr. kategori.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GET /orders (filtre: purchase_round_id, route_id, payment_status)
giver admin en oversigt pr. ordre med deltagerinfo, vinlinjer og
beregnet total. POST /orders/{id}/mark-paid registrerer betalingen
(payment_status=paid, paid_at) og udløser mail-event #3
(payment_confirmed) — afviser med 409 hvis ordren allerede er betalt.
Udtrukket render/send/log-logikken fra 8a's _send_order_confirmation
til en delt app/services/order_mail.py::send_order_event_mail,
parameteriseret over MailEventType, så order_confirmed og
payment_confirmed ikke længere duplikerer den samme kode. Som med
order_confirmed er mail-afsendelsen best-effort: en manglende
skabelon eller afsenderkonfiguration blokerer aldrig selve
betalingsregistreringen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dokumenterer bekræftelsessiden (/bestilling-modtaget) og de to fund
fra brugerens browser-gennemgang, samt fjerner den nu forældede note
om at JS-laget ikke var browser-verificeret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Server-renderet af FastAPI selv (Jinja2 + vanilla JS/CSS, intet
build-step) mod det eksisterende domæneopløste JSON-API fra 8b.
GET / viser bestillingssiden når en runde er åben (rundeoverskrift,
intro_text, kategori-grupperet vinkatalog med live-beregnet
EUR/DKK-total, bestillingsformular), ellers en tilmeldingsside.
GET /tilmelding er altid tilgængelig uafhængigt af rundestatus, og
GET /afhentning viser rutens meeting_info. Layoutet er modelleret
efter den gamle sides bestillingsside. get_current_round's
serialiseringslogik er udtrukket til en delt _build_page_info-helper,
så JSON-API'et og de nye HTML-sider genbruger nøjagtig samme
domæne-/rundeopslag.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Efter en afsluttet opgave skal der fremover gås tilbage i plan mode
automatisk, i stedet for at fortsætte til næste opgave uden at spørge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Offentlige endpoints afgør nu ruten via Host-headeren
(Route.public_domain) i stedet for route_id i URL'en, så én
frontend kan betjene flere organisationers domæner via reverse
proxy. GET /public/current-round returnerer altid 200 med
current_round: null når ingen runde er åben (i stedet for 404),
og nyt POST /public/signup lader deltagere tilmelde sig med kun
navn+email uden at røre et eksisterende telefonnummer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Order/OrderLine now have a real write path (the public order API), so
their "model exists, no API yet" notes are replaced with what's
actually there. Participant's entry documents the ad-hoc-create/
always-update-on-order behavior. Mail-events #2 marked implemented
with the same detail level as #1. Fase 1 task 8 split into 8a (done)/
8b (pending, own stack decision) matching the 7a/7b/7c precedent.
Added a short process note at the top, per explicit user request:
this file should be updated after each (sub)task, not just on request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Datamodel section now reflects reality: PurchaseRound's season/year
was replaced with opens_at/order_deadline_at/pickup_at + a free-text
name (per user feedback during task 2); WineOffering's vintage was
dropped (folded into name); added the MailTemplate/MailLog models and
Route's sender_name/sender_email that opgave 7 introduced; noted
Order/OrderLine exist as models but have no API yet.
Fase 1 task list marks 1-7 done and expands 7 into its actual 7a/7b/7c
shape. Added a short auth line (JWT + sudo-style elevation) and
resolved the "Mail: SMTP-udbyder — afklares" placeholder now that
Postal is wired up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Config-driven DATABASE_URL shared by the app and Alembic's env.py
(fixes v1's config drift), psycopg3 driver, auto-discovering
app/models package for SQLModel.metadata, and a /health endpoint
that exercises the DB dependency end-to-end.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>