114a32e6f2
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
321 lines
16 KiB
TypeScript
321 lines
16 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import { type ReactNode } from "react";
|
|
import { type LedgerEvent } from "../api.js";
|
|
import { formatMoney } from "../lib/format.js";
|
|
import { renderReason } from "../lib/reason.js";
|
|
import { Modal } from "./Modal.js";
|
|
import { SnapshotStrip } from "./SnapshotStrip.js";
|
|
|
|
// Shared ledger-event presentation: the colour/label map, the clickable feed ROW, and
|
|
// the read-only DETAIL modal (full signed payload + snapshots + chain provenance). Used
|
|
// by the booth live feed AND the shift activity log so both render — and open — events
|
|
// identically. See wiki/concepts/append-only-event-chain.md.
|
|
|
|
export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
|
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
|
|
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
|
|
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
|
|
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
|
|
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
|
|
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
|
|
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
|
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
|
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
|
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
|
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
|
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
|
};
|
|
|
|
/**
|
|
* A refused-ACTION event is a benign WARNING, not a red-flag anomaly. The ledger type is
|
|
* `anomaly` for both (immutable history), but a refused exit / refused subscription /
|
|
* refused entry (e.g. a double card-scan, an at-capacity subscriber, an already-closed
|
|
* session) is an EXPECTED outcome — not fraud. We classify it from the payload flags the
|
|
* flows already sign (`exitRefused` / `entryRefused` / `permitRefused`) and show it as an
|
|
* amber "REFUZUAR / REFUSED" warning, reserving red "ANOMALI" for genuine anomalies
|
|
* (barrier-open failure, opened-without-ticket, …). Display-only — no ledger change.
|
|
*/
|
|
export function isRefusedWarning(e: LedgerEvent): boolean {
|
|
if (e.type !== "anomaly") return false;
|
|
const p = e.payload;
|
|
return !!(p && (p.exitRefused || p.entryRefused || p.permitRefused));
|
|
}
|
|
|
|
/** The label key + colour to render for an event, applying the refused-warning split. */
|
|
export function eventStyleFor(e: LedgerEvent): { labelKey: string; color: string } {
|
|
if (isRefusedWarning(e)) return { labelKey: "booth.evtRefused", color: "text-term-amber" };
|
|
return EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
|
|
}
|
|
|
|
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
|
function hhmmss(iso: string): string {
|
|
const d = new Date(iso);
|
|
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
|
|
}
|
|
|
|
/** Red-flag classification badges computed from the signed payload. */
|
|
export function eventBadges(p: LedgerEvent["payload"]): string[] {
|
|
if (!p) return [];
|
|
const keys: string[] = [];
|
|
if (p.entryRefused) keys.push("booth.badgeEntryRefused");
|
|
if (p.exitRefused) keys.push("booth.badgeExitRefused");
|
|
if (p.full) keys.push("booth.badgeLotFull");
|
|
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
|
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
|
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
|
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
|
// Subscriber entered outside their plan's allowed window → will owe a transient charge
|
|
// for the minutes actually parked out-of-window, priced + collected (gated) at exit.
|
|
// (`windowOwedMinor` is the old fixed-amount stamp, kept so historic events still badge.)
|
|
if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0))
|
|
keys.push("booth.badgeWindowCharge");
|
|
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
|
|
return keys;
|
|
}
|
|
|
|
/** The i18n key for a subscriber's access medium (`via`), or null. */
|
|
export function viaKey(p: LedgerEvent["payload"]): string | null {
|
|
if (!p) return null;
|
|
if (p.via === "qr") return "booth.viaQr";
|
|
if (p.via === "card") return "booth.viaCard";
|
|
if (p.via === "plate") return "booth.viaPlate";
|
|
return null;
|
|
}
|
|
|
|
/** A short money summary for payment events (e.g. "350.00 ALL"). */
|
|
export function paymentSummary(p: LedgerEvent["payload"]): string | null {
|
|
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
|
|
return formatMoney(p.amountMinor, p.currency);
|
|
}
|
|
|
|
/** What to SHOW for an event's actor. A subscription occurrence has an opaque
|
|
* `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`,
|
|
* so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */
|
|
export function displayIdentity(e: LedgerEvent): string {
|
|
return e.subscriberLabel ?? e.identity ?? "—";
|
|
}
|
|
|
|
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the
|
|
* time/label/#index columns aligned across rows; the identity, plate, badges and reason flow
|
|
* inline in the middle column and wrap there only when they run out of width. */
|
|
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
|
const { t } = useTranslation();
|
|
const style = eventStyleFor(e);
|
|
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
|
// A refused-action event is a benign WARNING (amber), distinct from a genuine red
|
|
// anomaly. Only true anomalies get the red row tint + the "no reason" fallback.
|
|
const refusedWarning = isRefusedWarning(e);
|
|
const isAnomaly = e.type === "anomaly" && !refusedWarning;
|
|
const p = e.payload;
|
|
const reason = renderReason(p, t);
|
|
const amount = paymentSummary(p);
|
|
const badges = eventBadges(p);
|
|
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen(e)}
|
|
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-start gap-x-3 border-b border-term-border/50 px-1 py-1 text-left text-[0.75rem] tabular-nums hover:bg-term-panel-2 ${
|
|
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
|
}`}
|
|
>
|
|
<span className="py-px text-term-muted">{hhmmss(e.occurredAt)}</span>
|
|
<span className={`py-px shrink-0 font-semibold ${style.color}`}>{label}</span>
|
|
{/* Identity + plate + detail all flow in ONE wrapping line — they fill the available
|
|
width and only wrap to a second line when this cell actually runs out of room (no
|
|
forced second row). Keeps time/label/#index in their columns. */}
|
|
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 py-px">
|
|
<span className="break-all text-term-text">{displayIdentity(e)}</span>
|
|
{e.plate && (
|
|
<span
|
|
className="shrink-0 rounded border border-term-border px-1 text-[0.6875rem] font-semibold tracking-wide text-term-amber"
|
|
title={t("booth.plateTitle")}
|
|
>
|
|
{e.plate}
|
|
</span>
|
|
)}
|
|
{badges.map((k) => (
|
|
<span
|
|
key={k}
|
|
className="shrink-0 rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
|
|
>
|
|
{t(k)}
|
|
</span>
|
|
))}
|
|
{detail && (
|
|
<span className={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
|
)}
|
|
</span>
|
|
<span className="py-px text-term-muted">#{e.index}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** One label/value line in the event-detail modal. */
|
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[0.75rem]">
|
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
|
|
<span className="min-w-0 break-words text-term-text">{children}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Full read-only detail for one ledger event: business fields + the human-readable
|
|
* reason + the session's entry/exit snapshots, then the signed-chain provenance
|
|
* (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable;
|
|
* this only DISPLAYS the signed record. */
|
|
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const style = eventStyleFor(e);
|
|
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
|
const p = e.payload;
|
|
const reason = renderReason(p, t);
|
|
const badges = eventBadges(p);
|
|
const isAnomaly = e.type === "anomaly" && !isRefusedWarning(e);
|
|
|
|
// Pretty money for any minor-unit amount in the payload.
|
|
const money =
|
|
p && typeof p.amountMinor === "number" && typeof p.currency === "string"
|
|
? formatMoney(p.amountMinor, p.currency)
|
|
: null;
|
|
// Pull out the business fields worth a labelled row. Everything else (and the raw
|
|
// bytes) lives behind the audit disclosure — the operator sees a clean summary.
|
|
const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null;
|
|
const plate = typeof p?.plate === "string" ? p.plate : null;
|
|
const category = typeof p?.category === "string" ? p.category : null;
|
|
const operator = typeof p?.operator === "string" ? p.operator : null;
|
|
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
|
// cash_review fields: the admin's decision on a drawer movement (+ who / note / the
|
|
// reviewed movement id). A flag only — it never moves cash. See wiki/concepts/shift.md.
|
|
const decision = p?.decision === "authorize" || p?.decision === "deny" ? p.decision : null;
|
|
const reviewedBy = typeof p?.reviewedBy === "string" ? p.reviewedBy : null;
|
|
const reviewNote = typeof p?.note === "string" ? p.note : null;
|
|
const refId = typeof p?.refId === "string" ? p.refId : null;
|
|
|
|
return (
|
|
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
|
<div className="flex flex-col gap-3">
|
|
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
|
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
|
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
|
{(reason || money) && (
|
|
<div className={`mt-1 text-[0.8125rem] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
|
{reason ?? money}
|
|
</div>
|
|
)}
|
|
{!reason && !money && isAnomaly && (
|
|
<div className="mt-1 text-[0.8125rem] text-term-red/90">{t("booth.evtNoReason")}</div>
|
|
)}
|
|
{badges.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{badges.map((k) => (
|
|
<span
|
|
key={k}
|
|
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
|
|
>
|
|
{t(k)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
|
<div>
|
|
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
|
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
|
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
|
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
|
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
|
|
{/* When we showed a subscriber NAME above, also expose the raw occurrence id
|
|
(the SUBSESS-… session key) for traceability against the ledger. */}
|
|
{e.subscriberLabel && e.identity && (
|
|
<DetailRow label={t("booth.edOccurrence")}>
|
|
<code className="text-[0.6875rem] text-term-muted">{e.identity}</code>
|
|
</DetailRow>
|
|
)}
|
|
{money && (
|
|
<DetailRow label={t("booth.edAmount")}>
|
|
<span className="text-term-cyan">{money}</span>
|
|
</DetailRow>
|
|
)}
|
|
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
|
{viaKey(p) && (
|
|
<DetailRow label={t("booth.edVia")}>
|
|
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
|
</DetailRow>
|
|
)}
|
|
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
|
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
|
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
|
{/* cash_review: the admin's decision + who + why (for a denial). */}
|
|
{decision && (
|
|
<DetailRow label={t("booth.edDecision")}>
|
|
<span className={decision === "authorize" ? "text-term-green" : "text-term-red"}>
|
|
{t(`booth.decision.${decision}`)}
|
|
</span>
|
|
</DetailRow>
|
|
)}
|
|
{reviewedBy && <DetailRow label={t("booth.edReviewedBy")}>{reviewedBy}</DetailRow>}
|
|
{reviewNote && <DetailRow label={t("booth.edReviewNote")}>{reviewNote}</DetailRow>}
|
|
{refId && (
|
|
<DetailRow label={t("booth.edReviewRef")}>
|
|
<code className="text-[0.6875rem] text-term-muted">{refId}</code>
|
|
</DetailRow>
|
|
)}
|
|
{sessionRef && sessionRef !== e.identity && (
|
|
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
|
)}
|
|
{tariffVersionId && (
|
|
<DetailRow label={t("booth.edTariffVersion")}>
|
|
<code className="text-[0.6875rem] text-term-muted">{tariffVersionId}</code>
|
|
</DetailRow>
|
|
)}
|
|
</div>
|
|
|
|
{/* The entry/exit evidence images for this session's identity. */}
|
|
{e.identity && (
|
|
<div>
|
|
<div className="mb-1.5 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
|
<SnapshotStrip identity={e.identity} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Audit data — collapsed by default. The signed-chain provenance (signature,
|
|
key, prev-hash) and the raw payload are an auditor's concern, not the
|
|
operator's; tucking them behind a disclosure keeps the common view clean
|
|
while preserving the tamper-evidence trail on demand. */}
|
|
<details className="rounded-term border border-term-border bg-term-panel-2">
|
|
<summary className="cursor-pointer select-none px-3 py-2 text-[0.6875rem] uppercase tracking-wider text-term-muted hover:text-term-text">
|
|
{t("booth.edAuditData")}
|
|
</summary>
|
|
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
|
<DetailRow label={t("booth.edSignature")}>
|
|
<code className="break-all text-[0.6875rem] text-term-muted">{e.signature}</code>
|
|
</DetailRow>
|
|
<DetailRow label={t("booth.edKeyId")}>
|
|
<code className="text-[0.6875rem] text-term-muted">{e.keyId}</code>
|
|
</DetailRow>
|
|
<DetailRow label={t("booth.edPrevHash")}>
|
|
<code className="break-all text-[0.6875rem] text-term-muted">{e.prevHash ?? "—"}</code>
|
|
</DetailRow>
|
|
<div className="mb-1.5 mt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
|
{t("booth.edRawPayload")}
|
|
</div>
|
|
{p && Object.keys(p).length > 0 ? (
|
|
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[0.6875rem] text-term-text">
|
|
{JSON.stringify(p, null, 2)}
|
|
</pre>
|
|
) : (
|
|
<div className="text-[0.75rem] text-term-muted">{t("booth.edNoPayload")}</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|