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
|
(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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
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