feat(shift): confirm-before-close with X-report + split tickets vs subscriptions; fix dark <select>
CI / check (push) Failing after 31s
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:
@@ -51,6 +51,10 @@ export interface ShiftSummary {
|
|||||||
readonly cardTotalMinor: number;
|
readonly cardTotalMinor: number;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly paymentCount: number;
|
readonly paymentCount: number;
|
||||||
|
readonly ticketTotalMinor: number;
|
||||||
|
readonly subscriptionTotalMinor: number;
|
||||||
|
readonly subscriptionSalesMinor: number;
|
||||||
|
readonly subscriptionWindowMinor: number;
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
readonly cashAddedMinor: number;
|
readonly cashAddedMinor: number;
|
||||||
readonly cashRemovedMinor: number;
|
readonly cashRemovedMinor: number;
|
||||||
@@ -65,6 +69,15 @@ export interface ShiftReport {
|
|||||||
readonly cardTotalMinor: number;
|
readonly cardTotalMinor: number;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly paymentCount: number;
|
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) ---
|
// --- Drawer (physical cash till; carries across shifts) ---
|
||||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
@@ -160,6 +173,10 @@ export class ShiftService {
|
|||||||
cashTotalMinor?: number;
|
cashTotalMinor?: number;
|
||||||
cardTotalMinor?: number;
|
cardTotalMinor?: number;
|
||||||
paymentCount?: number;
|
paymentCount?: number;
|
||||||
|
ticketTotalMinor?: number;
|
||||||
|
subscriptionTotalMinor?: number;
|
||||||
|
subscriptionSalesMinor?: number;
|
||||||
|
subscriptionWindowMinor?: number;
|
||||||
openingFloatMinor?: number;
|
openingFloatMinor?: number;
|
||||||
cashAddedMinor?: number;
|
cashAddedMinor?: number;
|
||||||
cashRemovedMinor?: number;
|
cashRemovedMinor?: number;
|
||||||
@@ -180,6 +197,16 @@ export class ShiftService {
|
|||||||
cardTotalMinor: pl.cardTotalMinor ?? 0,
|
cardTotalMinor: pl.cardTotalMinor ?? 0,
|
||||||
currency: pl.currency ?? null,
|
currency: pl.currency ?? null,
|
||||||
paymentCount: pl.paymentCount ?? 0,
|
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,
|
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||||
@@ -348,14 +375,29 @@ export class ShiftService {
|
|||||||
|
|
||||||
let cashTotalMinor = 0;
|
let cashTotalMinor = 0;
|
||||||
let cardTotalMinor = 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;
|
let currency: string | null = null;
|
||||||
for (const p of payments) {
|
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;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
if (pl.tender === "card") cardTotalMinor += amt;
|
if (pl.tender === "card") cardTotalMinor += amt;
|
||||||
else cashTotalMinor += 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;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
|
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||||
|
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
|
||||||
|
|
||||||
// --- Drawer figures ---
|
// --- Drawer figures ---
|
||||||
// Opening float was fixed on shift_open (inherited from the chain at start);
|
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||||
@@ -403,6 +445,10 @@ export class ShiftService {
|
|||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency,
|
currency,
|
||||||
paymentCount: payments.length,
|
paymentCount: payments.length,
|
||||||
|
ticketTotalMinor,
|
||||||
|
subscriptionTotalMinor,
|
||||||
|
subscriptionSalesMinor,
|
||||||
|
subscriptionWindowMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -437,6 +483,10 @@ export class ShiftService {
|
|||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency,
|
currency,
|
||||||
paymentCount,
|
paymentCount,
|
||||||
|
ticketTotalMinor,
|
||||||
|
subscriptionTotalMinor,
|
||||||
|
subscriptionSalesMinor,
|
||||||
|
subscriptionWindowMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -455,6 +505,10 @@ export class ShiftService {
|
|||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency: currency ?? undefined,
|
currency: currency ?? undefined,
|
||||||
paymentCount,
|
paymentCount,
|
||||||
|
ticketTotalMinor,
|
||||||
|
subscriptionTotalMinor,
|
||||||
|
subscriptionSalesMinor,
|
||||||
|
subscriptionWindowMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -492,6 +546,12 @@ export class ShiftService {
|
|||||||
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Kartë: ${money(r.cardTotalMinor)} ${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 --",
|
"-- Arka --",
|
||||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
|||||||
cardTotalMinor: x.cardTotalMinor,
|
cardTotalMinor: x.cardTotalMinor,
|
||||||
currency: x.currency,
|
currency: x.currency,
|
||||||
paymentCount: x.paymentCount,
|
paymentCount: x.paymentCount,
|
||||||
|
ticketTotalMinor: x.ticketTotalMinor,
|
||||||
|
subscriptionTotalMinor: x.subscriptionTotalMinor,
|
||||||
|
subscriptionSalesMinor: x.subscriptionSalesMinor,
|
||||||
|
subscriptionWindowMinor: x.subscriptionWindowMinor,
|
||||||
openingFloatMinor: x.openingFloatMinor,
|
openingFloatMinor: x.openingFloatMinor,
|
||||||
cashAddedMinor: x.cashAddedMinor,
|
cashAddedMinor: x.cashAddedMinor,
|
||||||
cashRemovedMinor: x.cashRemovedMinor,
|
cashRemovedMinor: x.cashRemovedMinor,
|
||||||
@@ -320,6 +324,10 @@ function ShiftActivityLog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
|
<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.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, 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="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">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
<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.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
|
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, 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>
|
||||||
</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">
|
<div className="text-[13px] tabular-nums">
|
||||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<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.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
<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="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">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
<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.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
|
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, 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 (
|
return (
|
||||||
<div className="flex justify-between gap-2">
|
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||||
<span className="text-term-muted">{label}</span>
|
<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>
|
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+13
-3
@@ -690,7 +690,17 @@ export interface ShiftStatus {
|
|||||||
drawerMinor: number;
|
drawerMinor: number;
|
||||||
currency: string | null;
|
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;
|
operator: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: 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
|
/** 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
|
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||||||
* snapshot instant. */
|
* snapshot instant. */
|
||||||
export interface XReport {
|
export interface XReport extends ShiftSourceSplit {
|
||||||
operator: string;
|
operator: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string; // = asOf
|
endedAt: string; // = asOf
|
||||||
@@ -762,7 +772,7 @@ export function recordCashVoucher(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A completed shift (reconstructed from its signed Z-report). */
|
/** A completed shift (reconstructed from its signed Z-report). */
|
||||||
export interface ShiftSummary {
|
export interface ShiftSummary extends ShiftSourceSplit {
|
||||||
id: string;
|
id: string;
|
||||||
index: number;
|
index: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
|
|||||||
@@ -164,6 +164,17 @@ body,
|
|||||||
height: 100%;
|
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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--color-term-bg);
|
background: var(--color-term-bg);
|
||||||
@@ -244,6 +255,14 @@ body {
|
|||||||
.textarea:disabled {
|
.textarea:disabled {
|
||||||
@apply cursor-not-allowed opacity-50;
|
@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 */
|
/* Small / dense variant for inline table cells */
|
||||||
.input-sm {
|
.input-sm {
|
||||||
height: var(--control-h-sm);
|
height: var(--control-h-sm);
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const en: Catalog = {
|
|||||||
badgeOverstay: "overstay",
|
badgeOverstay: "overstay",
|
||||||
badgeOverstayTitle:
|
badgeOverstayTitle:
|
||||||
"Paid session. The customer failed to exit during the grace period. A new period began.",
|
"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
|
// filters
|
||||||
filterSearchSessions: "Search ticket / subscriber / plate…",
|
filterSearchSessions: "Search ticket / subscriber / plate…",
|
||||||
filterSearchFeed: "Search event / identity / plate…",
|
filterSearchFeed: "Search event / identity / plate…",
|
||||||
@@ -604,6 +604,10 @@ export const en: Catalog = {
|
|||||||
payments: "Payments:",
|
payments: "Payments:",
|
||||||
cash: "Cash:",
|
cash: "Cash:",
|
||||||
card: "Card:",
|
card: "Card:",
|
||||||
|
srcTickets: "Tickets:",
|
||||||
|
srcSubscriptions: "Subscriptions:",
|
||||||
|
srcSubSales: "sales",
|
||||||
|
srcSubWindow: "out-of-window",
|
||||||
drawerSection: "— Drawer —",
|
drawerSection: "— Drawer —",
|
||||||
openingFloat: "Opening float:",
|
openingFloat: "Opening float:",
|
||||||
cashTaken: "Cash taken:",
|
cashTaken: "Cash taken:",
|
||||||
@@ -637,6 +641,10 @@ export const en: Catalog = {
|
|||||||
payments: "Payments",
|
payments: "Payments",
|
||||||
cash: "Cash",
|
cash: "Cash",
|
||||||
card: "Card",
|
card: "Card",
|
||||||
|
srcTickets: "Tickets",
|
||||||
|
srcSubscriptions: "Subscriptions",
|
||||||
|
srcSubSales: "subs sales",
|
||||||
|
srcSubWindow: "out-of-window",
|
||||||
expectedDrawer: "Expected drawer",
|
expectedDrawer: "Expected drawer",
|
||||||
filterFrom: "From",
|
filterFrom: "From",
|
||||||
filterTo: "To",
|
filterTo: "To",
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const sq = {
|
|||||||
badgeOverstay: "tej afatit",
|
badgeOverstay: "tej afatit",
|
||||||
badgeOverstayTitle:
|
badgeOverstayTitle:
|
||||||
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
|
"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
|
// filtra
|
||||||
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
||||||
filterSearchFeed: "Kërko event / identitet / targë…",
|
filterSearchFeed: "Kërko event / identitet / targë…",
|
||||||
@@ -616,6 +616,10 @@ export const sq = {
|
|||||||
payments: "Pagesa:",
|
payments: "Pagesa:",
|
||||||
cash: "Para:",
|
cash: "Para:",
|
||||||
card: "Kartë:",
|
card: "Kartë:",
|
||||||
|
srcTickets: "Bileta:",
|
||||||
|
srcSubscriptions: "Abonime:",
|
||||||
|
srcSubSales: "shitje",
|
||||||
|
srcSubWindow: "jashtë orarit",
|
||||||
drawerSection: "— Arka —",
|
drawerSection: "— Arka —",
|
||||||
openingFloat: "Bilanci fillestar:",
|
openingFloat: "Bilanci fillestar:",
|
||||||
cashTaken: "Para të marra:",
|
cashTaken: "Para të marra:",
|
||||||
@@ -649,6 +653,10 @@ export const sq = {
|
|||||||
payments: "Pagesa",
|
payments: "Pagesa",
|
||||||
cash: "Para",
|
cash: "Para",
|
||||||
card: "Kartë",
|
card: "Kartë",
|
||||||
|
srcTickets: "Bileta",
|
||||||
|
srcSubscriptions: "Abonime",
|
||||||
|
srcSubSales: "shitje abonimesh",
|
||||||
|
srcSubWindow: "jashtë orarit",
|
||||||
expectedDrawer: "Gjëndje arke",
|
expectedDrawer: "Gjëndje arke",
|
||||||
// Filter (admin only).
|
// Filter (admin only).
|
||||||
filterFrom: "Nga",
|
filterFrom: "Nga",
|
||||||
|
|||||||
+92
-3
@@ -8,10 +8,11 @@ import {
|
|||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 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 { qk, queryClient } from "./lib/query.js";
|
||||||
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme } from "./lib/theme.js";
|
import { applyTheme } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
@@ -199,6 +200,18 @@ function ShiftButton() {
|
|||||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
|
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
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") {
|
async function act(kind: "open" | "close") {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -235,7 +248,7 @@ function ShiftButton() {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy || blockedByOther}
|
disabled={busy || blockedByOther}
|
||||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
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}`}
|
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
|
||||||
>
|
>
|
||||||
{busy ? t("shift.opening") : label}
|
{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>
|
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||||
)}
|
)}
|
||||||
{err && <span className="text-[10px] text-term-red">{err}</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ login ————————————————————————
|
|||||||
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
||||||
no variance gate, no manager override.
|
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
|
> **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`/
|
> (`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`,
|
> `Para në dorë`/`-- Arka --`/`Arka e pritur`…) with the human date format `19 Qershor 2026 10:48:25`,
|
||||||
|
|||||||
+12
@@ -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
|
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
|
changed, price + planId frozen, cross-plan rejected. build+lint 14/14, i18n parity (sq+en). Live DB
|
||||||
untouched. See [[subscription]] "Version correction".
|
untouched. See [[subscription]] "Version correction".
|
||||||
|
|
||||||
|
## [2026-06-21] feat | Shift report split (tickets vs subscriptions) + confirm-before-close + dark <select>
|
||||||
|
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
|
||||||
|
<select> 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".
|
||||||
|
|||||||
Reference in New Issue
Block a user