Opgave 11b (del 2): Runder + vinliste

RoundsPage (liste + opret) og RoundDetailPage (/runder/:roundId —
redigér, status-overgange, slet, "kopiér denne runde", nestet
vinliste-CRUD). Ingen backend-ændringer nødvendige, al CRUD fandtes
allerede (opgave 6). 409-fejl fra rundens dato-constraint vises
direkte som API'ets fejlbesked i stedet for at duplikere reglen i
frontend.

To fund fra brugertest, begge rettet:
1. Åbningsdato og bestillingsfrist krævede et tidspunkt
   (datetime-local) uden reelt behov — skiftet til rene datofelter
   (dateUtils.ts). Afhentningstidspunktet beholder klokkeslæt.
2. Kun kladde-runder kunne slettes fra UI'et. Lukkede runder kan nu
   også slettes, når eleveret — samme elevations-mønster som
   deltager-hård-sletning i del 1 (elevatedToken fra AuthContext).
   Åbne runder kan fortsat aldrig slettes (håndhævet server-side).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-29 01:59:46 +02:00
parent 62d464fda2
commit 588888cd9e
7 changed files with 674 additions and 3 deletions

View file

@ -290,6 +290,33 @@ på dette:
(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); 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).
## Mail-events
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
(opgave 7a-c): skabelon pr. rute (`MailTemplate`,
@ -322,7 +349,7 @@ logges/advares om i stedet.
## Fase 1 — nuværende scope
**Færdige opgaver (1-10, 11a, 11b del 1):**
**Færdige opgaver (1-10, 11a, 11b del 1-2):**
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
2. ✅ Datamodellerne (Organization/Route-hierarki)
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
@ -375,8 +402,8 @@ logges/advares om i stedet.
"Admin-UI-arkitektur" ovenfor.
- **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 +
(frontend-infrastruktur + Deltagere-CRUD), **del 2** ✅
(runder + vinliste), **del 3** (mail-skabelon-editor +
annoncér-knap, ikke bygget).
- **11c**: Ordreoversigt/markér betalt — ikke bygget.

View file

@ -132,3 +132,35 @@ td {
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;
}

View file

@ -3,6 +3,8 @@ import { AuthProvider, useAuth } from './AuthContext'
import { Layout } from './Layout'
import { LoginPage } from './pages/LoginPage'
import { ParticipantsPage } from './pages/ParticipantsPage'
import { RoundDetailPage } from './pages/RoundDetailPage'
import { RoundsPage } from './pages/RoundsPage'
import './App.css'
function AppRoutes() {
@ -15,6 +17,8 @@ function AppRoutes() {
<Routes>
<Route element={<Layout />}>
<Route path="/deltagere" element={<ParticipantsPage />} />
<Route path="/runder" element={<RoundsPage />} />
<Route path="/runder/:roundId" element={<RoundDetailPage />} />
<Route path="*" element={<Navigate to="/deltagere" replace />} />
</Route>
</Routes>

View file

@ -22,6 +22,7 @@ export function Layout() {
<span className="navbar-brand">Fælles Vinindkøb — Admin</span>
<div className="navbar-links">
<NavLink to="/deltagere">Deltagere</NavLink>
<NavLink to="/runder">Runder</NavLink>
{currentUser?.is_superadmin &&
(isElevated ? (
<span className="elevated-badge" title="Udløber automatisk efter 5 minutter">

29
admin-ui/src/dateUtils.ts Normal file
View file

@ -0,0 +1,29 @@
/** 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()
}

View file

@ -0,0 +1,420 @@
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: '' }
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 [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 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={handleCopy}>
Kopiér denne 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>
<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>
)
}

View file

@ -0,0 +1,158 @@
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
}
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 [routeId, setRouteId] = useState<number | null>(null)
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(() => {
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)
})
}, [])
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 (loading) return <p>Indlæser...</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={() => 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>
)
}