808fb26ab6
The TRM tokens were good but every screen hand-rolled inputs and buttons as bare outlines on near-black panels, so fields, cards and buttons were visually indistinguishable. Add a component layer (.input/.select/.textarea as recessed slots, .btn family with a FILLED primary, .card scaffolding) and adopt it across the booth/shift/login/tariff/site screens — several of which were still light-theme inline styles dropped on a dark background. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
167 lines
6.8 KiB
TypeScript
167 lines
6.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
|
|
|
|
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
|
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
|
// totals + the DRAWER picture (opening float carried from the prior shift, cash
|
|
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
|
|
// Available to cashier/operator/admin (readonly has no shift).
|
|
|
|
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
|
|
|
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
|
const { t } = useTranslation();
|
|
const [startedAt, setStartedAt] = useState<string | null>(null);
|
|
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
|
const [currency, setCurrency] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
// Cash-movement form (admin only).
|
|
const [moveAmount, setMoveAmount] = useState("");
|
|
const [moveReason, setMoveReason] = useState("");
|
|
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
|
|
|
function refresh() {
|
|
fetchShift()
|
|
.then((s) => {
|
|
setStartedAt(s.open?.startedAt ?? null);
|
|
setDrawerMinor(s.drawerMinor);
|
|
setCurrency(s.currency);
|
|
})
|
|
.catch(() => {
|
|
/* readonly / not permitted — hide control */
|
|
});
|
|
}
|
|
useEffect(refresh, []);
|
|
|
|
async function start() {
|
|
setBusy(true);
|
|
setErr(null);
|
|
setReport(null);
|
|
try {
|
|
const { startedAt } = await openShift();
|
|
setStartedAt(startedAt);
|
|
refresh();
|
|
} catch (e) {
|
|
setErr((e as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
async function end() {
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const z = await closeShift();
|
|
setReport(z);
|
|
setStartedAt(null);
|
|
refresh();
|
|
} catch (e) {
|
|
setErr((e as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function move(sign: 1 | -1) {
|
|
setMoveMsg(null);
|
|
const major = Number(moveAmount);
|
|
if (!Number.isFinite(major) || major <= 0) {
|
|
setMoveMsg(t("shift.enterPositive"));
|
|
return;
|
|
}
|
|
try {
|
|
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
|
|
setMoveAmount("");
|
|
setMoveReason("");
|
|
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
|
|
refresh();
|
|
} catch (e) {
|
|
setMoveMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="card mt-6 max-w-md p-4">
|
|
<div className="flex flex-wrap items-center gap-2 text-[13px]">
|
|
<strong className="uppercase tracking-wider text-term-muted">{t("shift.label")}</strong>
|
|
{startedAt ? (
|
|
<>
|
|
<span className="font-semibold text-term-green">{t("shift.open")}</span>
|
|
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
|
|
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
|
|
{busy ? t("shift.ending") : t("shift.endShift")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="text-term-muted">{t("shift.notStarted")}</span>
|
|
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
|
{busy ? t("shift.starting") : t("shift.startShift")}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* Live drawer balance (what's in the till right now / inherited). */}
|
|
{drawerMinor != null && (
|
|
<div className="mt-2 text-[12px] text-term-text">
|
|
{t("shift.drawer")} <strong className="tabular-nums">{money(drawerMinor, currency)}</strong>
|
|
{startedAt && <span className="text-term-muted"> {t("shift.openingFloatInherited")}</span>}
|
|
</div>
|
|
)}
|
|
|
|
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
|
|
|
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
|
{isAdmin && (
|
|
<div className="mt-4 border-t border-term-border pt-3">
|
|
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
|
|
{t("shift.drawerCashAdmin")}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<input
|
|
className="input w-28"
|
|
value={moveAmount}
|
|
onChange={(e) => setMoveAmount(e.target.value)}
|
|
placeholder={t("shift.amount")}
|
|
inputMode="decimal"
|
|
/>
|
|
<input
|
|
className="input min-w-36 flex-1"
|
|
value={moveReason}
|
|
onChange={(e) => setMoveReason(e.target.value)}
|
|
placeholder={t("shift.reasonPlaceholder")}
|
|
/>
|
|
<button type="button" className="btn btn-go btn-sm" onClick={() => move(1)}>{t("shift.load")}</button>
|
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
|
</div>
|
|
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
|
|
</div>
|
|
)}
|
|
|
|
{report && (
|
|
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
|
|
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
|
<div className="text-term-text">{t("shift.payments")} {report.paymentCount}</div>
|
|
<div className="text-term-text">{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
|
<div className="text-term-text">{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
|
<div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
|
|
<div className="text-term-text">{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
|
|
<div className="text-term-text">{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
|
|
<div className="text-term-text">{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
|
|
<div className="text-term-text">{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
|
|
<div className="font-semibold text-term-text">
|
|
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
|
|
</div>
|
|
<div className={report.printed ? "mt-1 text-term-green" : "mt-1 text-term-amber"}>
|
|
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|