Opgave 11c: Ordreoversigt + markér betalt (harmonika-UI)

OrdersPage (/runder/:roundId/ordrer, linket fra RoundsPage og
RoundDetailPage) — en to-niveaus harmonika designet af brugeren:
øverste fold viser rundens flasketotal, total og udestående beløb
(sum af ordrer der endnu ikke er betalt), foldet ud til rundens
samlede, kategori-grupperede vinliste. Derunder én fold pr. ordre
(sorteret efter order_number, rød/grøn kant for betalt/ubetalt-status),
foldet ud til "Markér betalt" (POST /orders/{id}/mark-paid) + ordrens
egen kategori-grupperede vinliste. Ingen backend-ændringer nødvendige
— alt data kommer fra opgave 9's eksisterende GET /orders, al
aggregering sker client-side.

Verificeret i en fuldstændig isoleret sandbox (midlertidig separat
rute) for slet ikke at røre de 308 rigtige deltageres data.

To fund fra brugertest, begge rettet:
1. Beløb blev vist i EUR — vises nu i DKK via rundens eur_dkk_rate
   (falder tilbage til EUR hvis runden ikke har en kurs), samme
   omregningsprincip som den offentlige side/kvitteringsmailen.
2. Vinlisterne manglede kategori-gruppering (kun den samlede liste
   havde det først) og viste antal efter vinnavn i stedet for før —
   begge lister er nu kategori-grupperede med antal først.

Dette afslutter opgave 11 (hele admin-UI'et).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-29 02:57:55 +02:00
parent cae8657ec8
commit 16d89db527
6 changed files with 336 additions and 4 deletions

View file

@ -206,8 +206,8 @@ at være for stort til én omgang og er selv delt i tre dele, alle
stadig under "11b" (ingen ny bogstav-opdeling), **alle tre nu ✅**: stadig under "11b" (ingen ny bogstav-opdeling), **alle tre nu ✅**:
**del 1** (delt frontend-infrastruktur + Deltagere-CRUD), **del 2** **del 1** (delt frontend-infrastruktur + Deltagere-CRUD), **del 2**
(runder + vinliste), **del 3** (mail-skabelon-editor + (runder + vinliste), **del 3** (mail-skabelon-editor +
annoncér-knap). **11c** (ordreoversigt/markér betalt) er ikke bygget annoncér-knap). **11c** (ordreoversigt/markér betalt) er nu også ✅ —
endnu. hele opgave 11 er dermed færdig.
**Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript, **Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript,
almindelig CSS — ingen framework) serveres af **samme FastAPI-app** almindelig CSS — ingen framework) serveres af **samme FastAPI-app**
@ -341,6 +341,33 @@ backend-ændringer nødvendige — opgave 7a's `MailTemplate`-CRUD og
utilsigtet rigtig annoncering til de 308 rigtige deltagere under utilsigtet rigtig annoncering til de 308 rigtige deltagere under
udvikling. udvikling.
**11c** (ordreoversigt/markér betalt) afslutter opgave 11. Ingen
backend-ændringer nødvendige — alt data kommer fra opgave 9's
allerede eksisterende `GET /orders?purchase_round_id=<id>`
(`OrderAdminView`), al aggregering (flaske-/beløbstotal, udestående,
den runde-brede vinliste) beregnes client-side.
- **`pages/OrdersPage.tsx`** (`/runder/:roundId/ordrer`, linket fra
`RoundsPage` og `RoundDetailPage`) — en to-niveaus harmonika
(bestemt af brugeren): øverste fold viser rundens samlede
flasketal, total og **udestående** (defineret som summen af
ordrer der endnu ikke er markeret betalt — ikke bogstaveligt "total
minus ubetalte", som ville givet summen af de betalte ordrer i
stedet), foldet ud til den samlede, **kategori-grupperede**
vinliste for hele runden. Derunder én fold pr. ordre (sorteret
efter `order_number`, med en rød/grøn kant der viser
betalt/ubetalt-status uden at skulle folde ud), foldet ud til en
"Markér betalt"-knap (`POST /orders/{id}/mark-paid`, skjult og
erstattet af "Betalt [dato]" hvis allerede betalt) + ordrens egen
**kategori-grupperede** vinliste. Alle folder kan være åbne
samtidig.
- **Beløb vises i DKK** (rundens `eur_dkk_rate`, samme
omregningsprincip som den offentlige bestillingsside og
kvitteringsmailen) — falder tilbage til EUR hvis runden ikke har en
kurs sat. Fundet under brugertest: beløb blev oprindeligt vist i
EUR, og både den samlede og de individuelle vinlister manglede
kategori-gruppering samt havde antal efter vinnavn i stedet for
før — alle tre rettet.
## 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`,
@ -373,7 +400,7 @@ logges/advares om i stedet.
## Fase 1 — nuværende scope ## Fase 1 — nuværende scope
**Færdige opgaver (1-10, 11a, 11b):** **Færdige opgaver (1-11):**
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,
@ -428,7 +455,8 @@ logges/advares om i stedet.
(se "Admin-UI-arkitektur" ovenfor), alle færdige: **del 1** (se "Admin-UI-arkitektur" ovenfor), alle færdige: **del 1**
(frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder + (frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder +
vinliste), **del 3** (mail-skabelon-editor + annoncér-knap). vinliste), **del 3** (mail-skabelon-editor + annoncér-knap).
- **11c**: Ordreoversigt/markér betalt — ikke bygget. - **11c**: ✅ Ordreoversigt/markér betalt — se
"Admin-UI-arkitektur" ovenfor. Hele opgave 11 er dermed færdig.
**Resterende opgaver:** **Resterende opgaver:**
12. Testkør en rigtig runde gennem hele flowet 12. Testkør en rigtig runde gennem hele flowet

