diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index bbe20c8..ecf6851 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -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}`, diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index f761862..3b83bd5 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -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({ )}
+
+
+
+
@@ -374,6 +382,13 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
{t("shift.zReport")} — {report.operator}
+ +
+
+
+
+
+
@@ -389,10 +404,16 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
) : ( - // Confirm — show the live takings/drawer before closing. + // Confirm — show the live takings (split by source) + drawer before closing.

{t("shift.endConfirm")}

+
+
+
+
+
+
@@ -471,6 +492,13 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}
+ +
+
+
+
+
+
@@ -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 ( -
- {label} +
+ {label} {value}
); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 4a6fdca..c4ef2b3 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 { /** 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; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 66627ad..745f192 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -164,6 +164,17 @@ body, height: 100%; } +/* Tell the engine the UI is dark so NATIVE controls — the 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
+ ); +} + +/** 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 ( + +
+

{t("shift.endConfirm")}

+ {!x ? ( +

{t("common.loading")}

+ ) : ( + <> +
+ + + {/* Split by source — the operator's ask: subscription money apart from tickets. */} + + + + +
+
+ + + +
+ + )} +
+ + +
+
+
+ ); +} + +function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { + return ( +
+ + {label} + + {value}
); } diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md index 361c6f9..3ee18d8 100644 --- a/wiki/concepts/shift.md +++ b/wiki/concepts/shift.md @@ -80,6 +80,19 @@ login ———————————————————————— That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count, no variance gate, no manager override. +> **Takings split by SOURCE + confirm-before-close (2026-06-21).** Two related changes: +> 1. The report now splits takings into **Tickets** (transient) vs **Subscriptions** (monthly +> `subscriptionSale` + a subscriber's out-of-window `subscriptionWindowCharge`), so the operator +> sees subscriber money apart from ticket money. The buckets are derived from the signed payment +> payload flags and always reconcile to `cash + card` (a payment with neither flag is a ticket). +> Computed once in `#summariseWindow`, carried on the signed `shift_z_report` payload +> (`ticketTotalMinor`/`subscriptionTotalMinor`/`subscriptionSalesMinor`/`subscriptionWindowMinor`), +> shown in the X-report, the close modal, the history detail, and the printed Z-report; old reports +> that predate the fields default subscription to 0 (ticket absorbs the whole take). +> 2. The **header shift button no longer closes directly** — a stray click would sign an irreversible +> Z-report. It opens a **confirm modal showing the live X-report** (the source split + expected +> drawer) with Cancel / End-shift. Opening a shift stays immediate (no such risk). + > **Z-report is now Albanian (2026-06-19).** The printed Z-report labels were hardcoded English > (`Operator:`/`From:`/`Cash:`) with raw ISO timestamps; now fully Albanian (`Operatori`/`Nga`/`Deri`/ > `Para në dorë`/`-- Arka --`/`Arka e pritur`…) with the human date format `19 Qershor 2026 10:48:25`, diff --git a/wiki/log.md b/wiki/log.md index b3fb4bd..078d3f5 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1256,3 +1256,15 @@ Server-logged for audit. UI: admin-only "Versioni" picker in the edit modal, lis effective date + timeframe summary, current pre-selected. Verified on a writable DB copy: version changed, price + planId frozen, cross-plan rejected. build+lint 14/14, i18n parity (sq+en). Live DB untouched. See [[subscription]] "Version correction". + +## [2026-06-21] feat | Shift report split (tickets vs subscriptions) + confirm-before-close + dark popups rendering WHITE on WebKitGTK (Tauri Linux) via color-scheme + explicit option colours. +Verified the split on a read-only DB copy (tickets 0, subs 10,200 = 10,000 sale + 200 out-of-window, +reconciles). build+lint 14/14, i18n parity (sq+en). See [[shift]] "Takings split by source".