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:
@@ -407,6 +407,23 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module charges folded into the settlement (e.g. a car wash ordered
|
||||
with "pay at booth") — one "+" line each; the Total below includes
|
||||
them. See wiki/decisions/venue-modules.md. */}
|
||||
{!isSubscription &&
|
||||
(s.chargeLines ?? []).length > 0 &&
|
||||
s.currency != null && (
|
||||
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||
<div className="text-term-muted">{t("booth.charges")}</div>
|
||||
{(s.chargeLines ?? []).map((c, i) => (
|
||||
<div key={i} className="flex justify-between text-term-text">
|
||||
<span>{c.label}</span>
|
||||
<span className="tabular-nums">+{formatMoney(c.amountMinor, s.currency!)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||
out-of-window window charge; then show that amount. For an overstay the
|
||||
amount is the TOP-UP delta, not the whole stay. */}
|
||||
|
||||
@@ -13,18 +13,21 @@ import {
|
||||
type DrawerMovement,
|
||||
type MovementStatus,
|
||||
type ShiftSummary,
|
||||
type TillId,
|
||||
} from "./api.js";
|
||||
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { shiftKey } from "./lib/use-shift.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { tillOf, type LedgerEvent } from "@parking/shared";
|
||||
|
||||
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
||||
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
||||
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
|
||||
// activity (every cash payment and voucher, live), the movement record/review flow
|
||||
// (unchanged), and the closed-shift drawer history. All figures come from the signed
|
||||
// chain — the drawer is a single site-wide till that carries across shifts. See
|
||||
// wiki/concepts/shift.md.
|
||||
// chain. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub
|
||||
// shows one till at a time — a switch appears when the site has more than one — and
|
||||
// every panel below is scoped to it. See wiki/concepts/shift.md "Tills".
|
||||
|
||||
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
||||
|
||||
@@ -53,6 +56,11 @@ function StatusBadge({ status }: { status: MovementStatus }) {
|
||||
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [till, setTill] = useState<TillId>("booth");
|
||||
// Which tills exist here (the booth + effective money-taking modules') — from the
|
||||
// booth's status read, which every till answer carries.
|
||||
const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") });
|
||||
const tills = status.data?.tills ?? ["booth"];
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||
// A voucher moves the open shift's added/removed figures too (the X-report).
|
||||
@@ -61,17 +69,28 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
||||
{/* Till switch — only when there is more than one drawer to look at. */}
|
||||
{tills.length > 1 && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{tills.map((x) => (
|
||||
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setTill(x)}>
|
||||
{t(`till.${x}Long`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 1: the till NOW + the record form. */}
|
||||
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
||||
<StatePanel />
|
||||
{canCreate && <RecordPanel onDone={refresh} />}
|
||||
<StatePanel till={till} />
|
||||
{canCreate && <RecordPanel till={till} onDone={refresh} />}
|
||||
</div>
|
||||
|
||||
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
|
||||
<TodayPanel />
|
||||
<MovementsPanel canReview={canReview} onChanged={refresh} />
|
||||
<ShiftHistoryPanel />
|
||||
<TodayPanel till={till} />
|
||||
<MovementsPanel till={till} canReview={canReview} onChanged={refresh} />
|
||||
<ShiftHistoryPanel till={till} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -81,13 +100,13 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
||||
// Balance from the chain + the open shift's running X-report breakdown, so the big
|
||||
// number is always explainable: float + cash takings + in − out = expected = balance.
|
||||
|
||||
function StatePanel() {
|
||||
function StatePanel({ till }: { till: TillId }) {
|
||||
const { t } = useTranslation();
|
||||
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
|
||||
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
||||
const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 });
|
||||
const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
|
||||
const report = useQuery({
|
||||
queryKey: ["shift", "xreport"],
|
||||
queryFn: fetchShiftReport,
|
||||
queryKey: ["shift", "xreport", till],
|
||||
queryFn: () => fetchShiftReport(till),
|
||||
enabled: status.data?.open != null,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
@@ -149,7 +168,7 @@ function StatePanel() {
|
||||
// Every drawer-touching event since local midnight: cash payments (the current
|
||||
// shift's incomings, live) + vouchers. Card payments never enter the till.
|
||||
|
||||
function TodayPanel() {
|
||||
function TodayPanel({ till }: { till: TillId }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({
|
||||
queryKey: ["drawer", "today"],
|
||||
@@ -157,9 +176,12 @@ function TodayPanel() {
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
// This till's drawer-touching events only (a bay payment is wash-till money; a
|
||||
// parking payment is booth money — tillOf() is the one shared rule).
|
||||
const rows = (q.data?.events ?? []).filter((e) => {
|
||||
if (tillOf(e.payload) !== till) return false;
|
||||
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
||||
if (e.type !== "payment") return false;
|
||||
if (e.type !== "payment" && e.type !== "carwash_payment") return false;
|
||||
return (e.payload as { tender?: string } | null)?.tender !== "card";
|
||||
});
|
||||
|
||||
@@ -171,7 +193,7 @@ function TodayPanel() {
|
||||
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
|
||||
const amt = pl.amountMinor ?? 0;
|
||||
if (pl.currency) cur = pl.currency;
|
||||
if (e.type === "payment") {
|
||||
if (e.type === "payment" || e.type === "carwash_payment") {
|
||||
cashIn += amt;
|
||||
payments++;
|
||||
} else {
|
||||
@@ -225,7 +247,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
|
||||
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
||||
const time = formatClock(e.occurredAt);
|
||||
const label =
|
||||
e.type === "payment"
|
||||
e.type === "payment" || e.type === "carwash_payment"
|
||||
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
|
||||
return (
|
||||
@@ -243,13 +265,13 @@ function TodayRow({ e }: { e: LedgerEvent }) {
|
||||
|
||||
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
|
||||
|
||||
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
|
||||
function MovementsPanel({ till, canReview, onChanged }: { till: TillId; canReview: boolean; onChanged: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
||||
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
||||
const q = useQuery({
|
||||
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
|
||||
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
|
||||
queryKey: ["drawer", "movements", canReview ? statusFilter : "", till],
|
||||
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till),
|
||||
});
|
||||
const movements = q.data?.movements ?? [];
|
||||
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||
@@ -316,9 +338,9 @@ function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChange
|
||||
// --- Closed shifts, drawer-focused -------------------------------------------
|
||||
// Scope follows /api/shifts: operators see their own, admins all.
|
||||
|
||||
function ShiftHistoryPanel() {
|
||||
function ShiftHistoryPanel({ till }: { till: TillId }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
|
||||
const q = useQuery({ queryKey: ["shifts", "drawer-history", till], queryFn: () => fetchShifts({ till }) });
|
||||
const shifts = (q.data?.shifts ?? []).slice(0, 50);
|
||||
const showOperator = q.data?.scope === "all";
|
||||
|
||||
@@ -377,14 +399,14 @@ function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: b
|
||||
|
||||
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
||||
|
||||
function RecordPanel({ onDone }: { onDone: () => void }) {
|
||||
function RecordPanel({ till, onDone }: { till: TillId; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [amount, setAmount] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
const record = useMutation({
|
||||
mutationFn: (type: "cash_in" | "cash_out") =>
|
||||
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
|
||||
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim(), till }),
|
||||
onSuccess: (r) => {
|
||||
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
|
||||
setAmount("");
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { Spinner } from "./ui/Spinner.js";
|
||||
|
||||
/**
|
||||
* Shift control for ONE TILL — the till's single-open shift expressed as one button:
|
||||
* - no shift open → "Open shift" (enabled; opens this operator's shift on the till)
|
||||
* - 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).
|
||||
* The header renders it for the booth; the wash desk renders it for the carwash till
|
||||
* (its labels then name the till, so the two are never confused). On open/close it
|
||||
* invalidates the shift status, the per-shift log, and occupancy.
|
||||
* See wiki/concepts/shift.md "Tills".
|
||||
*/
|
||||
export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift(till);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
// Closing a shift signs the Z-report and is irreversible, so the 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(till);
|
||||
else await closeShift(till);
|
||||
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: ["shifts"] });
|
||||
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The booth keeps its historical wording; any other till names itself.
|
||||
const tillName = t(`till.${till}`);
|
||||
const label = blockedByOther
|
||||
? till === "booth"
|
||||
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
||||
: t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })
|
||||
: isMine
|
||||
? till === "booth"
|
||||
? t("shift.headerClose")
|
||||
: t("shift.tillClose", { till: tillName })
|
||||
: till === "booth"
|
||||
? t("shift.headerOpen")
|
||||
: t("shift.tillOpen", { till: tillName });
|
||||
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">
|
||||
{till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })}
|
||||
</span>
|
||||
)}
|
||||
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||||
{confirmingClose && (
|
||||
<CloseShiftConfirm
|
||||
till={till}
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmingClose(false)}
|
||||
onConfirm={async () => {
|
||||
await act("close");
|
||||
setConfirmingClose(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirm-before-close modal for the shift button. Fetches the till's 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({
|
||||
till,
|
||||
busy,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
till: TillId;
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm", till], queryFn: () => fetchShiftReport(till) });
|
||||
const x = q.data;
|
||||
const cur = x?.currency ?? null;
|
||||
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
|
||||
|
||||
return (
|
||||
<Modal open onClose={onCancel} title={till === "booth" ? t("shift.endShift") : t("shift.tillClose", { till: t(`till.${till}`) })} 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 — only meaningful on the booth (a wash till has no
|
||||
tickets or subscriptions; its takings are the bay payments). */}
|
||||
{till === "booth" && (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MERCHANT_VALIDATION_MODES } from "@parking/shared";
|
||||
import {
|
||||
fetchUsers,
|
||||
saveValidationProgram,
|
||||
@@ -30,7 +31,7 @@ export function stationLabelKey(id: StationId): string {
|
||||
}
|
||||
|
||||
/** A blank program draft for a station enabled for the first time. */
|
||||
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
|
||||
export function defaultProgram(id: string, label: string): Omit<ValidationProgramView, "id"> {
|
||||
return {
|
||||
name: label,
|
||||
mode: "comp",
|
||||
@@ -56,13 +57,36 @@ const toInt = (s: string): number | null => {
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
/** Like toInt but 0 is valid (a tolerance of "not a minute more"). */
|
||||
const toNonNeg = (s: string): number | null => {
|
||||
const v = s.trim();
|
||||
if (v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) && n >= 0 ? n : null;
|
||||
};
|
||||
const MODE_LABEL_KEY: Record<ValidationMode, string> = {
|
||||
comp: "val.modeComp",
|
||||
timeCredit: "val.modeTimeCredit",
|
||||
fixed: "val.modeFixed",
|
||||
percent: "val.modePercent",
|
||||
doneTolerance: "val.modeDoneTolerance",
|
||||
washPrice: "val.modeWashPrice",
|
||||
};
|
||||
|
||||
function StationForm({
|
||||
/** One validation program's editor. Also reused by the Car Wash module for its
|
||||
* sponsorship program (`hideUsers`: that program is applied by the wash flow, not by
|
||||
* bound merchant users). */
|
||||
export function StationForm({
|
||||
program,
|
||||
onSaved,
|
||||
hideUsers = false,
|
||||
modes = MERCHANT_VALIDATION_MODES,
|
||||
}: {
|
||||
program: ValidationProgramView;
|
||||
onSaved: (p: ValidationProgramView) => void;
|
||||
hideUsers?: boolean;
|
||||
/** Which discount modes to offer (merchant stations vs the car wash differ). */
|
||||
modes?: readonly ValidationMode[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(program.name);
|
||||
@@ -96,6 +120,7 @@ function StationForm({
|
||||
const valid = useMemo(() => {
|
||||
if (!name.trim()) return false;
|
||||
if (mode === "timeCredit") return toInt(minutes) != null;
|
||||
if (mode === "doneTolerance") return toNonNeg(minutes) != null;
|
||||
if (mode === "percent") {
|
||||
const p = toInt(percent);
|
||||
return p != null && p <= 100;
|
||||
@@ -110,7 +135,7 @@ function StationForm({
|
||||
const saved = await saveValidationProgram(program.id, {
|
||||
name: name.trim(),
|
||||
mode,
|
||||
minutes: mode === "timeCredit" ? toInt(minutes) : null,
|
||||
minutes: mode === "timeCredit" ? toInt(minutes) : mode === "doneTolerance" ? toNonNeg(minutes) : null,
|
||||
percent: mode === "percent" ? toInt(percent) : null,
|
||||
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
|
||||
maxPerDay: toInt(maxPerDay),
|
||||
@@ -140,11 +165,12 @@ function StationForm({
|
||||
<div className="field">
|
||||
<span className="label">{t("val.mode")}</span>
|
||||
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
||||
<option value="comp">{t("val.modeComp")}</option>
|
||||
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
|
||||
<option value="fixed">{t("val.modeFixed")}</option>
|
||||
<option value="percent">{t("val.modePercent")}</option>
|
||||
{modes.map((m) => (
|
||||
<option key={m} value={m}>{t(MODE_LABEL_KEY[m])}</option>
|
||||
))}
|
||||
</select>
|
||||
{mode === "doneTolerance" && <span className="hint">{t("val.modeDoneToleranceHint")}</span>}
|
||||
{mode === "washPrice" && <span className="hint">{t("val.modeWashPriceHint")}</span>}
|
||||
</div>
|
||||
{mode === "timeCredit" && (
|
||||
<div className="field">
|
||||
@@ -152,6 +178,12 @@ function StationForm({
|
||||
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
||||
</div>
|
||||
)}
|
||||
{mode === "doneTolerance" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.toleranceMinutes")}</span>
|
||||
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="15" />
|
||||
</div>
|
||||
)}
|
||||
{mode === "percent" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.percent")}</span>
|
||||
@@ -168,6 +200,7 @@ function StationForm({
|
||||
<span className="label">{t("val.maxPerDay")}</span>
|
||||
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
||||
</div>
|
||||
{!hideUsers && (
|
||||
<div>
|
||||
<div className="label">{t("val.users")}</div>
|
||||
<span className="hint block">{t("val.usersHint")}</span>
|
||||
@@ -192,6 +225,7 @@ function StationForm({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
||||
{t("site.save")}
|
||||
|
||||
+58
-22
@@ -16,7 +16,7 @@ import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js"
|
||||
import { logFailedRequest } from "./lib/logger.js";
|
||||
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||
import { inTauri } from "./lib/tauri-env.js";
|
||||
import type { AppLogRecord, ModuleId, ValidationLine, ValidationMode } from "@parking/shared";
|
||||
import type { AppLogRecord, ChargeLine, ModuleId, TillId, ValidationLine, ValidationMode } from "@parking/shared";
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
const CSRF_HEADER = "X-CSRF-Token";
|
||||
@@ -1062,18 +1062,30 @@ export function deleteSubscription(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
// --- Shifts ---------------------------------------------------------------
|
||||
// A shift is opened ON A TILL (booth | carwash …): one open shift per till, each with
|
||||
// its own drawer and Z-report. Every call below takes the till, defaulting to the
|
||||
// booth. See wiki/concepts/shift.md "Tills".
|
||||
|
||||
export interface ShiftStatus {
|
||||
/** The requesting (logged-in) operator. */
|
||||
operator: string;
|
||||
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
||||
export type { TillId };
|
||||
|
||||
/** One till's shift state (at most one shift open per till). */
|
||||
export interface TillShiftStatus {
|
||||
till: TillId;
|
||||
/** The till's open shift (startedAt + whose), or null if none open. */
|
||||
open: { startedAt: string; operator: string | null } | null;
|
||||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||
isMine: boolean;
|
||||
/** Live physical drawer balance (cash payments + cash movements). */
|
||||
/** Live physical drawer balance of this till (cash payments + cash movements). */
|
||||
drawerMinor: number;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
export interface ShiftStatus extends TillShiftStatus {
|
||||
/** The requesting (logged-in) operator. */
|
||||
operator: string;
|
||||
/** Every till addressable at this site (the booth + effective modules' tills). */
|
||||
tills: TillId[];
|
||||
}
|
||||
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
|
||||
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
|
||||
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
|
||||
@@ -1085,6 +1097,7 @@ export interface ShiftSourceSplit {
|
||||
}
|
||||
|
||||
export interface ShiftReport extends ShiftSourceSplit {
|
||||
till: TillId;
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
@@ -1100,20 +1113,27 @@ export interface ShiftReport extends ShiftSourceSplit {
|
||||
printed: boolean;
|
||||
}
|
||||
|
||||
export function fetchShift(): Promise<ShiftStatus> {
|
||||
return apiFetch("/api/shift/current");
|
||||
const tillQs = (till?: TillId) => (till && till !== "booth" ? `?till=${till}` : "");
|
||||
|
||||
export function fetchShift(till: TillId = "booth"): Promise<ShiftStatus> {
|
||||
return apiFetch(`/api/shift/current${tillQs(till)}`);
|
||||
}
|
||||
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||
return apiFetch("/api/shift/open", { method: "POST" });
|
||||
/** Every till's shift state in one read (the shift hub lists each open shift). */
|
||||
export function fetchShiftTills(): Promise<{ operator: string; tills: TillShiftStatus[] }> {
|
||||
return apiFetch("/api/shift/tills");
|
||||
}
|
||||
export function closeShift(): Promise<ShiftReport> {
|
||||
return apiFetch("/api/shift/close", { method: "POST" });
|
||||
export function openShift(till: TillId = "booth"): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
|
||||
return apiFetch("/api/shift/open", { method: "POST", body: JSON.stringify({ till }) });
|
||||
}
|
||||
export function closeShift(till: TillId = "booth"): Promise<ShiftReport> {
|
||||
return apiFetch("/api/shift/close", { method: "POST", body: JSON.stringify({ till }) });
|
||||
}
|
||||
|
||||
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
||||
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||||
* snapshot instant. */
|
||||
export interface XReport extends ShiftSourceSplit {
|
||||
till: TillId;
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string; // = asOf
|
||||
@@ -1129,8 +1149,8 @@ export interface XReport extends ShiftSourceSplit {
|
||||
}
|
||||
|
||||
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
||||
export async function fetchShiftReport(): Promise<XReport | null> {
|
||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||
export async function fetchShiftReport(till: TillId = "booth"): Promise<XReport | null> {
|
||||
return (await apiFetch<XReport | undefined>(`/api/shift/report${tillQs(till)}`)) ?? null;
|
||||
}
|
||||
|
||||
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||||
@@ -1144,6 +1164,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
|
||||
export interface DrawerMovement {
|
||||
id: string;
|
||||
type: "cash_in" | "cash_out";
|
||||
/** Which drawer the cash moved in/out of. */
|
||||
till: TillId;
|
||||
/** Positive magnitude; direction is the type. */
|
||||
amountMinor: number;
|
||||
currency: string | null;
|
||||
@@ -1165,8 +1187,11 @@ export function recordDrawerMovement(args: {
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
currency?: string;
|
||||
/** Which drawer (default: the booth). */
|
||||
till?: TillId;
|
||||
}): Promise<{
|
||||
type: "cash_in" | "cash_out";
|
||||
till: TillId;
|
||||
amountMinor: number;
|
||||
voucherNo: string;
|
||||
balanceMinor: number;
|
||||
@@ -1177,18 +1202,21 @@ export function recordDrawerMovement(args: {
|
||||
|
||||
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
||||
* and may filter by status (the pending review queue). */
|
||||
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
||||
export function fetchDrawerMovements(status?: MovementStatus, till?: TillId): Promise<{
|
||||
movements: DrawerMovement[];
|
||||
scope: "all" | "self";
|
||||
}> {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
return apiFetch(`/api/drawer/movements${qs}`);
|
||||
const qs = new URLSearchParams();
|
||||
if (status) qs.set("status", status);
|
||||
if (till) qs.set("till", till);
|
||||
const q = qs.toString();
|
||||
return apiFetch(`/api/drawer/movements${q ? `?${q}` : ""}`);
|
||||
}
|
||||
|
||||
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
|
||||
* the amount that carries across shifts). */
|
||||
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
|
||||
return apiFetch("/api/drawer/balance");
|
||||
/** A till's physical drawer balance NOW (cash payments + vouchers over the whole chain
|
||||
* — the amount that carries across that till's shifts). */
|
||||
export function fetchDrawerBalance(till: TillId = "booth"): Promise<{ till: TillId; balanceMinor: number; currency: string | null }> {
|
||||
return apiFetch(`/api/drawer/balance${tillQs(till)}`);
|
||||
}
|
||||
|
||||
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||
@@ -1204,6 +1232,8 @@ export function reviewDrawerMovement(args: {
|
||||
export interface ShiftSummary extends ShiftSourceSplit {
|
||||
id: string;
|
||||
index: number;
|
||||
/** The till this shift reconciled. */
|
||||
till: TillId;
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
@@ -1221,16 +1251,19 @@ export interface ShiftSummary extends ShiftSourceSplit {
|
||||
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
|
||||
* filtered by operator + a from/to window over the shift start. `scope` echoes
|
||||
* which the server applied, so the UI can show/hide the filter. */
|
||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string; till?: TillId } = {}): Promise<{
|
||||
shifts: ShiftSummary[];
|
||||
scope: "all" | "self";
|
||||
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
||||
operators?: string[];
|
||||
/** Every till addressable at this site — more than one → show the till filter/badges. */
|
||||
tills: TillId[];
|
||||
}> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.operator) qs.set("operator", params.operator);
|
||||
if (params.from) qs.set("from", params.from);
|
||||
if (params.to) qs.set("to", params.to);
|
||||
if (params.till) qs.set("till", params.till);
|
||||
const q = qs.toString();
|
||||
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
|
||||
}
|
||||
@@ -1359,6 +1392,9 @@ export interface SessionLookup {
|
||||
grossMinor: number | null;
|
||||
discountMinor: number | null;
|
||||
validationLines: ValidationLine[];
|
||||
/** Module charges folded into `amountMinor` (e.g. a car wash paid at the booth). */
|
||||
chargeLines: ChargeLine[];
|
||||
chargesMinor: number | null;
|
||||
}
|
||||
|
||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, type CarWashPayAt } from "@parking/shared";
|
||||
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
|
||||
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
|
||||
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
|
||||
|
||||
// Setup → Car wash: the master data (vehicle categories, services, the category ×
|
||||
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
|
||||
// validation program (id "carwash"), composed with the same editor the merchant
|
||||
// programs use. See wiki/decisions/venue-modules.md ("v1 answers").
|
||||
|
||||
type Item = { id?: string; name: string; active: boolean };
|
||||
|
||||
const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, ""));
|
||||
const toMinor = (s: string): number | null => {
|
||||
const v = s.trim();
|
||||
if (v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
|
||||
};
|
||||
|
||||
function ListEditor({
|
||||
title,
|
||||
items,
|
||||
onChange,
|
||||
addLabel,
|
||||
}: {
|
||||
title: string;
|
||||
items: Item[];
|
||||
onChange: (items: Item[]) => void;
|
||||
addLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
|
||||
{items.map((it, i) => (
|
||||
<div key={it.id ?? `new-${i}`} className="flex items-center gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={it.name}
|
||||
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))}
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-[0.75rem] text-term-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={it.active}
|
||||
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, active: e.target.checked } : x)))}
|
||||
/>
|
||||
{t("wash.active")}
|
||||
</label>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => onChange(items.filter((_, j) => j !== i))}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
|
||||
+ {addLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
|
||||
const [categories, setCategories] = useState<Item[]>([]);
|
||||
const [services, setServices] = useState<Item[]>([]);
|
||||
/** Price inputs keyed "categoryId|serviceId" (major units as typed). New rows have no
|
||||
* id yet, so the matrix keys use the row INDEX until saved. */
|
||||
const [prices, setPrices] = useState<Record<string, string>>({});
|
||||
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
|
||||
const [payAt, setPayAt] = useState<CarWashPayAt>("booth");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [program, setProgram] = useState<ValidationProgramView | null>(null);
|
||||
|
||||
function load() {
|
||||
fetchCarwashSettings()
|
||||
.then((s) => {
|
||||
setSettings(s);
|
||||
setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
|
||||
setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
|
||||
const p: Record<string, string> = {};
|
||||
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||
setPrices(p);
|
||||
setPayAt(s.payAt);
|
||||
})
|
||||
.catch((e) => setMsg((e as Error).message));
|
||||
fetchValidationPrograms()
|
||||
.then((r) => {
|
||||
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
|
||||
setProgram(existing ?? { id: CARWASH_PROGRAM_ID, ...defaultProgram(CARWASH_PROGRAM_ID, t("wash.sponsorshipLabel")) });
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
useEffect(load, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const keyOf = (c: Item, ci: number, s: Item, si: number) => `${c.id ?? `#${ci}`}|${s.id ?? `#${si}`}`;
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
const listBody = {
|
||||
categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
|
||||
services: services.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
|
||||
};
|
||||
// New rows have no id until the server assigns one, and the price matrix is keyed
|
||||
// by ids — so save the lists first, map each new row to the id that came back (the
|
||||
// server returns rows in the order sent), then save the prices in a second call.
|
||||
// One button, two requests; the user just sees "Saved."
|
||||
let cats = categories;
|
||||
let svcs = services;
|
||||
if (categories.some((c) => !c.id) || services.some((s) => !s.id)) {
|
||||
// Keep only the prices whose rows survive this save (a removed row's prices
|
||||
// would be refused as unknown ids).
|
||||
const keepC = new Set(categories.map((c) => c.id).filter(Boolean));
|
||||
const keepS = new Set(services.map((s) => s.id).filter(Boolean));
|
||||
const first = await saveCarwashSettings({
|
||||
...listBody,
|
||||
prices: (settings?.prices ?? []).filter((p) => keepC.has(p.categoryId) && keepS.has(p.serviceId)),
|
||||
});
|
||||
cats = categories.map((c, i) => ({ ...c, id: c.id ?? first.categories[i]?.id }));
|
||||
svcs = services.map((s, i) => ({ ...s, id: s.id ?? first.services[i]?.id }));
|
||||
}
|
||||
const priceRows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
||||
categories.forEach((c, ci) =>
|
||||
services.forEach((s, si) => {
|
||||
const v = toMinor(prices[keyOf(c, ci, s, si)] ?? "");
|
||||
const cid = cats[ci]?.id;
|
||||
const sid = svcs[si]?.id;
|
||||
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
|
||||
}),
|
||||
);
|
||||
const saved = await saveCarwashSettings({
|
||||
categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
|
||||
services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
|
||||
prices: priceRows,
|
||||
payAt,
|
||||
});
|
||||
setSettings(saved);
|
||||
setPayAt(saved.payAt);
|
||||
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
|
||||
setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
|
||||
const p: Record<string, string> = {};
|
||||
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||
setPrices(p);
|
||||
setMsg(t("wash.saved"));
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const currency = settings?.currency ?? "";
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||
<section className="card w-full max-w-2xl p-4">
|
||||
<div className="grid gap-4">
|
||||
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} />
|
||||
<ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} />
|
||||
|
||||
<div>
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("wash.prices")} {currency && <span className="normal-case tracking-normal">({currency})</span>}
|
||||
</div>
|
||||
<span className="hint">{t("wash.pricesHint")}</span>
|
||||
{categories.length > 0 && services.length > 0 && (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="text-[0.75rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-1 pr-3 text-left text-term-muted"></th>
|
||||
{services.map((s, si) => (
|
||||
<th key={s.id ?? `#${si}`} className="py-1 pr-3 text-left">{s.name || "…"}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((c, ci) => (
|
||||
<tr key={c.id ?? `#${ci}`}>
|
||||
<td className="py-1 pr-3 font-semibold">{c.name || "…"}</td>
|
||||
{services.map((s, si) => {
|
||||
const k = keyOf(c, ci, s, si);
|
||||
return (
|
||||
<td key={k} className="py-1 pr-3">
|
||||
<input
|
||||
className="input w-24 text-right tabular-nums"
|
||||
value={prices[k] ?? ""}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setPrices((p) => ({ ...p, [k]: e.target.value }))}
|
||||
placeholder="—"
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="label">{t("wash.payAt")}</span>
|
||||
<div className="flex gap-4 text-[0.75rem]">
|
||||
{CARWASH_PAY_AT.map((v) => (
|
||||
<label key={v} className="flex items-center gap-1.5">
|
||||
<input type="radio" name="carwash-payAt" className="accent-term-amber" checked={payAt === v} disabled={!canEdit} onChange={() => setPayAt(v)} />
|
||||
{t(v === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("wash.save")}</button>
|
||||
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canEdit && program && (
|
||||
<section className="card w-full max-w-md p-4">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div>
|
||||
<span className="hint">{t("wash.sponsorshipHint")}</span>
|
||||
<div className="mt-2">
|
||||
<StationForm program={program} onSaved={setProgram} hideUsers modes={CARWASH_VALIDATION_MODES} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Tender } from "@parking/shared";
|
||||
import { formatMoney } from "../../lib/format.js";
|
||||
import { useShift } from "../../lib/use-shift.js";
|
||||
import { ShiftButton } from "../../ShiftControl.js";
|
||||
import {
|
||||
createCarwashOrder,
|
||||
fetchCarwashOrders,
|
||||
fetchCarwashSettings,
|
||||
lookupCarwashTicket,
|
||||
markCarwashDone,
|
||||
payCarwashAtBay,
|
||||
voidCarwashOrder,
|
||||
type CarwashOrderView,
|
||||
type CarwashSettingsView,
|
||||
type CarwashTicketLookup,
|
||||
} from "./api.js";
|
||||
|
||||
// The wash desk (/wash): intake a wash against a parking ticket (category × service →
|
||||
// price, where the money is taken), then work the queue — a plain list of open orders,
|
||||
// oldest first: Done / Pay at bay / Void. Bay money lands on the WASH TILL: the desk
|
||||
// carries that till's own shift control, and the pay buttons are gated on the wash
|
||||
// operator's shift (the booth's shift does not cover the bay — the two drawers
|
||||
// reconcile separately). See wiki/decisions/venue-modules.md + shift.md "Tills".
|
||||
|
||||
const QK = ["carwash", "orders"] as const;
|
||||
|
||||
function timeOf(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function WashDesk() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [lookup, setLookup] = useState<CarwashTicketLookup | null>(null);
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [serviceId, setServiceId] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [voiding, setVoiding] = useState<{ id: string; reason: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCarwashSettings().then(setSettings).catch((e) => setMsg((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const orders = useQuery({
|
||||
queryKey: QK,
|
||||
queryFn: () => fetchCarwashOrders("open"),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
// Finished washes (done + paid, or voided) — the most recent ones, newest first, so
|
||||
// the desk can answer "did we wash that car?" without leaving the screen.
|
||||
const finished = useQuery({
|
||||
queryKey: [...QK, "recent"],
|
||||
queryFn: () => fetchCarwashOrders("recent"),
|
||||
refetchInterval: 15000,
|
||||
select: (r) => r.orders.filter((o) => o.closed).slice(0, 50),
|
||||
});
|
||||
|
||||
const categories = useMemo(() => (settings?.categories ?? []).filter((c) => c.active), [settings]);
|
||||
const services = useMemo(() => (settings?.services ?? []).filter((s) => s.active), [settings]);
|
||||
const price = useMemo(
|
||||
() => settings?.prices.find((p) => p.categoryId === categoryId && p.serviceId === serviceId) ?? null,
|
||||
[settings, categoryId, serviceId],
|
||||
);
|
||||
const currency = settings?.currency ?? lookup?.currency ?? null;
|
||||
|
||||
async function doLookup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
if (!ticket.trim()) return;
|
||||
try {
|
||||
setLookup(await lookupCarwashTicket(ticket));
|
||||
} catch (err) {
|
||||
setMsg((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: QK }); // also matches [...QK, "recent"]
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createCarwashOrder({ identity: lookup!.identity, categoryId, serviceId }),
|
||||
onSuccess: () => {
|
||||
setMsg(t("wash.created"));
|
||||
setLookup(null);
|
||||
setTicket("");
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => setMsg((e as Error).message),
|
||||
});
|
||||
const done = useMutation({
|
||||
mutationFn: (id: string) => markCarwashDone(id),
|
||||
onSuccess: () => void invalidate(),
|
||||
onError: (e) => setMsg((e as Error).message),
|
||||
});
|
||||
const pay = useMutation({
|
||||
mutationFn: ({ id, tender }: { id: string; tender: Tender }) => payCarwashAtBay(id, tender),
|
||||
onSuccess: () => void invalidate(),
|
||||
onError: (e) => setMsg((e as Error).message),
|
||||
});
|
||||
const voidIt = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) => voidCarwashOrder(id, reason),
|
||||
onSuccess: () => {
|
||||
setVoiding(null);
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => setMsg((e as Error).message),
|
||||
});
|
||||
|
||||
const canCreate =
|
||||
lookup?.found && lookup.open && !!categoryId && !!serviceId && price != null && !create.isPending;
|
||||
|
||||
// The wash till's shift: money at the bay is only takeable while MY wash shift is open.
|
||||
const washShift = useShift("carwash");
|
||||
const canTakeMoney = washShift.isMine;
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||
<section className="flex w-full flex-wrap items-center gap-3 rounded-term border border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.tillTitle")}</span>
|
||||
<ShiftButton till="carwash" />
|
||||
{washShift.status && (
|
||||
<span className="text-[0.75rem] tabular-nums text-term-muted">
|
||||
{t("wash.drawerNow")}{" "}
|
||||
<span className="font-semibold text-term-text">
|
||||
{formatMoney(washShift.status.drawerMinor, washShift.status.currency ?? currency ?? "")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="basis-full text-[0.6875rem] text-term-muted">
|
||||
{washShift.blockedByOther ? t("wash.tillOtherHint", { operator: washShift.heldBy ?? "?" }) : t("wash.tillHint")}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="card w-full max-w-md p-4">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.intake")}</div>
|
||||
<form onSubmit={doLookup} className="mt-3 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={ticket}
|
||||
onChange={(e) => {
|
||||
setTicket(e.target.value);
|
||||
setLookup(null);
|
||||
}}
|
||||
placeholder={t("wash.ticketPh")}
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button type="submit" className="btn btn-sm" disabled={!ticket.trim()}>
|
||||
{t("wash.lookup")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{lookup && !lookup.found && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.notFound")}</p>}
|
||||
{lookup?.found && !lookup.open && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.closed")}</p>}
|
||||
{lookup?.found && lookup.open && (
|
||||
<div className="mt-3 grid gap-3">
|
||||
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-term-muted">{t("wash.ticket")}</span>
|
||||
<span className="font-mono">{lookup.identity}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-term-muted">{t("wash.plate")}</span>
|
||||
<span className="font-mono">{lookup.plate ?? "—"}</span>
|
||||
</div>
|
||||
{lookup.enteredAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-term-muted">{t("wash.enteredAt")}</span>
|
||||
<span className="tabular-nums">{timeOf(lookup.enteredAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{lookup.orders.filter((o) => !o.closed).length > 0 && (
|
||||
<div className="mt-1 text-term-amber">{t("wash.alreadyOpen")}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("wash.category")}</span>
|
||||
<select className="select" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("wash.service")}</span>
|
||||
<select className="select" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[0.8125rem]">
|
||||
<span className="text-term-muted">{t("wash.price")}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{price && currency ? formatMoney(price.priceMinor, currency) : categoryId && serviceId ? t("wash.noPrice") : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Where the money is taken is the SITE's setting (Setup → Car wash), shown
|
||||
here so the operator knows what this order will do — never chosen per order. */}
|
||||
<div className="flex items-center justify-between text-[0.75rem]">
|
||||
<span className="text-term-muted">{t("wash.payAt")}</span>
|
||||
<span>{settings ? t(settings.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay") : "—"}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!canCreate} onClick={() => create.mutate()}>
|
||||
{t("wash.create")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{msg && <p className="mt-3 text-[0.75rem] text-term-muted">{msg}</p>}
|
||||
</section>
|
||||
|
||||
<section className="card w-full max-w-3xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.queue")}</div>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void orders.refetch()}>↻</button>
|
||||
</div>
|
||||
{(orders.data?.orders ?? []).length === 0 ? (
|
||||
<p className="mt-3 text-[0.75rem] text-term-muted">{t("wash.empty")}</p>
|
||||
) : (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-[0.75rem]">
|
||||
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">{t("wash.time")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.ticket")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.plate")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.what")}</th>
|
||||
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.payAt")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.status")}</th>
|
||||
<th className="py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(orders.data?.orders ?? []).map((o: CarwashOrderView) => (
|
||||
<tr key={o.id} className="border-t border-term-border/50">
|
||||
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.createdAt)}</td>
|
||||
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
|
||||
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
|
||||
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className={o.status === "done" ? "text-term-green" : "text-term-amber"}>
|
||||
{t(o.status === "done" ? "wash.statusDone" : "wash.statusOpen")}
|
||||
</span>
|
||||
<span className="text-term-muted"> · </span>
|
||||
<span className={o.paidAt ? "text-term-green" : "text-term-muted"}>
|
||||
{t(o.paidAt ? "wash.paid" : "wash.unpaid")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{o.status === "open" && (
|
||||
<button type="button" className="btn btn-sm btn-primary" disabled={done.isPending} onClick={() => done.mutate(o.id)}>
|
||||
{t("wash.done")}
|
||||
</button>
|
||||
)}
|
||||
{o.payAt === "bay" && !o.paidAt && (
|
||||
<>
|
||||
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "cash" })}>
|
||||
{t("wash.payCash")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "card" })}>
|
||||
{t("wash.payCard")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!o.paidAt && (
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setVoiding({ id: o.id, reason: "" })}>
|
||||
{t("wash.void")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiding?.id === o.id && (
|
||||
<div className="mt-1 flex gap-1">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={voiding.reason}
|
||||
placeholder={t("wash.voidReason")}
|
||||
onChange={(e) => setVoiding({ id: o.id, reason: e.target.value })}
|
||||
/>
|
||||
<button type="button" className="btn btn-sm btn-danger" disabled={voidIt.isPending} onClick={() => voidIt.mutate(voiding)}>
|
||||
{t("wash.void")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setVoiding(null)}>
|
||||
{t("subs.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.finished")}</div>
|
||||
{(finished.data ?? []).length === 0 ? (
|
||||
<p className="mt-2 text-[0.75rem] text-term-muted">{t("wash.finishedEmpty")}</p>
|
||||
) : (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-[0.75rem]">
|
||||
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">{t("wash.time")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.ticket")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.plate")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.what")}</th>
|
||||
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.payAt")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.status")}</th>
|
||||
<th className="py-1">{t("wash.by")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-term-muted">
|
||||
{(finished.data ?? []).map((o: CarwashOrderView) => (
|
||||
<tr key={o.id} className="border-t border-term-border/50">
|
||||
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.doneAt ?? o.paidAt ?? o.createdAt)}</td>
|
||||
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
|
||||
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
|
||||
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{o.status === "void" ? (
|
||||
<span className="text-term-red">{t("wash.voided")}{o.voidReason ? ` · ${o.voidReason}` : ""}</span>
|
||||
) : (
|
||||
<span className="text-term-green">
|
||||
{t("wash.statusDone")} · {t("wash.paid")}{o.tender ? ` (${t(o.tender === "card" ? "wash.card" : "wash.cash")})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5">{o.status === "void" ? o.voidBy ?? "" : o.paidBy ?? o.doneBy ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender } from "@parking/shared";
|
||||
import { apiFetch } from "../../api.js";
|
||||
|
||||
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
|
||||
// client) never learns about wash endpoints. Shapes come from @parking/shared.
|
||||
|
||||
export type { CarWashPayAt, CarwashOrderView, CarwashSettingsView };
|
||||
|
||||
export interface CarwashTicketLookup {
|
||||
identity: string;
|
||||
found: boolean;
|
||||
open: boolean;
|
||||
subscription: boolean;
|
||||
plate: string | null;
|
||||
enteredAt: string | null;
|
||||
currency: string | null;
|
||||
orders: CarwashOrderView[];
|
||||
}
|
||||
|
||||
export interface CarwashSettingsBody {
|
||||
categories?: { id?: string; name: string; active?: boolean }[];
|
||||
services?: { id?: string; name: string; active?: boolean }[];
|
||||
prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
|
||||
/** Where wash money is taken at this site (site-level; the desk no longer asks). */
|
||||
payAt?: CarWashPayAt;
|
||||
}
|
||||
|
||||
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
||||
return apiFetch("/api/carwash/settings");
|
||||
}
|
||||
export function saveCarwashSettings(body: CarwashSettingsBody): Promise<CarwashSettingsView> {
|
||||
return apiFetch("/api/carwash/settings", { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function lookupCarwashTicket(identity: string): Promise<CarwashTicketLookup> {
|
||||
return apiFetch(`/api/carwash/session/${encodeURIComponent(identity.trim())}`);
|
||||
}
|
||||
export function fetchCarwashOrders(scope: "open" | "recent" = "open"): Promise<{ orders: CarwashOrderView[] }> {
|
||||
return apiFetch(`/api/carwash/orders?scope=${scope}`);
|
||||
}
|
||||
export function createCarwashOrder(body: {
|
||||
identity: string;
|
||||
categoryId: string;
|
||||
serviceId: string;
|
||||
}): Promise<CarwashOrderView> {
|
||||
return apiFetch("/api/carwash/orders", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function markCarwashDone(id: string): Promise<CarwashOrderView> {
|
||||
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/done`, { method: "POST" });
|
||||
}
|
||||
export function payCarwashAtBay(id: string, tender: Tender): Promise<CarwashOrderView> {
|
||||
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/pay`, { method: "POST", body: JSON.stringify({ tender }) });
|
||||
}
|
||||
export function voidCarwashOrder(id: string, reason: string): Promise<CarwashOrderView> {
|
||||
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/void`, { method: "POST", body: JSON.stringify({ reason }) });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createRoute, redirect, useRouteContext } from "@tanstack/react-router";
|
||||
import type { AnyRoute } from "@tanstack/react-router";
|
||||
import { can } from "../../api.js";
|
||||
import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js";
|
||||
import type { RouterContext } from "../../router.js";
|
||||
import { CarWashSetup } from "./CarWashSetup.js";
|
||||
import { WashDesk } from "./WashDesk.js";
|
||||
|
||||
// Car Wash — the pilot venue module, web side (wiki/decisions/venue-modules.md).
|
||||
// Two screens: the wash desk (/wash, carwash:read) and Setup → Car wash
|
||||
// (/setup/carwash, site:read; editing needs site:update). Both gate on the module
|
||||
// being effective at this site AND the permission; the server enforces the same.
|
||||
|
||||
function gate(perm: string) {
|
||||
return ({ context }: { context: unknown }) => {
|
||||
const ctx = context as RouterContext;
|
||||
// Bounce to the landing resolver, never straight to the booth (a wash-only role
|
||||
// has no booth to land on).
|
||||
if (!moduleOn(ctx.user, "carwash") || !can(ctx.user, perm)) throw redirect({ to: "/" });
|
||||
};
|
||||
}
|
||||
|
||||
export const carwashModule: WebModule = {
|
||||
id: "carwash",
|
||||
nav: [{ to: "/wash", labelKey: "nav.wash", perm: "carwash:read" }],
|
||||
landing: { to: "/wash", labelKey: "nav.wash", perm: "carwash:read" },
|
||||
routes(root: RootRoute) {
|
||||
const washRoute = createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: "/wash",
|
||||
beforeLoad: gate("carwash:read"),
|
||||
component: WashDesk,
|
||||
});
|
||||
return [washRoute];
|
||||
},
|
||||
setupNav: [{ to: "/setup/carwash", labelKey: "nav.carwash", perm: "site:read" }],
|
||||
setupRoutes(setup: AnyRoute) {
|
||||
const setupCarwashRoute = createRoute({
|
||||
getParentRoute: () => setup,
|
||||
path: "/carwash",
|
||||
beforeLoad: gate("site:read"),
|
||||
component: function CarWashSetupRoute() {
|
||||
const { user } = useRouteContext({ strict: false }) as RouterContext;
|
||||
return <CarWashSetup canEdit={can(user, "site:update")} />;
|
||||
},
|
||||
});
|
||||
return [setupCarwashRoute];
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WebModule } from "../lib/modules.js";
|
||||
import { carwashModule } from "./carwash/index.js";
|
||||
import { validationModule } from "./validation/index.js";
|
||||
|
||||
// The web-side module registry, in display order. Adding a module = its folder here
|
||||
@@ -6,4 +7,4 @@ import { validationModule } from "./validation/index.js";
|
||||
// into the nav and the route tree and never names a module's screens itself.
|
||||
// `parking` has no folder yet — its screens are still declared directly in
|
||||
// router.tsx; they move behind this seam subsystem by subsystem.
|
||||
export const WEB_MODULES: readonly WebModule[] = [validationModule];
|
||||
export const WEB_MODULES: readonly WebModule[] = [validationModule, carwashModule];
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ValidateScreen } from "../../ValidateScreen.js";
|
||||
export const validationModule: WebModule = {
|
||||
id: "validation",
|
||||
nav: [{ to: "/validate", labelKey: "nav.validate", perm: "validation:create" }],
|
||||
landing: { to: "/validate", labelKey: "nav.validate", perm: "validation:create" },
|
||||
routes(root: RootRoute) {
|
||||
const validateRoute = createRoute({
|
||||
getParentRoute: () => root,
|
||||
@@ -20,7 +21,7 @@ export const validationModule: WebModule = {
|
||||
beforeLoad: ({ context }) => {
|
||||
const ctx = context as RouterContext;
|
||||
if (!moduleOn(ctx.user, "validation") || !can(ctx.user, "validation:create")) {
|
||||
throw redirect({ to: "/booth" });
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
},
|
||||
component: function ValidateRoute() {
|
||||
|
||||
+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) ?? []),
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
||||
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
|
||||
carwash_order: { labelKey: "booth.evtCarwashOrder", color: "text-term-cyan" },
|
||||
carwash_payment: { labelKey: "booth.evtCarwashPayment", color: "text-term-cyan" },
|
||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user