diff --git a/apps/web/src/routes/admin.tsx b/apps/web/src/routes/admin.tsx index 3d4a134..02ccdd0 100644 --- a/apps/web/src/routes/admin.tsx +++ b/apps/web/src/routes/admin.tsx @@ -5,8 +5,20 @@ import { redirect, useNavigate, } from "@tanstack/react-router"; -import { Check, Download, LogOut, Search, Users, X } from "lucide-react"; -import { useState } from "react"; +import { + Check, + ChevronDown, + ChevronsUpDown, + ChevronUp, + Clipboard, + ClipboardCheck, + Download, + LogOut, + Search, + Users, + X, +} from "lucide-react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -46,6 +58,38 @@ function AdminPage() { const [page, setPage] = useState(1); const pageSize = 20; + const [copiedId, setCopiedId] = useState(null); + + const handleCopyManageUrl = (token: string, id: string) => { + const url = `${window.location.origin}/manage/${token}`; + navigator.clipboard.writeText(url).then(() => { + setCopiedId(id); + toast.success("Beheerlink gekopieerd"); + setTimeout(() => setCopiedId(null), 2000); + }); + }; + + type SortKey = + | "naam" + | "email" + | "type" + | "details" + | "gasten" + | "betaling" + | "datum"; + type SortDir = "asc" | "desc"; + const [sortKey, setSortKey] = useState("datum"); + const [sortDir, setSortDir] = useState("desc"); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir("asc"); + } + }; + const statsQuery = useQuery(orpc.getRegistrationStats.queryOptions()); const registrationsQuery = useQuery( @@ -147,6 +191,76 @@ function AdminPage() { .filter((r) => r.registrationType === "watcher") .reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0); + const sortedRegistrations = useMemo(() => { + const sorted = [...registrations].sort((a, b) => { + let valA: string | number = 0; + let valB: string | number = 0; + switch (sortKey) { + case "naam": + valA = `${a.firstName} ${a.lastName}`.toLowerCase(); + valB = `${b.firstName} ${b.lastName}`.toLowerCase(); + break; + case "email": + valA = a.email.toLowerCase(); + valB = b.email.toLowerCase(); + break; + case "type": + valA = a.registrationType ?? ""; + valB = b.registrationType ?? ""; + break; + case "details": + valA = + (a.registrationType === "performer" + ? a.artForm + : `€${a.drinkCardValue ?? 5}`) ?? ""; + valB = + (b.registrationType === "performer" + ? b.artForm + : `€${b.drinkCardValue ?? 5}`) ?? ""; + break; + case "gasten": { + const countGuests = (g: string | null) => { + if (!g) return 0; + try { + const p = JSON.parse(g); + return Array.isArray(p) ? p.length : 0; + } catch { + return 0; + } + }; + valA = countGuests(a.guests as string | null); + valB = countGuests(b.guests as string | null); + break; + } + case "betaling": + valA = a.paymentStatus ?? "pending"; + valB = b.paymentStatus ?? "pending"; + break; + case "datum": + valA = new Date(a.createdAt).getTime(); + valB = new Date(b.createdAt).getTime(); + break; + } + if (valA < valB) return sortDir === "asc" ? -1 : 1; + if (valA > valB) return sortDir === "asc" ? 1 : -1; + return 0; + }); + return sorted; + }, [registrations, sortKey, sortDir]); + + const SortIcon = ({ col }: { col: SortKey }) => { + if (sortKey !== col) + return ; + return sortDir === "asc" ? ( + + ) : ( + + ); + }; + + const thClass = + "px-4 py-3 text-left font-medium text-xs text-white/60 uppercase tracking-wider cursor-pointer select-none hover:text-white/90 whitespace-nowrap"; + return (
{/* Header */} @@ -453,38 +567,41 @@ function AdminPage() { - - - - - - - - - - - @@ -492,128 +609,128 @@ function AdminPage() { {registrationsQuery.isLoading ? ( - ) : registrations.length === 0 ? ( + ) : sortedRegistrations.length === 0 ? ( ) : ( - registrations.map((reg) => { + sortedRegistrations.map((reg) => { const isPerformer = reg.registrationType === "performer"; + + const guestCount = (() => { + if (!reg.guests) return 0; + try { + const g = JSON.parse(reg.guests as string); + return Array.isArray(g) ? g.length : 0; + } catch { + return 0; + } + })(); + + const detailLabel = isPerformer + ? reg.artForm || "-" + : `€${reg.drinkCardValue ?? 5} drinkkaart`; + + const dateLabel = (() => { + try { + return new Date(reg.createdAt).toLocaleDateString( + "nl-BE", + { + day: "2-digit", + month: "2-digit", + year: "numeric", + }, + ); + } catch { + return "-"; + } + })(); + return ( - - - - - - - - - - + diff --git a/apps/web/src/routes/api/webhook/lemonsqueezy.ts b/apps/web/src/routes/api/webhook/lemonsqueezy.ts index e3af5b3..c293a2b 100644 --- a/apps/web/src/routes/api/webhook/lemonsqueezy.ts +++ b/apps/web/src/routes/api/webhook/lemonsqueezy.ts @@ -77,11 +77,14 @@ async function handleWebhook({ request }: { request: Request }) { const orderId = event.data.id; const customerId = String(event.data.attributes.customer_id); - // Update registration in database + // Update registration in database. + // Covers both "pending" (initial payment) and "extra_payment_pending" + // (delta payment after adding guests to an already-paid registration). await db .update(registration) .set({ paymentStatus: "paid", + paymentAmount: 0, // delta has been settled lemonsqueezyOrderId: orderId, lemonsqueezyCustomerId: customerId, paidAt: new Date(), diff --git a/apps/web/src/routes/manage.$token.tsx b/apps/web/src/routes/manage.$token.tsx index b91cb22..dca62f8 100644 --- a/apps/web/src/routes/manage.$token.tsx +++ b/apps/web/src/routes/manage.$token.tsx @@ -82,6 +82,29 @@ function PendingBadge() { ); } +function ExtraPaymentBadge({ amountCents }: { amountCents: number }) { + const euros = amountCents / 100; + return ( +
+ + + + Extra betaling vereist — €{euros.toFixed(euros % 1 === 0 ? 0 : 2)} +
+ ); +} + // --------------------------------------------------------------------------- // Edit form (inline on the manage page) // --------------------------------------------------------------------------- @@ -139,7 +162,9 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) { const isWatcher = formData.registrationType === "watcher"; const totalDrinkCard = isWatcher ? calculateDrinkCard(formGuests.length) : 0; const originalGuestCount = parseGuests(initialData.guests).length; - const isPaid = initialData.paymentStatus === "paid"; + const isPaid = + initialData.paymentStatus === "paid" || + initialData.paymentStatus === "extra_payment_pending"; const extraGuests = formGuests.length - originalGuestCount; function handleSubmit(e: React.FormEvent) { @@ -546,7 +571,13 @@ function ManageRegistrationPage() { {/* Payment status */} {!isPerformer && (
- {data.paymentStatus === "paid" ? : } + {data.paymentStatus === "paid" ? ( + + ) : data.paymentStatus === "extra_payment_pending" ? ( + + ) : ( + + )}
)} @@ -625,16 +656,22 @@ function ManageRegistrationPage() { {/* Actions */}
- {!isPerformer && data.paymentStatus !== "paid" && ( - - )} + {!isPerformer && + (data.paymentStatus === "pending" || + data.paymentStatus === "extra_payment_pending") && ( + + )}
- Naam + handleSort("naam")}> + Naam - Email + handleSort("email")}> + Email + Telefoon - Type + handleSort("type")}> + Type - Kunstvorm / Drinkkaart + handleSort("details")} + > + Details - Gezelschap + handleSort("gasten")} + > + Gasten - Ervaring + handleSort("betaling")} + > + Betaling - 16+ + handleSort("datum")}> + Datum - Betaald - - Betaald - - Datum + + Link
Laden...
Geen registraties gevonden
+ {reg.firstName} {reg.lastName} + {reg.email} + {reg.phone || "-"} + {isPerformer ? "Artiest" : "Bezoeker"} - {isPerformer - ? reg.artForm || "-" - : `€${reg.drinkCardValue ?? 5} drinkkaart`} + + {detailLabel} - {isPerformer - ? "-" - : (() => { - if (!reg.guests) return "-"; - try { - const guests = JSON.parse( - reg.guests as string, - ); - const count = Array.isArray(guests) - ? guests.length - : 0; - return count > 0 - ? `${count} gast${count === 1 ? "" : "en"}` - : "-"; - } catch { - return "-"; - } - })()} + + {guestCount > 0 + ? `${guestCount} gast${guestCount === 1 ? "" : "en"}` + : "-"} - {isPerformer ? reg.experience || "-" : "-"} - + {isPerformer ? ( - reg.isOver16 ? ( - - ) : ( - - ) - ) : ( - "-" - )} - - {isPerformer ? ( - - + - ) : reg.paymentStatus === "paid" ? ( - - - Betaald icoon - - + + Betaald + ) : reg.paymentStatus === + "extra_payment_pending" ? ( + + + Extra (€ + {((reg.paymentAmount ?? 0) / 100).toFixed(0)}) + ) : ( - - - In afwachting icoon - - + Open )} - {new Date(reg.createdAt).toLocaleDateString( - "nl-BE", + + {dateLabel} + + {reg.managementToken ? ( + + ) : ( + )}