feat(shift): confirm-before-close with X-report + split tickets vs subscriptions; fix dark <select>
CI / check (push) Failing after 31s

Three changes:

1. Confirm-before-close. The header shift button closed the shift directly — a
   stray click would sign the irreversible Z-report. It now opens a confirm modal
   showing the live X-report (takings split by source + expected drawer) with
   Cancel / End-shift. Opening a shift stays immediate (no such risk).

2. Split takings by SOURCE. The report separates Tickets (transient) from
   Subscriptions (monthly sales + a subscriber's out-of-window charge), so the
   operator sees subscriber money apart from ticket money. Buckets are derived
   from the signed payment payload flags (subscriptionSale /
   subscriptionWindowCharge) and always reconcile to cash + card (a payment with
   neither flag is a ticket). Computed in #summariseWindow, carried on the signed
   shift_z_report payload, and shown in the X-report, the close modal, the shift
   history detail, and the printed Z-report. Reports predating the fields default
   subscription to 0 (ticket absorbs the whole take), so old shifts still
   reconcile.

3. Fix dark-theme native <select> popups rendering WHITE on WebKitGTK (the Tauri
   Linux WebView): set color-scheme dark/light on <html> per theme + explicit
   <option> colours, so the OS-drawn dropdown list follows the theme.

Verified the split on a read-only DB copy: tickets 0, subscriptions 10,200
(10,000 sale + 200 out-of-window), reconciles to cash+card. build+lint 14/14,
i18n parity (sq+en).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-21 14:30:14 +02:00
parent 78d1f6808a
commit eb47016ae3
9 changed files with 260 additions and 13 deletions
+61 -1
View File
@@ -51,6 +51,10 @@ export interface ShiftSummary {
readonly cardTotalMinor: number;
readonly currency: string | null;
readonly paymentCount: number;
readonly ticketTotalMinor: number;
readonly subscriptionTotalMinor: number;
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
readonly openingFloatMinor: number;
readonly cashAddedMinor: number;
readonly cashRemovedMinor: number;
@@ -65,6 +69,15 @@ export interface ShiftReport {
readonly cardTotalMinor: number;
readonly currency: string | null;
readonly paymentCount: number;
// --- Takings split by SOURCE (cash+card combined; the drawer cash/card stay above) ---
/** Transient TICKET money (the default — any payment not flagged subscription). */
readonly ticketTotalMinor: number;
/** All SUBSCRIBER money = monthly sales + out-of-window charges. */
readonly subscriptionTotalMinor: number;
/** Subscription SALES only (the prepaid monthly/period fee). */
readonly subscriptionSalesMinor: number;
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
readonly subscriptionWindowMinor: number;
// --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number;
@@ -160,6 +173,10 @@ export class ShiftService {
cashTotalMinor?: number;
cardTotalMinor?: number;
paymentCount?: number;
ticketTotalMinor?: number;
subscriptionTotalMinor?: number;
subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number;
openingFloatMinor?: number;
cashAddedMinor?: number;
cashRemovedMinor?: number;
@@ -180,6 +197,16 @@ export class ShiftService {
cardTotalMinor: pl.cardTotalMinor ?? 0,
currency: pl.currency ?? null,
paymentCount: pl.paymentCount ?? 0,
// Split-by-source fields (added 2026-06-21). Old reports lack them → default the
// subscription buckets to 0 and let ticket absorb the whole take, so the buckets
// still reconcile to cash+card for a pre-split shift.
subscriptionSalesMinor: pl.subscriptionSalesMinor ?? 0,
subscriptionWindowMinor: pl.subscriptionWindowMinor ?? 0,
subscriptionTotalMinor:
pl.subscriptionTotalMinor ?? (pl.subscriptionSalesMinor ?? 0) + (pl.subscriptionWindowMinor ?? 0),
ticketTotalMinor:
pl.ticketTotalMinor ??
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -348,14 +375,29 @@ export class ShiftService {
let cashTotalMinor = 0;
let cardTotalMinor = 0;
// Split by SOURCE: subscription SALES (the prepaid fee), subscriber OUT-OF-WINDOW
// charges, and everything else = transient TICKET money. Both subscriber kinds roll
// up into subscriptionTotal; the rest is ticketTotal. The flags ride the signed
// payment payload (subscriptionSale / subscriptionWindowCharge — see pay-station +
// the subscription sale path).
let subscriptionSalesMinor = 0;
let subscriptionWindowMinor = 0;
let currency: string | null = null;
for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload;
const pl = (p.payload ?? {}) as LedgerPayload & {
subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean;
};
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (pl.tender === "card") cardTotalMinor += amt;
else cashTotalMinor += amt;
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription)
if (pl.currency) currency = pl.currency;
}
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
// --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start);
@@ -403,6 +445,10 @@ export class ShiftService {
cardTotalMinor,
currency,
paymentCount: payments.length,
ticketTotalMinor,
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -437,6 +483,10 @@ export class ShiftService {
cardTotalMinor,
currency,
paymentCount,
ticketTotalMinor,
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -455,6 +505,10 @@ export class ShiftService {
cardTotalMinor,
currency: currency ?? undefined,
paymentCount,
ticketTotalMinor,
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -492,6 +546,12 @@ export class ShiftService {
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"",
"-- Arkëtime sipas burimit --",
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
"",
"-- Arka --",
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
+32 -4
View File
@@ -90,6 +90,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
cardTotalMinor: x.cardTotalMinor,
currency: x.currency,
paymentCount: x.paymentCount,
ticketTotalMinor: x.ticketTotalMinor,
subscriptionTotalMinor: x.subscriptionTotalMinor,
subscriptionSalesMinor: x.subscriptionSalesMinor,
subscriptionWindowMinor: x.subscriptionWindowMinor,
openingFloatMinor: x.openingFloatMinor,
cashAddedMinor: x.cashAddedMinor,
cashRemovedMinor: x.cashRemovedMinor,
@@ -320,6 +324,10 @@ function ShiftActivityLog({
)}
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<Figure label={t("shifts.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
@@ -374,6 +382,13 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
<span />
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubSales")} value={money(report.subscriptionSalesMinor, report.currency)} sub />
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
@@ -389,10 +404,16 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
</div>
</div>
) : (
// Confirm — show the live takings/drawer before closing.
// Confirm — show the live takings (split by source) + drawer before closing.
<div className="text-[13px] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<Figure label={t("shift.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
@@ -471,6 +492,13 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubSales")} value={money(x.subscriptionSalesMinor, x.currency)} sub />
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
@@ -505,10 +533,10 @@ function ActivityRow({ e }: { e: LedgerEvent }) {
);
}
function Figure({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className="flex justify-between gap-2">
<span className="text-term-muted">{label}</span>
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span>
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
</div>
);
+13 -3
View File
@@ -690,7 +690,17 @@ export interface ShiftStatus {
drawerMinor: number;
currency: string | null;
}
export interface ShiftReport {
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
export interface ShiftSourceSplit {
ticketTotalMinor: number;
subscriptionTotalMinor: number;
subscriptionSalesMinor: number;
subscriptionWindowMinor: number;
}
export interface ShiftReport extends ShiftSourceSplit {
operator: string;
startedAt: string;
endedAt: string;
@@ -719,7 +729,7 @@ export function closeShift(): Promise<ShiftReport> {
/** 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 {
export interface XReport extends ShiftSourceSplit {
operator: string;
startedAt: string;
endedAt: string; // = asOf
@@ -762,7 +772,7 @@ export function recordCashVoucher(args: {
}
/** A completed shift (reconstructed from its signed Z-report). */
export interface ShiftSummary {
export interface ShiftSummary extends ShiftSourceSplit {
id: string;
index: number;
operator: string;
+19
View File
@@ -164,6 +164,17 @@ body,
height: 100%;
}
/* Tell the engine the UI is dark so NATIVE controls — the <select> option popup,
scrollbars, date pickers, form widgets — render dark too. WebKitGTK (the Tauri
Linux WebView) otherwise paints the dropdown list with the OS light palette, so a
dark-theme <select> opened to a WHITE option list. `.theme-light` flips it back. */
html {
color-scheme: dark;
}
html.theme-light {
color-scheme: light;
}
body {
margin: 0;
background: var(--color-term-bg);
@@ -244,6 +255,14 @@ body {
.textarea:disabled {
@apply cursor-not-allowed opacity-50;
}
/* Native <option> popup colours. `color-scheme: dark` (on <html>) handles most
engines, but WebKitGTK (Tauri Linux) needs the option row colours set explicitly
or the open dropdown list stays white-on-light. The light theme re-lightens below. */
.select option,
.select optgroup {
background-color: var(--color-term-panel);
color: var(--color-term-text);
}
/* Small / dense variant for inline table cells */
.input-sm {
height: var(--control-h-sm);
+9 -1
View File
@@ -111,7 +111,7 @@ export const en: Catalog = {
badgeOverstay: "overstay",
badgeOverstayTitle:
"Paid session. The customer failed to exit during the grace period. A new period began.",
plateTitle: "Licence plate recognized by the camera (advisory — not an access decision).",
plateTitle: "Licence plate recognized ANPR.",
// filters
filterSearchSessions: "Search ticket / subscriber / plate…",
filterSearchFeed: "Search event / identity / plate…",
@@ -604,6 +604,10 @@ export const en: Catalog = {
payments: "Payments:",
cash: "Cash:",
card: "Card:",
srcTickets: "Tickets:",
srcSubscriptions: "Subscriptions:",
srcSubSales: "sales",
srcSubWindow: "out-of-window",
drawerSection: "— Drawer —",
openingFloat: "Opening float:",
cashTaken: "Cash taken:",
@@ -637,6 +641,10 @@ export const en: Catalog = {
payments: "Payments",
cash: "Cash",
card: "Card",
srcTickets: "Tickets",
srcSubscriptions: "Subscriptions",
srcSubSales: "subs sales",
srcSubWindow: "out-of-window",
expectedDrawer: "Expected drawer",
filterFrom: "From",
filterTo: "To",
+9 -1
View File
@@ -113,7 +113,7 @@ export const sq = {
badgeOverstay: "tej afatit",
badgeOverstayTitle:
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
plateTitle: "Targa e njohur nga kamera (orientuese — nuk është vendim aksesi).",
plateTitle: "Targa e njohur nga ANPR",
// filtra
filterSearchSessions: "Kërko biletë / abonent / targë…",
filterSearchFeed: "Kërko event / identitet / targë…",
@@ -616,6 +616,10 @@ export const sq = {
payments: "Pagesa:",
cash: "Para:",
card: "Kartë:",
srcTickets: "Bileta:",
srcSubscriptions: "Abonime:",
srcSubSales: "shitje",
srcSubWindow: "jashtë orarit",
drawerSection: "— Arka —",
openingFloat: "Bilanci fillestar:",
cashTaken: "Para të marra:",
@@ -649,6 +653,10 @@ export const sq = {
payments: "Pagesa",
cash: "Para",
card: "Kartë",
srcTickets: "Bileta",
srcSubscriptions: "Abonime",
srcSubSales: "shitje abonimesh",
srcSubWindow: "jashtë orarit",
expectedDrawer: "Gjëndje arke",
// Filter (admin only).
filterFrom: "Nga",
+92 -3
View File
@@ -8,10 +8,11 @@ import {
} from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
import { can, closeShift, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
import { can, closeShift, fetchShiftReport, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
import { qk, queryClient } from "./lib/query.js";
import { Modal } from "./ui/Modal.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
@@ -199,6 +200,18 @@ function ShiftButton() {
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Closing a shift signs the Z-report and is irreversible, so the header button never
// closes directly (a stray click would end the shift) — it opens a confirm modal that
// shows the live X-report first. Opening a shift has no such risk → immediate.
const [confirmingClose, setConfirmingClose] = useState(false);
function onClick() {
if (isMine) {
setConfirmingClose(true);
} else {
void act("open");
}
}
async function act(kind: "open" | "close") {
setBusy(true);
@@ -235,7 +248,7 @@ function ShiftButton() {
type="button"
disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={() => act(isMine ? "close" : "open")}
onClick={onClick}
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
>
{busy ? t("shift.opening") : label}
@@ -244,6 +257,82 @@ function ShiftButton() {
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)}
{err && <span className="text-[10px] text-term-red">{err}</span>}
{confirmingClose && (
<CloseShiftConfirm
busy={busy}
onCancel={() => setConfirmingClose(false)}
onConfirm={async () => {
await act("close");
setConfirmingClose(false);
}}
/>
)}
</div>
);
}
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
* expected drawer before committing the irreversible Z-report. */
function CloseShiftConfirm({
busy,
onCancel,
onConfirm,
}: {
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
const x = q.data;
const cur = x?.currency ?? null;
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
return (
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
<div className="text-[13px] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
) : (
<>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubSales")} value={fmt(x.subscriptionSalesMinor)} sub />
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div>
</>
)}
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
{t("subs.cancel")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
{busy ? t("shift.ending") : t("shift.endShift")}
</button>
</div>
</div>
</Modal>
);
}
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span className={`text-[11px] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}>
{label}
</span>
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
</div>
);
}