fix:manage edit for extra bezoekers payments

This commit is contained in:
2026-03-03 14:28:59 +01:00
parent 0a1d1db9ec
commit e6f52e6a73
6 changed files with 338 additions and 134 deletions

View File

@@ -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<string | null>(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<SortKey>("datum");
const [sortDir, setSortDir] = useState<SortDir>("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 <ChevronsUpDown className="ml-1 inline h-3.5 w-3.5 opacity-40" />;
return sortDir === "asc" ? (
<ChevronUp className="ml-1 inline h-3.5 w-3.5" />
) : (
<ChevronDown className="ml-1 inline h-3.5 w-3.5" />
);
};
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 (
<div className="min-h-screen bg-[#214e51]">
{/* Header */}
@@ -453,38 +567,41 @@ function AdminPage() {
<table className="w-full">
<thead>
<tr className="border-white/10 border-b">
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Naam
<th className={thClass} onClick={() => handleSort("naam")}>
Naam <SortIcon col="naam" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Email
<th className={thClass} onClick={() => handleSort("email")}>
Email <SortIcon col="email" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Telefoon
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Type
<th className={thClass} onClick={() => handleSort("type")}>
Type <SortIcon col="type" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Kunstvorm / Drinkkaart
<th
className={thClass}
onClick={() => handleSort("details")}
>
Details <SortIcon col="details" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Gezelschap
<th
className={thClass}
onClick={() => handleSort("gasten")}
>
Gasten <SortIcon col="gasten" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Ervaring
<th
className={thClass}
onClick={() => handleSort("betaling")}
>
Betaling <SortIcon col="betaling" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
16+
<th className={thClass} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" />
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Betaald
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Betaald
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Datum
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Link
</th>
</tr>
</thead>
@@ -492,128 +609,128 @@ function AdminPage() {
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={10}
className="px-6 py-8 text-center text-white/60"
colSpan={9}
className="px-4 py-8 text-center text-white/60"
>
Laden...
</td>
</tr>
) : registrations.length === 0 ? (
) : sortedRegistrations.length === 0 ? (
<tr>
<td
colSpan={10}
className="px-6 py-8 text-center text-white/60"
colSpan={9}
className="px-4 py-8 text-center text-white/60"
>
Geen registraties gevonden
</td>
</tr>
) : (
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 (
<tr
key={reg.id}
className="border-white/5 border-b hover:bg-white/5"
>
<td className="px-6 py-4 text-white">
<td className="px-4 py-3 font-medium text-white">
{reg.firstName} {reg.lastName}
</td>
<td className="px-6 py-4 text-white/80">
<td className="px-4 py-3 text-sm text-white/70">
{reg.email}
</td>
<td className="px-6 py-4 text-white/80">
<td className="px-4 py-3 text-sm text-white/60">
{reg.phone || "-"}
</td>
<td className="px-6 py-4">
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
</td>
<td className="px-6 py-4 text-white/80">
{isPerformer
? reg.artForm || "-"
: `${reg.drinkCardValue ?? 5} drinkkaart`}
<td className="px-4 py-3 text-sm text-white/70">
{detailLabel}
</td>
<td className="px-6 py-4 text-white/80">
{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 "-";
}
})()}
<td className="px-4 py-3 text-sm text-white/70">
{guestCount > 0
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
: "-"}
</td>
<td className="px-6 py-4 text-white/80">
{isPerformer ? reg.experience || "-" : "-"}
</td>
<td className="px-6 py-4 text-white/80">
<td className="px-4 py-3">
{isPerformer ? (
reg.isOver16 ? (
<span className="text-green-400"></span>
) : (
<span className="text-red-400"></span>
)
) : (
"-"
)}
</td>
<td className="px-6 py-4">
{isPerformer ? (
<span className="text-white/40">-</span>
<span className="text-sm text-white/30">-</span>
) : reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 text-green-400 text-sm">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<title>Betaald icoon</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
<Check className="h-3.5 w-3.5" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
</span>
) : (
<span className="inline-flex items-center gap-1 text-sm text-yellow-400">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<title>In afwachting icoon</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-sm text-yellow-400">
Open
</span>
)}
</td>
<td className="px-6 py-4 text-white/60">
{new Date(reg.createdAt).toLocaleDateString(
"nl-BE",
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
{dateLabel}
</td>
<td className="px-4 py-3">
{reg.managementToken ? (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
) : (
<span className="text-sm text-white/20"></span>
)}
</td>
</tr>

View File

@@ -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(),

View File

@@ -82,6 +82,29 @@ function PendingBadge() {
);
}
function ExtraPaymentBadge({ amountCents }: { amountCents: number }) {
const euros = amountCents / 100;
return (
<div className="inline-flex items-center gap-2 rounded-full border border-orange-400/40 bg-orange-400/10 px-4 py-1.5 font-semibold text-orange-400 text-sm">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-label="Extra betaling vereist"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
Extra betaling vereist {euros.toFixed(euros % 1 === 0 ? 0 : 2)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 && (
<div className="mb-6">
{data.paymentStatus === "paid" ? <PaidBadge /> : <PendingBadge />}
{data.paymentStatus === "paid" ? (
<PaidBadge />
) : data.paymentStatus === "extra_payment_pending" ? (
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
) : (
<PendingBadge />
)}
</div>
)}
@@ -625,16 +656,22 @@ function ManageRegistrationPage() {
{/* Actions */}
<div className="mt-8 flex flex-wrap items-center gap-4">
{!isPerformer && data.paymentStatus !== "paid" && (
<button
type="button"
onClick={() => checkoutMutation.mutate({ token })}
disabled={checkoutMutation.isPending}
className="bg-teal-400 px-8 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-teal-300 disabled:opacity-50"
>
{checkoutMutation.isPending ? "Laden..." : "Nu betalen"}
</button>
)}
{!isPerformer &&
(data.paymentStatus === "pending" ||
data.paymentStatus === "extra_payment_pending") && (
<button
type="button"
onClick={() => checkoutMutation.mutate({ token })}
disabled={checkoutMutation.isPending}
className={`px-8 py-3 font-['Intro',sans-serif] text-lg transition-all hover:scale-105 disabled:opacity-50 ${data.paymentStatus === "extra_payment_pending" ? "bg-orange-400 text-white hover:bg-orange-300" : "bg-teal-400 text-[#214e51] hover:bg-teal-300"}`}
>
{checkoutMutation.isPending
? "Laden..."
: data.paymentStatus === "extra_payment_pending"
? `Extra betalen (€${((data.paymentAmount ?? 0) / 100).toFixed(0)})`
: "Nu betalen"}
</button>
)}
<button
type="button"
onClick={() => setIsEditing(true)}