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 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 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 popup colours. `color-scheme: dark` (on ) 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);
diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts
index d35222d..199e75c 100644
--- a/apps/web/src/lib/i18n/en.ts
+++ b/apps/web/src/lib/i18n/en.ts
@@ -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",
diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts
index 2cc9a63..6a078d9 100644
--- a/apps/web/src/lib/i18n/sq.ts
+++ b/apps/web/src/lib/i18n/sq.ts
@@ -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",
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index b3ead45..79bfd66 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -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(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() {
{t("shift.headerNoShift")}
)}
{err && {err} }
+ {confirmingClose && (
+ setConfirmingClose(false)}
+ onConfirm={async () => {
+ await act("close");
+ setConfirmingClose(false);
+ }}
+ />
+ )}
+
+ );
+}
+
+/** 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. */}
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ {t("subs.cancel")}
+
+
+ {busy ? t("shift.ending") : t("shift.endShift")}
+
+
+
+
+ );
+}
+
+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
+Three UI/report changes. (1) The [[shift]] report now splits takings by SOURCE — Tickets (transient)
+vs Subscriptions (monthly sales + a subscriber's out-of-window charge), derived from the signed
+payment payload flags (subscriptionSale / subscriptionWindowCharge), always reconciling to cash+card.
+Carried on the signed shift_z_report payload + shown in X-report, close modal, history detail, and the
+printed Z-report; pre-split reports default subscription to 0. (2) The header shift button no longer
+closes directly — it opens a confirm modal showing the live X-report (the split + expected drawer)
+before signing the irreversible Z-report. Opening stays immediate. (3) Fixed dark-theme native
+ 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".