server+web: shifts — open/close + signed Z-report (manned mode)
A shift is two signed ledger events, no mutable table: new shift_open event type + existing shift_z_report. The operator is the logged-in user (carried in event identity); a shift is open iff their latest shift event is a shift_open. ShiftService: close sums payment events in [start,end] by tender (cash/card, by payment time), appends the signed shift_z_report (totals/counts/window), and prints via a new generic PrinterDevice.printReport(title, lines) (Rongta ESC/POS text) to a booth-receipt printer. Print is best-effort — a failed print does not undo the signed close. Routes (cashier/operator/admin): GET /api/shift/current, POST /api/shift/open (409 if open), POST /api/shift/close (409 if none). Web ShiftControl in the shell (non-readonly): Start/End + Z-report totals. Verified: open -> double-open 409 -> payments (cash+card; one outside the window excluded) -> close totals correct + signed + printed -> close-again 409 -> re-open ok; readonly 403; verifyChain ok.
This commit is contained in:
@@ -3,6 +3,7 @@ import { fetchMe, logout, type SessionUser } from "./api.js";
|
||||
import { Login } from "./Login.js";
|
||||
import { PermitManager } from "./PermitManager.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
import { ShiftControl } from "./ShiftControl.js";
|
||||
import { TariffComposer } from "./TariffComposer.js";
|
||||
|
||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||
@@ -40,6 +41,7 @@ export function App() {
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
{user.role !== "readonly" && <ShiftControl />}
|
||||
{user.role === "admin" ? (
|
||||
<>
|
||||
<SetupWizard />
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { closeShift, fetchShift, openShift, 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).
|
||||
|
||||
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
||||
|
||||
export function ShiftControl() {
|
||||
const [startedAt, setStartedAt] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchShift()
|
||||
.then((s) => setStartedAt(s.open?.startedAt ?? null))
|
||||
.catch(() => {
|
||||
/* readonly / not permitted — hide control */
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function start() {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setReport(null);
|
||||
try {
|
||||
const { startedAt } = await openShift();
|
||||
setStartedAt(startedAt);
|
||||
} 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);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||
<strong>Shift:</strong>{" "}
|
||||
{startedAt ? (
|
||||
<>
|
||||
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "}
|
||||
<button type="button" onClick={end} disabled={busy}>
|
||||
{busy ? "Ending…" : "End shift"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: "#777" }}>not started</span>{" "}
|
||||
<button type="button" onClick={start} disabled={busy}>
|
||||
{busy ? "Starting…" : "Start shift"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
||||
{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" }}>
|
||||
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -277,3 +277,30 @@ export function revokePermit(id: string): Promise<Permit> {
|
||||
export function deletePermit(id: string): Promise<void> {
|
||||
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Shifts ---------------------------------------------------------------
|
||||
|
||||
export interface ShiftStatus {
|
||||
operator: string;
|
||||
open: { startedAt: string } | null;
|
||||
}
|
||||
export interface ShiftReport {
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
cashTotalMinor: number;
|
||||
cardTotalMinor: number;
|
||||
currency: string | null;
|
||||
paymentCount: number;
|
||||
printed: boolean;
|
||||
}
|
||||
|
||||
export function fetchShift(): Promise<ShiftStatus> {
|
||||
return apiFetch("/api/shift/current");
|
||||
}
|
||||
export function openShift(): Promise<{ startedAt: string }> {
|
||||
return apiFetch("/api/shift/open", { method: "POST" });
|
||||
}
|
||||
export function closeShift(): Promise<ShiftReport> {
|
||||
return apiFetch("/api/shift/close", { method: "POST" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user