e14e31a840
Closes the three known follow-ups of the Tills decision (venue-modules.md): - Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events by payload till, other events by their owning module's till, everything else booth), applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today" panel and the booth feed (history + live pushes). The events route admits a role that holds a module feed permission without event:read and returns only that module's event types — the live-socket rule. - Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by module; the ticket bucket excludes them (Bileta = parking only); printed "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:". - Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the one reading of the role field (the entry/booth loaders treated any non-booth role as an entry dispenser). Footer label "at wash desk". Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new requireAnyPermission) — the Wash operator job could not load the desk's category and service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules, log) updated. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
222 lines
9.4 KiB
TypeScript
222 lines
9.4 KiB
TypeScript
import { Fragment, 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 { status, isOpen, isMine, blockedByOther, heldBy } = useShift(till);
|
|
// The till's `shift` guard (booth shift:create / wash carwash:cash). A role that may
|
|
// only LOOK sees the state text, never the button; the server refuses the same.
|
|
const canWork = status?.canWork ?? false;
|
|
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">
|
|
{canWork && (
|
|
<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>
|
|
)}
|
|
{!canWork && isOpen && (
|
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
|
{till === "booth" ? t("shift.headerHeldByShort", { operator: heldBy ?? "?" }) : t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })}
|
|
</span>
|
|
)}
|
|
{!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)} />
|
|
{/* Module money that rode the ticket (a booth-paid wash) — only when any did. */}
|
|
{Object.entries(x.chargesByModuleMinor ?? {})
|
|
.filter(([, v]) => (v ?? 0) > 0)
|
|
.map(([m, v]) => (
|
|
<Fragment key={m}>
|
|
<ConfirmFigure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={fmt(v ?? 0)} />
|
|
<span />
|
|
</Fragment>
|
|
))}
|
|
{/* 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>
|
|
);
|
|
}
|