Compare commits
No commits in common. "d3e6b92d9fc7ac5c951408f20c8264d2d6fcf848" and "b6b14aefac5b4f95ba81aa1b4743c19bfcfcb839" have entirely different histories.
d3e6b92d9f
...
b6b14aefac
36 changed files with 4 additions and 4068 deletions
|
|
@ -8,7 +8,3 @@ POSTAL_API_KEY=change-me
|
|||
# Find med: dig +short TXT <dkim-selector>._domainkey.<dit-domæne>
|
||||
# og brug værdien af "p=" (base64 DER, ingen PEM-indpakning nødvendig)
|
||||
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,7 +26,3 @@ dist/
|
|||
# Raw personal-data exports used for one-off migrations — not for version control
|
||||
old_participants.json
|
||||
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,16 +16,9 @@ designes fra start til at kunne understøtte flere organisationer (fx
|
|||
vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
|
||||
é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
|
||||
- Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations)
|
||||
- Frontend: React + TypeScript admin-UI (`admin-ui/`, opgave 11a —
|
||||
scaffold, se "Admin-UI-arkitektur" nedenfor) + den offentlige
|
||||
- Frontend: React (admin-UI, ikke bygget endnu) + den offentlige
|
||||
bestillings-/tilmeldingsside (opgave 8c) — server-renderet af
|
||||
FastAPI selv (Jinja2-templates i `app/templates/` + vanilla JS/CSS i
|
||||
`app/static/`, intet build-step). Fortsat intet login for deltagere.
|
||||
|
|
@ -68,20 +61,6 @@ selvbetjenings-registrering i systemet.
|
|||
`Host`-header (ingen `route_id` i URL'en for de offentlige
|
||||
endpoints) — ét frontend-build kan dermed betjene flere
|
||||
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
|
||||
- `name`, `email`, `phone`, `is_active`
|
||||
|
|
@ -218,190 +197,6 @@ admin printer siden fra browseren. Renderes via
|
|||
offentlige side, egen `Jinja2Templates`-instans i
|
||||
`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
|
||||
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
|
||||
(opgave 7a-c): skabelon pr. rute (`MailTemplate`,
|
||||
|
|
@ -434,7 +229,7 @@ logges/advares om i stedet.
|
|||
|
||||
## Fase 1 — nuværende scope
|
||||
|
||||
**Færdige opgaver (1-11):**
|
||||
**Færdige opgaver (1-10):**
|
||||
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
||||
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
||||
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
||||
|
|
@ -482,29 +277,15 @@ logges/advares om i stedet.
|
|||
irreversibel) der afviser med `409` hvis ordren allerede er betalt.
|
||||
10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order
|
||||
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:**
|
||||
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9
|
||||
12. Testkør en rigtig runde gennem hele flowet
|
||||
|
||||
## Fase 2 — senere
|
||||
- enablebanking.com: automatisk match af indbetalinger mod ubetalte
|
||||
ordrer → kalder samme "markér betalt"-logik
|
||||
- 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)
|
||||
- sold_only_by_case-flag + validering, hvis det bliver relevant
|
||||
- i18n/fransk oversættelse af den offentlige side (8c blev bevidst
|
||||
|
|
|
|||
227
INSTALL.md
227
INSTALL.md
|
|
@ -1,227 +0,0 @@
|
|||
# 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,8 +1,7 @@
|
|||
# Vinindkøb v2
|
||||
|
||||
Admin-backend til fælles vinindkøb (FastAPI + SQLModel + PostgreSQL +
|
||||
Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel, og
|
||||
[INSTALL.md](INSTALL.md) for produktionsopsætning på en server.
|
||||
Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel.
|
||||
|
||||
## Kom i gang
|
||||
|
||||
|
|
@ -14,19 +13,3 @@ uv run fastapi dev app/main.py
|
|||
```
|
||||
|
||||
`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
24
admin-ui/.gitignore
vendored
|
|
@ -1,24 +0,0 @@
|
|||
# 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?
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<!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
1353
admin-ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/** 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()
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
body {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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>,
|
||||
)
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,459 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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 }]),
|
||||
),
|
||||
},
|
||||
})
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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,7 +12,6 @@ class Settings(BaseSettings):
|
|||
postal_base_url: str
|
||||
postal_api_key: str
|
||||
postal_webhook_public_key_b64: str
|
||||
admin_domain: str = "admin.localhost"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from fastapi import FastAPI
|
|||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.admin_site import AdminDomainDispatch
|
||||
from app.db import SessionDep
|
||||
from app.routers import (
|
||||
auth,
|
||||
|
|
@ -13,7 +12,6 @@ from app.routers import (
|
|||
public_orders,
|
||||
public_site,
|
||||
purchase_rounds,
|
||||
routes,
|
||||
webhooks,
|
||||
wine_categories,
|
||||
wine_offerings,
|
||||
|
|
@ -29,11 +27,9 @@ app.include_router(wine_categories.router)
|
|||
app.include_router(mail_templates.router)
|
||||
app.include_router(mail_logs.router)
|
||||
app.include_router(orders.router)
|
||||
app.include_router(routes.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(public_orders.router)
|
||||
app.include_router(public_site.router)
|
||||
app.add_middleware(AdminDomainDispatch)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
|
|
|||
|
|
@ -35,7 +35,3 @@ class Route(RouteBase, table=True):
|
|||
mail_templates: List["MailTemplate"] = Relationship(
|
||||
back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
|
||||
)
|
||||
|
||||
|
||||
class RoutePublic(RouteBase):
|
||||
id: int
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
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())
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
"""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