From d360e704df3af9a3a7f0529953fd1ed3ea2160cb Mon Sep 17 00:00:00 2001 From: carsten Date: Tue, 29 Sep 2026 03:11:28 +0200 Subject: [PATCH] =?UTF-8?q?Tilf=C3=B8j=20permanent=20test-rute=20+=20rute-?= =?UTF-8?q?v=C3=A6lger=20i=20admin-UI'et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opretter en ny, rigtig rute ("Fælles Vinindkøb (test)", id 9, test.vinindkoeb.dk, afsender "Test - Fælles Vinindkøb" ) direkte i databasen, så brugeren kan teste hele flowet — inkl. rigtig mail-udsendelse via Postal — uden at røre de 308 rigtige deltagere. Mere robust end de midlertidige isolerede sandboxes brugt til verifikation indtil nu. Med to ruter under samme organisation kan admin-UI'et ikke længere antage "der er kun én rute": RouteContext.tsx (samme mønster som AuthContext) henter ruterne én gang efter login og holder det valgte rute-id (persisteret i localStorage). Layout.tsx får en rute-vælger i navbaren (kun vist når der er mere end én rute). ParticipantsPage, RoundsPage og TemplatesPage havde hver deres egen "GET /routes, brug routes[0]"-logik — omskrevet til at læse fra useRoute() i stedet, så de automatisk genindlæser når man skifter rute. DNS-post og reverse proxy-indgang for test.vinindkoeb.dk skal stadig sættes op manuelt uden for denne kodebase, samme mønster som vinindkoeb.dk/admin.vinindkoeb.dk. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 28 ++++++++++++ admin-ui/src/App.css | 4 ++ admin-ui/src/App.tsx | 23 +++++----- admin-ui/src/Layout.tsx | 15 +++++++ admin-ui/src/RouteContext.tsx | 59 +++++++++++++++++++++++++ admin-ui/src/pages/ParticipantsPage.tsx | 30 ++++--------- admin-ui/src/pages/RoundsPage.tsx | 30 ++++--------- admin-ui/src/pages/TemplatesPage.tsx | 29 +++++------- 8 files changed, 145 insertions(+), 73 deletions(-) create mode 100644 admin-ui/src/RouteContext.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 419dcce..b8bba3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,20 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun `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" ), 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` @@ -368,6 +382,20 @@ den runde-brede vinliste) beregnes client-side. 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 +` selectRoute(Number(e.target.value))} + > + {routes.map((route) => ( + + ))} + + )} Deltagere Runder Skabeloner diff --git a/admin-ui/src/RouteContext.tsx b/admin-ui/src/RouteContext.tsx new file mode 100644 index 0000000..b734f45 --- /dev/null +++ b/admin-ui/src/RouteContext.tsx @@ -0,0 +1,59 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { apiFetch, ApiError } from './api' + +export interface Route { + id: number + name: string +} + +const ROUTE_STORAGE_KEY = 'vinindkoeb_admin_route_id' + +interface RouteState { + routes: Route[] + selectedRouteId: number | null + selectRoute: (id: number) => void + loading: boolean + error: string | null +} + +const RouteContext = createContext(null) + +export function RouteProvider({ children }: { children: ReactNode }) { + const [routes, setRoutes] = useState([]) + const [selectedRouteId, setSelectedRouteId] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + apiFetch('/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 ( + + {children} + + ) +} + +export function useRoute(): RouteState { + const context = useContext(RouteContext) + if (!context) throw new Error('useRoute skal bruges inden i RouteProvider') + return context +} diff --git a/admin-ui/src/pages/ParticipantsPage.tsx b/admin-ui/src/pages/ParticipantsPage.tsx index 25246c7..7a91516 100644 --- a/admin-ui/src/pages/ParticipantsPage.tsx +++ b/admin-ui/src/pages/ParticipantsPage.tsx @@ -1,11 +1,7 @@ import { useEffect, useState, type FormEvent } from 'react' import { apiFetch, ApiError } from '../api' import { useAuth } from '../AuthContext' - -interface Route { - id: number - name: string -} +import { useRoute } from '../RouteContext' interface Participant { id: number @@ -26,7 +22,7 @@ const emptyForm: NewParticipantForm = { name: '', email: '', phone: '' } export function ParticipantsPage() { const { currentUser, isElevated, elevatedToken } = useAuth() - const [routeId, setRouteId] = useState(null) + const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute() const [participants, setParticipants] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -53,21 +49,10 @@ export function ParticipantsPage() { } 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) - }) - }, []) + if (routeId === null) return + loadParticipants(routeId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [routeId]) async function handleCreate(event: FormEvent) { event.preventDefault() @@ -125,7 +110,8 @@ export function ParticipantsPage() { } } - if (loading) return

Indlæser...

+ if (routeLoading || (loading && routeId !== null)) return

Indlæser...

+ if (routeId === null) return

{routeError ?? 'Ingen ruter fundet for din organisation'}

return (
diff --git a/admin-ui/src/pages/RoundsPage.tsx b/admin-ui/src/pages/RoundsPage.tsx index fa840d0..4c08c06 100644 --- a/admin-ui/src/pages/RoundsPage.tsx +++ b/admin-ui/src/pages/RoundsPage.tsx @@ -2,11 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react' import { useNavigate } from 'react-router-dom' import { apiFetch, ApiError } from '../api' import { useAuth } from '../AuthContext' - -interface Route { - id: number - name: string -} +import { useRoute } from '../RouteContext' interface PurchaseRound { id: number @@ -35,7 +31,7 @@ function formatDateTime(iso: string | null): string { export function RoundsPage() { const navigate = useNavigate() const { currentUser, isElevated, elevatedToken } = useAuth() - const [routeId, setRouteId] = useState(null) + const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute() const [rounds, setRounds] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -55,21 +51,10 @@ export function RoundsPage() { } useEffect(() => { - apiFetch('/routes') - .then((routes) => { - if (routes.length === 0) { - setError('Ingen ruter fundet for din organisation') - setLoading(false) - return - } - setRouteId(routes[0].id) - return loadRounds(routes[0].id) - }) - .catch((err) => { - setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ruter') - setLoading(false) - }) - }, []) + if (routeId === null) return + loadRounds(routeId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [routeId]) async function handleCreate(event: FormEvent) { event.preventDefault() @@ -110,7 +95,8 @@ export function RoundsPage() { } } - if (loading) return

Indlæser...

+ if (routeLoading || (loading && routeId !== null)) return

Indlæser...

+ if (routeId === null) return

{routeError ?? 'Ingen ruter fundet for din organisation'}

return (
diff --git a/admin-ui/src/pages/TemplatesPage.tsx b/admin-ui/src/pages/TemplatesPage.tsx index 0154d9e..00c3b0e 100644 --- a/admin-ui/src/pages/TemplatesPage.tsx +++ b/admin-ui/src/pages/TemplatesPage.tsx @@ -1,10 +1,6 @@ import { useEffect, useState, type FormEvent } from 'react' import { apiFetch, ApiError } from '../api' - -interface Route { - id: number - name: string -} +import { useRoute } from '../RouteContext' type EventType = 'round_announced' | 'order_confirmed' | 'payment_confirmed' @@ -133,7 +129,7 @@ function TemplateSection({ } export function TemplatesPage() { - const [routeId, setRouteId] = useState(null) + const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute() const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -144,24 +140,19 @@ export function TemplatesPage() { } useEffect(() => { - apiFetch('/routes') - .then(async (routes) => { - if (routes.length === 0) { - setError('Ingen ruter fundet for din organisation') - setLoading(false) - return - } - setRouteId(routes[0].id) - await loadTemplates(routes[0].id) - }) + 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 (loading) return

Indlæser...

- if (routeId === null) return

{error}

+ if (routeLoading || (loading && routeId !== null)) return

Indlæser...

+ if (routeId === null) return

{routeError ?? 'Ingen ruter fundet for din organisation'}

return (