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>
158 lines
4.8 KiB
TypeScript
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>
|
|
)
|
|
}
|