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(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 (
{canWork && ( )} {!canWork && isOpen && ( {till === "booth" ? t("shift.headerHeldByShort", { operator: heldBy ?? "?" }) : t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })} )} {!isOpen && ( {till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })} )} {err && {err}} {confirmingClose && ( setConfirmingClose(false)} onConfirm={async () => { await act("close"); setConfirmingClose(false); }} /> )}
); } /** 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 (

{t("shift.endConfirm")}

{!x ? (

{t("common.loading")}

) : ( <>
{/* Split by source — only meaningful on the booth (a wash till has no tickets or subscriptions; its takings are the bay payments). */} {till === "booth" && ( <> {/* 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]) => ( ))} {/* 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). */} )}
{CARD_PAYMENTS_ENABLED && } {/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
)}
); } function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { return (
{label} {/* The money/number never splits across lines (e.g. "89,650 ALL"). */} {value}
); }