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
+32 -8
View File
@@ -41,6 +41,8 @@ import { SiteSettings } from "./SiteSettings.js";
import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { DrawerManager } from "./DrawerManager.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js";
import { RecycleBin } from "./RecycleBin.js";
@@ -379,7 +381,7 @@ function CloseShiftConfirm({
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
<span />
@@ -430,6 +432,11 @@ function RootLayout() {
<nav className="flex items-center gap-1">
<NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shifts" label={t("nav.shifts")} />
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
user can do either. See wiki/concepts/shift.md. */}
{(show("drawer:create") || show("drawer:review")) && (
<NavLink to="/drawer" label={t("nav.drawer")} />
)}
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
Shown if the user can reach ANY of its tabs. */}
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
@@ -548,14 +555,30 @@ const shiftRoute = createRoute({
component: function ShiftRoute() {
const { user } = rootRoute.useRouteContext();
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
// The CURRENT shift's pane carries the actions (open/close, drawer voucher, takings),
// each opening a modal. `canManage` = shift:create (start/end + raise vouchers); a
// voucher additionally needs an admin's password sign-off server-side.
// The CURRENT shift's pane carries the actions (open/close, takings), each opening a
// modal. `canManage` = shift:create (start/end). Drawer cash movements moved to /drawer
// (2026-07-01).
return <ShiftsHistory user={user} canManage={can(user, "shift:create")} />;
},
});
const drawerRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/drawer",
// Reachable by anyone who can record OR review; the component shows the right view per
// permission. Guard on the broader of the two (create) so a review-only admin still gets
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
beforeLoad: ({ context }) => {
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
throw redirect({ to: "/booth" });
}
},
component: function DrawerRoute() {
const { user } = rootRoute.useRouteContext();
return (
<ShiftsHistory
user={user}
canManage={can(user, "shift:create")}
canVoucher={can(user, "shift:create")}
<DrawerManager
canCreate={can(user, "drawer:create")}
canReview={can(user, "drawer:review")}
/>
);
},
@@ -723,6 +746,7 @@ const routeTree = rootRoute.addChildren([
...legacyRedirects,
profileRoute,
shiftRoute,
drawerRoute,
reportsRoute,
subscriptionsRoute.addChildren([
subscriptionsIndexRoute,