import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchDrawerBalance, fetchDrawerMovements, fetchEvents, fetchShift, fetchShiftReport, fetchShifts, recordDrawerMovement, reviewDrawerMovement, type DrawerMovement, type MovementStatus, type ShiftSummary, type TillId, } from "./api.js"; import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js"; import { shiftKey } from "./lib/use-shift.js"; import { Panel } from "./ui/Panel.js"; import { tillOf, type LedgerEvent } from "@parking/shared"; // The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen // answers "what's in the till and why": the CURRENT drawer balance with the open // shift's running breakdown (float + takings + vouchers = expected), TODAY's cash // activity (every cash payment and voucher, live), the movement record/review flow // (unchanged), and the closed-shift drawer history. All figures come from the signed // chain. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub // shows one till at a time — a switch appears when the site has more than one — and // every panel below is scoped to it. See wiki/concepts/shift.md "Tills". const money = (m: number, cur: string | null) => formatMoney(m, cur ?? ""); /** Local midnight, ISO — the "today" window for the activity feed. */ function startOfToday(): string { const d = new Date(); d.setHours(0, 0, 0, 0); return d.toISOString(); } function StatusBadge({ status }: { status: MovementStatus }) { const { t } = useTranslation(); const cls = status === "authorized" ? "border-term-green/60 text-term-green" : status === "denied" ? "border-term-red/60 text-term-red" : "border-term-amber/60 text-term-amber"; return ( {t(`drawer.status.${status}`)} ); } export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) { const { t } = useTranslation(); const qc = useQueryClient(); const [till, setTill] = useState("booth"); // Which tills exist here (the booth + effective money-taking modules') — from the // booth's status read, which every till answer carries. const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") }); const tills = status.data?.tills ?? ["booth"]; const refresh = () => { void qc.invalidateQueries({ queryKey: ["drawer"] }); // A voucher moves the open shift's added/removed figures too (the X-report). void qc.invalidateQueries({ queryKey: ["shift"] }); }; return (
{/* Till switch — only when there is more than one drawer to look at. */} {tills.length > 1 && (
{tills.map((x) => ( ))}
)} {/* Row 1: the till NOW + the record form. */}
{canCreate && }
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
); } // --- The drawer NOW --------------------------------------------------------- // Balance from the chain + the open shift's running X-report breakdown, so the big // number is always explainable: float + cash takings + in − out = expected = balance. function StatePanel({ till }: { till: TillId }) { const { t } = useTranslation(); const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 }); const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) }); const report = useQuery({ queryKey: ["shift", "xreport", till], queryFn: () => fetchShiftReport(till), enabled: status.data?.open != null, refetchInterval: 10_000, }); const x = status.data?.open ? report.data : null; const cur = balance.data?.currency ?? x?.currency ?? null; // The current SHIFT's own balance: what this shift changed in the till // (takings + vouchers), i.e. everything above the inherited opening float. const shiftDelta = x ? x.expectedDrawerMinor - x.openingFloatMinor : null; return (
{balance.data ? money(balance.data.balanceMinor, cur) : "…"}
{shiftDelta != null && (
{t("drawer.thisShift")} {shiftDelta >= 0 ? "+" : ""} {money(shiftDelta, cur)}
)}
{status.data?.open ? t("drawer.openShift", { operator: status.data.open.operator }) + " · " + formatRelativeDateTime(status.data.open.startedAt, t) : t("drawer.noShiftOpen")}
{/* The running breakdown, only while a shift is open (it's the X-report). */} {x && (
{t("shifts.openingFloat")}
{money(x.openingFloatMinor, cur)}
{t("shifts.cashTaken")} · {t("shifts.payments")} {x.paymentCount}
{money(x.cashTotalMinor, cur)}
{t("shifts.cashAdded")}
{money(x.cashAddedMinor, cur)}
{t("shifts.cashRemoved")}
{money(-x.cashRemovedMinor, cur)}
{t("shifts.expectedDrawer")}
{money(x.expectedDrawerMinor, cur)}
)}
); } // --- Today's cash activity --------------------------------------------------- // Every drawer-touching event since local midnight: cash payments (the current // shift's incomings, live) + vouchers. Card payments never enter the till. function TodayPanel({ till }: { till: TillId }) { const { t } = useTranslation(); const q = useQuery({ queryKey: ["drawer", "today"], queryFn: () => fetchEvents(1000, startOfToday()), refetchInterval: 15_000, }); // This till's drawer-touching events only (a bay payment is wash-till money; a // parking payment is booth money — tillOf() is the one shared rule). const rows = (q.data?.events ?? []).filter((e) => { if (tillOf(e.payload) !== till) return false; if (e.type === "cash_in" || e.type === "cash_out") return true; if (e.type !== "payment" && e.type !== "carwash_payment") return false; return (e.payload as { tender?: string } | null)?.tender !== "card"; }); let cashIn = 0; let vouchersNet = 0; let payments = 0; let cur: string | null = null; for (const e of rows) { const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string }; const amt = pl.amountMinor ?? 0; if (pl.currency) cur = pl.currency; if (e.type === "payment" || e.type === "carwash_payment") { cashIn += amt; payments++; } else { vouchersNet += e.type === "cash_in" ? Math.abs(amt) : -Math.abs(amt); } } return ( 0 ? ( {t("drawer.todayPayments", { count: payments })} · {money(cashIn, cur)} {vouchersNet !== 0 && ( <> {" "} · {money(vouchersNet, cur)} )} ) : null } className="min-h-0" >
{q.isError ? (
{(q.error as Error).message}
) : q.isLoading ? (
{t("common.loading")}
) : rows.length === 0 ? (
{t("drawer.noActivity")}
) : ( {rows.map((e) => ( ))}
)}
); } function TodayRow({ e }: { e: LedgerEvent }) { const { t } = useTranslation(); const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string }; const amt = pl.amountMinor ?? 0; const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt); const time = formatClock(e.occurredAt); const label = e.type === "payment" || e.type === "carwash_payment" ? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}` : `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`; return ( {time} {label} {money(signed, pl.currency ?? null)} ); } // --- Movements (record + review) — the pre-redesign feature, unchanged ------ function MovementsPanel({ till, canReview, onChanged }: { till: TillId; canReview: boolean; onChanged: () => void }) { const { t } = useTranslation(); // Reviewers can filter the list (the pending queue); operators always see their own, all. const [statusFilter, setStatusFilter] = useState(""); const q = useQuery({ queryKey: ["drawer", "movements", canReview ? statusFilter : "", till], queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till), }); const movements = q.data?.movements ?? []; const pendingCount = movements.filter((m) => m.status === "pending").length; return ( 0 ? ( {t("drawer.pendingCount", { count: pendingCount })} ) : null } className="min-h-0" >
{canReview && (
{(["", "pending", "authorized", "denied"] as const).map((s) => ( ))}
)}
{q.isLoading ? (
{t("common.loading")}
) : movements.length === 0 ? (
{t("drawer.empty")}
) : ( {canReview && } {canReview && {movements.map((m) => ( ))}
{t("drawer.colWhen")} {t("drawer.colType")} {t("drawer.colAmount")} {t("drawer.colReason")}{t("drawer.colOperator")}{t("drawer.colStatus")}}
)}
); } // --- Closed shifts, drawer-focused ------------------------------------------- // Scope follows /api/shifts: operators see their own, admins all. function ShiftHistoryPanel({ till }: { till: TillId }) { const { t } = useTranslation(); const q = useQuery({ queryKey: ["shifts", "drawer-history", till], queryFn: () => fetchShifts({ till }) }); const shifts = (q.data?.shifts ?? []).slice(0, 50); const showOperator = q.data?.scope === "all"; return (
{q.isLoading ? (
{t("common.loading")}
) : shifts.length === 0 ? (
{t("drawer.noShifts")}
) : (
{shifts.map((s) => ( ))}
)}
); } function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: boolean }) { const { t } = useTranslation(); const cur = s.currency; return (
{showOperator ? `${s.operator} · ` : ""} {formatRelativeDateTime(s.startedAt, t)} {money(s.expectedDrawerMinor, cur)}
{money(s.openingFloatMinor, cur)} → +{money(s.cashTotalMinor, cur)} {s.cashAddedMinor > 0 && ( +{money(s.cashAddedMinor, cur)} )} {s.cashRemovedMinor > 0 && ( −{money(s.cashRemovedMinor, cur)} )}
); } // --- Record form (unchanged from the pre-redesign feature) ------------------ function RecordPanel({ till, onDone }: { till: TillId; onDone: () => void }) { const { t } = useTranslation(); const [amount, setAmount] = useState(""); const [reason, setReason] = useState(""); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); const record = useMutation({ mutationFn: (type: "cash_in" | "cash_out") => recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim(), till }), onSuccess: (r) => { setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) }); setAmount(""); setReason(""); onDone(); }, onError: (e) => setMsg({ ok: false, text: (e as Error).message }), }); function submit(type: "cash_in" | "cash_out") { setMsg(null); const major = Number(amount); if (!Number.isFinite(major) || major <= 0) { setMsg({ ok: false, text: t("drawer.enterPositive") }); return; } record.mutate(type); } return (
setAmount(e.target.value)} placeholder={t("drawer.amount")} inputMode="decimal" /> setReason(e.target.value)} placeholder={t("drawer.reasonPlaceholder")} />
{t("drawer.recordHint")}
{msg && (
{msg.text}
)}
); } function MovementRow({ m, canReview, onReviewed }: { m: DrawerMovement; canReview: boolean; onReviewed: () => void }) { const { t } = useTranslation(); const [note, setNote] = useState(""); const [noteOpen, setNoteOpen] = useState(false); const review = useMutation({ mutationFn: (decision: "authorize" | "deny") => reviewDrawerMovement({ refId: m.id, decision, note: note.trim() || undefined }), onSuccess: onReviewed, }); // Direction sign for display: cash_in is +, cash_out is −. const signed = m.type === "cash_in" ? m.amountMinor : -m.amountMinor; return ( {formatRelativeDateTime(m.at, t)} {m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")} {m.voucherNo && {m.voucherNo}} {money(signed, m.currency)} {m.reason || "—"} {canReview && {m.operator}} {m.status !== "pending" && m.reviewedBy && (
{m.reviewedBy} {m.reviewNote ? ` · ${m.reviewNote}` : ""}
)} {canReview && ( {m.status === "pending" ? (
{noteOpen && ( setNote(e.target.value)} placeholder={t("drawer.denyNotePlaceholder")} /> )} {review.isError && {(review.error as Error).message}}
) : null} )} ); }