feat(drawer): drawer hub — balance now, this-shift figure, daily activity, shift history; busy spinners
/drawer was record + review only: no current balance, no sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub: - Drawer now: the till's running balance (new GET /api/drawer/balance, shift:read — exposes the service's existing drawerBalance(); the drawer is one site-wide till, same exposure the X-report already had) with the open shift's X-report breakdown alongside (float + takings + vouchers = expected = balance) and a "This shift: ±X" figure (expected − opening float — the shift's own contribution vs what it inherited). - Today's cash activity: every cash payment + voucher since local midnight from the signed chain, live, with day totals (card never enters the till). - Record + movements/review: the 2026-07-01 flow, unchanged. - Closed shifts: drawer-focused history via the scope-aware /api/shifts (float → takings ± vouchers → expected per shift). Also: every shift open/close button (header, /shifts, pay modal, end- shift confirm) now shows an animated spinner + dims while busy — the old label-swap-only feedback read as a dead click when a shift open ran slow. The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain)) is recorded as an open item in wiki/concepts/shift.md with the fix sketch: fold from the last z-report's signed expectedDrawerMinor forward. No new ledger surface — one read-only endpoint; RBAC test added. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+328
-60
@@ -2,23 +2,39 @@ 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,
|
||||
} from "./api.js";
|
||||
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
|
||||
// Drawer cash movements. Operators RECORD receipts (Mandat Arkëtimi / cash_in) and
|
||||
// disbursements (Mandat Pagese / cash_out) freely; admins REVIEW them after the fact
|
||||
// (authorize/deny — a flag, never a cash reversal). A denial is a judgment about the
|
||||
// operator, settled outside the app: the drawer balance is untouched. See
|
||||
// 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 — the drawer is a single site-wide till that carries across shifts. See
|
||||
// wiki/concepts/shift.md.
|
||||
|
||||
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 =
|
||||
@@ -37,6 +53,198 @@ function StatusBadge({ status }: { status: MovementStatus }) {
|
||||
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
||||
{/* Row 1: the till NOW + the record form. */}
|
||||
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
||||
<StatePanel />
|
||||
{canCreate && <RecordPanel onDone={refresh} />}
|
||||
</div>
|
||||
|
||||
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
|
||||
<TodayPanel />
|
||||
<MovementsPanel canReview={canReview} onChanged={refresh} />
|
||||
<ShiftHistoryPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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() {
|
||||
const { t } = useTranslation();
|
||||
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
|
||||
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
||||
const report = useQuery({
|
||||
queryKey: ["shift", "xreport"],
|
||||
queryFn: fetchShiftReport,
|
||||
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 (
|
||||
<Panel title={t("drawer.stateTitle")}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-term-cyan tabular-nums">
|
||||
{balance.data ? money(balance.data.balanceMinor, cur) : "…"}
|
||||
</div>
|
||||
{shiftDelta != null && (
|
||||
<div className="mt-0.5 text-[0.8125rem] tabular-nums">
|
||||
<span className="text-term-muted">{t("drawer.thisShift")} </span>
|
||||
<span className={shiftDelta < 0 ? "font-semibold text-term-red" : "font-semibold text-term-green"}>
|
||||
{shiftDelta >= 0 ? "+" : ""}
|
||||
{money(shiftDelta, cur)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 text-[0.6875rem] text-term-muted">
|
||||
{status.data?.open
|
||||
? t("drawer.openShift", { operator: status.data.open.operator }) +
|
||||
" · " +
|
||||
formatRelativeDateTime(status.data.open.startedAt, t)
|
||||
: t("drawer.noShiftOpen")}
|
||||
</div>
|
||||
</div>
|
||||
{/* The running breakdown, only while a shift is open (it's the X-report). */}
|
||||
{x && (
|
||||
<dl className="grid grid-cols-[max-content_max-content] gap-x-4 gap-y-0.5 text-[0.75rem] tabular-nums">
|
||||
<dt className="text-term-muted">{t("shifts.openingFloat")}</dt>
|
||||
<dd className="text-right text-term-text">{money(x.openingFloatMinor, cur)}</dd>
|
||||
<dt className="text-term-muted">
|
||||
{t("shifts.cashTaken")} · {t("shifts.payments")} {x.paymentCount}
|
||||
</dt>
|
||||
<dd className="text-right text-term-green">{money(x.cashTotalMinor, cur)}</dd>
|
||||
<dt className="text-term-muted">{t("shifts.cashAdded")}</dt>
|
||||
<dd className="text-right text-term-text">{money(x.cashAddedMinor, cur)}</dd>
|
||||
<dt className="text-term-muted">{t("shifts.cashRemoved")}</dt>
|
||||
<dd className="text-right text-term-red">{money(-x.cashRemovedMinor, cur)}</dd>
|
||||
<dt className="border-t border-term-border pt-0.5 font-semibold text-term-muted">{t("shifts.expectedDrawer")}</dt>
|
||||
<dd className="border-t border-term-border pt-0.5 text-right font-semibold text-term-text">
|
||||
{money(x.expectedDrawerMinor, cur)}
|
||||
</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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() {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({
|
||||
queryKey: ["drawer", "today"],
|
||||
queryFn: () => fetchEvents(1000, startOfToday()),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const rows = (q.data?.events ?? []).filter((e) => {
|
||||
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
||||
if (e.type !== "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") {
|
||||
cashIn += amt;
|
||||
payments++;
|
||||
} else {
|
||||
vouchersNet += e.type === "cash_in" ? Math.abs(amt) : -Math.abs(amt);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={t("drawer.todayTitle")}
|
||||
right={
|
||||
rows.length > 0 ? (
|
||||
<span className="text-[0.6875rem] tabular-nums text-term-muted">
|
||||
{t("drawer.todayPayments", { count: payments })} · <span className="text-term-green">{money(cashIn, cur)}</span>
|
||||
{vouchersNet !== 0 && (
|
||||
<>
|
||||
{" "}
|
||||
· <span className={vouchersNet < 0 ? "text-term-red" : "text-term-green"}>{money(vouchersNet, cur)}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||
{q.isError ? (
|
||||
<div className="text-[0.75rem] text-term-red">{(q.error as Error).message}</div>
|
||||
) : q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.noActivity")}</div>
|
||||
) : (
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<tbody>
|
||||
{rows.map((e) => (
|
||||
<TodayRow key={e.id} e={e} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
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 = new Date(e.occurredAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const label =
|
||||
e.type === "payment"
|
||||
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
|
||||
return (
|
||||
<tr className="border-t border-term-border/40">
|
||||
<td className="whitespace-nowrap py-1 pr-2 text-term-muted">{time}</td>
|
||||
<td className="max-w-0 truncate py-1 pr-2 text-term-text" title={pl.reason || undefined}>
|
||||
{label}
|
||||
</td>
|
||||
<td className={`whitespace-nowrap py-1 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
|
||||
{money(signed, pl.currency ?? null)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
|
||||
|
||||
function MovementsPanel({ canReview, onChanged }: { 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<MovementStatus | "">("");
|
||||
const q = useQuery({
|
||||
@@ -47,68 +255,128 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
||||
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{canCreate && <RecordPanel onDone={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />}
|
||||
|
||||
<Panel
|
||||
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||
right={
|
||||
canReview && pendingCount > 0 ? (
|
||||
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{t("drawer.pendingCount", { count: pendingCount })}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{canReview && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||
<button
|
||||
key={s || "all"}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : movements.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.empty")}</div>
|
||||
) : (
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
|
||||
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
|
||||
{canReview && <th className="px-2 py-1.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m) => (
|
||||
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Panel
|
||||
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||
right={
|
||||
canReview && pendingCount > 0 ? (
|
||||
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{t("drawer.pendingCount", { count: pendingCount })}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{canReview && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||
<button
|
||||
key={s || "all"}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : movements.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.empty")}</div>
|
||||
) : (
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
|
||||
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
|
||||
{canReview && <th className="px-2 py-1.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m) => (
|
||||
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={onChanged} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Closed shifts, drawer-focused -------------------------------------------
|
||||
// Scope follows /api/shifts: operators see their own, admins all.
|
||||
|
||||
function ShiftHistoryPanel() {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
|
||||
const shifts = (q.data?.shifts ?? []).slice(0, 50);
|
||||
const showOperator = q.data?.scope === "all";
|
||||
|
||||
return (
|
||||
<Panel title={t("drawer.historyTitle")} className="min-h-0">
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||
{q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : shifts.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.noShifts")}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{shifts.map((s) => (
|
||||
<ShiftDrawerCard key={s.id} s={s} showOperator={showOperator} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const cur = s.currency;
|
||||
return (
|
||||
<div className="card p-2.5 text-[0.75rem]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-term-text">
|
||||
{showOperator ? `${s.operator} · ` : ""}
|
||||
{formatRelativeDateTime(s.startedAt, t)}
|
||||
</span>
|
||||
<span className="font-semibold text-term-text tabular-nums" title={t("shifts.expectedDrawer")}>
|
||||
{money(s.expectedDrawerMinor, cur)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-x-3 text-term-muted tabular-nums">
|
||||
<span title={t("shifts.openingFloat")}>{money(s.openingFloatMinor, cur)} →</span>
|
||||
<span className="text-term-green" title={t("shifts.cashTaken")}>
|
||||
+{money(s.cashTotalMinor, cur)}
|
||||
</span>
|
||||
{s.cashAddedMinor > 0 && (
|
||||
<span className="text-term-green" title={t("shifts.cashAdded")}>
|
||||
+{money(s.cashAddedMinor, cur)}
|
||||
</span>
|
||||
)}
|
||||
{s.cashRemovedMinor > 0 && (
|
||||
<span className="text-term-red" title={t("shifts.cashRemoved")}>
|
||||
−{money(s.cashRemovedMinor, cur)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
||||
|
||||
function RecordPanel({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [amount, setAmount] = useState("");
|
||||
|
||||
Reference in New Issue
Block a user