diff --git a/CLAUDE.md b/CLAUDE.md index 570dacb..419dcce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ✅**: **del 1** (delt frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder + vinliste), **del 3** (mail-skabelon-editor + -annoncér-knap). **11c** (ordreoversigt/markér betalt) er ikke bygget -endnu. +annoncér-knap). **11c** (ordreoversigt/markér betalt) er nu også ✅ — +hele opgave 11 er dermed færdig. **Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript, 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 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=` +(`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 1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret** (opgave 7a-c): skabelon pr. rute (`MailTemplate`, @@ -373,7 +400,7 @@ logges/advares om i stedet. ## Fase 1 — nuværende scope -**Færdige opgaver (1-10, 11a, 11b):** +**Færdige opgaver (1-11):** 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding 2. ✅ Datamodellerne (Organization/Route-hierarki) 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** (frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder + 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:** 12. Testkør en rigtig runde gennem hele flowet diff --git a/admin-ui/src/App.css b/admin-ui/src/App.css index a0cf3aa..a747b38 100644 --- a/admin-ui/src/App.css +++ b/admin-ui/src/App.css @@ -164,3 +164,41 @@ td { padding: 8px 16px; 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; +} diff --git a/admin-ui/src/App.tsx b/admin-ui/src/App.tsx index 5b548cc..af69de2 100644 --- a/admin-ui/src/App.tsx +++ b/admin-ui/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, BrowserRouter, Routes } from 'react-router-dom' import { AuthProvider, useAuth } from './AuthContext' import { Layout } from './Layout' import { LoginPage } from './pages/LoginPage' +import { OrdersPage } from './pages/OrdersPage' import { ParticipantsPage } from './pages/ParticipantsPage' import { RoundDetailPage } from './pages/RoundDetailPage' import { RoundsPage } from './pages/RoundsPage' @@ -20,6 +21,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/admin-ui/src/pages/OrdersPage.tsx b/admin-ui/src/pages/OrdersPage.tsx new file mode 100644 index 0000000..f1eae8d --- /dev/null +++ b/admin-ui/src/pages/OrdersPage.tsx @@ -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(null) + const [orders, setOrders] = useState([]) + const [wineOfferings, setWineOfferings] = useState([]) + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [summaryOpen, setSummaryOpen] = useState(false) + const [openOrderIds, setOpenOrderIds] = useState>(new Set()) + + async function loadOrders(forRoundId: string) { + const data = await apiFetch(`/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(`/purchase-rounds/${roundId}`), + apiFetch(`/wine-offerings?purchase_round_id=${roundId}&limit=500`), + apiFetch('/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

Indlæser...

+ if (!round) return

{error ?? 'Runden blev ikke fundet'}

+ + 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() + 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() + 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() + 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() + 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 ( +
+

Ordreoversigt

+ {error &&

{error}

} + + {orders.length === 0 ? ( +

Ingen bestillinger endnu for denne runde.

+ ) : ( +
+
+ + {summaryOpen && ( +
+ {Array.from(wineRowsByCategory.entries()).map(([categoryName, rows]) => ( + + + + + + + + {rows.map((row) => ( + + + + + ))} + +
{categoryName}
{row.quantity}{row.name}
+ ))} +
+ )} +
+ + {orders.map((order) => ( +
+ + {openOrderIds.has(order.id) && ( +
+ {order.payment_status === 'paid' ? ( +

+ Betalt {order.paid_at ? new Date(order.paid_at).toLocaleString('da-DK') : ''} +

+ ) : ( + + )} + {groupOrderLinesByCategory(order.order_lines).map(([categoryName, lines]) => ( + + + + + + + + {lines.map((line) => ( + + + + + + ))} + +
{categoryName}
{line.quantity}{line.wine_name}{formatEur(Number(line.unit_price))}
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ) +} diff --git a/admin-ui/src/pages/RoundDetailPage.tsx b/admin-ui/src/pages/RoundDetailPage.tsx index 2e6b0d9..5695b1d 100644 --- a/admin-ui/src/pages/RoundDetailPage.tsx +++ b/admin-ui/src/pages/RoundDetailPage.tsx @@ -318,6 +318,9 @@ export function RoundDetailPage() { /> + diff --git a/admin-ui/src/pages/RoundsPage.tsx b/admin-ui/src/pages/RoundsPage.tsx index a5c566f..fa840d0 100644 --- a/admin-ui/src/pages/RoundsPage.tsx +++ b/admin-ui/src/pages/RoundsPage.tsx @@ -143,6 +143,7 @@ export function RoundsPage() { {formatDateTime(round.pickup_at)} + {(round.status === 'draft' || (round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && (