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 (API'ets maksimum). Rigtig pagination er udskudt til Fase 2, hvis
listen en dag vokser forbi 500. 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 ## 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`,
@ -322,7 +349,7 @@ logges/advares om i stedet.
## Fase 1 — nuværende scope ## 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 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
2. ✅ Datamodellerne (Organization/Route-hierarki) 2. ✅ Datamodellerne (Organization/Route-hierarki)
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, 3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
@ -375,8 +402,8 @@ logges/advares om i stedet.
"Admin-UI-arkitektur" ovenfor. "Admin-UI-arkitektur" ovenfor.
- **11b**: Deltagere, runder/vinliste, annoncering — delt i tre - **11b**: Deltagere, runder/vinliste, annoncering — delt i tre
(se "Admin-UI-arkitektur" ovenfor): **del 1** ✅ (se "Admin-UI-arkitektur" ovenfor): **del 1** ✅
(frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder + (frontend-infrastruktur + Deltagere-CRUD), **del 2** ✅
vinliste, ikke bygget), **del 3** (mail-skabelon-editor + (runder + vinliste), **del 3** (mail-skabelon-editor +
annoncér-knap, ikke bygget). annoncér-knap, ikke bygget).
- **11c**: Ordreoversigt/markér betalt — ikke bygget. - **11c**: Ordreoversigt/markér betalt — ikke bygget.

View file

@ -132,3 +132,35 @@ td {
padding: 6px 12px; padding: 6px 12px;
cursor: pointer; 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 { Layout } from './Layout'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { ParticipantsPage } from './pages/ParticipantsPage' import { ParticipantsPage } from './pages/ParticipantsPage'
import { RoundDetailPage } from './pages/RoundDetailPage'
import { RoundsPage } from './pages/RoundsPage'
import './App.css' import './App.css'
function AppRoutes() { function AppRoutes() {
@ -15,6 +17,8 @@ function AppRoutes() {
<Routes> <Routes>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/deltagere" element={<ParticipantsPage />} /> <Route path="/deltagere" element={<ParticipantsPage />} />
<Route path="/runder" element={<RoundsPage />} />
<Route path="/runder/:roundId" element={<RoundDetailPage />} />
<Route path="*" element={<Navigate to="/deltagere" replace />} /> <Route path="*" element={<Navigate to="/deltagere" replace />} />
</Route> </Route>
</Routes> </Routes>

View file

@ -22,6 +22,7 @@ export function Layout() {
<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">
<NavLink to="/deltagere">Deltagere</NavLink> <NavLink to="/deltagere">Deltagere</NavLink>
<NavLink to="/runder">Runder</NavLink>
{currentUser?.is_superadmin && {currentUser?.is_superadmin &&
(isElevated ? ( (isElevated ? (
<span className="elevated-badge" title="Udløber automatisk efter 5 minutter"> <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>
)
}