feat: mid-shift X-report (read-only takings-so-far)
Let the operator see, on demand during an open shift, the opening float inherited, cash/card collected so far, pay-ins/pay-outs, and the current expected drawer balance — without closing. GET /api/shift/report (shift:read; 204 when no shift is open) returns the same drawer projection the Z-report computes. Factored that math into a shared ShiftService.#summariseWindow(open, asOf) used by BOTH the X-report (asOf=now, read-only) and close()'s Z-report (asOf=endedAt, signed), so the two can't drift. The X-report appends NOTHING — it's a snapshot, not an accountability mark; the Z-report at close remains the signed record. UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header still shows the live drawer total for the at-a-glance figure. Verified against a copy of the live DB: X figures match drawerBalance(), the drawer identity holds, zero events appended, chain still verifies. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { closeShift, fetchShift, openShift, recordCashVoucher, type ShiftReport } from "./api.js";
|
||||
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
|
||||
@@ -18,6 +26,7 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
||||
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).
|
||||
@@ -44,6 +53,7 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setReport(null);
|
||||
setXReport(null);
|
||||
try {
|
||||
const { startedAt } = await openShift();
|
||||
setStartedAt(startedAt);
|
||||
@@ -57,6 +67,7 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
||||
async function end() {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setXReport(null);
|
||||
try {
|
||||
const z = await closeShift();
|
||||
setReport(z);
|
||||
@@ -68,6 +79,16 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
||||
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);
|
||||
@@ -108,6 +129,9 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
||||
<>
|
||||
<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>
|
||||
@@ -182,6 +206,28 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
||||
</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>
|
||||
|
||||
@@ -627,6 +627,29 @@ export function closeShift(): Promise<ShiftReport> {
|
||||
return apiFetch("/api/shift/close", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
||||
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||||
* snapshot instant. */
|
||||
export interface XReport {
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string; // = asOf
|
||||
asOf: string;
|
||||
cashTotalMinor: number;
|
||||
cardTotalMinor: number;
|
||||
currency: string | null;
|
||||
paymentCount: number;
|
||||
openingFloatMinor: number;
|
||||
cashAddedMinor: number;
|
||||
cashRemovedMinor: number;
|
||||
expectedDrawerMinor: number;
|
||||
}
|
||||
|
||||
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
||||
export async function fetchShiftReport(): Promise<XReport | null> {
|
||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||
}
|
||||
|
||||
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
||||
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
||||
|
||||
@@ -528,6 +528,10 @@ export const en: Catalog = {
|
||||
enterPositive: "Enter a positive amount.",
|
||||
drawerNow: "Drawer now {{amount}}.",
|
||||
zReport: "Z-REPORT",
|
||||
viewTakings: "Takings so far",
|
||||
xReport: "X-REPORT (so far)",
|
||||
asOf: "as of",
|
||||
xReportHint: "Read-only snapshot — nothing is recorded. The Z-report at close will sign these figures.",
|
||||
payments: "Payments:",
|
||||
cash: "Cash:",
|
||||
card: "Card:",
|
||||
|
||||
@@ -540,6 +540,10 @@ export const sq = {
|
||||
enterPositive: "Shkruaj një shumë pozitive.",
|
||||
drawerNow: "Arka tani {{amount}}.",
|
||||
zReport: "RAPORT Z",
|
||||
viewTakings: "Arkëtimet deri tani",
|
||||
xReport: "RAPORT X (deri tani)",
|
||||
asOf: "deri më",
|
||||
xReportHint: "Pamje vetëm për lexim — asgjë nuk regjistrohet. Raporti Z në mbyllje i nënshkruan këto shifra.",
|
||||
payments: "Pagesa:",
|
||||
cash: "Para:",
|
||||
card: "Kartë:",
|
||||
|
||||
Reference in New Issue
Block a user