From cb68cbafdb399322385dd4debc5095971bb93040 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 20 Jun 2026 16:22:55 +0200 Subject: [PATCH] feat: mid-shift X-report (read-only takings-so-far) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/routes/shift.ts | 10 +++++ apps/server/src/shift-service.ts | 69 +++++++++++++++++++++++++------- apps/web/src/ShiftControl.tsx | 48 +++++++++++++++++++++- apps/web/src/api.ts | 23 +++++++++++ apps/web/src/lib/i18n/en.ts | 4 ++ apps/web/src/lib/i18n/sq.ts | 4 ++ wiki/concepts/shift.md | 16 +++++--- wiki/log.md | 13 ++++++ 8 files changed, 166 insertions(+), 21 deletions(-) diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index dd9b3f1..1e6f5fc 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -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 diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index 5b7dde3..bbe20c8 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -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 { - 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 { + 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 = { + 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 & { 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 { + 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 }; diff --git a/apps/web/src/ShiftControl.tsx b/apps/web/src/ShiftControl.tsx index 6624619..f527149 100644 --- a/apps/web/src/ShiftControl.tsx +++ b/apps/web/src/ShiftControl.tsx @@ -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(null); const [busy, setBusy] = useState(false); const [report, setReport] = useState(null); + const [xReport, setXReport] = useState(null); const [err, setErr] = useState(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 }) { <> {t("shift.open")} {t("shift.since")} {new Date(startedAt).toLocaleString()} + @@ -182,6 +206,28 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) { )} + {/* Mid-shift X-report — read-only "takings so far" (no event appended). */} + {xReport && ( +
+
{t("shift.xReport")} — {xReport.operator}
+
+ {t("shift.asOf")} {new Date(xReport.asOf).toLocaleString()} +
+
{t("shift.payments")} {xReport.paymentCount}
+
{t("shift.cash")} {money(xReport.cashTotalMinor, xReport.currency)}
+
{t("shift.card")} {money(xReport.cardTotalMinor, xReport.currency)}
+
{t("shift.drawerSection")}
+
{t("shift.openingFloat")} {money(xReport.openingFloatMinor, xReport.currency)}
+
{t("shift.cashTaken")} {money(xReport.cashTotalMinor, xReport.currency)}
+
{t("shift.cashAdded")} {money(xReport.cashAddedMinor, xReport.currency)}
+
{t("shift.cashRemoved")} {money(xReport.cashRemovedMinor, xReport.currency)}
+
+ {t("shift.expectedDrawer")} {money(xReport.expectedDrawerMinor, xReport.currency)} +
+
{t("shift.xReportHint")}
+
+ )} + {report && (
{t("shift.zReport")} — {report.operator}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index dfa6e3c..15fa070 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -627,6 +627,29 @@ export function closeShift(): Promise { 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 { + return (await apiFetch("/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). */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index d431092..87b4c4a 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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:", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index c2cade1..b9e99d0 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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ë:", diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md index d1ed1a3..361c6f9 100644 --- a/wiki/concepts/shift.md +++ b/wiki/concepts/shift.md @@ -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). diff --git a/wiki/log.md b/wiki/log.md index 7bcab11..3b1b30c 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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]].