Tilføj permanent test-rute + rute-vælger i admin-UI'et

Opretter en ny, rigtig rute ("Fælles Vinindkøb (test)", id 9,
test.vinindkoeb.dk, afsender "Test - Fælles Vinindkøb"
<test@vinindkoeb.dk>) 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 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-29 03:11:28 +02:00
parent 16d89db527
commit d360e704df
8 changed files with 145 additions and 73 deletions

View file

@ -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 `Host`-header (ingen `route_id` i URL'en for de offentlige
endpoints) — ét frontend-build kan dermed betjene flere endpoints) — ét frontend-build kan dermed betjene flere
organisationer organisationer
- **To rigtige ruter findes nu** (id 4, `vinindkoeb.dk` — den
rigtige, med de 308 rigtige deltagere) og en permanent test-rute
("Fælles Vinindkøb (test)", id 9, `test.vinindkoeb.dk`, afsender
"Test - Fælles Vinindkøb" <test@vinindkoeb.dk>), oprettet direkte
i databasen (ingen rute-opret-skærm i admin-UI'et endnu). Formål:
lade brugeren teste hele flowet (inkl. rigtig mail-udsendelse via
Postal) uden at røre de rigtige deltagere — mere robust end de
midlertidige isolerede sandboxes der er brugt til verifikation
indtil nu. Test-ruten er tom (ingen deltagere/runder/skabeloner
seedet) — brugeren opretter selv testdata via admin-UI'et.
**Kræver stadig manuel opsætning uden for denne kodebase:** en
DNS-post for `test.vinindkoeb.dk` + en reverse proxy-indgang til
samme backend (samme mønster som `vinindkoeb.dk`/
`admin.vinindkoeb.dk`).
- **Participant** — tilhører en Route - **Participant** — tilhører en Route
- `name`, `email`, `phone`, `is_active` - `name`, `email`, `phone`, `is_active`
@ -368,6 +382,20 @@ den runde-brede vinliste) beregnes client-side.
kategori-gruppering samt havde antal efter vinnavn i stedet for kategori-gruppering samt havde antal efter vinnavn i stedet for
før — alle tre rettet. før — alle tre rettet.
**Rute-vælger** (tilføjet efter 11c, da en anden rigtig test-rute kom
til — se Route ovenfor): `admin-ui/src/RouteContext.tsx`, samme
Context-mønster som `AuthContext.tsx`. Henter `GET /routes` én gang
efter login, holder `selectedRouteId` (gemt i `localStorage`, så
valget overlever en sideopdatering — falder tilbage til den første
rute hvis intet/et ugyldigt valg er gemt). `Layout.tsx` viser et
`<select>` i navbaren (kun når der er mere end én rute).
`ParticipantsPage`/`RoundsPage`/`TemplatesPage` brugte hver deres egen
`GET /routes`-kald + antog "første rute" — de er nu omskrevet til at
læse `selectedRouteId` fra `useRoute()` i stedet, og genindlæser
automatisk når man skifter rute i navbaren. `RoundDetailPage`/
`OrdersPage` er uændrede — de arbejder allerede på én bestemt rundes
id fra URL'en, uafhængigt af den globalt valgte rute.
## Mail-events ## Mail-events
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret** 1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
(opgave 7a-c): skabelon pr. rute (`MailTemplate`, (opgave 7a-c): skabelon pr. rute (`MailTemplate`,

View file

@ -58,6 +58,10 @@
gap: 16px; gap: 16px;
} }
.route-select {
padding: 4px 8px;
}
.navbar-links a { .navbar-links a {
color: #fff; color: #fff;
text-decoration: none; text-decoration: none;

View file

@ -6,6 +6,7 @@ import { OrdersPage } from './pages/OrdersPage'
import { ParticipantsPage } from './pages/ParticipantsPage' import { ParticipantsPage } from './pages/ParticipantsPage'
import { RoundDetailPage } from './pages/RoundDetailPage' import { RoundDetailPage } from './pages/RoundDetailPage'
import { RoundsPage } from './pages/RoundsPage' import { RoundsPage } from './pages/RoundsPage'
import { RouteProvider } from './RouteContext'
import { TemplatesPage } from './pages/TemplatesPage' import { TemplatesPage } from './pages/TemplatesPage'
import './App.css' import './App.css'
@ -16,16 +17,18 @@ function AppRoutes() {
if (!currentUser) return <LoginPage /> if (!currentUser) return <LoginPage />
return ( return (
<Routes> <RouteProvider>
<Route element={<Layout />}> <Routes>
<Route path="/deltagere" element={<ParticipantsPage />} /> <Route element={<Layout />}>
<Route path="/runder" element={<RoundsPage />} /> <Route path="/deltagere" element={<ParticipantsPage />} />
<Route path="/runder/:roundId" element={<RoundDetailPage />} /> <Route path="/runder" element={<RoundsPage />} />
<Route path="/runder/:roundId/ordrer" element={<OrdersPage />} /> <Route path="/runder/:roundId" element={<RoundDetailPage />} />
<Route path="/skabeloner" element={<TemplatesPage />} /> <Route path="/runder/:roundId/ordrer" element={<OrdersPage />} />
<Route path="*" element={<Navigate to="/deltagere" replace />} /> <Route path="/skabeloner" element={<TemplatesPage />} />
</Route> <Route path="*" element={<Navigate to="/deltagere" replace />} />
</Routes> </Route>
</Routes>
</RouteProvider>
) )
} }

View file

@ -2,9 +2,11 @@ import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { ApiError } from './api' import { ApiError } from './api'
import { useAuth } from './AuthContext' import { useAuth } from './AuthContext'
import { useRoute } from './RouteContext'
export function Layout() { export function Layout() {
const { currentUser, logout, isElevated, requestElevation } = useAuth() const { currentUser, logout, isElevated, requestElevation } = useAuth()
const { routes, selectedRouteId, selectRoute } = useRoute()
const [elevationError, setElevationError] = useState<string | null>(null) const [elevationError, setElevationError] = useState<string | null>(null)
async function handleElevate() { async function handleElevate() {
@ -21,6 +23,19 @@ export function Layout() {
<nav className="navbar"> <nav className="navbar">
<span className="navbar-brand">Fælles Vinindkøb — Admin</span> <span className="navbar-brand">Fælles Vinindkøb — Admin</span>
<div className="navbar-links"> <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="/deltagere">Deltagere</NavLink>
<NavLink to="/runder">Runder</NavLink> <NavLink to="/runder">Runder</NavLink>
<NavLink to="/skabeloner">Skabeloner</NavLink> <NavLink to="/skabeloner">Skabeloner</NavLink>

View file

@ -0,0 +1,59 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { apiFetch, ApiError } from './api'
export interface Route {
id: number
name: string
}
const ROUTE_STORAGE_KEY = 'vinindkoeb_admin_route_id'
interface RouteState {
routes: Route[]
selectedRouteId: number | null
selectRoute: (id: number) => void
loading: boolean
error: string | null
}
const RouteContext = createContext<RouteState | null>(null)
export function RouteProvider({ children }: { children: ReactNode }) {
const [routes, setRoutes] = useState<Route[]>([])
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<Route[]>('/routes')
.then((data) => {
setRoutes(data)
if (data.length === 0) return
const stored = localStorage.getItem(ROUTE_STORAGE_KEY)
const storedId = stored ? Number(stored) : null
const validStoredId = storedId !== null && data.some((r) => r.id === storedId) ? storedId : null
setSelectedRouteId(validStoredId ?? data[0].id)
})
.catch((err) => {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ruter')
})
.finally(() => setLoading(false))
}, [])
function selectRoute(id: number) {
setSelectedRouteId(id)
localStorage.setItem(ROUTE_STORAGE_KEY, String(id))
}
return (
<RouteContext.Provider value={{ routes, selectedRouteId, selectRoute, loading, error }}>
{children}
</RouteContext.Provider>
)
}
export function useRoute(): RouteState {
const context = useContext(RouteContext)
if (!context) throw new Error('useRoute skal bruges inden i RouteProvider')
return context
}

View file

@ -1,11 +1,7 @@
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { apiFetch, ApiError } from '../api' import { apiFetch, ApiError } from '../api'
import { useAuth } from '../AuthContext' import { useAuth } from '../AuthContext'
import { useRoute } from '../RouteContext'
interface Route {
id: number
name: string
}
interface Participant { interface Participant {
id: number id: number
@ -26,7 +22,7 @@ const emptyForm: NewParticipantForm = { name: '', email: '', phone: '' }
export function ParticipantsPage() { export function ParticipantsPage() {
const { currentUser, isElevated, elevatedToken } = useAuth() const { currentUser, isElevated, elevatedToken } = useAuth()
const [routeId, setRouteId] = useState<number | null>(null) const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute()
const [participants, setParticipants] = useState<Participant[]>([]) const [participants, setParticipants] = useState<Participant[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -53,21 +49,10 @@ export function ParticipantsPage() {
} }
useEffect(() => { useEffect(() => {
apiFetch<Route[]>('/routes') if (routeId === null) return
.then((routes) => { loadParticipants(routeId)
if (routes.length === 0) { // eslint-disable-next-line react-hooks/exhaustive-deps
setError('Ingen ruter fundet for din organisation') }, [routeId])
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) { async function handleCreate(event: FormEvent) {
event.preventDefault() event.preventDefault()
@ -125,7 +110,8 @@ export function ParticipantsPage() {
} }
} }
if (loading) return <p>Indlæser...</p> 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 ( return (
<div> <div>

View file

@ -2,11 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { apiFetch, ApiError } from '../api' import { apiFetch, ApiError } from '../api'
import { useAuth } from '../AuthContext' import { useAuth } from '../AuthContext'
import { useRoute } from '../RouteContext'
interface Route {
id: number
name: string
}
interface PurchaseRound { interface PurchaseRound {
id: number id: number
@ -35,7 +31,7 @@ function formatDateTime(iso: string | null): string {
export function RoundsPage() { export function RoundsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { currentUser, isElevated, elevatedToken } = useAuth() const { currentUser, isElevated, elevatedToken } = useAuth()
const [routeId, setRouteId] = useState<number | null>(null) const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute()
const [rounds, setRounds] = useState<PurchaseRound[]>([]) const [rounds, setRounds] = useState<PurchaseRound[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -55,21 +51,10 @@ export function RoundsPage() {
} }
useEffect(() => { useEffect(() => {
apiFetch<Route[]>('/routes') if (routeId === null) return
.then((routes) => { loadRounds(routeId)
if (routes.length === 0) { // eslint-disable-next-line react-hooks/exhaustive-deps
setError('Ingen ruter fundet for din organisation') }, [routeId])
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)
})
}, [])
async function handleCreate(event: FormEvent) { async function handleCreate(event: FormEvent) {
event.preventDefault() event.preventDefault()
@ -110,7 +95,8 @@ export function RoundsPage() {
} }
} }
if (loading) return <p>Indlæser...</p> 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 ( return (
<div> <div>

View file

@ -1,10 +1,6 @@
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { apiFetch, ApiError } from '../api' import { apiFetch, ApiError } from '../api'
import { useRoute } from '../RouteContext'
interface Route {
id: number
name: string
}
type EventType = 'round_announced' | 'order_confirmed' | 'payment_confirmed' type EventType = 'round_announced' | 'order_confirmed' | 'payment_confirmed'
@ -133,7 +129,7 @@ function TemplateSection({
} }
export function TemplatesPage() { export function TemplatesPage() {
const [routeId, setRouteId] = useState<number | null>(null) const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute()
const [templates, setTemplates] = useState<MailTemplate[]>([]) const [templates, setTemplates] = useState<MailTemplate[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -144,24 +140,19 @@ export function TemplatesPage() {
} }
useEffect(() => { useEffect(() => {
apiFetch<Route[]>('/routes') if (routeId === null) return
.then(async (routes) => { setLoading(true)
if (routes.length === 0) { setError(null)
setError('Ingen ruter fundet for din organisation') loadTemplates(routeId)
setLoading(false)
return
}
setRouteId(routes[0].id)
await loadTemplates(routes[0].id)
})
.catch((err) => { .catch((err) => {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente skabeloner') setError(err instanceof ApiError ? err.message : 'Kunne ikke hente skabeloner')
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeId])
if (loading) return <p>Indlæser...</p> if (routeLoading || (loading && routeId !== null)) return <p>Indlæser...</p>
if (routeId === null) return <p className="error">{error}</p> if (routeId === null) return <p className="error">{routeError ?? 'Ingen ruter fundet for din organisation'}</p>
return ( return (
<div> <div>