feat(shift): cash drawer balance carried across shifts + admin cash movements
New signed cash_movement event (admin-only): load/remove drawer float, signed + attributed. ShiftService folds cash payments + movements by time into a drawer balance; shift open auto-inherits the prior shift's expected closing drawer as its opening float; the Z-report reports opening/taken/added/removed/expected (= next shift's opening float). Card payments excluded (settle to bank). Routes: POST /api/cash-movement, drawer in GET /api/shift/current. ShiftControl shows the live drawer + admin load/remove form + Z-report drawer block. Wiki: shift.md.
This commit is contained in:
@@ -1,25 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js";
|
||||
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. Available to cashier/operator/admin (readonly has no shift).
|
||||
// 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() {
|
||||
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
// 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))
|
||||
.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);
|
||||
@@ -28,6 +42,7 @@ export function ShiftControl() {
|
||||
try {
|
||||
const { startedAt } = await openShift();
|
||||
setStartedAt(startedAt);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
@@ -41,6 +56,7 @@ export function ShiftControl() {
|
||||
const z = await closeShift();
|
||||
setReport(z);
|
||||
setStartedAt(null);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
@@ -48,6 +64,24 @@ export function ShiftControl() {
|
||||
}
|
||||
}
|
||||
|
||||
async function move(sign: 1 | -1) {
|
||||
setMoveMsg(null);
|
||||
const major = Number(moveAmount);
|
||||
if (!Number.isFinite(major) || major <= 0) {
|
||||
setMoveMsg("Enter a positive amount.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
|
||||
setMoveAmount("");
|
||||
setMoveReason("");
|
||||
setMoveMsg(`Drawer now ${money(r.balanceMinor, currency)}.`);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setMoveMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||
<strong>Shift:</strong>{" "}
|
||||
@@ -66,14 +100,58 @@ export function ShiftControl() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Live drawer balance (what's in the till right now / inherited). */}
|
||||
{drawerMinor != null && (
|
||||
<div style={{ marginTop: "0.5rem", color: "#555" }}>
|
||||
Drawer: <strong>{money(drawerMinor, currency)}</strong>
|
||||
{startedAt && <span style={{ color: "#888" }}> (opening float inherited from the prior shift)</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
||||
|
||||
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
|
||||
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
|
||||
Drawer cash (admin) — load or remove the float
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input
|
||||
value={moveAmount}
|
||||
onChange={(e) => setMoveAmount(e.target.value)}
|
||||
placeholder="amount"
|
||||
inputMode="decimal"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<input
|
||||
value={moveReason}
|
||||
onChange={(e) => setMoveReason(e.target.value)}
|
||||
placeholder="reason (e.g. opening float)"
|
||||
style={{ flex: 1, minWidth: 140 }}
|
||||
/>
|
||||
<button type="button" onClick={() => move(1)}>Load +</button>
|
||||
<button type="button" onClick={() => move(-1)}>Remove −</button>
|
||||
</div>
|
||||
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
||||
<div>Payments: {report.paymentCount}</div>
|
||||
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
|
||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}>
|
||||
<div style={{ marginTop: "0.4rem", color: "#666" }}>— Drawer —</div>
|
||||
<div>Opening float: {money(report.openingFloatMinor, report.currency)}</div>
|
||||
<div>Cash taken: {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>Cash added: {money(report.cashAddedMinor, report.currency)}</div>
|
||||
<div>Cash removed: {money(report.cashRemovedMinor, report.currency)}</div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
Expected drawer: {money(report.expectedDrawerMinor, report.currency)}
|
||||
</div>
|
||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
|
||||
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user