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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user