feat:drinkkaart
This commit is contained in:
236
apps/web/src/routes/admin/drinkkaart.tsx
Normal file
236
apps/web/src/routes/admin/drinkkaart.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
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 className="min-h-screen bg-[#214e51]">
|
||||
{/* Header */}
|
||||
<header className="border-white/10 border-b bg-[#214e51]/95 px-6 py-5">
|
||||
<div className="mx-auto flex max-w-2xl items-center gap-4">
|
||||
<Link to="/admin" className="text-sm text-white/60 hover:text-white">
|
||||
← Admin
|
||||
</Link>
|
||||
<h1 className="font-['Intro',sans-serif] text-2xl text-white">
|
||||
Drinkkaart Beheer
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-2xl space-y-8 px-6 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user