diff --git a/CLAUDE.md b/CLAUDE.md index c526443..5b621b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,9 +201,14 @@ offentlige side, egen `Jinja2Templates`-instans i ## Admin-UI-arkitektur (opgave 11) Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a** -(scaffold + hosting-mekanisme, ✅), **11b** (deltagere, -runder/vinliste, annoncering) og **11c** (ordreoversigt/markér betalt) -er selve admin-skærmene, ikke bygget endnu. +(scaffold + hosting-mekanisme, ✅). **11b** viste sig under afklaring +at være for stort til én omgang og er selv delt i tre dele, alle +stadig under "11b" (ingen ny bogstav-opdeling): **del 1** (delt +frontend-infrastruktur + Deltagere-CRUD, ✅, se nedenfor), **del 2** +(runder + vinliste, ikke bygget) og **del 3** (mail-skabelon-editor + +annoncér-knap, ikke bygget — "annoncér" er reelt ubrugelig uden en +måde at oprette en `round_announced`-skabelon på). **11c** +(ordreoversigt/markér betalt) er heller ikke bygget endnu. **Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript, almindelig CSS — ingen framework) serveres af **samme FastAPI-app** @@ -244,12 +249,46 @@ 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 — selve admin-skærmene er 11b/11c): en login-side -(`admin-ui/src/App.tsx`, email+password → `POST /auth/login` +kæden virker): en login-side, email+password → `POST /auth/login` form-encoded per `OAuth2PasswordRequestForm`, JWT gemmes i -`localStorage`), som ved succes kalder `GET /auth/me` og viser den -indloggede brugers navn/email. Login og log ud er browser-testet af -brugeren (ModHeader, `Host: admin.localhost`) og virker. +`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. ## Mail-events 1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret** @@ -283,7 +322,7 @@ logges/advares om i stedet. ## Fase 1 — nuværende scope -**Færdige opgaver (1-10, 11a):** +**Færdige opgaver (1-10, 11a, 11b del 1):** 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding 2. ✅ Datamodellerne (Organization/Route-hierarki) 3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, @@ -334,7 +373,11 @@ logges/advares om i stedet. 11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre: - **11a**: ✅ Scaffold + domæne-baseret hosting-mekanisme — se "Admin-UI-arkitektur" ovenfor. - - **11b**: Deltagere, runder/vinliste, annoncering — ikke bygget. + - **11b**: Deltagere, runder/vinliste, annoncering — delt i tre + (se "Admin-UI-arkitektur" ovenfor): **del 1** ✅ + (frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder + + vinliste, ikke bygget), **del 3** (mail-skabelon-editor + + annoncér-knap, ikke bygget). - **11c**: Ordreoversigt/markér betalt — ikke bygget. **Resterende opgaver:** @@ -344,6 +387,12 @@ logges/advares om i stedet. - 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 diff --git a/admin-ui/package-lock.json b/admin-ui/package-lock.json index ce4b888..cd03f70 100644 --- a/admin-ui/package-lock.json +++ b/admin-ui/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@types/node": "^24.13.3", @@ -676,6 +677,19 @@ } } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1125,6 +1139,44 @@ "react": "^19.3.0" } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", @@ -1165,6 +1217,12 @@ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/admin-ui/package.json b/admin-ui/package.json index 01e6c97..ba90f4c 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@types/node": "^24.13.3", diff --git a/admin-ui/src/App.css b/admin-ui/src/App.css index c510a91..9e8b335 100644 --- a/admin-ui/src/App.css +++ b/admin-ui/src/App.css @@ -4,7 +4,6 @@ padding: 24px; border: 1px solid #ddd; border-radius: 8px; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } .login-card h1 { @@ -38,3 +37,98 @@ 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; +} + +.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; +} diff --git a/admin-ui/src/App.tsx b/admin-ui/src/App.tsx index 415d4db..b0d842b 100644 --- a/admin-ui/src/App.tsx +++ b/admin-ui/src/App.tsx @@ -1,93 +1,33 @@ -import { useState, type FormEvent } from 'react' +import { Navigate, Route, BrowserRouter, Routes } from 'react-router-dom' +import { AuthProvider, useAuth } from './AuthContext' +import { Layout } from './Layout' +import { LoginPage } from './pages/LoginPage' +import { ParticipantsPage } from './pages/ParticipantsPage' import './App.css' -interface CurrentUser { - id: number - email: string - name: string - is_active: boolean - is_superadmin: boolean - organization_id: number -} +function AppRoutes() { + const { currentUser, loading } = useAuth() -const TOKEN_STORAGE_KEY = 'vinindkoeb_admin_token' + if (loading) return

