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}`,