feat(drawer): operator records cash movements, admin reviews after (own /drawer route)

Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.

- New `drawer` resource: drawer:create (operator records; admin-revocable per
  role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
  default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
  A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
  touches the drawer balance (the correction is settled outside the app). This
  is what keeps a late review from leaking into the next operator's inherited
  drawer — a denial that lands after the reviewed shift closed moves no cash.
  Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
  op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
  (operator: record + own; admin: review queue + all). routes/drawer.ts lifted
  from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
  for its other job = admin-sees-all-shifts). New DrawerManager.tsx.

Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
  movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
  Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
  on-site), matching the card-tender gate.

shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-01 11:17:20 +02:00
parent 018328a877
commit 114a32e6f2
18 changed files with 879 additions and 206 deletions
+8 -61
View File
@@ -8,12 +8,12 @@ import {
fetchShiftReport,
fetchShifts,
openShift,
recordCashVoucher,
type ShiftReport,
type ShiftSummary,
type SessionUser,
} from "./api.js";
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { Modal } from "./ui/Modal.js";
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
import type { LedgerEvent } from "@parking/shared";
@@ -88,7 +88,7 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
};
}
export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { user: SessionUser | null; canManage?: boolean; canVoucher?: boolean }) {
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
const { t } = useTranslation();
const [preset, setPreset] = useState<Preset>("week");
const [operator, setOperator] = useState("");
@@ -198,7 +198,6 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
isMine={isMine}
showOperator={isAdmin}
canManage={canManage}
canVoucher={canVoucher}
onChanged={refreshAll}
/>
) : (
@@ -257,7 +256,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
<span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>
{CARD_PAYMENTS_ENABLED && <span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>}
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
</div>
</button>
@@ -270,7 +269,6 @@ function ShiftActivityLog({
isMine,
showOperator,
canManage,
canVoucher,
onChanged,
}: {
shift: ShiftSummary;
@@ -278,11 +276,10 @@ function ShiftActivityLog({
isMine: boolean;
showOperator: boolean;
canManage: boolean;
canVoucher: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
const [modal, setModal] = useState<null | "end" | "takings">(null);
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
// (full signed payload + snapshots + chain provenance).
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
@@ -311,7 +308,6 @@ function ShiftActivityLog({
{isCurrent && isMine && canManage && (
<span className="flex flex-wrap gap-1.5">
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
{canVoucher && <button type="button" className="btn btn-sm" onClick={() => setModal("voucher")}>{t("shift.drawerVoucher")}</button>}
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
</span>
)}
@@ -325,7 +321,7 @@ function ShiftActivityLog({
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
<Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />
{CARD_PAYMENTS_ENABLED && <Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />}
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
</div>
</div>
@@ -340,7 +336,6 @@ function ShiftActivityLog({
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
</div>
);
@@ -385,7 +380,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />}
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
@@ -411,7 +406,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />}
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<span />
@@ -430,54 +425,6 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
);
}
function VoucherModal({ currency, onClose, onDone }: { currency: string | null; onClose: () => void; onDone: () => void }) {
const { t } = useTranslation();
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [authName, setAuthName] = useState("");
const [authPassword, setAuthPassword] = useState("");
const [msg, setMsg] = useState<string | null>(null);
async function submit(type: "cash_in" | "cash_out") {
setMsg(null);
const major = Number(amount);
if (!Number.isFinite(major) || major <= 0) return setMsg(t("shift.enterPositive"));
if (!authName.trim() || !authPassword) return setMsg(t("shift.authRequired"));
try {
const r = await recordCashVoucher({ type, amountMinor: Math.round(major * 100), reason: reason.trim(), authorizedBy: authName.trim(), authorizerPassword: authPassword });
setMsg(t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }));
setAmount("");
setReason("");
setAuthPassword("");
onDone();
} catch (e) {
setMsg((e as Error).message);
}
}
return (
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
<div className="flex flex-col gap-2 text-[0.8125rem]">
<div className="flex flex-wrap items-center gap-2">
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
</div>
<div className="flex flex-wrap items-center gap-2">
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
</div>
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
<div className="mt-1 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => submit("cash_out")}>{t("shift.mandatPagese")}</button>
</div>
</div>
</Modal>
);
}
function TakingsModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
@@ -500,7 +447,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />}
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />