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
+42 -6
View File
@@ -58,12 +58,42 @@ export const en: Catalog = {
users: "Users",
roles: "Roles",
shifts: "Shifts",
drawer: "Drawer",
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs",
backup: "Backup",
profile: "Profile",
},
drawer: {
recordTitle: "Record a cash movement",
amount: "amount",
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
recordHint: "Recorded to the drawer immediately. An admin reviews it afterward.",
mandatArketimi: "Receipt (in) +",
mandatPagese: "Disbursement (out) −",
enterPositive: "Enter a positive amount.",
recorded: "{{no}} recorded. Drawer now {{amount}}.",
myTitle: "My cash movements",
allTitle: "Cash movements",
pendingCount: "{{count}} pending",
filterAll: "All",
empty: "No cash movements yet.",
colWhen: "When",
colType: "Type",
colAmount: "Amount",
colReason: "Reason",
colOperator: "Operator",
colStatus: "Status",
status: {
pending: "pending",
authorized: "authorized",
denied: "denied",
},
authorize: "Authorize",
deny: "Deny",
denyNotePlaceholder: "reason for denial (optional)",
},
profile: {
title: "My profile",
accountSection: "Account",
@@ -183,6 +213,8 @@ export const en: Catalog = {
evtCashMovement: "CASH",
evtCashIn: "PAY-IN",
evtCashOut: "PAY-OUT",
evtCashReview: "REVIEW",
decision: { authorize: "authorized", deny: "denied" },
evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
// live-feed event detail line + classification badges (computed from payload)
@@ -224,6 +256,10 @@ export const en: Catalog = {
edPlate: "Plate",
edCategory: "Category",
edOperator: "Operator",
edDecision: "Review decision",
edReviewedBy: "Reviewed by",
edReviewNote: "Note",
edReviewRef: "Movement ref",
edTariffVersion: "Tariff version",
edRawPayload: "Raw signed payload",
edOccurrence: "Occurrence id",
@@ -709,9 +745,9 @@ export const en: Catalog = {
srcSubWindow: "out-of-window",
drawerSection: "— Drawer —",
openingFloat: "Opening cash:",
cashTaken: "Cash taken:",
cashAdded: "Cash added:",
cashRemoved: "Cash removed:",
cashTaken: "Daily takings:",
cashAdded: "Receipts:",
cashRemoved: "Disbursements:",
expectedDrawer: "Expected drawer:",
printedToReceipt: "Printed to booth receipt.",
recordedNoPrinter: "Recorded (no printer to print to).",
@@ -760,9 +796,9 @@ export const en: Catalog = {
current: "current",
drawerSection: "Drawer",
openingFloat: "Opening cash",
cashTaken: "Cash taken",
cashAdded: "Cash added",
cashRemoved: "Cash removed",
cashTaken: "Daily takings",
cashAdded: "Receipts",
cashRemoved: "Disbursements",
loadFailed: "Failed to load shifts.",
},
reports: {
+42 -6
View File
@@ -60,12 +60,42 @@ export const sq = {
users: "Përdoruesit",
roles: "Rolet",
shifts: "Turnet",
drawer: "Arka",
reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget",
backup: "Kopje rezervë",
profile: "Profili",
},
drawer: {
recordTitle: "Regjistro një lëvizje arke",
amount: "shuma",
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
recordHint: "Regjistrohet menjëherë në arkë. Një admin e shqyrton më pas.",
mandatArketimi: "Arkëtim (hyrje) +",
mandatPagese: "Pagesë (dalje) −",
enterPositive: "Fut një shumë pozitive.",
recorded: "{{no}} u regjistrua. Arka tani {{amount}}.",
myTitle: "Lëvizjet e mia të arkës",
allTitle: "Lëvizjet e arkës",
pendingCount: "{{count}} në pritje",
filterAll: "Të gjitha",
empty: "Asnjë lëvizje arke ende.",
colWhen: "Kur",
colType: "Lloji",
colAmount: "Shuma",
colReason: "Arsyeja",
colOperator: "Operatori",
colStatus: "Statusi",
status: {
pending: "në pritje",
authorized: "autorizuar",
denied: "refuzuar",
},
authorize: "Autorizo",
deny: "Refuzo",
denyNotePlaceholder: "arsyeja e refuzimit (opsionale)",
},
profile: {
title: "Profili im",
accountSection: "Llogaria",
@@ -187,6 +217,8 @@ export const sq = {
evtCashMovement: "ARKË",
evtCashIn: "ARKËTIM",
evtCashOut: "PAGESË",
evtCashReview: "SHQYRTIM",
decision: { authorize: "autorizuar", deny: "refuzuar" },
evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR",
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
@@ -228,6 +260,10 @@ export const sq = {
edPlate: "Targa",
edCategory: "Kategoria",
edOperator: "Operatori",
edDecision: "Vendimi i shqyrtimit",
edReviewedBy: "Shqyrtuar nga",
edReviewNote: "Shënim",
edReviewRef: "Ref. lëvizjes",
edTariffVersion: "Versioni i tarifës",
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
edOccurrence: "ID e hyrjes",
@@ -722,9 +758,9 @@ export const sq = {
srcSubWindow: "jashtë orarit",
drawerSection: "— Arka —",
openingFloat: "Arka fillestare:",
cashTaken: "Para të marra:",
cashAdded: "Para të shtuara:",
cashRemoved: "Para të hequra:",
cashTaken: "Xhiro ditore:",
cashAdded: "Arkëtime:",
cashRemoved: "Pagesa:",
expectedDrawer: "Gjëndje Arke:",
printedToReceipt: "Printuar te printeri i kabinës.",
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
@@ -775,9 +811,9 @@ export const sq = {
// Expanded drawer detail.
drawerSection: "Arka",
openingFloat: "Arka fillestare",
cashTaken: "Para të marra",
cashAdded: "Para të shtuara",
cashRemoved: "Para të hequra",
cashTaken: "Xhiro ditore",
cashAdded: "Arkëtime",
cashRemoved: "Pagesa",
loadFailed: "Ngarkimi i turneve dështoi.",
},
reports: {