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 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}`,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+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
|
||||
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 <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