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:
@@ -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:
|
||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||
|
||||
@@ -323,21 +323,28 @@ export class ShiftService {
|
||||
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> {
|
||||
const open = this.openShiftFor(operator);
|
||||
if (!open) throw new NoOpenShiftError(operator);
|
||||
/**
|
||||
* Project the drawer/takings figures for a shift's window `[startedAt, asOf]`.
|
||||
* Pure read over the signed chain — appends NOTHING — so it backs BOTH the
|
||||
* 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 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).
|
||||
const payments = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "payment"))
|
||||
.all()
|
||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
|
||||
|
||||
let cashTotalMinor = 0;
|
||||
let cardTotalMinor = 0;
|
||||
@@ -359,7 +366,7 @@ export class ShiftService {
|
||||
? openPl.openingFloatMinor
|
||||
: 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-±
|
||||
// cash_movement. All carry a POSITIVE magnitude except legacy, which is signed.
|
||||
const movements = this.#db
|
||||
@@ -370,7 +377,7 @@ export class ShiftService {
|
||||
(r) =>
|
||||
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||||
r.occurredAt >= startedAt &&
|
||||
r.occurredAt <= endedAt,
|
||||
r.occurredAt <= asOf,
|
||||
);
|
||||
let cashAddedMinor = 0;
|
||||
let cashRemovedMinor = 0;
|
||||
@@ -384,14 +391,14 @@ export class ShiftService {
|
||||
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.
|
||||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||
|
||||
const report: Omit<ShiftReport, "printed"> = {
|
||||
return {
|
||||
operator,
|
||||
startedAt,
|
||||
endedAt,
|
||||
endedAt: asOf,
|
||||
cashTotalMinor,
|
||||
cardTotalMinor,
|
||||
currency,
|
||||
@@ -401,6 +408,40 @@ export class ShiftService {
|
||||
cashRemovedMinor,
|
||||
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({
|
||||
type: "shift_z_report",
|
||||
@@ -413,7 +454,7 @@ export class ShiftService {
|
||||
cashTotalMinor,
|
||||
cardTotalMinor,
|
||||
currency: currency ?? undefined,
|
||||
paymentCount: payments.length,
|
||||
paymentCount,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -424,7 +465,7 @@ export class ShiftService {
|
||||
const printed = await this.#printZReport(report);
|
||||
|
||||
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}`,
|
||||
);
|
||||
return { ...report, printed };
|
||||
|
||||
@@ -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ë:",
|
||||
|
||||
+10
-6
@@ -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
|
||||
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).
|
||||
- **Mid-shift report / X-report — REQUESTED 2026-06-20, not yet built.** The operator wants to see,
|
||||
on demand during the shift, the **opening float inherited**, **cash collected so far**, the
|
||||
pay-ins/pay-outs, and the **current expected drawer balance** — without closing. It's the same
|
||||
drawer projection the Z-report computes, just read-only and mid-shift. (The header already shows the
|
||||
live drawer *total*; this is the full breakdown.) Deferred behind the voucher re-model done the same
|
||||
day; build next if wanted.
|
||||
- **Mid-shift report / X-report — BUILT 2026-06-20.** On demand during the shift, the operator sees
|
||||
the **opening float inherited**, **cash/card collected so far**, the **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 into a
|
||||
shared `ShiftService.#summariseWindow(open, asOf)` so X (asOf = now, read-only) and Z (asOf =
|
||||
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
|
||||
(relates to [[open-questions]] #1 lane topology).
|
||||
|
||||
+13
@@ -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
|
||||
operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED,
|
||||
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]].
|
||||
|
||||
Reference in New Issue
Block a user