feat(tills): per-till activity log, wash bucket on the booth Z-report, wash-desk printer role

Closes the three known follow-ups of the Tills decision (venue-modules.md):

- Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events
  by payload till, other events by their owning module's till, everything else booth),
  applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today"
  panel and the booth feed (history + live pushes). The events route admits a role that
  holds a module feed permission without event:read and returns only that module's
  event types — the live-socket rule.
- Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by
  module; the ticket bucket excludes them (Bileta = parking only); printed
  "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:".
- Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling
  back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the
  one reading of the role field (the entry/booth loaders treated any non-booth role as
  an entry dispenser). Footer label "at wash desk".

Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new
requireAnyPermission) — the Wash operator job could not load the desk's category and
service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules,
log) updated.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-06 12:37:26 +02:00
parent ea304bbfd1
commit e14e31a840
27 changed files with 414 additions and 91 deletions
+27 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
@@ -91,6 +91,7 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workabl
subscriptionTotalMinor: x.subscriptionTotalMinor,
subscriptionSalesMinor: x.subscriptionSalesMinor,
subscriptionWindowMinor: x.subscriptionWindowMinor,
chargesByModuleMinor: x.chargesByModuleMinor,
openingFloatMinor: x.openingFloatMinor,
cashAddedMinor: x.cashAddedMinor,
cashRemovedMinor: x.cashRemovedMinor,
@@ -332,6 +333,24 @@ function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s:
);
}
/** One figure per module whose money rode this till's tickets (a booth-paid wash) —
* nothing when none did, so booth-only sites see the report they always saw. `spacer`
* keeps a 2-column grid's pairs aligned. */
function ChargeFigures({ charges, cur, spacer }: { charges?: Partial<Record<string, number>>; cur: string | null; spacer?: boolean }) {
const { t } = useTranslation();
const rows = Object.entries(charges ?? {}).filter(([, v]) => (v ?? 0) > 0);
return (
<>
{rows.map(([m, v]) => (
<Fragment key={m}>
<Figure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={money(v ?? 0, cur)} />
{spacer && <span />}
</Fragment>
))}
</>
);
}
function ShiftActivityLog({
shift,
isCurrent,
@@ -356,9 +375,10 @@ function ShiftActivityLog({
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
// Per till: the booth's log has no wash-desk activity in it, and vice versa.
const q = useQuery({
queryKey: ["shift-events", shift.id, shift.endedAt],
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt),
queryKey: ["shift-events", shift.id, shift.endedAt, shift.till],
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt, shift.till),
refetchInterval: isCurrent ? 5000 : false,
});
const events = q.data?.events ?? [];
@@ -389,6 +409,7 @@ function ShiftActivityLog({
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} />
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
@@ -446,6 +467,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<span />
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
<ChargeFigures charges={report.chargesByModuleMinor} cur={report.currency} spacer />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span />
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
@@ -472,6 +494,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} spacer />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span />
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
@@ -513,6 +536,7 @@ function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void })
<span />
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
<ChargeFigures charges={x.chargesByModuleMinor} cur={x.currency} spacer />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span />
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />