feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission

Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
  (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
  Finished list). Orders freeze names + price; their life is signed (carwash_order,
  carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
  config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
  chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
  signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
  price off the fee (floored at 0), resolved at done and anchored at the order's intake
  (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
  the wash. Long durations render y/d/h/m.

Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
  re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
  vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
  till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
  open shift with till badges + filter; drawer hub switches tills.

Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.

Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-05 13:23:09 +02:00
parent 23d6379be8
commit a9ccf9e20c
46 changed files with 3966 additions and 510 deletions
+18 -6
View File
@@ -16,10 +16,24 @@ export function formatMoney(amountMinor: number, currency: string): string {
export function formatDuration(fromIso: string, toIso: string): string {
const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "—";
const mins = Math.floor(ms / 60_000);
const h = Math.floor(mins / 60);
return formatMinutesLong(Math.floor(ms / 60_000));
}
/** "Xy Xd Xh Xm" with the leading zero units dropped — a stay of 1797h reads as
* "74d 21h 23m", not a wall of hours (a stale/forgotten ticket is a real case on a
* booth; the number should still be readable at a glance). Years only past 365 days. */
export function formatMinutesLong(totalMinutes: number): string {
const mins = Math.max(0, Math.floor(totalMinutes));
const y = Math.floor(mins / (365 * 24 * 60));
const d = Math.floor((mins % (365 * 24 * 60)) / (24 * 60));
const h = Math.floor((mins % (24 * 60)) / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
const parts: string[] = [];
if (y > 0) parts.push(`${y}y`);
if (y > 0 || d > 0) parts.push(`${d}d`);
if (y > 0 || d > 0 || h > 0) parts.push(`${h}h`);
parts.push(`${m}m`);
return parts.join(" ");
}
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
@@ -41,9 +55,7 @@ export function formatCountdown(untilIso: string | null, nowMs: number = Date.no
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
export function formatMinutes(mins: number): string {
if (!Number.isFinite(mins) || mins < 0) return "—";
const m = Math.round(mins);
const h = Math.floor(m / 60);
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
return formatMinutesLong(Math.round(mins));
}
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
+81
View File
@@ -64,13 +64,74 @@ export const en: Catalog = {
name: {
parking: "Parking",
validation: "Merchant validations (Bar)",
carwash: "Car wash",
},
},
wash: {
tillTitle: "Wash till",
tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.",
tillOtherHint: "{{operator}} holds the wash shift; only they can take money at the bay.",
drawerNow: "Wash drawer now",
intake: "New wash",
ticketPh: "Parking ticket (scan or type)",
lookup: "Look up",
notFound: "No session for this ticket.",
closed: "This session is already closed.",
ticket: "Ticket",
plate: "Plate",
enteredAt: "Entered",
alreadyOpen: "This ticket already has an open wash order.",
category: "Vehicle category",
service: "Service",
price: "Price",
noPrice: "no price set for this pair",
payAt: "Payment",
payAtBooth: "At the booth",
payAtBay: "At the bay",
payAtBoothHint: "Added to the parking settlement; the exit barrier opens after the booth payment.",
payAtBayHint: "You take the money here; the customer leaves by scanning the ticket at the exit reader (the parking sponsorship must cover the fee).",
create: "Create order",
created: "Order created.",
queue: "Open orders",
empty: "Nothing to wash.",
time: "Time",
what: "Wash",
status: "Status",
statusOpen: "in progress",
statusDone: "done",
paid: "paid",
unpaid: "unpaid",
done: "Done",
payCash: "Paid cash",
payCard: "Paid card",
void: "Void",
voidReason: "Reason",
categories: "Vehicle categories",
services: "Services",
prices: "Prices",
pricesHint: "One price per category × service. Leave a cell blank to make that pair unsellable.",
addCategory: "category",
addService: "service",
active: "active",
save: "Save",
saved: "Saved.",
finished: "Finished",
finishedEmpty: "No finished washes yet.",
voided: "voided",
by: "By",
cash: "cash",
card: "card",
sponsorship: "Parking discount",
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
sponsorshipLabel: "Car wash",
},
update: {
available: "Update available",
prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)",
},
nav: {
wash: "Car wash",
carwash: "Car wash",
booth: "Booth",
shift: "Shift",
setup: "Setup",
@@ -256,6 +317,9 @@ export const en: Catalog = {
evtCashReview: "REVIEW",
evtConfigChange: "CONFIG",
evtValidation: "VALIDATION",
evtCarwashOrder: "CAR WASH",
evtCarwashPayment: "WASH PAYMENT",
charges: "Extra charges",
decision: { authorize: "authorized", deny: "denied" },
evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
@@ -775,6 +839,11 @@ export const en: Catalog = {
modeTimeCredit: "First minutes free",
modeFixed: "Amount off (typed at scan)",
modePercent: "Percent off",
modeDoneTolerance: "Free while the wash runs (+ tolerance)",
modeDoneToleranceHint: "The time from the wash order to \"done\", plus the tolerance minutes, comes off the parking. Time parked before the order and after the tolerance is charged at the tariff.",
modeWashPrice: "Wash price off the parking fee",
modeWashPriceHint: "The parking fee minus the wash price; never below zero.",
toleranceMinutes: "Tolerance after done (minutes)",
minutes: "Free minutes",
percent: "Percent (%)",
maxAmount: "Cap per validation",
@@ -903,6 +972,18 @@ export const en: Catalog = {
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
openNow: "Open shift now",
opening: "Opening…",
// Tills — a shift belongs to a drawer (booth / wash desk), not the site.
tillOpen: "Open {{till}} shift",
tillClose: "Close {{till}} shift",
tillHeldByShort: "{{till}}: {{operator}}",
tillNoShift: "No {{till}} shift",
},
till: {
booth: "Booth",
carwash: "Wash",
boothLong: "Booth till",
carwashLong: "Wash till",
all: "All tills",
},
shifts: {
title: "Shift history",
+81
View File
@@ -67,13 +67,74 @@ export const sq = {
name: {
parking: "Parkimi",
validation: "Validime tregtare (Bar)",
carwash: "Lavazh",
},
},
wash: {
tillTitle: "Arka e lavazhit",
tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.",
tillOtherHint: "{{operator}} e ka turnin e lavazhit; vetëm ai mund të marrë para te lavazhi.",
drawerNow: "Arka e lavazhit tani",
intake: "Lavazh i ri",
ticketPh: "Bileta e parkimit (skano ose shkruaj)",
lookup: "Kërko",
notFound: "Nuk ka sesion për këtë biletë.",
closed: "Ky sesion është mbyllur.",
ticket: "Bileta",
plate: "Targa",
enteredAt: "Hyri",
alreadyOpen: "Kjo biletë ka tashmë një porosi lavazhi të hapur.",
category: "Kategoria e mjetit",
service: "Shërbimi",
price: "Çmimi",
noPrice: "nuk ka çmim për këtë kombinim",
payAt: "Pagesa",
payAtBooth: "Në kabinë",
payAtBay: "Në lavazh",
payAtBoothHint: "Shtohet në llogarinë e parkimit; barriera e daljes hapet pas pagesës në kabinë.",
payAtBayHint: "Paratë merren këtu; klienti del duke skanuar biletën te lexuesi i daljes (sponsorizimi i parkimit duhet ta mbulojë tarifën).",
create: "Krijo porosinë",
created: "Porosia u krijua.",
queue: "Porositë e hapura",
empty: "Asgjë për të larë.",
time: "Ora",
what: "Lavazhi",
status: "Statusi",
statusOpen: "në proces",
statusDone: "mbaroi",
paid: "paguar",
unpaid: "papaguar",
done: "Mbaroi",
payCash: "Paguar cash",
payCard: "Paguar me kartë",
void: "Anulo",
voidReason: "Arsyeja",
categories: "Kategoritë e mjeteve",
services: "Shërbimet",
prices: "Çmimet",
pricesHint: "Një çmim për çdo kategori × shërbim. Lëre bosh një qelizë që ai kombinim të mos shitet.",
addCategory: "kategori",
addService: "shërbim",
active: "aktiv",
save: "Ruaj",
saved: "U ruajt.",
finished: "Të mbaruara",
finishedEmpty: "Ende asnjë lavazh i mbaruar.",
voided: "anuluar",
by: "Nga",
cash: "cash",
card: "kartë",
sponsorship: "Zbritje parkimi",
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
sponsorshipLabel: "Lavazh",
},
update: {
available: "Përditësim i disponueshëm",
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)",
},
nav: {
wash: "Lavazh",
carwash: "Lavazh",
booth: "Kabina",
shift: "Turni",
setup: "Konfigurimi",
@@ -261,6 +322,9 @@ export const sq = {
evtCashReview: "SHQYRTIM",
evtConfigChange: "KONFIG",
evtValidation: "VALIDIM",
evtCarwashOrder: "LAVAZH",
evtCarwashPayment: "PAGESË LAVAZHI",
charges: "Shtesa",
decision: { authorize: "autorizuar", deny: "refuzuar" },
evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR",
@@ -788,6 +852,11 @@ export const sq = {
modeTimeCredit: "Minutat e para falas",
modeFixed: "Zbritje shume (shkruhet në skanim)",
modePercent: "Zbritje në përqindje",
modeDoneTolerance: "Falas gjatë lavazhit (+ tolerancë)",
modeDoneToleranceHint: "Koha nga porosia e lavazhit deri te \"mbaroi\", plus minutat e tolerancës, zbritet nga parkimi. Koha e parkuar para porosisë dhe pas tolerancës paguhet sipas tarifës.",
modeWashPrice: "Çmimi i lavazhit zbritet nga parkimi",
modeWashPriceHint: "Tarifa e parkimit minus çmimin e lavazhit; asnjëherë nën zero.",
toleranceMinutes: "Toleranca pas mbarimit (minuta)",
minutes: "Minuta falas",
percent: "Përqindja (%)",
maxAmount: "Tavani i zbritjes për validim",
@@ -917,6 +986,18 @@ export const sq = {
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
openNow: "Hap turnin tani",
opening: "Duke hapur…",
// Arkat — turni i përket një arke (kabina / lavazhi), jo gjithë sitit.
tillOpen: "Hap turnin e {{till}}",
tillClose: "Mbyll turnin e {{till}}",
tillHeldByShort: "{{till}}: {{operator}}",
tillNoShift: "Pa turn {{till}}",
},
till: {
booth: "kabinës",
carwash: "lavazhit",
boothLong: "Arka e kabinës",
carwashLong: "Arka e lavazhit",
all: "Të gjitha arkat",
},
shifts: {
title: "Historiku i turneve",
+8
View File
@@ -31,7 +31,15 @@ export interface WebModule {
id: ModuleId;
/** Header nav entries, in display order. */
nav: readonly WebModuleNav[];
/** Where a user whose role has NO booth (`session:read`) lands after login, if the
* module is on and the role holds `perm` — e.g. the wash desk for a wash operator,
* the scan screen for a merchant. First match in WEB_MODULES order wins. */
landing?: WebModuleNav;
/** Build this module's routes under the given root. Called once at router
* assembly; each route's own beforeLoad must gate on moduleOn + permission. */
routes(root: RootRoute): AnyRoute[];
/** Setup tabs (under /setup), if the module has admin configuration. */
setupNav?: readonly WebModuleNav[];
/** Build this module's routes under the /setup layout route. */
setupRoutes?(setup: AnyRoute): AnyRoute[];
}
+2 -1
View File
@@ -99,7 +99,8 @@ export function useLiveFeed(enabled: boolean = true): void {
msg.event.type === "shift_z_report" ||
msg.event.type === "cash_movement" ||
msg.event.type === "cash_in" ||
msg.event.type === "cash_out"
msg.event.type === "cash_out" ||
msg.event.type === "carwash_payment"
) {
void qc.invalidateQueries({ queryKey: qk.shift });
}
+12 -7
View File
@@ -1,18 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { fetchShift, type ShiftStatus } from "../api.js";
import { fetchShift, type ShiftStatus, type TillId } from "../api.js";
import { qk } from "./query.js";
// Shared shift status for the whole app — the header control, the booth screen's
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
// they never disagree about whether a shift is open and whose it is. A shift is a
// SITE-WIDE single-open accountability period (at most one open at a time). The WS
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
// live without polling. See wiki/concepts/shift.md.
// per-TILL single-open accountability period (at most one open per till). The
// default till is the booth; the wash desk reads its own (`useShift("carwash")`).
// The WS invalidates qk.shift (a prefix, so every till) on shift_open/shift_z_report/
// cash movements, so this stays live without polling. See wiki/concepts/shift.md.
export interface ShiftState {
/** Raw status from the server (null while loading / on error). */
status: ShiftStatus | undefined;
/** Is ANY shift open site-wide? */
/** Is a shift open on this till? */
isOpen: boolean;
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
isMine: boolean;
@@ -25,8 +26,12 @@ export interface ShiftState {
isLoading: boolean;
}
export function useShift(): ShiftState {
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
/** Query key of one till's shift status — under the qk.shift prefix so the WS
* invalidation reaches every till. */
export const shiftKey = (till: TillId) => [...qk.shift, "current", till] as const;
export function useShift(till: TillId = "booth"): ShiftState {
const q = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
const s = q.data;
const isOpen = s?.open != null;
const isMine = s?.isMine ?? false;