feat(shift): current shift in the list + modal actions; full-width layout everywhere
Shift screen: - The standalone ShiftControl block is gone from /shift. The open/CURRENT shift now appears at the TOP of the shift list (CURRENT badge, live figures synthesized from the X-report), unified with history. Selecting it shows its live activity log. - Shift ACTIONS moved into the current shift's detail pane, each opening a MODAL: End shift (confirm → signed Z-report result), drawer voucher (Mandat in/out), takings-so-far (X-report). When no shift is open, a Start-shift button shows. - The current shift's log auto-refreshes (5s); a closed shift is bounded by its window. /setup/shifts stays read-only history (no manage props). Deleted the now- orphaned ShiftControl.tsx. Layout: - Every screen is now full-width like /booth — stripped the per-screen `mx-auto max-w-*` caps (Logs, Subscriptions, Plans, Tariff, Users, Roles, Setup layout, Shifts). The shell <main> already provides padding. Build+lint 12/12 (i18n parity). Verified a live open shift surfaces as the CURRENT list entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,252 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
closeShift,
|
||||
fetchShift,
|
||||
fetchShiftReport,
|
||||
openShift,
|
||||
recordCashVoucher,
|
||||
type ShiftReport,
|
||||
type XReport,
|
||||
} 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). Operators RAISE a drawer cash voucher
|
||||
// (Mandat Arkëtimi / Mandat Pagese); an admin AUTHORIZES it with their password.
|
||||
// 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({ canVoucher = false }: { canVoucher?: 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 [xReport, setXReport] = useState<XReport | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// Drawer-voucher form. Operator raises; an admin authorizes (name + password).
|
||||
const [moveAmount, setMoveAmount] = useState("");
|
||||
const [moveReason, setMoveReason] = useState("");
|
||||
const [authName, setAuthName] = useState("");
|
||||
const [authPassword, setAuthPassword] = 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);
|
||||
setXReport(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);
|
||||
setXReport(null);
|
||||
try {
|
||||
const z = await closeShift();
|
||||
setReport(z);
|
||||
setStartedAt(null);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
// Mid-shift X-report: read-only "takings so far" (appends nothing). Re-fetched on
|
||||
// each click so it's always current.
|
||||
async function viewReport() {
|
||||
setErr(null);
|
||||
try {
|
||||
setXReport(await fetchShiftReport());
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function voucher(type: "cash_in" | "cash_out") {
|
||||
setMoveMsg(null);
|
||||
const major = Number(moveAmount);
|
||||
if (!Number.isFinite(major) || major <= 0) {
|
||||
setMoveMsg(t("shift.enterPositive"));
|
||||
return;
|
||||
}
|
||||
if (!authName.trim() || !authPassword) {
|
||||
setMoveMsg(t("shift.authRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await recordCashVoucher({
|
||||
type,
|
||||
amountMinor: Math.round(major * 100),
|
||||
reason: moveReason.trim(),
|
||||
authorizedBy: authName.trim(),
|
||||
authorizerPassword: authPassword,
|
||||
});
|
||||
setMoveAmount("");
|
||||
setMoveReason("");
|
||||
setAuthPassword("");
|
||||
setMoveMsg(
|
||||
t("shift.voucherRecorded", { no: r.voucherNo, 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" onClick={viewReport} disabled={busy}>
|
||||
{t("shift.viewTakings")}
|
||||
</button>
|
||||
<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>}
|
||||
|
||||
{/* Drawer cash voucher: operator RAISES, an admin AUTHORIZES (name + password).
|
||||
cash_in = Mandat Arkëtimi (pay-IN), cash_out = Mandat Pagese (pay-OUT). */}
|
||||
{canVoucher && (
|
||||
<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.drawerVoucher")}
|
||||
</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")}
|
||||
/>
|
||||
</div>
|
||||
{/* Admin sign-off — the float can only move with an admin's authorization. */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-36"
|
||||
value={authName}
|
||||
onChange={(e) => setAuthName(e.target.value)}
|
||||
placeholder={t("shift.authName")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
className="input w-36"
|
||||
type="password"
|
||||
value={authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
placeholder={t("shift.authPassword")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => voucher("cash_in")}>
|
||||
{t("shift.mandatArketimi")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => voucher("cash_out")}>
|
||||
{t("shift.mandatPagese")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
||||
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mid-shift X-report — read-only "takings so far" (no event appended). */}
|
||||
{xReport && (
|
||||
<div className="mt-4 rounded-term border border-term-cyan/40 bg-term-bg p-3 text-[12px] tabular-nums">
|
||||
<div className="font-semibold text-term-cyan">{t("shift.xReport")} — {xReport.operator}</div>
|
||||
<div className="text-term-muted">
|
||||
{t("shift.asOf")} {new Date(xReport.asOf).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-term-text">{t("shift.payments")} {xReport.paymentCount}</div>
|
||||
<div className="text-term-text">{t("shift.cash")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.card")} {money(xReport.cardTotalMinor, xReport.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(xReport.openingFloatMinor, xReport.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.cashTaken")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.cashAdded")} {money(xReport.cashAddedMinor, xReport.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.cashRemoved")} {money(xReport.cashRemovedMinor, xReport.currency)}</div>
|
||||
<div className="font-semibold text-term-text">
|
||||
{t("shift.expectedDrawer")} {money(xReport.expectedDrawerMinor, xReport.currency)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-term-muted">{t("shift.xReportHint")}</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user