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:
parent
62d464fda2
commit
588888cd9e
7 changed files with 674 additions and 3 deletions
33
CLAUDE.md
33
CLAUDE.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
29
admin-ui/src/dateUtils.ts
Normal 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()
|
||||
}
|
||||
420
admin-ui/src/pages/RoundDetailPage.tsx
Normal file
420
admin-ui/src/pages/RoundDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
admin-ui/src/pages/RoundsPage.tsx
Normal file
158
admin-ui/src/pages/RoundsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue