vinindkoeb/admin-ui/src/pages/RoundsPage.tsx
carsten 588888cd9e 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>
2026-09-29 01:59:46 +02:00

158 lines
4.8 KiB
TypeScript

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>
)
}