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:
2026-06-20 16:22:55 +02:00
parent 2835f78635
commit cb68cbafdb
8 changed files with 166 additions and 21 deletions
+10
View File
@@ -60,6 +60,16 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db:
}; };
}); });
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
// as of now. Appends nothing — it's not an accountability mark, just a projection
// (the Z-report at close is the signed record). 204 when no shift is open.
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
const report = shift.currentReport();
if (!report) return reply.code(204).send();
return report;
});
// Completed shift history. SCOPED by permission: // Completed shift history. SCOPED by permission:
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored. // - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
// - `shift:cash` (admin-grade) → all operators, optionally filtered by // - `shift:cash` (admin-grade) → all operators, optionally filtered by
+55 -14
View File
@@ -323,21 +323,28 @@ export class ShiftService {
return { startedAt, openingFloatMinor }; return { startedAt, openingFloatMinor };
} }
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */ /**
async close(operator: string): Promise<ShiftReport> { * Project the drawer/takings figures for a shift's window `[startedAt, asOf]`.
const open = this.openShiftFor(operator); * Pure read over the signed chain — appends NOTHING — so it backs BOTH the
if (!open) throw new NoOpenShiftError(operator); * mid-shift X-report (asOf = now, shift still open) and the Z-report at close
* (asOf = endedAt). The figures are identical projections; only the persistence
* differs (X = read-only, Z = signed + carried forward).
*/
#summariseWindow(
open: typeof ledgerEvents.$inferSelect,
asOf: string,
): Omit<ShiftReport, "printed"> {
const operator = open.identity ?? "?";
const startedAt = open.occurredAt; const startedAt = open.occurredAt;
const endedAt = new Date().toISOString();
// All payments taken in [startedAt, endedAt], summed by tender. Payment time = // All payments taken in [startedAt, asOf], summed by tender. Payment time =
// the operator who handled the money (decision: sum by payment time). // the operator who handled the money (decision: sum by payment time).
const payments = this.#db const payments = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
.where(eq(ledgerEvents.type, "payment")) .where(eq(ledgerEvents.type, "payment"))
.all() .all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt); .filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
let cashTotalMinor = 0; let cashTotalMinor = 0;
let cardTotalMinor = 0; let cardTotalMinor = 0;
@@ -359,7 +366,7 @@ export class ShiftService {
? openPl.openingFloatMinor ? openPl.openingFloatMinor
: this.#drawerBalanceAt(startedAt).balanceMinor; : this.#drawerBalanceAt(startedAt).balanceMinor;
// Drawer movements within the shift window, split into added (+) and removed (−). // Drawer movements within the window, split into added (+) and removed (−).
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-± // Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
// cash_movement. All carry a POSITIVE magnitude except legacy, which is signed. // cash_movement. All carry a POSITIVE magnitude except legacy, which is signed.
const movements = this.#db const movements = this.#db
@@ -370,7 +377,7 @@ export class ShiftService {
(r) => (r) =>
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") && (r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
r.occurredAt >= startedAt && r.occurredAt >= startedAt &&
r.occurredAt <= endedAt, r.occurredAt <= asOf,
); );
let cashAddedMinor = 0; let cashAddedMinor = 0;
let cashRemovedMinor = 0; let cashRemovedMinor = 0;
@@ -384,14 +391,14 @@ export class ShiftService {
if (pl.currency) currency = pl.currency; if (pl.currency) currency = pl.currency;
} }
// Expected drawer at close = opening + cash taken + added − removed. This is the // Expected drawer = opening + cash taken + added − removed. At close this is the
// figure the NEXT shift inherits as its opening float. // figure the NEXT shift inherits as its opening float.
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor; const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
const report: Omit<ShiftReport, "printed"> = { return {
operator, operator,
startedAt, startedAt,
endedAt, endedAt: asOf,
cashTotalMinor, cashTotalMinor,
cardTotalMinor, cardTotalMinor,
currency, currency,
@@ -401,6 +408,40 @@ export class ShiftService {
cashRemovedMinor, cashRemovedMinor,
expectedDrawerMinor, expectedDrawerMinor,
}; };
}
/**
* Mid-shift X-report: a READ-ONLY "so far" snapshot of the open shift's takings +
* drawer, computed as of now. Appends nothing (it's not an accountability mark —
* the Z-report at close is). Returns null when no shift is open. The same
* projection the Z-report prints, so the operator sees exactly what their close
* will show. See wiki/concepts/shift.md.
*/
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
const open = this.currentOpenShift();
if (!open) return null;
const asOf = new Date().toISOString();
return { ...this.#summariseWindow(open, asOf), asOf };
}
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
async close(operator: string): Promise<ShiftReport> {
const open = this.openShiftFor(operator);
if (!open) throw new NoOpenShiftError(operator);
const endedAt = new Date().toISOString();
const report = this.#summariseWindow(open, endedAt);
const {
startedAt,
cashTotalMinor,
cardTotalMinor,
currency,
paymentCount,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
expectedDrawerMinor,
} = report;
await this.#log.append({ await this.#log.append({
type: "shift_z_report", type: "shift_z_report",
@@ -413,7 +454,7 @@ export class ShiftService {
cashTotalMinor, cashTotalMinor,
cardTotalMinor, cardTotalMinor,
currency: currency ?? undefined, currency: currency ?? undefined,
paymentCount: payments.length, paymentCount,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -424,7 +465,7 @@ export class ShiftService {
const printed = await this.#printZReport(report); const printed = await this.#printZReport(report);
this.#logger.info( this.#logger.info(
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` + `shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`, `drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
); );
return { ...report, printed }; return { ...report, printed };
+47 -1
View File
@@ -1,6 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; 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 // 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 // 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 [currency, setCurrency] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [report, setReport] = useState<ShiftReport | null>(null); const [report, setReport] = useState<ShiftReport | null>(null);
const [xReport, setXReport] = useState<XReport | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
// Drawer-voucher form. Operator raises; an admin authorizes (name + password). // Drawer-voucher form. Operator raises; an admin authorizes (name + password).
@@ -44,6 +53,7 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
setBusy(true); setBusy(true);
setErr(null); setErr(null);
setReport(null); setReport(null);
setXReport(null);
try { try {
const { startedAt } = await openShift(); const { startedAt } = await openShift();
setStartedAt(startedAt); setStartedAt(startedAt);
@@ -57,6 +67,7 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
async function end() { async function end() {
setBusy(true); setBusy(true);
setErr(null); setErr(null);
setXReport(null);
try { try {
const z = await closeShift(); const z = await closeShift();
setReport(z); setReport(z);
@@ -68,6 +79,16 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
setBusy(false); 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") { async function voucher(type: "cash_in" | "cash_out") {
setMoveMsg(null); 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="font-semibold text-term-green">{t("shift.open")}</span>
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</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}> <button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
{busy ? t("shift.ending") : t("shift.endShift")} {busy ? t("shift.ending") : t("shift.endShift")}
</button> </button>
@@ -182,6 +206,28 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
</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 && ( {report && (
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums"> <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="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
+23
View File
@@ -627,6 +627,29 @@ export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" }); 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 /** 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. * (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
* Operator-raised, admin-authorized (authorizedBy + their password). */ * Operator-raised, admin-authorized (authorizedBy + their password). */
+4
View File
@@ -528,6 +528,10 @@ export const en: Catalog = {
enterPositive: "Enter a positive amount.", enterPositive: "Enter a positive amount.",
drawerNow: "Drawer now {{amount}}.", drawerNow: "Drawer now {{amount}}.",
zReport: "Z-REPORT", 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:", payments: "Payments:",
cash: "Cash:", cash: "Cash:",
card: "Card:", card: "Card:",
+4
View File
@@ -540,6 +540,10 @@ export const sq = {
enterPositive: "Shkruaj një shumë pozitive.", enterPositive: "Shkruaj një shumë pozitive.",
drawerNow: "Arka tani {{amount}}.", drawerNow: "Arka tani {{amount}}.",
zReport: "RAPORT Z", 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:", payments: "Pagesa:",
cash: "Para:", cash: "Para:",
card: "Kartë:", card: "Kartë:",
+10 -6
View File
@@ -204,11 +204,15 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the - **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
the money. Confirm that's the intended accountability (vs. by entry). the money. Confirm that's the intended accountability (vs. by entry).
- **Mid-shift report / X-report — REQUESTED 2026-06-20, not yet built.** The operator wants to see, - **Mid-shift report / X-report — BUILT 2026-06-20.** On demand during the shift, the operator sees
on demand during the shift, the **opening float inherited**, **cash collected so far**, the the **opening float inherited**, **cash/card collected so far**, the **pay-ins/pay-outs**, and the
pay-ins/pay-outs, and the **current expected drawer balance** — without closing. It's the same **current expected drawer balance** — without closing. `GET /api/shift/report` (`shift:read`, 204
drawer projection the Z-report computes, just read-only and mid-shift. (The header already shows the when no shift is open) returns the SAME drawer projection the Z-report computes, factored into a
live drawer *total*; this is the full breakdown.) Deferred behind the voucher re-model done the same shared `ShiftService.#summariseWindow(open, asOf)` so X (asOf = now, read-only) and Z (asOf =
day; build next if wanted. endedAt, signed) can never drift. **It appends NOTHING** — it's not an accountability mark (the
Z-report at close is the signed record). UI: a "Takings so far" button on the shift control opens a
cyan X-report panel; the header still shows the live drawer *total* for the at-a-glance number.
Verified against a copy of the live DB: matches `drawerBalance()`, drawer identity holds, 0 events
appended, chain still verifies.
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site - **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
(relates to [[open-questions]] #1 lane topology). (relates to [[open-questions]] #1 lane topology).
+13
View File
@@ -1118,3 +1118,16 @@ three types. Verified against a COPY of the live DB with the real signing module
Updated [[shift]] (Drawer balance section, math, worked example, open items). Prompted by the Updated [[shift]] (Drawer balance section, math, worked example, open items). Prompted by the
operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED, operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED,
not yet built (see [[shift]] Open). not yet built (see [[shift]] Open).
## [2026-06-20] feat | Mid-shift X-report (read-only takings-so-far)
The operator can now 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 open) returns the
SAME projection the Z-report prints, factored into a shared `ShiftService.#summariseWindow
(open, asOf)` so X (asOf=now, read-only) and Z (asOf=endedAt, signed) can't drift. Appends
NOTHING — it's a snapshot, not an accountability mark (the Z at close is the signed record).
UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header
keeps the live drawer total. Verified on a copy of the live DB: matches drawerBalance(),
drawer identity holds (expected = opening + cash + added − removed), 0 events appended, chain
verifies. Build + lint 12/12. Resolves the X-report item flagged the same day in [[shift]].