Files
parking_solution/apps/web/src/ShiftControl.tsx
T
julian 55d6242c7d
CI / check (push) Successful in 46s
Build & push images / images (push) Successful in 2m58s
Build desktop / desktop (push) Successful in 4m53s
feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix",
open-questions #16) — the grid stays the enforcement layer:

- Move 1: each desk's money is guarded by that desk's own permissions. Manifest
  tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create
  (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes
  resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot
  touch the booth by construction. Replaces the session:read borrowing (tillPermission).
  /api/shift/tills lists the role's readable tills with canWork; history/movements
  without a till filter return the union of readable tills.
- Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor,
  merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and
  "partial job" lints (warnings, never blocks).
- Move 3: the live WebSocket admits any watch permission (event/session/device read or
  a module's feedPermission) and filters every push per role; report:read is the
  reports screen only.

Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves
the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's
role applies on the next request and a deleted user's session ends with 401.

Tests: till guards + look-only role, feed rules, every job's permissions exist, role
reassignment without re-login. 353/353.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 14:45:48 +02:00

213 lines
8.9 KiB
TypeScript

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 { 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)} />
{/* 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>
);
}