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
+22
View File
@@ -23,6 +23,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
@@ -187,6 +188,12 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
const category = typeof p?.category === "string" ? p.category : null;
const operator = typeof p?.operator === "string" ? p.operator : null;
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
// cash_review fields: the admin's decision on a drawer movement (+ who / note / the
// reviewed movement id). A flag only — it never moves cash. See wiki/concepts/shift.md.
const decision = p?.decision === "authorize" || p?.decision === "deny" ? p.decision : null;
const reviewedBy = typeof p?.reviewedBy === "string" ? p.reviewedBy : null;
const reviewNote = typeof p?.note === "string" ? p.note : null;
const refId = typeof p?.refId === "string" ? p.refId : null;
return (
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
@@ -244,6 +251,21 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
{/* cash_review: the admin's decision + who + why (for a denial). */}
{decision && (
<DetailRow label={t("booth.edDecision")}>
<span className={decision === "authorize" ? "text-term-green" : "text-term-red"}>
{t(`booth.decision.${decision}`)}
</span>
</DetailRow>
)}
{reviewedBy && <DetailRow label={t("booth.edReviewedBy")}>{reviewedBy}</DetailRow>}
{reviewNote && <DetailRow label={t("booth.edReviewNote")}>{reviewNote}</DetailRow>}
{refId && (
<DetailRow label={t("booth.edReviewRef")}>
<code className="text-[0.6875rem] text-term-muted">{refId}</code>
</DetailRow>
)}
{sessionRef && sessionRef !== e.identity && (
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
)}