Indlæser...

+ if (!currentUser) return -async function login(email: string, password: string): Promise { - 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) { - const body = await response.json().catch(() => null) - throw new Error(body?.detail || 'Login mislykkedes') - } - const data = await response.json() - return data.access_token as string -} - -async function fetchCurrentUser(token: string): Promise { - const response = await fetch('/auth/me', { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!response.ok) { - throw new Error('Kunne ikke hente brugerinfo') - } - return response.json() + return ( + + }> + } /> + } /> + + + ) } function App() { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState(null) - const [currentUser, setCurrentUser] = useState(null) - - async function handleSubmit(event: FormEvent) { - event.preventDefault() - setError(null) - try { - const token = await login(email, password) - localStorage.setItem(TOKEN_STORAGE_KEY, token) - const user = await fetchCurrentUser(token) - setCurrentUser(user) - } catch (err) { - setError(err instanceof Error ? err.message : 'Der skete en fejl') - } - } - - function handleLogout() { - localStorage.removeItem(TOKEN_STORAGE_KEY) - setCurrentUser(null) - } - - if (currentUser) { - return ( -
-

Fælles Vinindkøb — Admin

-

- Logget ind som {currentUser.name} ({currentUser.email}) -

- -
- ) - } - return ( -
-

Fælles Vinindkøb — Admin

-
- - - - {error &&

{error}

} -
-
+ + + + + ) } diff --git a/admin-ui/src/AuthContext.tsx b/admin-ui/src/AuthContext.tsx new file mode 100644 index 0000000..213b195 --- /dev/null +++ b/admin-ui/src/AuthContext.tsx @@ -0,0 +1,97 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { + apiFetch, + clearToken, + decodeJwtExpiry, + elevate as apiElevate, + getToken, + login as apiLogin, + setToken, +} from './api' + +export interface CurrentUser { + id: number + email: string + name: string + is_active: boolean + is_superadmin: boolean + organization_id: number +} + +interface AuthState { + currentUser: CurrentUser | null + loading: boolean + login: (email: string, password: string) => Promise + logout: () => void + /** Kortlivet eleveret token — kun sat lige efter et bevidst "Forhøj rettigheder"-klik. */ + elevatedToken: string | null + isElevated: boolean + requestElevation: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [currentUser, setCurrentUser] = useState(null) + const [loading, setLoading] = useState(true) + const [elevatedToken, setElevatedToken] = useState(null) + const [elevatedExpiresAt, setElevatedExpiresAt] = useState(null) + const [, forceTick] = useState(0) + + useEffect(() => { + const token = getToken() + if (!token) { + setLoading(false) + return + } + apiFetch('/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('/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 ( + + {children} + + ) +} + +export function useAuth(): AuthState { + const context = useContext(AuthContext) + if (!context) throw new Error('useAuth skal bruges inden i AuthProvider') + return context +} diff --git a/admin-ui/src/Layout.tsx b/admin-ui/src/Layout.tsx new file mode 100644 index 0000000..ca96be0 --- /dev/null +++ b/admin-ui/src/Layout.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import { NavLink, Outlet } from 'react-router-dom' +import { ApiError } from './api' +import { useAuth } from './AuthContext' + +export function Layout() { + const { currentUser, logout, isElevated, requestElevation } = useAuth() + const [elevationError, setElevationError] = useState(null) + + async function handleElevate() { + setElevationError(null) + try { + await requestElevation() + } catch (err) { + setElevationError(err instanceof ApiError ? err.message : 'Kunne ikke forhøje rettigheder') + } + } + + return ( +
+ + {elevationError &&

{elevationError}

} +
+ +
+
+ ) +} diff --git a/admin-ui/src/api.ts b/admin-ui/src/api.ts new file mode 100644 index 0000000..9fae7c9 --- /dev/null +++ b/admin-ui/src/api.ts @@ -0,0 +1,74 @@ +export const TOKEN_STORAGE_KEY = 'vinindkoeb_admin_token' + +export class ApiError extends Error {} + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_STORAGE_KEY) +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_STORAGE_KEY, token) +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_STORAGE_KEY) +} + +async function extractErrorMessage(response: Response): Promise { + 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( + path: string, + options: RequestInit = {}, + token: string | null = getToken(), +): Promise { + 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 +} + +export async function login(email: string, password: string): Promise { + 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 { + 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 + } +} diff --git a/admin-ui/src/index.css b/admin-ui/src/index.css index 074d6e3..0f4815d 100644 --- a/admin-ui/src/index.css +++ b/admin-ui/src/index.css @@ -2,4 +2,5 @@ body { margin: 0; background: #fff; color: #222; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } diff --git a/admin-ui/src/pages/LoginPage.tsx b/admin-ui/src/pages/LoginPage.tsx new file mode 100644 index 0000000..be19f8e --- /dev/null +++ b/admin-ui/src/pages/LoginPage.tsx @@ -0,0 +1,38 @@ +import { useState, type FormEvent } from 'react' +import { useAuth } from '../AuthContext' +import { ApiError } from '../api' + +export function LoginPage() { + const { login } = useAuth() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(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 ( +
+

Fælles Vinindkøb — Admin

+
+ + + + {error &&

{error}

} +
+
+ ) +} diff --git a/admin-ui/src/pages/ParticipantsPage.tsx b/admin-ui/src/pages/ParticipantsPage.tsx new file mode 100644 index 0000000..25246c7 --- /dev/null +++ b/admin-ui/src/pages/ParticipantsPage.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { apiFetch, ApiError } from '../api' +import { useAuth } from '../AuthContext' + +interface Route { + id: number + name: string +} + +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 [routeId, setRouteId] = useState(null) + const [participants, setParticipants] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [newParticipant, setNewParticipant] = useState(emptyForm) + const [editingId, setEditingId] = useState(null) + const [editForm, setEditForm] = useState({ + ...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(`/participants?route_id=${forRouteId}&limit=500`) + setParticipants(data) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Kunne ikke hente deltagere') + } finally { + setLoading(false) + } + } + + useEffect(() => { + apiFetch('/routes') + .then((routes) => { + if (routes.length === 0) { + setError('Ingen ruter fundet for din organisation') + setLoading(false) + return + } + setRouteId(routes[0].id) + return loadParticipants(routes[0].id) + }) + .catch((err) => { + setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ruter') + setLoading(false) + }) + }, []) + + 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 (loading) return

Indlæser...

+ + return ( +
+

Deltagere

+ {error &&

{error}

} + {currentUser?.is_superadmin && !isElevated && ( +

Forhøj dine rettigheder (knap øverst) for at kunne slette en deltager permanent.

+ )} + +
+ setNewParticipant({ ...newParticipant, name: e.target.value })} + required + /> + setNewParticipant({ ...newParticipant, email: e.target.value })} + required + /> + setNewParticipant({ ...newParticipant, phone: e.target.value })} + required + /> + +
+ + + + + + + + + + + + + {participants.map((participant) => + editingId === participant.id ? ( + + + + ) : ( + + + + + + + + ), + )} + +
NavnEmailTelefonAktivHandlinger
+
+ setEditForm({ ...editForm, name: e.target.value })} + required + /> + setEditForm({ ...editForm, email: e.target.value })} + required + /> + setEditForm({ ...editForm, phone: e.target.value })} + /> + + + +
+
{participant.name}{participant.email}{participant.phone}{participant.is_active ? 'Ja' : 'Nej'} + + {currentUser?.is_superadmin && isElevated && ( + + )} +
+
+ ) +} diff --git a/app/admin_site.py b/app/admin_site.py index 06b6b07..f6209f6 100644 --- a/app/admin_site.py +++ b/app/admin_site.py @@ -20,6 +20,7 @@ _API_PREFIXES = ( "/mail-templates", "/mail-logs", "/orders", + "/routes", "/webhooks", "/public", "/health", diff --git a/app/main.py b/app/main.py index 1b55106..6babf08 100644 --- a/app/main.py +++ b/app/main.py @@ -13,6 +13,7 @@ from app.routers import ( public_orders, public_site, purchase_rounds, + routes, webhooks, wine_categories, wine_offerings, @@ -28,6 +29,7 @@ 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) diff --git a/app/models/route.py b/app/models/route.py index aaad81a..2fb2f69 100644 --- a/app/models/route.py +++ b/app/models/route.py @@ -35,3 +35,7 @@ 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 diff --git a/app/routers/routes.py b/app/routers/routes.py new file mode 100644 index 0000000..2258f48 --- /dev/null +++ b/app/routers/routes.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter +from sqlmodel import select + +from app.db import SessionDep +from app.dependencies import CurrentUser +from app.models.route import Route, RoutePublic + +router = APIRouter(prefix="/routes", tags=["routes"]) + + +@router.get("", response_model=list[RoutePublic]) +def list_routes(session: SessionDep, current_user: CurrentUser) -> list[Route]: + return list(session.exec(select(Route).where(Route.organization_id == current_user.organization_id)).all())