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:
+49
-10
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user