View file

@ -164,3 +164,41 @@ td {
padding: 8px 16px; padding: 8px 16px;
cursor: pointer; cursor: pointer;
} }
.accordion {
margin-top: 16px;
}
.accordion-item {
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 8px;
}
.accordion-header {
display: block;
width: 100%;
text-align: left;
padding: 12px;
background: #f7f7f7;
border: none;
cursor: pointer;
font-size: 1rem;
}
.accordion-header:hover {
background: #efefef;
}
.accordion-header.unpaid {
border-left: 4px solid #b3261e;
}
.accordion-header.paid {
border-left: 4px solid #2e7d32;
}
.accordion-body {
padding: 12px;
border-top: 1px solid #ddd;
}

View file

@ -2,6 +2,7 @@ import { Navigate, Route, BrowserRouter, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from './AuthContext' import { AuthProvider, useAuth } from './AuthContext'
import { Layout } from './Layout' import { Layout } from './Layout'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { OrdersPage } from './pages/OrdersPage'
import { ParticipantsPage } from './pages/ParticipantsPage' import { ParticipantsPage } from './pages/ParticipantsPage'
import { RoundDetailPage } from './pages/RoundDetailPage' import { RoundDetailPage } from './pages/RoundDetailPage'
import { RoundsPage } from './pages/RoundsPage' import { RoundsPage } from './pages/RoundsPage'
@ -20,6 +21,7 @@ function AppRoutes() {
<Route path="/deltagere" element={<ParticipantsPage />} /> <Route path="/deltagere" element={<ParticipantsPage />} />
<Route path="/runder" element={<RoundsPage />} /> <Route path="/runder" element={<RoundsPage />} />
<Route path="/runder/:roundId" element={<RoundDetailPage />} /> <Route path="/runder/:roundId" element={<RoundDetailPage />} />
<Route path="/runder/:roundId/ordrer" element={<OrdersPage />} />
<Route path="/skabeloner" element={<TemplatesPage />} /> <Route path="/skabeloner" element={<TemplatesPage />} />
<Route path="*" element={<Navigate to="/deltagere" replace />} /> <Route path="*" element={<Navigate to="/deltagere" replace />} />
</Route> </Route>

View file

@ -0,0 +1,260 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { apiFetch, ApiError } from '../api'
interface PurchaseRound {
id: number
name: string
eur_dkk_rate: string | null
}
interface WineCategory {
id: number
name: string
sort_order: number
}
interface WineOffering {
id: number
category_id: number
}
interface OrderLineAdminView {
id: number
order_id: number
wine_offering_id: number
quantity: number
wine_name: string
unit_price: string
}
interface OrderAdminView {
id: number
participant_id: number
purchase_round_id: number
order_number: number
payment_status: 'unpaid' | 'paid'
paid_at: string | null
created_at: string
participant_name: string
participant_email: string
participant_phone: string | null
order_lines: OrderLineAdminView[]
total_price_eur: string
}
export function OrdersPage() {
const { roundId } = useParams<{ roundId: string }>()
const [round, setRound] = useState<PurchaseRound | null>(null)
const [orders, setOrders] = useState<OrderAdminView[]>([])
const [wineOfferings, setWineOfferings] = useState<WineOffering[]>([])
const [categories, setCategories] = useState<WineCategory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [summaryOpen, setSummaryOpen] = useState(false)
const [openOrderIds, setOpenOrderIds] = useState<Set<number>>(new Set())
async function loadOrders(forRoundId: string) {
const data = await apiFetch<OrderAdminView[]>(`/orders?purchase_round_id=${forRoundId}&limit=500`)
setOrders(data.slice().sort((a, b) => a.order_number - b.order_number))
}
async function loadAll() {
if (!roundId) return
setLoading(true)
setError(null)
try {
const [roundData, wineOfferingData, categoryData] = await Promise.all([
apiFetch<PurchaseRound>(`/purchase-rounds/${roundId}`),
apiFetch<WineOffering[]>(`/wine-offerings?purchase_round_id=${roundId}&limit=500`),
apiFetch<WineCategory[]>('/wine-categories'),
loadOrders(roundId),
])
setRound(roundData)
setWineOfferings(wineOfferingData)
setCategories(categoryData)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ordrer')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roundId])
function toggleOrder(id: number) {
setOpenOrderIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleMarkPaid(order: OrderAdminView) {
if (!roundId) return
setError(null)
try {
await apiFetch(`/orders/${order.id}/mark-paid`, { method: 'POST' })
await loadOrders(roundId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke markere ordren som betalt')
}
}
if (loading) return <p>Indlæser...</p>
if (!round) return <p className="error">{error ?? 'Runden blev ikke fundet'}</p>
const rate = round.eur_dkk_rate ? Number(round.eur_dkk_rate) : null
function formatEur(amountEur: number): string {
if (rate) return `${(amountEur * rate).toFixed(2)} kr`
return `${amountEur.toFixed(2)} EUR`
}
const totalBottles = orders.reduce(
(sum, o) => sum + o.order_lines.reduce((lineSum, l) => lineSum + l.quantity, 0),
0,
)
const totalAmount = orders.reduce((sum, o) => sum + Number(o.total_price_eur), 0)
const outstanding = orders
.filter((o) => o.payment_status === 'unpaid')
.reduce((sum, o) => sum + Number(o.total_price_eur), 0)
const categoryByWineOfferingId = new Map<number, WineCategory>()
for (const offering of wineOfferings) {
const category = categories.find((c) => c.id === offering.category_id)
if (category) categoryByWineOfferingId.set(offering.id, category)
}
const wineAggregation = new Map<number, { name: string; quantity: number; category: WineCategory | undefined }>()
for (const order of orders) {
for (const line of order.order_lines) {
const existing = wineAggregation.get(line.wine_offering_id)
if (existing) existing.quantity += line.quantity
else
wineAggregation.set(line.wine_offering_id, {
name: line.wine_name,
quantity: line.quantity,
category: categoryByWineOfferingId.get(line.wine_offering_id),
})
}
}
const wineRows = Array.from(wineAggregation.values()).sort((a, b) => {
const orderA = a.category?.sort_order ?? 0
const orderB = b.category?.sort_order ?? 0
if (orderA !== orderB) return orderA - orderB
return a.name.localeCompare(b.name)
})
const wineRowsByCategory = new Map<string, typeof wineRows>()
for (const row of wineRows) {
const categoryName = row.category?.name ?? 'Ukategoriseret'
const list = wineRowsByCategory.get(categoryName) ?? []
list.push(row)
wineRowsByCategory.set(categoryName, list)
}
function groupOrderLinesByCategory(lines: OrderLineAdminView[]): [string, OrderLineAdminView[]][] {
const sorted = lines.slice().sort((a, b) => {
const orderA = categoryByWineOfferingId.get(a.wine_offering_id)?.sort_order ?? 0
const orderB = categoryByWineOfferingId.get(b.wine_offering_id)?.sort_order ?? 0
if (orderA !== orderB) return orderA - orderB
return a.wine_name.localeCompare(b.wine_name)
})
const groups = new Map<string, OrderLineAdminView[]>()
for (const line of sorted) {
const categoryName = categoryByWineOfferingId.get(line.wine_offering_id)?.name ?? 'Ukategoriseret'
const list = groups.get(categoryName) ?? []
list.push(line)
groups.set(categoryName, list)
}
return Array.from(groups.entries())
}
return (
<div>
<h1>Ordreoversigt</h1>
{error && <p className="error">{error}</p>}
{orders.length === 0 ? (
<p>Ingen bestillinger endnu for denne runde.</p>
) : (
<div className="accordion">
<div className="accordion-item">
<button className="accordion-header" onClick={() => setSummaryOpen((v) => !v)}>
{round.name} — {totalBottles} flasker — Total: {formatEur(totalAmount)} — Udestående:{' '}
{formatEur(outstanding)}
</button>
{summaryOpen && (
<div className="accordion-body">
{Array.from(wineRowsByCategory.entries()).map(([categoryName, rows]) => (
<table key={categoryName}>
<thead>
<tr>
<th colSpan={2}>{categoryName}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.name}>
<td>{row.quantity}</td>
<td>{row.name}</td>
</tr>
))}
</tbody>
</table>
))}
</div>
)}
</div>
{orders.map((order) => (
<div className="accordion-item" key={order.id}>
<button
className={`accordion-header ${order.payment_status === 'paid' ? 'paid' : 'unpaid'}`}
onClick={() => toggleOrder(order.id)}
>
#{order.order_number} — {order.participant_name} — {order.participant_phone ?? ''} —{' '}
{order.participant_email} — {formatEur(Number(order.total_price_eur))} —{' '}
{order.payment_status === 'paid' ? 'Betalt' : 'Ubetalt'}
</button>
{openOrderIds.has(order.id) && (
<div className="accordion-body">
{order.payment_status === 'paid' ? (
<p className="hint">
Betalt {order.paid_at ? new Date(order.paid_at).toLocaleString('da-DK') : ''}
</p>
) : (
<button onClick={() => handleMarkPaid(order)}>Markér betalt</button>
)}
{groupOrderLinesByCategory(order.order_lines).map(([categoryName, lines]) => (
<table key={categoryName}>
<thead>
<tr>
<th colSpan={3}>{categoryName}</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.id}>
<td>{line.quantity}</td>
<td>{line.wine_name}</td>
<td>{formatEur(Number(line.unit_price))}</td>
</tr>
))}
</tbody>
</table>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}

View file

@ -318,6 +318,9 @@ export function RoundDetailPage() {
/> />
</label> </label>
<button type="submit">Gem</button> <button type="submit">Gem</button>
<button type="button" onClick={() => navigate(`/runder/${roundId}/ordrer`)}>
Se ordrer
</button>
<button type="button" onClick={handleCopy}> <button type="button" onClick={handleCopy}>
Kopiér denne runde Kopiér denne runde
</button> </button>

View file

@ -143,6 +143,7 @@ export function RoundsPage() {
<td>{formatDateTime(round.pickup_at)}</td> <td>{formatDateTime(round.pickup_at)}</td>
<td> <td>
<button onClick={() => navigate(`/runder/${round.id}`)}>Redigér</button> <button onClick={() => navigate(`/runder/${round.id}`)}>Redigér</button>
<button onClick={() => navigate(`/runder/${round.id}/ordrer`)}>Se ordrer</button>
<button onClick={() => handleCopy(round)}>Kopiér</button> <button onClick={() => handleCopy(round)}>Kopiér</button>
{(round.status === 'draft' || {(round.status === 'draft' ||
(round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && ( (round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && (