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:
@@ -4,13 +4,14 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
closeShift,
|
||||
fetchEvents,
|
||||
fetchShift,
|
||||
fetchShiftReport,
|
||||
fetchShiftTills,
|
||||
fetchShifts,
|
||||
openShift,
|
||||
type ShiftReport,
|
||||
type ShiftSummary,
|
||||
type SessionUser,
|
||||
type TillId,
|
||||
} from "./api.js";
|
||||
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
@@ -24,7 +25,9 @@ import type { LedgerEvent } from "@parking/shared";
|
||||
// selected shift's signed activity log (every ledger event in its window). The current
|
||||
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
|
||||
// each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own;
|
||||
// an admin (shift:cash) sees all. See wiki/concepts/shift.md.
|
||||
// an admin (shift:cash) sees all. TILLS: a shift belongs to a till (booth / wash desk);
|
||||
// every open shift (one per till) lists on top, cards carry a till badge when the site
|
||||
// has more than one, and the list can be filtered by till. See wiki/concepts/shift.md.
|
||||
|
||||
function money(minor: number, currency: string | null): string {
|
||||
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
||||
@@ -47,28 +50,35 @@ function presetRange(p: Preset): { from: string; to: string } | null {
|
||||
return { from: iso(from), to: iso(now) };
|
||||
}
|
||||
|
||||
/** The CURRENT (open) shift, synthesized from the X-report so it lists alongside closed
|
||||
* shifts. `id` is a sentinel; `open` marks it for the badge + the action pane. null when
|
||||
* no shift is open (or not visible to the requester). */
|
||||
function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; isMine: boolean; refetch: () => void } {
|
||||
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
||||
const report = useQuery({
|
||||
queryKey: ["shift", "xreport"],
|
||||
queryFn: fetchShiftReport,
|
||||
enabled: status.data?.open != null,
|
||||
type CurrentShift = ShiftSummary & { open: true; isMine: boolean };
|
||||
|
||||
/** The CURRENT (open) shifts — one per till at most — each synthesized from its till's
|
||||
* X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open`
|
||||
* marks it for the badge + the action pane. Also returns every till the site has, so
|
||||
* the hub can offer "start shift" per till and show badges only when there are two. */
|
||||
function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch: () => void } {
|
||||
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
|
||||
const openTills = (status.data?.tills ?? []).filter((t) => t.open != null);
|
||||
// One X-report per open till (the key carries the till list so a newly opened
|
||||
// shift refetches).
|
||||
const reports = useQuery({
|
||||
queryKey: ["shift", "xreport", "hub", openTills.map((t) => t.till).join(",")],
|
||||
queryFn: async () => Promise.all(openTills.map((t) => fetchShiftReport(t.till))),
|
||||
enabled: openTills.length > 0,
|
||||
});
|
||||
const refetch = () => {
|
||||
void status.refetch();
|
||||
void report.refetch();
|
||||
void reports.refetch();
|
||||
};
|
||||
if (!status.data?.open || !report.data) return { current: null, isMine: status.data?.isMine ?? false, refetch };
|
||||
const x = report.data;
|
||||
return {
|
||||
isMine: status.data.isMine,
|
||||
refetch,
|
||||
current: {
|
||||
id: "__current__",
|
||||
const tills = status.data?.tills.map((t) => t.till) ?? ["booth"];
|
||||
const current: CurrentShift[] = [];
|
||||
openTills.forEach((t, i) => {
|
||||
const x = reports.data?.[i];
|
||||
if (!x) return;
|
||||
current.push({
|
||||
id: `__current__${t.till}`,
|
||||
index: Number.MAX_SAFE_INTEGER,
|
||||
till: x.till,
|
||||
operator: x.operator,
|
||||
startedAt: x.startedAt,
|
||||
endedAt: x.asOf,
|
||||
@@ -85,8 +95,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
||||
cashRemovedMinor: x.cashRemovedMinor,
|
||||
expectedDrawerMinor: x.expectedDrawerMinor,
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
isMine: t.isMine,
|
||||
});
|
||||
});
|
||||
return { current, tills, refetch };
|
||||
}
|
||||
|
||||
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
||||
@@ -96,14 +108,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
const [customFrom, setCustomFrom] = useState("");
|
||||
const [customTo, setCustomTo] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [tillFilter, setTillFilter] = useState<TillId | "">("");
|
||||
|
||||
const { current, isMine, refetch: refetchCurrent } = useCurrentShift();
|
||||
const { current, tills, refetch: refetchCurrent } = useCurrentShifts();
|
||||
const multiTill = tills.length > 1;
|
||||
|
||||
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
||||
const applied = {
|
||||
operator: operator.trim() || undefined,
|
||||
from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined,
|
||||
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
||||
till: tillFilter || undefined,
|
||||
};
|
||||
|
||||
// keepPreviousData: every filter change makes a NEW query key; without it the
|
||||
@@ -118,16 +133,23 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
const closed = q.data?.shifts ?? [];
|
||||
const operators = q.data?.operators ?? [];
|
||||
|
||||
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
||||
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
||||
// The current/open shifts sit at the TOP of the list (those visible to me: mine, or
|
||||
// all for an admin), honouring the till filter.
|
||||
const visibleCurrent = current.filter((c) => (c.isMine || isAdmin) && (!tillFilter || c.till === tillFilter));
|
||||
const list: (ShiftSummary & { open?: boolean; isMine?: boolean })[] = [...visibleCurrent, ...closed];
|
||||
const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null;
|
||||
const currentIds = visibleCurrent.map((c) => c.id).join(",");
|
||||
|
||||
// Default the selection to the current shift (if any), else the newest closed one.
|
||||
useEffect(() => {
|
||||
if (list.length === 0) setSelectedId(null);
|
||||
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q.data, current?.id]);
|
||||
}, [q.data, currentIds]);
|
||||
|
||||
// Tills with no open shift → offer "start" for each (gated on shift:create).
|
||||
const openOn = new Set(current.map((c) => c.till));
|
||||
const startable = tills.filter((x) => !openOn.has(x));
|
||||
|
||||
function refreshAll() {
|
||||
void q.refetch();
|
||||
@@ -145,9 +167,13 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
||||
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
||||
</h1>
|
||||
{/* No shift open → the only action is to start one (gated on shift:create). */}
|
||||
{canManage && !current && (
|
||||
<StartShiftButton onDone={refreshAll} />
|
||||
{/* A till with no open shift → the action is to start one (gated on shift:create). */}
|
||||
{canManage && startable.length > 0 && (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{startable.map((x) => (
|
||||
<StartShiftButton key={x} till={x} named={multiTill} onDone={refreshAll} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -175,6 +201,16 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{multiTill && (
|
||||
<div className="field">
|
||||
<select className="input w-40" value={tillFilter} onChange={(e) => setTillFilter(e.target.value as TillId | "")}>
|
||||
<option value="">{t("till.all")}</option>
|
||||
{tills.map((x) => (
|
||||
<option key={x} value={x}>{t(`till.${x}Long`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="field">
|
||||
{/* <span className="label">{t("shifts.operator")}</span> */}
|
||||
@@ -202,7 +238,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
|
||||
)}
|
||||
{list.map((s) => (
|
||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} showTill={multiTill} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -211,8 +247,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
<ShiftActivityLog
|
||||
shift={selected}
|
||||
isCurrent={!!selected.open}
|
||||
isMine={isMine}
|
||||
isMine={!!selected.isMine}
|
||||
showOperator={isAdmin}
|
||||
showTill={multiTill}
|
||||
canManage={canManage}
|
||||
onChanged={refreshAll}
|
||||
/>
|
||||
@@ -225,7 +262,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
);
|
||||
}
|
||||
|
||||
function StartShiftButton({ onDone }: { onDone: () => void }) {
|
||||
function StartShiftButton({ till, named, onDone }: { till: TillId; named: boolean; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
@@ -233,7 +270,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await openShift();
|
||||
await openShift(till);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -249,6 +286,8 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {t("shift.starting")}
|
||||
</span>
|
||||
) : named ? (
|
||||
t("shift.tillOpen", { till: t(`till.${till}`) })
|
||||
) : (
|
||||
t("shift.startShift")
|
||||
)}
|
||||
@@ -257,7 +296,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; open: boolean; selected: boolean; onClick: () => void }) {
|
||||
/** Which drawer a shift reconciled — shown only when the site has more than one. */
|
||||
function TillBadge({ till }: { till: TillId }) {
|
||||
const { t } = useTranslation();
|
||||
return <span className="rounded border border-term-cyan/60 px-1 text-[0.625rem] uppercase tracking-wider text-term-cyan">{t(`till.${till}`)}</span>;
|
||||
}
|
||||
|
||||
function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; showTill: boolean; open: boolean; selected: boolean; onClick: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const cur = s.currency;
|
||||
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
||||
@@ -270,6 +315,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||
{showTill && <TillBadge till={s.till} />}
|
||||
{showOperator ? s.operator : when(s.startedAt)}
|
||||
</span>
|
||||
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||
@@ -290,6 +336,7 @@ function ShiftActivityLog({
|
||||
isCurrent,
|
||||
isMine,
|
||||
showOperator,
|
||||
showTill,
|
||||
canManage,
|
||||
onChanged,
|
||||
}: {
|
||||
@@ -297,6 +344,7 @@ function ShiftActivityLog({
|
||||
isCurrent: boolean;
|
||||
isMine: boolean;
|
||||
showOperator: boolean;
|
||||
showTill: boolean;
|
||||
canManage: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
@@ -322,6 +370,7 @@ function ShiftActivityLog({
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||
{showTill && <TillBadge till={shift.till} />}
|
||||
{showOperator && `${shift.operator} · `}
|
||||
{formatRelativeDateTime(shift.startedAt, t)}
|
||||
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||
@@ -358,7 +407,7 @@ function ShiftActivityLog({
|
||||
|
||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
||||
{modal === "takings" && <TakingsModal till={shift.till} onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -376,7 +425,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
setReport(await closeShift());
|
||||
setReport(await closeShift(shift.till));
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -447,9 +496,9 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
);
|
||||
}
|
||||
|
||||
function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal", till], queryFn: () => fetchShiftReport(till) });
|
||||
const x = q.data;
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
||||
|
||||
Reference in New Issue
Block a user