fix:manage edit for extra bezoekers payments
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user