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:
+36
-190
@@ -8,15 +8,12 @@ import {
|
||||
} from "@tanstack/react-router";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||
import {
|
||||
can,
|
||||
closeShift,
|
||||
fetchShiftReport,
|
||||
fetchVersion,
|
||||
logout,
|
||||
openShift,
|
||||
setLanguagePref,
|
||||
setThemePref,
|
||||
setFontScalePref,
|
||||
@@ -24,15 +21,15 @@ import {
|
||||
FONT_SCALE_MAX,
|
||||
FONT_SCALE_STEP,
|
||||
} from "./api.js";
|
||||
import { qk, queryClient } from "./lib/query.js";
|
||||
import { queryClient } from "./lib/query.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { Spinner } from "./ui/Spinner.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
import { inTauri } from "./lib/origin.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||
import { ShiftButton } from "./ShiftControl.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
import { BoothScreen } from "./BoothScreen.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
@@ -45,7 +42,6 @@ import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||
import { DrawerManager } from "./DrawerManager.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
import { BackupSettings } from "./BackupSettings.js";
|
||||
import { WEB_MODULES } from "./modules/index.js";
|
||||
@@ -198,6 +194,12 @@ function SetupLayout() {
|
||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||
{/* Venue-module setup tabs (e.g. Car wash) — module on AND permission. */}
|
||||
{WEB_MODULES.flatMap((m) =>
|
||||
(m.setupNav ?? [])
|
||||
.filter((n) => moduleOn(user, m.id) && show(n.perm))
|
||||
.map((n) => <SetupTab key={n.to} to={n.to} label={t(n.labelKey)} />),
|
||||
)}
|
||||
{show("site:read") && <VersionBadge />}
|
||||
<DesktopVersionBadge />
|
||||
<DesktopServerButton />
|
||||
@@ -363,176 +365,7 @@ function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: Se
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
|
||||
* - another's shift open → disabled, labelled with who holds it (you can neither
|
||||
* open yours nor close theirs until they hand over).
|
||||
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
|
||||
*/
|
||||
function ShiftButton() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
// Closing a shift signs the Z-report and is irreversible, so the header button never
|
||||
// closes directly (a stray click would end the shift) — it opens a confirm modal that
|
||||
// shows the live X-report first. Opening a shift has no such risk → immediate.
|
||||
const [confirmingClose, setConfirmingClose] = useState(false);
|
||||
|
||||
function onClick() {
|
||||
if (isMine) {
|
||||
setConfirmingClose(true);
|
||||
} else {
|
||||
void act("open");
|
||||
}
|
||||
}
|
||||
|
||||
async function act(kind: "open" | "close") {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
if (kind === "open") await openShift();
|
||||
else await closeShift();
|
||||
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled when another operator holds the shift (can't open or close).
|
||||
const label = blockedByOther
|
||||
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
||||
: isMine
|
||||
? t("shift.headerClose")
|
||||
: t("shift.headerOpen");
|
||||
const tone = blockedByOther
|
||||
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
||||
: isMine
|
||||
? "border-term-red text-term-red hover:bg-term-red/10"
|
||||
: "border-term-green text-term-green hover:bg-term-green/10";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || blockedByOther}
|
||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||
onClick={onClick}
|
||||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
|
||||
>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
)}
|
||||
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||||
{confirmingClose && (
|
||||
<CloseShiftConfirm
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmingClose(false)}
|
||||
onConfirm={async () => {
|
||||
await act("close");
|
||||
setConfirmingClose(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
|
||||
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
|
||||
* expected drawer before committing the irreversible Z-report. */
|
||||
function CloseShiftConfirm({
|
||||
busy,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
|
||||
const x = q.data;
|
||||
const cur = x?.currency ?? null;
|
||||
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
|
||||
|
||||
return (
|
||||
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||
{!x ? (
|
||||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||
<span />
|
||||
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||||
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
|
||||
{t("subs.cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {t("shift.ending")}
|
||||
</span>
|
||||
) : (
|
||||
t("shift.endShift")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||
<span
|
||||
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Header shift control lives in ShiftControl.tsx (shared with the wash desk, per till).
|
||||
|
||||
function RootLayout() {
|
||||
const { user, setUser } = rootRoute.useRouteContext();
|
||||
@@ -589,7 +422,10 @@ function RootLayout() {
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && show("shift:read") && <ShiftButton />}
|
||||
{/* The header button is the BOOTH till's; a role that cannot work the booth
|
||||
(no session:read — e.g. the wash operator, who has their own control on
|
||||
the wash desk) does not get it. The server refuses the same (403). */}
|
||||
{user && show("shift:read") && show("session:read") && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||
@@ -630,23 +466,32 @@ const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
beforeLoad: ({ context }) => {
|
||||
// A merchant-only user (validation:create without the booth's session:read)
|
||||
// lands on their scan-and-validate screen — if the validation module is on at
|
||||
// this site; everyone else on the booth.
|
||||
if (
|
||||
moduleOn(context.user, "validation") &&
|
||||
can(context.user, "validation:create") &&
|
||||
!can(context.user, "session:read")
|
||||
) {
|
||||
throw redirect({ to: "/validate" });
|
||||
}
|
||||
throw redirect({ to: "/booth" });
|
||||
// Landing = the first screen this role can actually use. The booth for anyone
|
||||
// with the booth's permission; otherwise the first venue-module landing the role
|
||||
// holds (wash desk for a wash operator, scan screen for a merchant); otherwise
|
||||
// the shift hub; otherwise the profile. Every guard that bounces sends people
|
||||
// HERE (never straight to the booth) so a booth-less role never dead-ends.
|
||||
throw redirect({ to: landingFor(context.user) });
|
||||
},
|
||||
});
|
||||
|
||||
function landingFor(user: SessionUser | null): string {
|
||||
if (can(user, "session:read")) return "/booth";
|
||||
for (const m of WEB_MODULES) {
|
||||
if (m.landing && moduleOn(user, m.id) && can(user, m.landing.perm)) return m.landing.to;
|
||||
}
|
||||
if (can(user, "shift:read")) return "/shifts";
|
||||
return "/profile";
|
||||
}
|
||||
|
||||
const boothRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/booth",
|
||||
// The booth is the parking operator's screen; a role without session:read (a wash
|
||||
// operator, a merchant) goes to its own landing instead of a screen that 403s.
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!can(context.user, "session:read")) throw redirect({ to: "/" });
|
||||
},
|
||||
component: BoothScreen,
|
||||
});
|
||||
|
||||
@@ -717,7 +562,7 @@ const drawerRoute = createRoute({
|
||||
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
||||
throw redirect({ to: "/booth" });
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
},
|
||||
component: function DrawerRoute() {
|
||||
@@ -916,6 +761,7 @@ const routeTree = rootRoute.addChildren([
|
||||
recycleBinRoute,
|
||||
logsRoute,
|
||||
backupRoute,
|
||||
...WEB_MODULES.flatMap((m) => m.setupRoutes?.(setupRoute) ?? []),
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user