Files
kunstenkamp/apps/web/src/routes/admin/drinkkaart.tsx

237 lines
6.9 KiB
TypeScript

import { useMutation } from "@tanstack/react-query";
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { AdminTransactionLog } from "@/components/drinkkaart/AdminTransactionLog";
import { DeductionPanel } from "@/components/drinkkaart/DeductionPanel";
import { ManualCreditForm } from "@/components/drinkkaart/ManualCreditForm";
import { QrScanner } from "@/components/drinkkaart/QrScanner";
import { ScanResultCard } from "@/components/drinkkaart/ScanResultCard";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/admin/drinkkaart")({
component: AdminDrinkkaartPage,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data?.user) {
throw redirect({ to: "/login" });
}
const user = session.data.user as { role?: string };
if (user.role !== "admin") {
throw redirect({ to: "/" });
}
},
});
type ScanState =
| { step: "idle" }
| { step: "scanning" }
| { step: "resolving" }
| {
step: "resolved";
drinkkaartId: string;
userId: string;
userName: string;
userEmail: string;
balance: number;
}
| {
step: "success";
transactionId: string;
userName: string;
balanceAfter: number;
}
| { step: "error"; message: string; retryable: boolean }
| { step: "manual_credit" };
function AdminDrinkkaartPage() {
const [scanState, setScanState] = useState<ScanState>({ step: "idle" });
const resolveQrMutation = useMutation(
orpc.drinkkaart.resolveQrToken.mutationOptions(),
);
const handleScan = useCallback(
(token: string) => {
setScanState({ step: "resolving" });
resolveQrMutation.mutate(
{ token },
{
onSuccess: (data) => {
setScanState({
step: "resolved",
drinkkaartId: data.drinkkaartId,
userId: data.userId,
userName: data.userName,
userEmail: data.userEmail,
balance: data.balance,
});
},
onError: (err: Error) => {
setScanState({
step: "error",
message: err.message ?? "Ongeldig QR-token",
retryable: true,
});
},
},
);
},
[resolveQrMutation],
);
const handleDeductSuccess = (transactionId: string, balanceAfter: number) => {
const userName =
scanState.step === "resolved" ? scanState.userName : "Gebruiker";
const amountDeducted =
scanState.step === "resolved"
? scanState.balance - balanceAfter
: undefined;
toast.success(
`Afschrijving verwerkt${amountDeducted !== undefined ? `${formatCents(amountDeducted)}` : ""}`,
);
setScanState({ step: "success", transactionId, userName, balanceAfter });
};
return (
<div>
{/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-4 py-4 sm:px-6 sm:py-5">
<div className="mx-auto flex max-w-2xl items-center gap-3 sm:gap-4">
<Link to="/admin" className="text-sm text-white/60 hover:text-white">
Admin
</Link>
<h1 className="font-['Intro',sans-serif] text-white text-xl sm:text-2xl">
Drinkkaart Beheer
</h1>
</div>
</header>
<main className="mx-auto max-w-2xl space-y-6 px-4 py-4 sm:space-y-8 sm:px-6 sm:py-8">
{/* Scan / State machine */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
{scanState.step === "idle" && (
<div className="space-y-4">
<p className="text-sm text-white/60">
Scan een QR-code of laad handmatig op.
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => setScanState({ step: "scanning" })}
className="flex-1 bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
Scan QR
</Button>
<Button
onClick={() => setScanState({ step: "manual_credit" })}
variant="outline"
className="flex-1 border-white/30 bg-transparent text-white hover:bg-white/10"
>
Handmatig opladen
</Button>
</div>
</div>
)}
{scanState.step === "scanning" && (
<QrScanner
onScan={handleScan}
onCancel={() => setScanState({ step: "idle" })}
/>
)}
{scanState.step === "resolving" && (
<div className="py-8 text-center">
<p className="text-white/60">QR-code wordt gecontroleerd...</p>
</div>
)}
{scanState.step === "resolved" && (
<div className="space-y-5">
<ScanResultCard
userName={scanState.userName}
userEmail={scanState.userEmail}
balance={scanState.balance}
/>
<DeductionPanel
drinkkaartId={scanState.drinkkaartId}
userName={scanState.userName}
userEmail={scanState.userEmail}
balance={scanState.balance}
onSuccess={handleDeductSuccess}
onCancel={() => setScanState({ step: "idle" })}
/>
</div>
)}
{scanState.step === "success" && (
<div className="space-y-5">
<div className="rounded-xl border border-green-400/20 bg-green-400/5 p-5 text-center">
<p className="font-semibold text-green-300 text-lg">
Betaling verwerkt
</p>
<p className="mt-1 text-sm text-white/60">
{scanState.userName} nieuw saldo:{" "}
<strong className="text-white">
{formatCents(scanState.balanceAfter)}
</strong>
</p>
</div>
<Button
onClick={() => setScanState({ step: "idle" })}
className="w-full bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
Volgende scan
</Button>
</div>
)}
{scanState.step === "error" && (
<div className="space-y-4">
<div className="rounded-xl border border-red-400/20 bg-red-400/5 p-4">
<p className="font-medium text-red-300">Fout</p>
<p className="mt-1 text-sm text-white/60">
{scanState.message}
</p>
</div>
<div className="flex gap-3">
{scanState.retryable && (
<Button
onClick={() => setScanState({ step: "scanning" })}
className="flex-1 bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
Opnieuw proberen
</Button>
)}
<Button
onClick={() => setScanState({ step: "idle" })}
variant="outline"
className="flex-1 border-white/20 bg-transparent text-white hover:bg-white/10"
>
Annuleren
</Button>
</div>
</div>
)}
{scanState.step === "manual_credit" && (
<ManualCreditForm onDone={() => setScanState({ step: "idle" })} />
)}
</div>
{/* Transaction log */}
<div>
<h2 className="mb-4 font-['Intro',sans-serif] text-white text-xl">
Transactieoverzicht
</h2>
<AdminTransactionLog />
</div>
</main>
</div>
);
}