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:
parent
16d89db527
commit
d360e704df
8 changed files with 145 additions and 73 deletions
28
CLAUDE.md
28
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" <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`
|
||||
|
|
@ -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
|
||||
`<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`,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@
|
|||
gap: 16px;
|
||||
}
|
||||
|
||||
.route-select {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.navbar-links a {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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'
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ function AppRoutes() {
|
|||
if (!currentUser) return <LoginPage />
|
||||
|
||||
return (
|
||||
<RouteProvider>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/deltagere" element={<ParticipantsPage />} />
|
||||
|
|
@ -26,6 +28,7 @@ function AppRoutes() {
|
|||
<Route path="*" element={<Navigate to="/deltagere" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</RouteProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ 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() {
|
||||
|
|
@ -21,6 +23,19 @@ export function Layout() {
|
|||
<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>
|
||||
|
|
|
|||
59
admin-ui/src/RouteContext.tsx
Normal file
59
admin-ui/src/RouteContext.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { apiFetch, ApiError } from './api'
|
||||
|
||||
export interface Route {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const ROUTE_STORAGE_KEY = 'vinindkoeb_admin_route_id'
|
||||
|
||||
interface RouteState {
|
||||
routes: Route[]
|
||||
selectedRouteId: number | null
|
||||
selectRoute: (id: number) => void
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const RouteContext = createContext<RouteState | null>(null)
|
||||
|
||||
export function RouteProvider({ children }: { children: ReactNode }) {
|
||||
const [routes, setRoutes] = useState<Route[]>([])
|
||||
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Route[]>('/routes')
|
||||
.then((data) => {
|
||||
setRoutes(data)
|
||||
if (data.length === 0) return
|
||||
const stored = localStorage.getItem(ROUTE_STORAGE_KEY)
|
||||
const storedId = stored ? Number(stored) : null
|
||||
const validStoredId = storedId !== null && data.some((r) => r.id === storedId) ? storedId : null
|
||||
setSelectedRouteId(validStoredId ?? data[0].id)
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ruter')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
function selectRoute(id: number) {
|
||||
setSelectedRouteId(id)
|
||||
localStorage.setItem(ROUTE_STORAGE_KEY, String(id))
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteContext.Provider value={{ routes, selectedRouteId, selectRoute, loading, error }}>
|
||||
{children}
|
||||
</RouteContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useRoute(): RouteState {
|
||||
const context = useContext(RouteContext)
|
||||
if (!context) throw new Error('useRoute skal bruges inden i RouteProvider')
|
||||
return context
|
||||
}
|
||||
|
|
@ -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<number | null>(null)
|
||||
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)
|
||||
|
|
@ -53,21 +49,10 @@ export function ParticipantsPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Route[]>('/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 <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 (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null)
|
||||
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)
|
||||
|
|
@ -55,21 +51,10 @@ export function RoundsPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Route[]>('/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 <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 (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null)
|
||||
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)
|
||||
|
|
@ -144,24 +140,19 @@ export function TemplatesPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Route[]>('/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 <p>Indlæser...</p>
|
||||
if (routeId === null) return <p className="error">{error}</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 (
|
||||
<div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue