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
+240
View File
@@ -0,0 +1,240 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchDrawerMovements,
recordDrawerMovement,
reviewDrawerMovement,
type DrawerMovement,
type MovementStatus,
} from "./api.js";
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
// Drawer cash movements. Operators RECORD receipts (Mandat Arkëtimi / cash_in) and
// disbursements (Mandat Pagese / cash_out) freely; admins REVIEW them after the fact
// (authorize/deny — a flag, never a cash reversal). A denial is a judgment about the
// operator, settled outside the app: the drawer balance is untouched. See
// wiki/concepts/shift.md.
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
function StatusBadge({ status }: { status: MovementStatus }) {
const { t } = useTranslation();
const cls =
status === "authorized"
? "border-term-green/60 text-term-green"
: status === "denied"
? "border-term-red/60 text-term-red"
: "border-term-amber/60 text-term-amber";
return (
<span className={`rounded border px-1 text-[0.625rem] uppercase tracking-wider ${cls}`}>
{t(`drawer.status.${status}`)}
</span>
);
}
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
// Reviewers can filter the list (the pending queue); operators always see their own, all.
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
const q = useQuery({
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
});
const movements = q.data?.movements ?? [];
const pendingCount = movements.filter((m) => m.status === "pending").length;
return (
<div className="flex h-full flex-col gap-3 p-3">
{canCreate && <RecordPanel onDone={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />}
<Panel
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
right={
canReview && pendingCount > 0 ? (
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
{t("drawer.pendingCount", { count: pendingCount })}
</span>
) : null
}
className="min-h-0 flex-1"
>
<div className="flex h-full flex-col">
{canReview && (
<div className="mb-2 flex items-center gap-1.5">
{(["", "pending", "authorized", "denied"] as const).map((s) => (
<button
key={s || "all"}
type="button"
onClick={() => setStatusFilter(s)}
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
</button>
))}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{q.isLoading ? (
<div className="text-term-muted">{t("common.loading")}</div>
) : movements.length === 0 ? (
<div className="text-term-muted">{t("drawer.empty")}</div>
) : (
<table className="w-full text-[0.75rem] tabular-nums">
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
{canReview && <th className="px-2 py-1.5" />}
</tr>
</thead>
<tbody>
{movements.map((m) => (
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />
))}
</tbody>
</table>
)}
</div>
</div>
</Panel>
</div>
);
}
function RecordPanel({ onDone }: { onDone: () => void }) {
const { t } = useTranslation();
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const record = useMutation({
mutationFn: (type: "cash_in" | "cash_out") =>
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
onSuccess: (r) => {
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
setAmount("");
setReason("");
onDone();
},
onError: (e) => setMsg({ ok: false, text: (e as Error).message }),
});
function submit(type: "cash_in" | "cash_out") {
setMsg(null);
const major = Number(amount);
if (!Number.isFinite(major) || major <= 0) {
setMsg({ ok: false, text: t("drawer.enterPositive") });
return;
}
record.mutate(type);
}
return (
<Panel title={t("drawer.recordTitle")}>
<div className="flex flex-col gap-2 text-[0.8125rem]">
<div className="flex flex-wrap items-center gap-2">
<input
className="input w-28"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={t("drawer.amount")}
inputMode="decimal"
/>
<input
className="input min-w-40 flex-1"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t("drawer.reasonPlaceholder")}
/>
</div>
<div className="text-[0.6875rem] text-term-muted">{t("drawer.recordHint")}</div>
{msg && (
<div className={`text-[0.75rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
)}
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-go btn-sm" disabled={record.isPending} onClick={() => submit("cash_in")}>
{t("drawer.mandatArketimi")}
</button>
<button type="button" className="btn btn-danger btn-sm" disabled={record.isPending} onClick={() => submit("cash_out")}>
{t("drawer.mandatPagese")}
</button>
</div>
</div>
</Panel>
);
}
function MovementRow({ m, canReview, onReviewed }: { m: DrawerMovement; canReview: boolean; onReviewed: () => void }) {
const { t } = useTranslation();
const [note, setNote] = useState("");
const [noteOpen, setNoteOpen] = useState(false);
const review = useMutation({
mutationFn: (decision: "authorize" | "deny") =>
reviewDrawerMovement({ refId: m.id, decision, note: note.trim() || undefined }),
onSuccess: onReviewed,
});
// Direction sign for display: cash_in is +, cash_out is −.
const signed = m.type === "cash_in" ? m.amountMinor : -m.amountMinor;
return (
<tr className="border-t border-term-border/50 align-top">
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">{formatRelativeDateTime(m.at, t)}</td>
<td className="px-2 py-1.5">
<span className={m.type === "cash_in" ? "text-term-green" : "text-term-red"}>
{m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}
</span>
{m.voucherNo && <span className="ml-1 text-[0.625rem] text-term-muted">{m.voucherNo}</span>}
</td>
<td className={`whitespace-nowrap px-2 py-1.5 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
{money(signed, m.currency)}
</td>
<td className="px-2 py-1.5 text-term-text">{m.reason || "—"}</td>
{canReview && <td className="px-2 py-1.5 text-term-muted">{m.operator}</td>}
<td className="px-2 py-1.5">
<StatusBadge status={m.status} />
{m.status !== "pending" && m.reviewedBy && (
<div className="mt-0.5 text-[0.5625rem] text-term-muted">
{m.reviewedBy}
{m.reviewNote ? ` · ${m.reviewNote}` : ""}
</div>
)}
</td>
{canReview && (
<td className="px-2 py-1.5 text-right">
{m.status === "pending" ? (
<div className="flex flex-col items-end gap-1">
<div className="flex gap-1">
<button type="button" className="btn btn-go btn-sm" disabled={review.isPending} onClick={() => review.mutate("authorize")}>
{t("drawer.authorize")}
</button>
<button
type="button"
className="btn btn-danger btn-sm"
disabled={review.isPending}
onClick={() => (noteOpen ? review.mutate("deny") : setNoteOpen(true))}
>
{t("drawer.deny")}
</button>
</div>
{noteOpen && (
<input
className="input w-44 text-[0.6875rem]"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("drawer.denyNotePlaceholder")}
/>
)}
{review.isError && <span className="text-[0.625rem] text-term-red">{(review.error as Error).message}</span>}
</div>
) : null}
</td>
)}
</tr>
);
}