Compare commits
10 commits
b6b14aefac
...
d3e6b92d9f
| Author | SHA1 | Date | |
|---|---|---|---|
| d3e6b92d9f | |||
| 6b5680af07 | |||
| e337c4a49e | |||
| d360e704df | |||
| 16d89db527 | |||
| cae8657ec8 | |||
| 588888cd9e | |||
| 62d464fda2 | |||
| fb4a3fb732 | |||
| 60fb7db119 |
36 changed files with 4068 additions and 4 deletions
|
|
@ -8,3 +8,7 @@ POSTAL_API_KEY=change-me
|
||||||
# Find med: dig +short TXT <dkim-selector>._domainkey.<dit-domæne>
|
# Find med: dig +short TXT <dkim-selector>._domainkey.<dit-domæne>
|
||||||
# og brug værdien af "p=" (base64 DER, ingen PEM-indpakning nødvendig)
|
# og brug værdien af "p=" (base64 DER, ingen PEM-indpakning nødvendig)
|
||||||
POSTAL_WEBHOOK_PUBLIC_KEY_B64=change-me
|
POSTAL_WEBHOOK_PUBLIC_KEY_B64=change-me
|
||||||
|
# Domænet React admin-UI'et (admin-ui/) serveres på — reverse proxy'en
|
||||||
|
# skal sende dette domæne til samme backend, ligesom organisationernes
|
||||||
|
# public_domain gør
|
||||||
|
ADMIN_DOMAIN=admin.vinindkoeb.dk
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -26,3 +26,7 @@ dist/
|
||||||
# Raw personal-data exports used for one-off migrations — not for version control
|
# Raw personal-data exports used for one-off migrations — not for version control
|
||||||
old_participants.json
|
old_participants.json
|
||||||
old-emails/
|
old-emails/
|
||||||
|
|
||||||
|
# React admin-UI (admin-ui/) build artefact + dependencies — genskabes af npm run build
|
||||||
|
app/admin_dist/
|
||||||
|
admin-ui/node_modules/
|
||||||
|
|
|
||||||
225
CLAUDE.md
225
CLAUDE.md
|
|
@ -16,9 +16,16 @@ designes fra start til at kunne understøtte flere organisationer (fx
|
||||||
vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
|
vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
|
||||||
én organisation/rute er i drift i dag.
|
én organisation/rute er i drift i dag.
|
||||||
|
|
||||||
|
Se [INSTALL.md](INSTALL.md) for produktionsopsætning på en server
|
||||||
|
(database, `.env`, admin-UI-build, domæner/reverse proxy, DNS,
|
||||||
|
Postal-webhook m.m.), og `scripts/create_admin_user.py` for at
|
||||||
|
oprette den første organisation/rute/admin-bruger — der findes ingen
|
||||||
|
selvbetjenings-registrering i systemet.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
- Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations)
|
- Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations)
|
||||||
- Frontend: React (admin-UI, ikke bygget endnu) + den offentlige
|
- Frontend: React + TypeScript admin-UI (`admin-ui/`, opgave 11a —
|
||||||
|
scaffold, se "Admin-UI-arkitektur" nedenfor) + den offentlige
|
||||||
bestillings-/tilmeldingsside (opgave 8c) — server-renderet af
|
bestillings-/tilmeldingsside (opgave 8c) — server-renderet af
|
||||||
FastAPI selv (Jinja2-templates i `app/templates/` + vanilla JS/CSS i
|
FastAPI selv (Jinja2-templates i `app/templates/` + vanilla JS/CSS i
|
||||||
`app/static/`, intet build-step). Fortsat intet login for deltagere.
|
`app/static/`, intet build-step). Fortsat intet login for deltagere.
|
||||||
|
|
@ -61,6 +68,20 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
|
||||||
`Host`-header (ingen `route_id` i URL'en for de offentlige
|
`Host`-header (ingen `route_id` i URL'en for de offentlige
|
||||||
endpoints) — ét frontend-build kan dermed betjene flere
|
endpoints) — ét frontend-build kan dermed betjene flere
|
||||||
organisationer
|
organisationer
|
||||||
|
- **To rigtige ruter findes nu** (id 4, `vinindkoeb.dk` — den
|
||||||
|
rigtige, med de 308 rigtige deltagere) og en permanent test-rute
|
||||||
|
("Fælles Vinindkøb (test)", id 9, `test.vinindkoeb.dk`, afsender
|
||||||
|
"Test - Fælles Vinindkøb" <test@vinindkoeb.dk>), oprettet direkte
|
||||||
|
i databasen (ingen rute-opret-skærm i admin-UI'et endnu). Formål:
|
||||||
|
lade brugeren teste hele flowet (inkl. rigtig mail-udsendelse via
|
||||||
|
Postal) uden at røre de rigtige deltagere — mere robust end de
|
||||||
|
midlertidige isolerede sandboxes der er brugt til verifikation
|
||||||
|
indtil nu. Test-ruten er tom (ingen deltagere/runder/skabeloner
|
||||||
|
seedet) — brugeren opretter selv testdata via admin-UI'et.
|
||||||
|
**Kræver stadig manuel opsætning uden for denne kodebase:** en
|
||||||
|
DNS-post for `test.vinindkoeb.dk` + en reverse proxy-indgang til
|
||||||
|
samme backend (samme mønster som `vinindkoeb.dk`/
|
||||||
|
`admin.vinindkoeb.dk`).
|
||||||
|
|
||||||
- **Participant** — tilhører en Route
|
- **Participant** — tilhører en Route
|
||||||
- `name`, `email`, `phone`, `is_active`
|
- `name`, `email`, `phone`, `is_active`
|
||||||
|
|
@ -197,6 +218,190 @@ admin printer siden fra browseren. Renderes via
|
||||||
offentlige side, egen `Jinja2Templates`-instans i
|
offentlige side, egen `Jinja2Templates`-instans i
|
||||||
`purchase_rounds.py`).
|
`purchase_rounds.py`).
|
||||||
|
|
||||||
|
## Admin-UI-arkitektur (opgave 11)
|
||||||
|
|
||||||
|
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), **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 nu også ✅ —
|
||||||
|
hele opgave 11 er dermed færdig.
|
||||||
|
|
||||||
|
**Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript,
|
||||||
|
almindelig CSS — ingen framework) serveres af **samme FastAPI-app**
|
||||||
|
som API'et og den offentlige side, men på sit **eget dedikerede
|
||||||
|
domæne** (`ADMIN_DOMAIN`-settingen, default `admin.localhost`,
|
||||||
|
sættes til fx `admin.vinindkoeb.dk` i produktion — reverse proxy'en
|
||||||
|
skal have en tilsvarende indgang, ligesom organisationernes
|
||||||
|
`public_domain`'er). Begrundelse: den offentlige side
|
||||||
|
(`public_site.py`) ejer allerede roden `/` domæneopløst via
|
||||||
|
`Host`-headeren (8b) — admin-UI'et kan derfor ikke også ligge på `/`
|
||||||
|
på et organisations-domæne uden at kollidere. Ved at give admin-UI'et
|
||||||
|
sit eget domæne, håndteret af samme `Host`-header-teknik, opnås desuden
|
||||||
|
**ingen CORS-behov**: admin-UI'ets `fetch`-kald til `/auth`,
|
||||||
|
`/participants` osv. går til samme origin siden selv blev hentet fra.
|
||||||
|
|
||||||
|
**Mekanismen** (`app/admin_site.py`): `AdminDomainDispatch`, en rå
|
||||||
|
ASGI-middleware registreret via `app.add_middleware(...)` i
|
||||||
|
`app/main.py`. Pr. request: hvis `Host` matcher `ADMIN_DOMAIN` **og**
|
||||||
|
stien ikke er et af de faste API-præfikser (`/auth`, `/participants`,
|
||||||
|
osv. — se `_API_PREFIXES`, skal holdes i sync med routerne i
|
||||||
|
`app/main.py`), serveres i stedet fra `app/admin_dist/` (Vites
|
||||||
|
build-output) via `SPAStaticFiles` (falder tilbage til `index.html`
|
||||||
|
for ukendte stier, så React Router's client-side routing virker).
|
||||||
|
Ellers går requesten uændret videre til den almindelige app (API'et
|
||||||
|
er allerede host-agnostisk — virker på ethvert domæne — og den
|
||||||
|
offentlige side fortsætter uændret på organisations-domænerne).
|
||||||
|
Et sikkerhedsnet: hvis `app/admin_dist/` ikke findes (frisk clone,
|
||||||
|
frontend'en er ikke bygget endnu), deaktiveres dispatch'en stille i
|
||||||
|
stedet for at crashe hele appen ved opstart.
|
||||||
|
|
||||||
|
**Build:** `admin-ui/vite.config.ts` skriver direkte til
|
||||||
|
`../app/admin_dist` (`npm run build` i `admin-ui/`, intet manuelt
|
||||||
|
kopi-trin). Både `admin-ui/node_modules/` og `app/admin_dist/` er
|
||||||
|
gitignored — en frisk clone skal køre `npm install && npm run build`
|
||||||
|
i `admin-ui/` før admin-domænet virker (API'et og den offentlige side
|
||||||
|
fungerer også uden). Lokal udvikling: `npm run dev` i `admin-ui/`
|
||||||
|
bruger Vites egen dev-server med `server.proxy` videresendt til
|
||||||
|
`http://127.0.0.1:8001`, uafhængigt af dispatch-mekanismen.
|
||||||
|
|
||||||
|
**11a's indhold er bevidst minimalt** (kun nok til at bevise hele
|
||||||
|
kæden virker): en login-side, email+password → `POST /auth/login`
|
||||||
|
form-encoded per `OAuth2PasswordRequestForm`, JWT gemmes i
|
||||||
|
`localStorage`. Login og log ud er browser-testet af brugeren
|
||||||
|
(ModHeader, `Host: admin.localhost`) og virker.
|
||||||
|
|
||||||
|
**11b del 1** (frontend-infrastruktur + Deltagere-CRUD) bygger videre
|
||||||
|
på dette:
|
||||||
|
- **`admin-ui/src/api.ts`** — delt `apiFetch`-wrapper (sætter
|
||||||
|
`Authorization: Bearer`, kaster en fejl med API'ets `detail`-besked
|
||||||
|
ved ikke-2xx), samt `elevate()`/`decodeJwtExpiry()`.
|
||||||
|
- **`admin-ui/src/AuthContext.tsx`** — React Context: session
|
||||||
|
overlever nu en sideopdatering (kalder `GET /auth/me` med det
|
||||||
|
gemte token ved appstart, i stedet for at nulstille til login-siden
|
||||||
|
— en mangel fra 11a). Holder desuden en **eleveret sessions-
|
||||||
|
tilstand** (`isElevated`, `elevatedToken`, `requestElevation()`):
|
||||||
|
eleveringen er bevidst en **synlig, separat handling** ("Forhøj
|
||||||
|
rettigheder"-knap i navbaren, ✅ browser-testet) — destruktive
|
||||||
|
knapper som "Slet permanent" er kun synlige/aktive mens
|
||||||
|
`isElevated` er sand (udløber automatisk efter ~5 minutter, samme
|
||||||
|
som backend'ens `elevation_expire_minutes`). Dette er bevidst
|
||||||
|
strengere end blot at kunne, teknisk set, kalde `/auth/elevate`
|
||||||
|
usynligt i baggrunden ved hvert destruktivt klik — elevering skal
|
||||||
|
*føles* som en bevidst handling, ikke ske transparent bag et enkelt
|
||||||
|
klik (fundet under brugertest: et enkelt klik + kun en
|
||||||
|
browser-`confirm()` var utilstrækkeligt).
|
||||||
|
- **`react-router-dom`** — `Layout.tsx` (navbar: Deltagere-link,
|
||||||
|
elevations-knap for superadmins, brugernavn, log ud) + en
|
||||||
|
**`ParticipantsPage`**: liste, opret, redigér (inkl. aktiv/inaktiv),
|
||||||
|
hård-sletning (kun synlig for superadmins der er eleverede).
|
||||||
|
- **Ny backend-route:** `GET /routes` (`app/routers/routes.py`,
|
||||||
|
`RoutePublic`-model) — minimal, kun læsning, org-scoped. UI'et har
|
||||||
|
brug for rutens id for at oprette deltagere; dette er **ikke** den
|
||||||
|
udskudte rute-indstillings-redigeringsskærm (se Fase 1 nedenfor).
|
||||||
|
- **Fundet og rettet under brugertest:** `GET /participants` uden et
|
||||||
|
eksplicit `limit` rammer API'ets default (100) — med 308 rigtige
|
||||||
|
deltagere (sorteret efter stigende id) betød det at hverken alle
|
||||||
|
deltagere eller nyoprettede deltagere (som altid får det højeste id)
|
||||||
|
kunne ses. `ParticipantsPage` sender nu eksplicit `limit=500`
|
||||||
|
(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
|
||||||
|
`<input type="date">`/`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, se nedenfor); link til opgave 10's
|
||||||
|
afhentningsliste (autentificeret HTML-endpoint — kræver en særskilt
|
||||||
|
løsning, da et almindeligt `<a href>` 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 `<textarea>`, 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.
|
||||||
|
|
||||||
|
**11c** (ordreoversigt/markér betalt) afslutter opgave 11. Ingen
|
||||||
|
backend-ændringer nødvendige — alt data kommer fra opgave 9's
|
||||||
|
allerede eksisterende `GET /orders?purchase_round_id=<id>`
|
||||||
|
(`OrderAdminView`), al aggregering (flaske-/beløbstotal, udestående,
|
||||||
|
den runde-brede vinliste) beregnes client-side.
|
||||||
|
- **`pages/OrdersPage.tsx`** (`/runder/:roundId/ordrer`, linket fra
|
||||||
|
`RoundsPage` og `RoundDetailPage`) — en to-niveaus harmonika
|
||||||
|
(bestemt af brugeren): øverste fold viser rundens samlede
|
||||||
|
flasketal, total og **udestående** (defineret som summen af
|
||||||
|
ordrer der endnu ikke er markeret betalt — ikke bogstaveligt "total
|
||||||
|
minus ubetalte", som ville givet summen af de betalte ordrer i
|
||||||
|
stedet), foldet ud til den samlede, **kategori-grupperede**
|
||||||
|
vinliste for hele runden. Derunder én fold pr. ordre (sorteret
|
||||||
|
efter `order_number`, med en rød/grøn kant der viser
|
||||||
|
betalt/ubetalt-status uden at skulle folde ud), foldet ud til en
|
||||||
|
"Markér betalt"-knap (`POST /orders/{id}/mark-paid`, skjult og
|
||||||
|
erstattet af "Betalt [dato]" hvis allerede betalt) + ordrens egen
|
||||||
|
**kategori-grupperede** vinliste. Alle folder kan være åbne
|
||||||
|
samtidig.
|
||||||
|
- **Beløb vises i DKK** (rundens `eur_dkk_rate`, samme
|
||||||
|
omregningsprincip som den offentlige bestillingsside og
|
||||||
|
kvitteringsmailen) — falder tilbage til EUR hvis runden ikke har en
|
||||||
|
kurs sat. Fundet under brugertest: beløb blev oprindeligt vist i
|
||||||
|
EUR, og både den samlede og de individuelle vinlister manglede
|
||||||
|
kategori-gruppering samt havde antal efter vinnavn i stedet for
|
||||||
|
før — alle tre rettet.
|
||||||
|
|
||||||
|
**Rute-vælger** (tilføjet efter 11c, da en anden rigtig test-rute kom
|
||||||
|
til — se Route ovenfor): `admin-ui/src/RouteContext.tsx`, samme
|
||||||
|
Context-mønster som `AuthContext.tsx`. Henter `GET /routes` én gang
|
||||||
|
efter login, holder `selectedRouteId` (gemt i `localStorage`, så
|
||||||
|
valget overlever en sideopdatering — falder tilbage til den første
|
||||||
|
rute hvis intet/et ugyldigt valg er gemt). `Layout.tsx` viser et
|
||||||
|
`<select>` i navbaren (kun når der er mere end én rute).
|
||||||
|
`ParticipantsPage`/`RoundsPage`/`TemplatesPage` brugte hver deres egen
|
||||||
|
`GET /routes`-kald + antog "første rute" — de er nu omskrevet til at
|
||||||
|
læse `selectedRouteId` fra `useRoute()` i stedet, og genindlæser
|
||||||
|
automatisk når man skifter rute i navbaren. `RoundDetailPage`/
|
||||||
|
`OrdersPage` er uændrede — de arbejder allerede på én bestemt rundes
|
||||||
|
id fra URL'en, uafhængigt af den globalt valgte rute.
|
||||||
|
|
||||||
## Mail-events
|
## Mail-events
|
||||||
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
|
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
|
||||||
(opgave 7a-c): skabelon pr. rute (`MailTemplate`,
|
(opgave 7a-c): skabelon pr. rute (`MailTemplate`,
|
||||||
|
|
@ -229,7 +434,7 @@ logges/advares om i stedet.
|
||||||
|
|
||||||
## Fase 1 — nuværende scope
|
## Fase 1 — nuværende scope
|
||||||
|
|
||||||
**Færdige opgaver (1-10):**
|
**Færdige opgaver (1-11):**
|
||||||
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
||||||
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
||||||
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
||||||
|
|
@ -277,15 +482,29 @@ logges/advares om i stedet.
|
||||||
irreversibel) der afviser med `409` hvis ordren allerede er betalt.
|
irreversibel) der afviser med `409` hvis ordren allerede er betalt.
|
||||||
10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order
|
10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order
|
||||||
og "Afhentningsliste" ovenfor.
|
og "Afhentningsliste" ovenfor.
|
||||||
|
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), 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 — se
|
||||||
|
"Admin-UI-arkitektur" ovenfor. Hele opgave 11 er dermed færdig.
|
||||||
|
|
||||||
**Resterende opgaver:**
|
**Resterende opgaver:**
|
||||||
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9
|
|
||||||
12. Testkør en rigtig runde gennem hele flowet
|
12. Testkør en rigtig runde gennem hele flowet
|
||||||
|
|
||||||
## Fase 2 — senere
|
## Fase 2 — senere
|
||||||
- enablebanking.com: automatisk match af indbetalinger mod ubetalte
|
- enablebanking.com: automatisk match af indbetalinger mod ubetalte
|
||||||
ordrer → kalder samme "markér betalt"-logik
|
ordrer → kalder samme "markér betalt"-logik
|
||||||
- Passkey-login til admin
|
- Passkey-login til admin
|
||||||
|
- Rigtig pagination i admin-UI'ets deltagerliste, hvis antallet af
|
||||||
|
deltagere vokser forbi API'ets maksimale sidestørrelse (500) — se
|
||||||
|
"Admin-UI-arkitektur" ovenfor
|
||||||
|
- Rute-indstillings-skærm i admin-UI'et (navn, mødested,
|
||||||
|
afsender-navn/email, `public_domain`) — kun én rute findes i dag,
|
||||||
|
direkte database-redigering er fint indtil videre
|
||||||
- Rute-vælger på bestillingssiden (når flere ruter er aktive samtidig)
|
- Rute-vælger på bestillingssiden (når flere ruter er aktive samtidig)
|
||||||
- sold_only_by_case-flag + validering, hvis det bliver relevant
|
- sold_only_by_case-flag + validering, hvis det bliver relevant
|
||||||
- i18n/fransk oversættelse af den offentlige side (8c blev bevidst
|
- i18n/fransk oversættelse af den offentlige side (8c blev bevidst
|
||||||
|
|
|
||||||
227
INSTALL.md
Normal file
227
INSTALL.md
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
# Installationsvejledning — produktionsopsætning
|
||||||
|
|
||||||
|
Denne vejledning dækker opsætning af Vinindkøb-backend'en (API +
|
||||||
|
offentlig side + admin-UI) på en rigtig server, fra en frisk
|
||||||
|
installation til en kørende løsning. Se [README.md](README.md) for
|
||||||
|
lokal udvikling.
|
||||||
|
|
||||||
|
Vejledningen forudsætter at der allerede findes en kørende
|
||||||
|
Postal-instans (selvhostet mailserver — se CLAUDE.md's "Tech stack")
|
||||||
|
med DKIM sat op for det domæne der sendes mails fra. Selve
|
||||||
|
Postal-opsætningen er uden for denne vejlednings omfang.
|
||||||
|
|
||||||
|
**Antaget topologi** (Proxmox): appen kører i sin egen LXC-container,
|
||||||
|
sammen med sin PostgreSQL-database. Reverse proxy'en (CloudPanel)
|
||||||
|
kører i en **anden** container/VM på samme Proxmox-host — appens
|
||||||
|
container skal derfor være tilgængelig over det interne
|
||||||
|
Proxmox-netværk, ikke kun lokalt i sig selv. Kører reverse proxy'en i
|
||||||
|
stedet i **samme** container som appen, kan trin 9's `--host` i
|
||||||
|
stedet sættes til `127.0.0.1`, og firewall-reglen i samme trin
|
||||||
|
udelades.
|
||||||
|
|
||||||
|
## 1. Opret Proxmox-containeren
|
||||||
|
|
||||||
|
- **Unprivileged LXC-container** — intet i denne app kræver
|
||||||
|
privilegeret adgang.
|
||||||
|
- **OS-template:** Debian 12 anbefales (stabil, letvægt). `uv` styrer
|
||||||
|
selv Python 3.13 uafhængigt af systemets Python-version, så
|
||||||
|
distro-valget er ikke kritisk.
|
||||||
|
- **Størrelse:** appen er lille (én organisation i drift i dag) og
|
||||||
|
kører sin egen Postgres i samme container — 1-2 vCPU, 1-2 GB RAM og
|
||||||
|
8-10 GB disk er rigeligt til at starte med.
|
||||||
|
- **Netværk:** giv containeren en **statisk IP** på Proxmox' interne
|
||||||
|
bridge (fx `vmbr0`), så CloudPanel-containeren kan nå den
|
||||||
|
konsistent.
|
||||||
|
|
||||||
|
## 2. Forudsætninger i containeren
|
||||||
|
|
||||||
|
- Python 3.13 (se `.python-version`)
|
||||||
|
- [`uv`](https://docs.astral.sh/uv/) (Python-pakkehåndtering)
|
||||||
|
- Node.js + npm (til at bygge admin-UI'et, `admin-ui/`)
|
||||||
|
- PostgreSQL (`apt install postgresql`, da databasen kører i samme
|
||||||
|
container som appen)
|
||||||
|
- git
|
||||||
|
|
||||||
|
## 3. Hent koden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> /sti/til/vinindkoeb
|
||||||
|
cd /sti/til/vinindkoeb
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Database
|
||||||
|
|
||||||
|
Opret en Postgres-bruger og -database der matcher formatet i
|
||||||
|
`DATABASE_URL` (`postgresql+psycopg://<bruger>:<kodeord>@<host>:5432/<database>`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER vinindkoeb WITH PASSWORD '...';
|
||||||
|
CREATE DATABASE vinindkoeb OWNER vinindkoeb;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Miljøvariabler
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Udfyld hvert felt i `.env`:
|
||||||
|
|
||||||
|
| Variabel | Hvor den kommer fra |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URL` | Fra trin 4 (`@localhost`, da Postgres kører i samme container) |
|
||||||
|
| `ENVIRONMENT` | `production` |
|
||||||
|
| `SECRET_KEY` | Generér med `openssl rand -hex 32` |
|
||||||
|
| `ACCESS_TOKEN_EXPIRE_MINUTES` / `ELEVATION_EXPIRE_MINUTES` | Standardværdierne er fine, med mindre andet ønskes |
|
||||||
|
| `POSTAL_BASE_URL` / `POSTAL_API_KEY` | Fra Postal-instansens admin-UI (opret en API-nøgle til denne server) |
|
||||||
|
| `POSTAL_WEBHOOK_PUBLIC_KEY_B64` | DKIM-nøglen for afsenderdomænet — find med `dig +short TXT <dkim-selector>._domainkey.<domæne>`, brug værdien af `p=` |
|
||||||
|
| `ADMIN_DOMAIN` | Domænet admin-UI'et skal serveres på, fx `admin.vinindkoeb.dk` |
|
||||||
|
|
||||||
|
## 6. Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
Dette opretter alle tabeller og seeder `WineCategory` automatisk
|
||||||
|
(vinkategorierne er globale, delt af alle organisationer/ruter).
|
||||||
|
|
||||||
|
## 7. Første organisation, rute og admin-bruger
|
||||||
|
|
||||||
|
Der findes ingen selvbetjenings-registrering — uden en bruger i
|
||||||
|
databasen kan ingen logge ind. Brug bootstrap-scriptet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/create_admin_user.py \
|
||||||
|
--org "Din Organisation" \
|
||||||
|
--route "Din Rute" \
|
||||||
|
--email admin@example.com \
|
||||||
|
--name "Dit Navn" \
|
||||||
|
--password "et-sikkert-kodeord"
|
||||||
|
```
|
||||||
|
|
||||||
|
Scriptet genbruger en organisation/rute med samme navn, hvis den
|
||||||
|
allerede findes — sikkert at køre igen for at oprette endnu en
|
||||||
|
admin-bruger på en eksisterende organisation.
|
||||||
|
|
||||||
|
## 8. Byg admin-UI'et
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin-ui
|
||||||
|
npm install
|
||||||
|
npm run build # skriver til ../app/admin_dist
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Uden dette trin kører API'et og den offentlige side fint, men
|
||||||
|
`ADMIN_DOMAIN` viser ingenting (se `app/admin_site.py`s
|
||||||
|
sikkerhedsnet).
|
||||||
|
|
||||||
|
## 9. Kør backend'en som en vedvarende service
|
||||||
|
|
||||||
|
Eksempel på en `systemd`-unit (`/etc/systemd/system/vinindkoeb.service`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Vinindkøb backend
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=vinindkoeb
|
||||||
|
WorkingDirectory=/sti/til/vinindkoeb
|
||||||
|
ExecStart=/sti/til/vinindkoeb/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=always
|
||||||
|
EnvironmentFile=/sti/til/vinindkoeb/.env
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now vinindkoeb
|
||||||
|
```
|
||||||
|
|
||||||
|
Da reverse proxy'en (CloudPanel) kører i en anden container, skal
|
||||||
|
backend'en kunne nås over Proxmox' interne netværk — deraf
|
||||||
|
`--host 0.0.0.0` i stedet for `127.0.0.1`. Det skal dog **ikke** stå
|
||||||
|
åbent for alt andet: brug Proxmox' egen indbyggede firewall på
|
||||||
|
containeren (fanen "Firewall" på containeren, eller Datacenter →
|
||||||
|
Firewall) til én regel: tillad TCP på port `8000` kun fra
|
||||||
|
CloudPanel-containerens IP, afvis ellers. Det er mere idiomatisk og
|
||||||
|
nemmere at vedligeholde end `iptables`/`nftables` inde i selve
|
||||||
|
gæste-OS'et.
|
||||||
|
|
||||||
|
## 10. Domæner + reverse proxy
|
||||||
|
|
||||||
|
Løsningen afgør selv hvilken rute/hvilket indhold der skal vises ud
|
||||||
|
fra requestens `Host`-header (se CLAUDE.md's "Route" og
|
||||||
|
"Admin-UI-arkitektur") — det betyder at **alle** domæner nedenfor
|
||||||
|
bare skal pege på samme kørende backend (samme container-IP og port,
|
||||||
|
fra trin 9), uanset hvilken reverse proxy-løsning der bruges:
|
||||||
|
|
||||||
|
- Organisationens/rutens `public_domain`-felt(er) i databasen, fx
|
||||||
|
`vinindkoeb.dk` og `test.vinindkoeb.dk`
|
||||||
|
- `ADMIN_DOMAIN` fra `.env`, fx `admin.vinindkoeb.dk`
|
||||||
|
|
||||||
|
**Med CloudPanel:** opret ét "Reverse Proxy"-site pr. domæne, der
|
||||||
|
peger på `<vinindkoeb-containerens-ip>:8000` (IP'en fra trin 1, porten
|
||||||
|
fra trin 9), og aktivér Let's Encrypt-certifikat for hvert site.
|
||||||
|
Bruges en anden løsning (nginx, Caddy, Traefik, ...), er princippet
|
||||||
|
det samme: én vhost/site pr. domæne, alle proxyet til samme
|
||||||
|
container-IP og port.
|
||||||
|
|
||||||
|
## 11. DNS
|
||||||
|
|
||||||
|
A-records (eller CNAME, alt efter opsætning) for hvert domæne fra
|
||||||
|
trin 10, der peger på **CloudPanel-serverens** offentlige IP-adresse
|
||||||
|
(ikke Vinindkøb-containerens — det er CloudPanel der tager imod
|
||||||
|
trafik udefra og sender den videre internt).
|
||||||
|
|
||||||
|
## 12. Postal-webhook
|
||||||
|
|
||||||
|
I Postal-instansens konfiguration: sæt webhook-URL'en til
|
||||||
|
`https://<det rigtige domæne>/webhooks/postal` (fx
|
||||||
|
`https://vinindkoeb.dk/webhooks/postal`) — det er sådan
|
||||||
|
leveringsstatus (sendt/leveret/afvist) opdateres asynkront på
|
||||||
|
`MailLog` (se opgave 7c).
|
||||||
|
|
||||||
|
## 13. Sæt rutens felter
|
||||||
|
|
||||||
|
For hver rute (den rigtige, og evt. en test-rute) skal disse felter
|
||||||
|
sættes direkte i databasen — der findes ingen rute-indstillings-skærm
|
||||||
|
i admin-UI'et endnu:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE route SET
|
||||||
|
public_domain = 'vinindkoeb.dk',
|
||||||
|
sender_name = 'Afsendernavn',
|
||||||
|
sender_email = 'afsender@vinindkoeb.dk'
|
||||||
|
WHERE id = <rute-id>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Verifikation
|
||||||
|
|
||||||
|
1. `curl https://<rigtigt domæne>/health` → `{"status":"ok","database":"ok"}`.
|
||||||
|
2. Besøg det rigtige domæne i en browser → bestillings-/tilmeldingssiden.
|
||||||
|
3. Besøg `ADMIN_DOMAIN` → login-siden, log ind med brugeren fra trin 7.
|
||||||
|
4. Afprøv en tilmelding/bestilling på test-ruten (hvis en sådan er
|
||||||
|
sat op) og bekræft at en rigtig mail bliver sendt og logget i
|
||||||
|
`MailLog`.
|
||||||
|
|
||||||
|
## 15. Fremtidige opdateringer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
uv sync
|
||||||
|
uv run alembic upgrade head
|
||||||
|
cd admin-ui && npm install && npm run build && cd ..
|
||||||
|
sudo systemctl restart vinindkoeb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
Ikke uddybet her, men en simpel `pg_dump`-baseret cron-opgave af
|
||||||
|
databasen anbefales som minimum.
|
||||||
19
README.md
19
README.md
|
|
@ -1,7 +1,8 @@
|
||||||
# Vinindkøb v2
|
# Vinindkøb v2
|
||||||
|
|
||||||
Admin-backend til fælles vinindkøb (FastAPI + SQLModel + PostgreSQL +
|
Admin-backend til fælles vinindkøb (FastAPI + SQLModel + PostgreSQL +
|
||||||
Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel.
|
Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel, og
|
||||||
|
[INSTALL.md](INSTALL.md) for produktionsopsætning på en server.
|
||||||
|
|
||||||
## Kom i gang
|
## Kom i gang
|
||||||
|
|
||||||
|
|
@ -13,3 +14,19 @@ uv run fastapi dev app/main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /health` bekræfter at appen kan nå databasen.
|
`GET /health` bekræfter at appen kan nå databasen.
|
||||||
|
|
||||||
|
## Admin-UI (React)
|
||||||
|
|
||||||
|
Serveres af samme app på et dedikeret domæne (`ADMIN_DOMAIN`, default
|
||||||
|
`admin.localhost`) — se CLAUDE.md's "Admin-UI-arkitektur". Skal bygges
|
||||||
|
før admin-domænet virker (API'et og den offentlige side kører fint
|
||||||
|
uden):
|
||||||
|
|
||||||
|
```
|
||||||
|
cd admin-ui
|
||||||
|
npm install
|
||||||
|
npm run build # skriver til ../app/admin_dist
|
||||||
|
```
|
||||||
|
|
||||||
|
Til udvikling af selve admin-UI'et: `npm run dev` i `admin-ui/`
|
||||||
|
(Vites egen dev-server, proxier API-kald til `127.0.0.1:8001`).
|
||||||
|
|
|
||||||
24
admin-ui/.gitignore
vendored
Normal file
24
admin-ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
admin-ui/.oxlintrc.json
Normal file
8
admin-ui/.oxlintrc.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
32
admin-ui/README.md
Normal file
32
admin-ui/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
12
admin-ui/index.html
Normal file
12
admin-ui/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Fælles Vinindkøb — Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1353
admin-ui/package-lock.json
generated
Normal file
1353
admin-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
admin-ui/package.json
Normal file
26
admin-ui/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "admin-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"oxlint": "^1.81.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
208
admin-ui/src/App.css
Normal file
208
admin-ui/src/App.css
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
.login-card {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 10vh auto;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card form label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card form input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .error {
|
||||||
|
color: #b3261e;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links a {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links a.active {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-user {
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevated-badge {
|
||||||
|
color: #7fd88f;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .error {
|
||||||
|
color: #b3261e;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form input {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form button {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f7f7f7;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header:hover {
|
||||||
|
background: #efefef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header.unpaid {
|
||||||
|
border-left: 4px solid #b3261e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header.paid {
|
||||||
|
border-left: 4px solid #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-body {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
45
admin-ui/src/App.tsx
Normal file
45
admin-ui/src/App.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Navigate, Route, BrowserRouter, Routes } from 'react-router-dom'
|
||||||
|
import { AuthProvider, useAuth } from './AuthContext'
|
||||||
|
import { Layout } from './Layout'
|
||||||
|
import { LoginPage } from './pages/LoginPage'
|
||||||
|
import { OrdersPage } from './pages/OrdersPage'
|
||||||
|
import { ParticipantsPage } from './pages/ParticipantsPage'
|
||||||
|
import { RoundDetailPage } from './pages/RoundDetailPage'
|
||||||
|
import { RoundsPage } from './pages/RoundsPage'
|
||||||
|
import { RouteProvider } from './RouteContext'
|
||||||
|
import { TemplatesPage } from './pages/TemplatesPage'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { currentUser, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) return <p>Indlæser...</p>
|
||||||
|
if (!currentUser) return <LoginPage />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/deltagere" element={<ParticipantsPage />} />
|
||||||
|
<Route path="/runder" element={<RoundsPage />} />
|
||||||
|
<Route path="/runder/:roundId" element={<RoundDetailPage />} />
|
||||||
|
<Route path="/runder/:roundId/ordrer" element={<OrdersPage />} />
|
||||||
|
<Route path="/skabeloner" element={<TemplatesPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/deltagere" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</RouteProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
97
admin-ui/src/AuthContext.tsx
Normal file
97
admin-ui/src/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
apiFetch,
|
||||||
|
clearToken,
|
||||||
|
decodeJwtExpiry,
|
||||||
|
elevate as apiElevate,
|
||||||
|
getToken,
|
||||||
|
login as apiLogin,
|
||||||
|
setToken,
|
||||||
|
} from './api'
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
is_active: boolean
|
||||||
|
is_superadmin: boolean
|
||||||
|
organization_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
currentUser: CurrentUser | null
|
||||||
|
loading: boolean
|
||||||
|
login: (email: string, password: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
/** Kortlivet eleveret token — kun sat lige efter et bevidst "Forhøj rettigheder"-klik. */
|
||||||
|
elevatedToken: string | null
|
||||||
|
isElevated: boolean
|
||||||
|
requestElevation: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [elevatedToken, setElevatedToken] = useState<string | null>(null)
|
||||||
|
const [elevatedExpiresAt, setElevatedExpiresAt] = useState<number | null>(null)
|
||||||
|
const [, forceTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiFetch<CurrentUser>('/auth/me', {}, token)
|
||||||
|
.then(setCurrentUser)
|
||||||
|
.catch(() => clearToken())
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Genberegn isElevated løbende, så "Slet permanent"-knapper forsvinder
|
||||||
|
// igen af sig selv når den eleverede session udløber (uden en sideopdatering).
|
||||||
|
useEffect(() => {
|
||||||
|
if (elevatedExpiresAt === null) return
|
||||||
|
const interval = setInterval(() => forceTick((n) => n + 1), 5000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [elevatedExpiresAt])
|
||||||
|
|
||||||
|
const isElevated = elevatedExpiresAt !== null && Date.now() < elevatedExpiresAt
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
const token = await apiLogin(email, password)
|
||||||
|
setToken(token)
|
||||||
|
const user = await apiFetch<CurrentUser>('/auth/me', {}, token)
|
||||||
|
setCurrentUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearToken()
|
||||||
|
setCurrentUser(null)
|
||||||
|
setElevatedToken(null)
|
||||||
|
setElevatedExpiresAt(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestElevation() {
|
||||||
|
const token = await apiElevate()
|
||||||
|
const exp = decodeJwtExpiry(token)
|
||||||
|
setElevatedToken(token)
|
||||||
|
setElevatedExpiresAt(exp ? exp * 1000 : Date.now() + 5 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{ currentUser, loading, login, logout, elevatedToken, isElevated, requestElevation }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) throw new Error('useAuth skal bruges inden i AuthProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
68
admin-ui/src/Layout.tsx
Normal file
68
admin-ui/src/Layout.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
|
import { ApiError } from './api'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
import { useRoute } from './RouteContext'
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
const { currentUser, logout, isElevated, requestElevation } = useAuth()
|
||||||
|
const { routes, selectedRouteId, selectRoute } = useRoute()
|
||||||
|
const [elevationError, setElevationError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleElevate() {
|
||||||
|
setElevationError(null)
|
||||||
|
try {
|
||||||
|
await requestElevation()
|
||||||
|
} catch (err) {
|
||||||
|
setElevationError(err instanceof ApiError ? err.message : 'Kunne ikke forhøje rettigheder')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<nav className="navbar">
|
||||||
|
<span className="navbar-brand">Fælles Vinindkøb — Admin</span>
|
||||||
|
<div className="navbar-links">
|
||||||
|
{routes.length > 1 && (
|
||||||
|
<select
|
||||||
|
className="route-select"
|
||||||
|
value={selectedRouteId ?? ''}
|
||||||
|
onChange={(e) => selectRoute(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{routes.map((route) => (
|
||||||
|
<option key={route.id} value={route.id}>
|
||||||
|
{route.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<NavLink to="/deltagere">Deltagere</NavLink>
|
||||||
|
<NavLink to="/runder">Runder</NavLink>
|
||||||
|
<NavLink to="/skabeloner">Skabeloner</NavLink>
|
||||||
|
{currentUser?.is_superadmin &&
|
||||||
|
(isElevated ? (
|
||||||
|
<span className="elevated-badge" title="Udløber automatisk efter 5 minutter">
|
||||||
|
Rettigheder forhøjet ✓
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button className="link-button" onClick={handleElevate}>
|
||||||
|
Forhøj rettigheder
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{currentUser && (
|
||||||
|
<>
|
||||||
|
<span className="navbar-user">{currentUser.name}</span>
|
||||||
|
<button className="link-button" onClick={logout}>
|
||||||
|
Log ud
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{elevationError && <p className="content error">{elevationError}</p>}
|
||||||
|
<main className="content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
admin-ui/src/RouteContext.tsx
Normal file
59
admin-ui/src/RouteContext.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { apiFetch, ApiError } from './api'
|
||||||
|
|
||||||
|
export interface Route {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE_STORAGE_KEY = 'vinindkoeb_admin_route_id'
|
||||||
|
|
||||||
|
interface RouteState {
|
||||||
|
routes: Route[]
|
||||||
|
selectedRouteId: number | null
|
||||||
|
selectRoute: (id: number) => void
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const RouteContext = createContext<RouteState | null>(null)
|
||||||
|
|
||||||
|
export function RouteProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [routes, setRoutes] = useState<Route[]>([])
|
||||||
|
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<Route[]>('/routes')
|
||||||
|
.then((data) => {
|
||||||
|
setRoutes(data)
|
||||||
|
if (data.length === 0) return
|
||||||
|
const stored = localStorage.getItem(ROUTE_STORAGE_KEY)
|
||||||
|
const storedId = stored ? Number(stored) : null
|
||||||
|
const validStoredId = storedId !== null && data.some((r) => r.id === storedId) ? storedId : null
|
||||||
|
setSelectedRouteId(validStoredId ?? data[0].id)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ruter')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function selectRoute(id: number) {
|
||||||
|
setSelectedRouteId(id)
|
||||||
|
localStorage.setItem(ROUTE_STORAGE_KEY, String(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteContext.Provider value={{ routes, selectedRouteId, selectRoute, loading, error }}>
|
||||||
|
{children}
|
||||||
|
</RouteContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoute(): RouteState {
|
||||||
|
const context = useContext(RouteContext)
|
||||||
|
if (!context) throw new Error('useRoute skal bruges inden i RouteProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
74
admin-ui/src/api.ts
Normal file
74
admin-ui/src/api.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
export const TOKEN_STORAGE_KEY = 'vinindkoeb_admin_token'
|
||||||
|
|
||||||
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string): void {
|
||||||
|
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken(): void {
|
||||||
|
localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractErrorMessage(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = await response.json()
|
||||||
|
if (typeof body.detail === 'string') return body.detail
|
||||||
|
if (Array.isArray(body.detail)) {
|
||||||
|
return body.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ')
|
||||||
|
}
|
||||||
|
return JSON.stringify(body.detail)
|
||||||
|
} catch {
|
||||||
|
return `Fejl (${response.status})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Autentificeret fetch mod API'et — bruger det gemte token med mindre et andet gives via `token`. */
|
||||||
|
export async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
token: string | null = getToken(),
|
||||||
|
): Promise<T> {
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const response = await fetch(path, { ...options, headers })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(await extractErrorMessage(response))
|
||||||
|
}
|
||||||
|
if (response.status === 204) return undefined as T
|
||||||
|
return response.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email: string, password: string): Promise<string> {
|
||||||
|
const response = await fetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ username: email, password }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(await extractErrorMessage(response))
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
return data.access_token as string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Henter et kortlivet eleveret token (kræver ikke ny password-indtastning). */
|
||||||
|
export async function elevate(): Promise<string> {
|
||||||
|
const data = await apiFetch<{ access_token: string }>('/auth/elevate', { method: 'POST' })
|
||||||
|
return data.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Læser `exp`-claimet (unix-sekunder) fra et JWT uden at verificere signaturen —
|
||||||
|
* kun til at vise/skjule UI, den rigtige kontrol sker altid server-side. */
|
||||||
|
export function decodeJwtExpiry(token: string): number | null {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||||
|
return typeof payload.exp === 'number' ? payload.exp : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
29
admin-ui/src/dateUtils.ts
Normal file
29
admin-ui/src/dateUtils.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/** Konverterer en ISO-dato (UTC, fra backend) til værdien et
|
||||||
|
* <input type="datetime-local"> 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 <input type="datetime-local">-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 <input type="date">-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()
|
||||||
|
}
|
||||||
6
admin-ui/src/index.css
Normal file
6
admin-ui/src/index.css
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: #222;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
10
admin-ui/src/main.tsx
Normal file
10
admin-ui/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
38
admin-ui/src/pages/LoginPage.tsx
Normal file
38
admin-ui/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useAuth } from '../AuthContext'
|
||||||
|
import { ApiError } from '../api'
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await login(email, password)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Der skete en fejl')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>Fælles Vinindkøb — Admin</h1>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Adgangskode
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Log ind</button>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
260
admin-ui/src/pages/OrdersPage.tsx
Normal file
260
admin-ui/src/pages/OrdersPage.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { apiFetch, ApiError } from '../api'
|
||||||
|
|
||||||
|
interface PurchaseRound {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
eur_dkk_rate: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WineCategory {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WineOffering {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderLineAdminView {
|
||||||
|
id: number
|
||||||
|
order_id: number
|
||||||
|
wine_offering_id: number
|
||||||
|
quantity: number
|
||||||
|
wine_name: string
|
||||||
|
unit_price: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderAdminView {
|
||||||
|
id: number
|
||||||
|
participant_id: number
|
||||||
|
purchase_round_id: number
|
||||||
|
order_number: number
|
||||||
|
payment_status: 'unpaid' | 'paid'
|
||||||
|
paid_at: string | null
|
||||||
|
created_at: string
|
||||||
|
participant_name: string
|
||||||
|
participant_email: string
|
||||||
|
participant_phone: string | null
|
||||||
|
order_lines: OrderLineAdminView[]
|
||||||
|
total_price_eur: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrdersPage() {
|
||||||
|
const { roundId } = useParams<{ roundId: string }>()
|
||||||
|
const [round, setRound] = useState<PurchaseRound | null>(null)
|
||||||
|
const [orders, setOrders] = useState<OrderAdminView[]>([])
|
||||||
|
const [wineOfferings, setWineOfferings] = useState<WineOffering[]>([])
|
||||||
|
const [categories, setCategories] = useState<WineCategory[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [summaryOpen, setSummaryOpen] = useState(false)
|
||||||
|
const [openOrderIds, setOpenOrderIds] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
async function loadOrders(forRoundId: string) {
|
||||||
|
const data = await apiFetch<OrderAdminView[]>(`/orders?purchase_round_id=${forRoundId}&limit=500`)
|
||||||
|
setOrders(data.slice().sort((a, b) => a.order_number - b.order_number))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
if (!roundId) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [roundData, wineOfferingData, categoryData] = await Promise.all([
|
||||||
|
apiFetch<PurchaseRound>(`/purchase-rounds/${roundId}`),
|
||||||
|
apiFetch<WineOffering[]>(`/wine-offerings?purchase_round_id=${roundId}&limit=500`),
|
||||||
|
apiFetch<WineCategory[]>('/wine-categories'),
|
||||||
|
loadOrders(roundId),
|
||||||
|
])
|
||||||
|
setRound(roundData)
|
||||||
|
setWineOfferings(wineOfferingData)
|
||||||
|
setCategories(categoryData)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ordrer')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAll()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roundId])
|
||||||
|
|
||||||
|
function toggleOrder(id: number) {
|
||||||
|
setOpenOrderIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkPaid(order: OrderAdminView) {
|
||||||
|
if (!roundId) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await apiFetch(`/orders/${order.id}/mark-paid`, { method: 'POST' })
|
||||||
|
await loadOrders(roundId)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke markere ordren som betalt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <p>Indlæser...</p>
|
||||||
|
if (!round) return <p className="error">{error ?? 'Runden blev ikke fundet'}</p>
|
||||||
|
|
||||||
|
const rate = round.eur_dkk_rate ? Number(round.eur_dkk_rate) : null
|
||||||
|
|
||||||
|
function formatEur(amountEur: number): string {
|
||||||
|
if (rate) return `${(amountEur * rate).toFixed(2)} kr`
|
||||||
|
return `${amountEur.toFixed(2)} EUR`
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBottles = orders.reduce(
|
||||||
|
(sum, o) => sum + o.order_lines.reduce((lineSum, l) => lineSum + l.quantity, 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const totalAmount = orders.reduce((sum, o) => sum + Number(o.total_price_eur), 0)
|
||||||
|
const outstanding = orders
|
||||||
|
.filter((o) => o.payment_status === 'unpaid')
|
||||||
|
.reduce((sum, o) => sum + Number(o.total_price_eur), 0)
|
||||||
|
|
||||||
|
const categoryByWineOfferingId = new Map<number, WineCategory>()
|
||||||
|
for (const offering of wineOfferings) {
|
||||||
|
const category = categories.find((c) => c.id === offering.category_id)
|
||||||
|
if (category) categoryByWineOfferingId.set(offering.id, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wineAggregation = new Map<number, { name: string; quantity: number; category: WineCategory | undefined }>()
|
||||||
|
for (const order of orders) {
|
||||||
|
for (const line of order.order_lines) {
|
||||||
|
const existing = wineAggregation.get(line.wine_offering_id)
|
||||||
|
if (existing) existing.quantity += line.quantity
|
||||||
|
else
|
||||||
|
wineAggregation.set(line.wine_offering_id, {
|
||||||
|
name: line.wine_name,
|
||||||
|
quantity: line.quantity,
|
||||||
|
category: categoryByWineOfferingId.get(line.wine_offering_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const wineRows = Array.from(wineAggregation.values()).sort((a, b) => {
|
||||||
|
const orderA = a.category?.sort_order ?? 0
|
||||||
|
const orderB = b.category?.sort_order ?? 0
|
||||||
|
if (orderA !== orderB) return orderA - orderB
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const wineRowsByCategory = new Map<string, typeof wineRows>()
|
||||||
|
for (const row of wineRows) {
|
||||||
|
const categoryName = row.category?.name ?? 'Ukategoriseret'
|
||||||
|
const list = wineRowsByCategory.get(categoryName) ?? []
|
||||||
|
list.push(row)
|
||||||
|
wineRowsByCategory.set(categoryName, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupOrderLinesByCategory(lines: OrderLineAdminView[]): [string, OrderLineAdminView[]][] {
|
||||||
|
const sorted = lines.slice().sort((a, b) => {
|
||||||
|
const orderA = categoryByWineOfferingId.get(a.wine_offering_id)?.sort_order ?? 0
|
||||||
|
const orderB = categoryByWineOfferingId.get(b.wine_offering_id)?.sort_order ?? 0
|
||||||
|
if (orderA !== orderB) return orderA - orderB
|
||||||
|
return a.wine_name.localeCompare(b.wine_name)
|
||||||
|
})
|
||||||
|
const groups = new Map<string, OrderLineAdminView[]>()
|
||||||
|
for (const line of sorted) {
|
||||||
|
const categoryName = categoryByWineOfferingId.get(line.wine_offering_id)?.name ?? 'Ukategoriseret'
|
||||||
|
const list = groups.get(categoryName) ?? []
|
||||||
|
list.push(line)
|
||||||
|
groups.set(categoryName, list)
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Ordreoversigt</h1>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p>Ingen bestillinger endnu for denne runde.</p>
|
||||||
|
) : (
|
||||||
|
<div className="accordion">
|
||||||
|
<div className="accordion-item">
|
||||||
|
<button className="accordion-header" onClick={() => setSummaryOpen((v) => !v)}>
|
||||||
|
{round.name} — {totalBottles} flasker — Total: {formatEur(totalAmount)} — Udestående:{' '}
|
||||||
|
{formatEur(outstanding)}
|
||||||
|
</button>
|
||||||
|
{summaryOpen && (
|
||||||
|
<div className="accordion-body">
|
||||||
|
{Array.from(wineRowsByCategory.entries()).map(([categoryName, rows]) => (
|
||||||
|
<table key={categoryName}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colSpan={2}>{categoryName}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.name}>
|
||||||
|
<td>{row.quantity}</td>
|
||||||
|
<td>{row.name}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orders.map((order) => (
|
||||||
|
<div className="accordion-item" key={order.id}>
|
||||||
|
<button
|
||||||
|
className={`accordion-header ${order.payment_status === 'paid' ? 'paid' : 'unpaid'}`}
|
||||||
|
onClick={() => toggleOrder(order.id)}
|
||||||
|
>
|
||||||
|
#{order.order_number} — {order.participant_name} — {order.participant_phone ?? ''} —{' '}
|
||||||
|
{order.participant_email} — {formatEur(Number(order.total_price_eur))} —{' '}
|
||||||
|
{order.payment_status === 'paid' ? 'Betalt' : 'Ubetalt'}
|
||||||
|
</button>
|
||||||
|
{openOrderIds.has(order.id) && (
|
||||||
|
<div className="accordion-body">
|
||||||
|
{order.payment_status === 'paid' ? (
|
||||||
|
<p className="hint">
|
||||||
|
Betalt {order.paid_at ? new Date(order.paid_at).toLocaleString('da-DK') : ''}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => handleMarkPaid(order)}>Markér betalt</button>
|
||||||
|
)}
|
||||||
|
{groupOrderLinesByCategory(order.order_lines).map(([categoryName, lines]) => (
|
||||||
|
<table key={categoryName}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colSpan={3}>{categoryName}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<tr key={line.id}>
|
||||||
|
<td>{line.quantity}</td>
|
||||||
|
<td>{line.wine_name}</td>
|
||||||
|
<td>{formatEur(Number(line.unit_price))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
212
admin-ui/src/pages/ParticipantsPage.tsx
Normal file
212
admin-ui/src/pages/ParticipantsPage.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { apiFetch, ApiError } from '../api'
|
||||||
|
import { useAuth } from '../AuthContext'
|
||||||
|
import { useRoute } from '../RouteContext'
|
||||||
|
|
||||||
|
interface Participant {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
phone: string | null
|
||||||
|
is_active: boolean
|
||||||
|
route_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewParticipantForm {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: NewParticipantForm = { name: '', email: '', phone: '' }
|
||||||
|
|
||||||
|
export function ParticipantsPage() {
|
||||||
|
const { currentUser, isElevated, elevatedToken } = useAuth()
|
||||||
|
const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute()
|
||||||
|
const [participants, setParticipants] = useState<Participant[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [newParticipant, setNewParticipant] = useState<NewParticipantForm>(emptyForm)
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState<NewParticipantForm & { is_active: boolean }>({
|
||||||
|
...emptyForm,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadParticipants(forRouteId: number) {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
// limit=500 er API'ets maksimum (le=500) — komfortabelt over de 308 rigtige
|
||||||
|
// deltagere i dag. Skal siden bygges rigtig pagination hvis antallet vokser videre.
|
||||||
|
const data = await apiFetch<Participant[]>(`/participants?route_id=${forRouteId}&limit=500`)
|
||||||
|
setParticipants(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente deltagere')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (routeId === null) return
|
||||||
|
loadParticipants(routeId)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [routeId])
|
||||||
|
|
||||||
|
async function handleCreate(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (routeId === null) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await apiFetch('/participants', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...newParticipant, route_id: routeId }),
|
||||||
|
})
|
||||||
|
setNewParticipant(emptyForm)
|
||||||
|
await loadParticipants(routeId)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke oprette deltager')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(participant: Participant) {
|
||||||
|
setEditingId(participant.id)
|
||||||
|
setEditForm({
|
||||||
|
name: participant.name,
|
||||||
|
email: participant.email,
|
||||||
|
phone: participant.phone ?? '',
|
||||||
|
is_active: participant.is_active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (editingId === null || routeId === null) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await apiFetch(`/participants/${editingId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(editForm),
|
||||||
|
})
|
||||||
|
setEditingId(null)
|
||||||
|
await loadParticipants(routeId)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme ændringer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHardDelete(participant: Participant) {
|
||||||
|
if (routeId === null || !elevatedToken) return
|
||||||
|
if (!window.confirm(`Slet ${participant.name} permanent? Dette kan ikke fortrydes.`)) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await apiFetch(`/participants/${participant.id}`, { method: 'DELETE' }, elevatedToken)
|
||||||
|
await loadParticipants(routeId)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette deltager')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeLoading || (loading && routeId !== null)) return <p>Indlæser...</p>
|
||||||
|
if (routeId === null) return <p className="error">{routeError ?? 'Ingen ruter fundet for din organisation'}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Deltagere</h1>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{currentUser?.is_superadmin && !isElevated && (
|
||||||
|
<p className="hint">Forhøj dine rettigheder (knap øverst) for at kunne slette en deltager permanent.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="inline-form">
|
||||||
|
<input
|
||||||
|
placeholder="Navn"
|
||||||
|
value={newParticipant.name}
|
||||||
|
onChange={(e) => setNewParticipant({ ...newParticipant, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={newParticipant.email}
|
||||||
|
onChange={(e) => setNewParticipant({ ...newParticipant, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Telefon"
|
||||||
|
value={newParticipant.phone}
|
||||||
|
onChange={(e) => setNewParticipant({ ...newParticipant, phone: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit">Ny deltager</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Telefon</th>
|
||||||
|
<th>Aktiv</th>
|
||||||
|
<th>Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{participants.map((participant) =>
|
||||||
|
editingId === participant.id ? (
|
||||||
|
<tr key={participant.id}>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<form onSubmit={handleSaveEdit} className="inline-form">
|
||||||
|
<input
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={editForm.phone}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.is_active}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, is_active: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Aktiv
|
||||||
|
</label>
|
||||||
|
<button type="submit">Gem</button>
|
||||||
|
<button type="button" onClick={() => setEditingId(null)}>
|
||||||
|
Fortryd
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={participant.id}>
|
||||||
|
<td>{participant.name}</td>
|
||||||
|
<td>{participant.email}</td>
|
||||||
|
<td>{participant.phone}</td>
|
||||||
|
<td>{participant.is_active ? 'Ja' : 'Nej'}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => startEdit(participant)}>Redigér</button>
|
||||||
|
{currentUser?.is_superadmin && isElevated && (
|
||||||
|
<button onClick={() => handleHardDelete(participant)}>Slet permanent</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
459
admin-ui/src/pages/RoundDetailPage.tsx
Normal file
459
admin-ui/src/pages/RoundDetailPage.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
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: '' }
|
||||||
|
|
||||||
|
interface AnnounceResult {
|
||||||
|
sent: number
|
||||||
|
failed: number
|
||||||
|
total_participants: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundDetailPage() {
|
||||||
|
const { roundId } = useParams<{ roundId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { currentUser, isElevated, elevatedToken } = useAuth()
|
||||||
|
const [round, setRound] = useState<PurchaseRound | null>(null)
|
||||||
|
const [form, setForm] = useState<RoundFormState | null>(null)
|
||||||
|
const [categories, setCategories] = useState<WineCategory[]>([])
|
||||||
|
const [wines, setWines] = useState<WineOffering[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [announceResult, setAnnounceResult] = useState<AnnounceResult | null>(null)
|
||||||
|
const [newWine, setNewWine] = useState<NewWineForm>(emptyWineForm)
|
||||||
|
const [editingWineId, setEditingWineId] = useState<number | null>(null)
|
||||||
|
const [editWineForm, setEditWineForm] = useState<NewWineForm>(emptyWineForm)
|
||||||
|
|
||||||
|
async function loadWines(forRoundId: string) {
|
||||||
|
const data = await apiFetch<WineOffering[]>(`/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<PurchaseRound>(`/purchase-rounds/${roundId}`),
|
||||||
|
apiFetch<WineCategory[]>('/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<PurchaseRound>(`/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<PurchaseRound>(`/purchase-rounds/${roundId}/copy`, { method: 'POST' })
|
||||||
|
navigate(`/runder/${copy.id}`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke kopiere runden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AnnounceResult>(`/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
|
||||||
|
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 <p>Indlæser...</p>
|
||||||
|
if (!round || !form) return <p className="error">{error ?? 'Runden blev ikke fundet'}</p>
|
||||||
|
|
||||||
|
const categoryName = (categoryId: number) => categories.find((c) => c.id === categoryId)?.name ?? '—'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{round.name}</h1>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="stacked-form">
|
||||||
|
<label>
|
||||||
|
Navn
|
||||||
|
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => setForm({ ...form, status: e.target.value as PurchaseRound['status'] })}
|
||||||
|
>
|
||||||
|
<option value="draft">Kladde</option>
|
||||||
|
<option value="open">Åben</option>
|
||||||
|
<option value="closed">Lukket</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Åbner
|
||||||
|
<input type="date" value={form.opens_at} onChange={(e) => setForm({ ...form, opens_at: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Bestillingsfrist
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.order_deadline_at}
|
||||||
|
onChange={(e) => setForm({ ...form, order_deadline_at: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Afhentning
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.pickup_at}
|
||||||
|
onChange={(e) => setForm({ ...form, pickup_at: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
EUR/DKK-kurs
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={form.eur_dkk_rate}
|
||||||
|
onChange={(e) => setForm({ ...form, eur_dkk_rate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Introtekst
|
||||||
|
<textarea value={form.intro_text} onChange={(e) => setForm({ ...form, intro_text: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Afhentningsinfo
|
||||||
|
<textarea
|
||||||
|
value={form.pickup_info_text}
|
||||||
|
onChange={(e) => setForm({ ...form, pickup_info_text: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Gem</button>
|
||||||
|
<button type="button" onClick={() => navigate(`/runder/${roundId}/ordrer`)}>
|
||||||
|
Se ordrer
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleCopy}>
|
||||||
|
Kopiér denne runde
|
||||||
|
</button>
|
||||||
|
{round.status === 'open' && (
|
||||||
|
<button type="button" onClick={handleAnnounce}>
|
||||||
|
Annoncér runde
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(round.status === 'draft' || (round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && (
|
||||||
|
<button type="button" onClick={handleDelete}>
|
||||||
|
Slet
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{round.status === 'closed' && currentUser?.is_superadmin && !isElevated && (
|
||||||
|
<p className="hint">Forhøj dine rettigheder (knap øverst) for at kunne slette en lukket runde.</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
{announceResult && (
|
||||||
|
<p className="hint">
|
||||||
|
Sendt: {announceResult.sent}, fejlet: {announceResult.failed}, deltagere i alt:{' '}
|
||||||
|
{announceResult.total_participants}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2>Vinliste</h2>
|
||||||
|
<form onSubmit={handleCreateWine} className="inline-form">
|
||||||
|
<input
|
||||||
|
placeholder="Navn"
|
||||||
|
value={newWine.name}
|
||||||
|
onChange={(e) => setNewWine({ ...newWine, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Pris"
|
||||||
|
value={newWine.price}
|
||||||
|
onChange={(e) => setNewWine({ ...newWine, price: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newWine.is_organic}
|
||||||
|
onChange={(e) => setNewWine({ ...newWine, is_organic: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Økologisk
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={newWine.category_id}
|
||||||
|
onChange={(e) => setNewWine({ ...newWine, category_id: e.target.value })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Vælg kategori
|
||||||
|
</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Ny vin</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Pris</th>
|
||||||
|
<th>Økologisk</th>
|
||||||
|
<th>Kategori</th>
|
||||||
|
<th>Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{wines.map((wine) =>
|
||||||
|
editingWineId === wine.id ? (
|
||||||
|
<tr key={wine.id}>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<form onSubmit={handleSaveWine} className="inline-form">
|
||||||
|
<input
|
||||||
|
value={editWineForm.name}
|
||||||
|
onChange={(e) => setEditWineForm({ ...editWineForm, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={editWineForm.price}
|
||||||
|
onChange={(e) => setEditWineForm({ ...editWineForm, price: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editWineForm.is_organic}
|
||||||
|
onChange={(e) => setEditWineForm({ ...editWineForm, is_organic: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Økologisk
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editWineForm.category_id}
|
||||||
|
onChange={(e) => setEditWineForm({ ...editWineForm, category_id: e.target.value })}
|
||||||
|
>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Gem</button>
|
||||||
|
<button type="button" onClick={() => setEditingWineId(null)}>
|
||||||
|
Fortryd
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={wine.id}>
|
||||||
|
<td>{wine.name}</td>
|
||||||
|
<td>{wine.price}</td>
|
||||||
|
<td>{wine.is_organic ? 'Ja' : 'Nej'}</td>
|
||||||
|
<td>{categoryName(wine.category_id)}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => startEditWine(wine)}>Redigér</button>
|
||||||
|
<button onClick={() => handleDeleteWine(wine)}>Slet</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
admin-ui/src/pages/RoundsPage.tsx
Normal file
145
admin-ui/src/pages/RoundsPage.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { apiFetch, ApiError } from '../api'
|
||||||
|
import { useAuth } from '../AuthContext'
|
||||||
|
import { useRoute } from '../RouteContext'
|
||||||
|
|
||||||
|
interface PurchaseRound {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
status: 'draft' | 'open' | 'closed'
|
||||||
|
opens_at: string | null
|
||||||
|
order_deadline_at: string | null
|
||||||
|
pickup_at: string | null
|
||||||
|
route_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<PurchaseRound['status'], string> = {
|
||||||
|
draft: 'Kladde',
|
||||||
|
open: 'Åben',
|
||||||
|
closed: 'Lukket',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
return iso ? new Date(iso).toLocaleDateString('da-DK') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null): string {
|
||||||
|
return iso ? new Date(iso).toLocaleString('da-DK') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { currentUser, isElevated, elevatedToken } = useAuth()
|
||||||
|
const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute()
|
||||||
|
const [rounds, setRounds] = useState<PurchaseRound[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
|
||||||
|
async function loadRounds(forRouteId: number) {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<PurchaseRound[]>(`/purchase-rounds?route_id=${forRouteId}&limit=500`)
|
||||||
|
setRounds(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente runder')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (routeId === null) return
|
||||||
|
loadRounds(routeId)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [routeId])
|
||||||
|
|
||||||
|
async function handleCreate(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (routeId === null) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const created = await apiFetch<PurchaseRound>('/purchase-rounds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newName, route_id: routeId }),
|
||||||
|
})
|
||||||
|
navigate(`/runder/${created.id}`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke oprette runde')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy(round: PurchaseRound) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const copy = await apiFetch<PurchaseRound>(`/purchase-rounds/${round.id}/copy`, { method: 'POST' })
|
||||||
|
navigate(`/runder/${copy.id}`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke kopiere runden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(round: PurchaseRound) {
|
||||||
|
if (routeId === null) return
|
||||||
|
if (!window.confirm(`Slet "${round.name}"?`)) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const token = round.status === 'closed' ? elevatedToken : undefined
|
||||||
|
await apiFetch(`/purchase-rounds/${round.id}`, { method: 'DELETE' }, token)
|
||||||
|
await loadRounds(routeId)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette runden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeLoading || (loading && routeId !== null)) return <p>Indlæser...</p>
|
||||||
|
if (routeId === null) return <p className="error">{routeError ?? 'Ingen ruter fundet for din organisation'}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Runder</h1>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="inline-form">
|
||||||
|
<input placeholder="Navn på ny runde" value={newName} onChange={(e) => setNewName(e.target.value)} required />
|
||||||
|
<button type="submit">Ny runde</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Åbner</th>
|
||||||
|
<th>Bestillingsfrist</th>
|
||||||
|
<th>Afhentning</th>
|
||||||
|
<th>Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rounds.map((round) => (
|
||||||
|
<tr key={round.id}>
|
||||||
|
<td>{round.name}</td>
|
||||||
|
<td>{statusLabels[round.status]}</td>
|
||||||
|
<td>{formatDate(round.opens_at)}</td>
|
||||||
|
<td>{formatDate(round.order_deadline_at)}</td>
|
||||||
|
<td>{formatDateTime(round.pickup_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => navigate(`/runder/${round.id}`)}>Redigér</button>
|
||||||
|
<button onClick={() => navigate(`/runder/${round.id}/ordrer`)}>Se ordrer</button>
|
||||||
|
<button onClick={() => handleCopy(round)}>Kopiér</button>
|
||||||
|
{(round.status === 'draft' ||
|
||||||
|
(round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && (
|
||||||
|
<button onClick={() => handleDelete(round)}>Slet</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
174
admin-ui/src/pages/TemplatesPage.tsx
Normal file
174
admin-ui/src/pages/TemplatesPage.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { apiFetch, ApiError } from '../api'
|
||||||
|
import { useRoute } from '../RouteContext'
|
||||||
|
|
||||||
|
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<void>
|
||||||
|
}) {
|
||||||
|
const [subject, setSubject] = useState(template?.subject ?? '')
|
||||||
|
const [bodyHtml, setBodyHtml] = useState(template?.body_html ?? '')
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<section>
|
||||||
|
<h2>{label}</h2>
|
||||||
|
<p className="hint">Tilgængelige variabler: {variables.map((v) => `{{${v}}}`).join(', ')}</p>
|
||||||
|
<form onSubmit={handleSave} className="stacked-form">
|
||||||
|
<label>
|
||||||
|
Emne
|
||||||
|
<input value={subject} onChange={(e) => setSubject(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Indhold (HTML)
|
||||||
|
<textarea value={bodyHtml} onChange={(e) => setBodyHtml(e.target.value)} rows={8} required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">{template ? 'Gem' : 'Opret'}</button>
|
||||||
|
{template && (
|
||||||
|
<button type="button" onClick={handleDelete}>
|
||||||
|
Slet
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatesPage() {
|
||||||
|
const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute()
|
||||||
|
const [templates, setTemplates] = useState<MailTemplate[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function loadTemplates(forRouteId: number) {
|
||||||
|
const data = await apiFetch<MailTemplate[]>(`/mail-templates?route_id=${forRouteId}`)
|
||||||
|
setTemplates(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (routeId === null) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
loadTemplates(routeId)
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente skabeloner')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [routeId])
|
||||||
|
|
||||||
|
if (routeLoading || (loading && routeId !== null)) return <p>Indlæser...</p>
|
||||||
|
if (routeId === null) return <p className="error">{routeError ?? 'Ingen ruter fundet for din organisation'}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Mail-skabeloner</h1>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{EVENT_TYPES.map(({ value, label, variables }) => (
|
||||||
|
<TemplateSection
|
||||||
|
key={value}
|
||||||
|
eventType={value}
|
||||||
|
label={label}
|
||||||
|
variables={variables}
|
||||||
|
template={templates.find((t) => t.event_type === value)}
|
||||||
|
routeId={routeId}
|
||||||
|
onChange={() => loadTemplates(routeId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
admin-ui/tsconfig.app.json
Normal file
26
admin-ui/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
admin-ui/tsconfig.json
Normal file
7
admin-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
admin-ui/tsconfig.node.json
Normal file
23
admin-ui/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
27
admin-ui/vite.config.ts
Normal file
27
admin-ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
const API_PREFIXES = [
|
||||||
|
'/auth',
|
||||||
|
'/participants',
|
||||||
|
'/purchase-rounds',
|
||||||
|
'/wine-offerings',
|
||||||
|
'/wine-categories',
|
||||||
|
'/mail-templates',
|
||||||
|
'/mail-logs',
|
||||||
|
'/orders',
|
||||||
|
]
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: '../app/admin_dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: Object.fromEntries(
|
||||||
|
API_PREFIXES.map((prefix) => [prefix, { target: 'http://127.0.0.1:8001', changeOrigin: true }]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
66
app/admin_site.py
Normal file
66
app/admin_site.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
from starlette.responses import Response
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
ADMIN_DIST_DIR = Path(__file__).parent / "admin_dist"
|
||||||
|
|
||||||
|
# Præfikser der altid rammer det almindelige API, uanset domæne — skal
|
||||||
|
# holdes i sync med routerne registreret i app/main.py
|
||||||
|
_API_PREFIXES = (
|
||||||
|
"/auth",
|
||||||
|
"/participants",
|
||||||
|
"/purchase-rounds",
|
||||||
|
"/wine-offerings",
|
||||||
|
"/wine-categories",
|
||||||
|
"/mail-templates",
|
||||||
|
"/mail-logs",
|
||||||
|
"/orders",
|
||||||
|
"/routes",
|
||||||
|
"/webhooks",
|
||||||
|
"/public",
|
||||||
|
"/health",
|
||||||
|
"/docs",
|
||||||
|
"/redoc",
|
||||||
|
"/openapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SPAStaticFiles(StaticFiles):
|
||||||
|
"""Standard SPA-fallback: ukendte stier (client-side routing)
|
||||||
|
falder tilbage til index.html i stedet for 404."""
|
||||||
|
|
||||||
|
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||||
|
try:
|
||||||
|
return await super().get_response(path, scope)
|
||||||
|
except StarletteHTTPException as exc:
|
||||||
|
if exc.status_code == 404:
|
||||||
|
return await super().get_response("index.html", scope)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDomainDispatch:
|
||||||
|
"""Sender requests mod admin-domænet til den byggede React-SPA i
|
||||||
|
stedet for den domæne-opløste offentlige side (public_site.py) —
|
||||||
|
men lader API-kald (/auth, /participants, ...) passere uændret,
|
||||||
|
så admin-UI'et kan kalde API'et uden CORS (samme origin)."""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.admin_domain = get_settings().admin_domain
|
||||||
|
self.spa_app = SPAStaticFiles(directory=str(ADMIN_DIST_DIR), html=True) if ADMIN_DIST_DIR.is_dir() else None
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http" or self.spa_app is None:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
headers = dict(scope["headers"])
|
||||||
|
host = headers.get(b"host", b"").decode().split(":")[0]
|
||||||
|
if host == self.admin_domain and not scope["path"].startswith(_API_PREFIXES):
|
||||||
|
await self.spa_app(scope, receive, send)
|
||||||
|
return
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
@ -12,6 +12,7 @@ class Settings(BaseSettings):
|
||||||
postal_base_url: str
|
postal_base_url: str
|
||||||
postal_api_key: str
|
postal_api_key: str
|
||||||
postal_webhook_public_key_b64: str
|
postal_webhook_public_key_b64: str
|
||||||
|
admin_domain: str = "admin.localhost"
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.admin_site import AdminDomainDispatch
|
||||||
from app.db import SessionDep
|
from app.db import SessionDep
|
||||||
from app.routers import (
|
from app.routers import (
|
||||||
auth,
|
auth,
|
||||||
|
|
@ -12,6 +13,7 @@ from app.routers import (
|
||||||
public_orders,
|
public_orders,
|
||||||
public_site,
|
public_site,
|
||||||
purchase_rounds,
|
purchase_rounds,
|
||||||
|
routes,
|
||||||
webhooks,
|
webhooks,
|
||||||
wine_categories,
|
wine_categories,
|
||||||
wine_offerings,
|
wine_offerings,
|
||||||
|
|
@ -27,9 +29,11 @@ app.include_router(wine_categories.router)
|
||||||
app.include_router(mail_templates.router)
|
app.include_router(mail_templates.router)
|
||||||
app.include_router(mail_logs.router)
|
app.include_router(mail_logs.router)
|
||||||
app.include_router(orders.router)
|
app.include_router(orders.router)
|
||||||
|
app.include_router(routes.router)
|
||||||
app.include_router(webhooks.router)
|
app.include_router(webhooks.router)
|
||||||
app.include_router(public_orders.router)
|
app.include_router(public_orders.router)
|
||||||
app.include_router(public_site.router)
|
app.include_router(public_site.router)
|
||||||
|
app.add_middleware(AdminDomainDispatch)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -35,3 +35,7 @@ class Route(RouteBase, table=True):
|
||||||
mail_templates: List["MailTemplate"] = Relationship(
|
mail_templates: List["MailTemplate"] = Relationship(
|
||||||
back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
|
back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePublic(RouteBase):
|
||||||
|
id: int
|
||||||
|
|
|
||||||
13
app/routers/routes.py
Normal file
13
app/routers/routes.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.db import SessionDep
|
||||||
|
from app.dependencies import CurrentUser
|
||||||
|
from app.models.route import Route, RoutePublic
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/routes", tags=["routes"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RoutePublic])
|
||||||
|
def list_routes(session: SessionDep, current_user: CurrentUser) -> list[Route]:
|
||||||
|
return list(session.exec(select(Route).where(Route.organization_id == current_user.organization_id)).all())
|
||||||
83
scripts/create_admin_user.py
Normal file
83
scripts/create_admin_user.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""One-off bootstrap: opretter den første organisation + rute + superadmin-bruger.
|
||||||
|
|
||||||
|
Der findes ingen selvbetjenings-registrering i systemet — uden en
|
||||||
|
bruger i databasen kan ingen logge ind overhovedet. Dette script
|
||||||
|
dækker det på en frisk installation.
|
||||||
|
|
||||||
|
Genbruger en organisation/rute med samme navn, hvis de allerede
|
||||||
|
findes (sikkert at køre flere gange, fx for at oprette endnu en
|
||||||
|
admin-bruger på en eksisterende organisation). Fejler tydeligt hvis
|
||||||
|
en bruger med samme email allerede findes, i stedet for at duplikere.
|
||||||
|
|
||||||
|
Brug:
|
||||||
|
uv run python scripts/create_admin_user.py \
|
||||||
|
--org "Fælles Vinindkøb" --route "Fælles Vinindkøb" \
|
||||||
|
--email admin@example.com --name "Dit Navn" --password "..."
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.db import engine
|
||||||
|
from app.models.organization import Organization
|
||||||
|
from app.models.route import Route
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--org", required=True, help="Organisationens navn (genbruges hvis den allerede findes)")
|
||||||
|
parser.add_argument("--route", required=True, help="Rutens navn (genbruges hvis den allerede findes)")
|
||||||
|
parser.add_argument("--email", required=True, help="Email til den nye admin-bruger")
|
||||||
|
parser.add_argument("--name", required=True, help="Navn på den nye admin-bruger")
|
||||||
|
parser.add_argument("--password", required=True, help="Adgangskode til den nye admin-bruger")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
existing_user = session.exec(select(User).where(User.email == args.email)).first()
|
||||||
|
if existing_user is not None:
|
||||||
|
print(f"Fejl: en bruger med email {args.email!r} findes allerede (id={existing_user.id}).")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
organization = session.exec(select(Organization).where(Organization.name == args.org)).first()
|
||||||
|
if organization is None:
|
||||||
|
organization = Organization(name=args.org)
|
||||||
|
session.add(organization)
|
||||||
|
session.flush()
|
||||||
|
print(f"Oprettede organisation {args.org!r} (id={organization.id}).")
|
||||||
|
else:
|
||||||
|
print(f"Genbruger eksisterende organisation {args.org!r} (id={organization.id}).")
|
||||||
|
|
||||||
|
route = session.exec(
|
||||||
|
select(Route).where(Route.name == args.route, Route.organization_id == organization.id)
|
||||||
|
).first()
|
||||||
|
if route is None:
|
||||||
|
route = Route(name=args.route, organization_id=organization.id)
|
||||||
|
session.add(route)
|
||||||
|
session.flush()
|
||||||
|
print(f"Oprettede rute {args.route!r} (id={route.id}).")
|
||||||
|
else:
|
||||||
|
print(f"Genbruger eksisterende rute {args.route!r} (id={route.id}).")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=args.email,
|
||||||
|
name=args.name,
|
||||||
|
hashed_password=hash_password(args.password),
|
||||||
|
is_active=True,
|
||||||
|
is_superadmin=True,
|
||||||
|
organization_id=organization.id,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
print(f"Oprettede superadmin-bruger {args.email!r} (id={user.id}).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Reference in a new issue