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
+49 -10
View File
@@ -1019,15 +1019,38 @@ export async function fetchShiftReport(): Promise<XReport | null> {
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
}
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
* Operator-raised, admin-authorized (authorizedBy + their password). */
export function recordCashVoucher(args: {
// --- Drawer cash movements (operator records, admin reviews) ---------------------
// Redesigned 2026-07-01: an operator RECORDS a receipt/disbursement freely; an admin
// REVIEWS it after the fact (authorize/deny — a flag, never a cash reversal). See
// wiki/concepts/shift.md.
export type MovementStatus = "pending" | "authorized" | "denied";
/** A drawer movement with its admin-review status. */
export interface DrawerMovement {
id: string;
type: "cash_in" | "cash_out";
/** Positive magnitude; direction is the type. */
amountMinor: number;
currency: string | null;
reason: string | null;
operator: string;
voucherNo: string | null;
at: string;
status: MovementStatus;
reviewedBy: string | null;
reviewNote: string | null;
reviewedAt: string | null;
}
/** Operator RECORDS a drawer movement — cash_in (Mandat Arkëtimi / pay-IN) or cash_out
* (Mandat Pagese / pay-OUT). Direction is the TYPE; amountMinor a positive magnitude.
* No admin sign-off at creation — it's reviewed afterward. */
export function recordDrawerMovement(args: {
type: "cash_in" | "cash_out";
amountMinor: number;
reason: string;
authorizedBy: string;
authorizerPassword: string;
currency?: string;
}): Promise<{
type: "cash_in" | "cash_out";
amountMinor: number;
@@ -1035,10 +1058,26 @@ export function recordCashVoucher(args: {
balanceMinor: number;
printed: boolean;
}> {
return apiFetch("/api/cash-voucher", {
method: "POST",
body: JSON.stringify(args),
});
return apiFetch("/api/drawer/movement", { method: "POST", body: JSON.stringify(args) });
}
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
* and may filter by status (the pending review queue). */
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
movements: DrawerMovement[];
scope: "all" | "self";
}> {
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
return apiFetch(`/api/drawer/movements${qs}`);
}
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
export function reviewDrawerMovement(args: {
refId: string;
decision: "authorize" | "deny";
note?: string;
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
return apiFetch("/api/drawer/review", { method: "POST", body: JSON.stringify(args) });
}
/** A completed shift (reconstructed from its signed Z-report). */