Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 42s

This commit is contained in:
2026-07-05 15:56:39 +02:00
13 changed files with 489 additions and 72 deletions
+7
View File
@@ -9,6 +9,9 @@ import { InvalidCashMovementError, type MovementStatus, type ShiftService } from
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
// only their own; reviewers see all + can filter status.
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
// payments + vouchers over the whole chain — the
// amount that carries across shifts).
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
// judgment about the operator settled outside the app, never a cash reversal.
@@ -73,6 +76,10 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
return { movements, scope: canReview ? "all" : "self" };
});
// The physical drawer balance now. Same visibility as the open shift's X-report
// (shift:read) — the drawer is a single site-wide till, not per-operator data.
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
const b = req.body ?? ({} as ReviewBody);
+18
View File
@@ -101,3 +101,21 @@ describe("CSRF double-submit on mutations", () => {
expect(put.statusCode).toBe(403);
});
});
describe("drawer balance (the till NOW)", () => {
it("shift:read gets the balance; a role without it is 403; no auth 401", async () => {
const anon = await app.inject({ method: "GET", url: "/api/drawer/balance" });
expect(anon.statusCode).toBe(401);
const viewer = await seedUser(db, { username: "till", roleId: "till", permissions: ["shift:read"] });
const { cookie } = await login(app, viewer.username, viewer.password);
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
expect(ok.statusCode).toBe(200);
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null });
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
const other = await login(app, outsider.username, outsider.password);
const denied = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie: other.cookie } });
expect(denied.statusCode).toBe(403);
});
});
+1 -1
View File
@@ -694,7 +694,7 @@ export class ShiftService {
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
"",
"-- Arka --",
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
`Fillimi: ${money(r.openingFloatMinor)} ${cur}`,
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
+8 -1
View File
@@ -21,6 +21,7 @@ import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
import { Spinner } from "./ui/Spinner.js";
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
@@ -281,7 +282,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
disabled={openingShift}
className="btn btn-go btn-sm mt-2"
>
{openingShift ? t("shift.opening") : t("shift.openNow")}
{openingShift ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.opening")}
</span>
) : (
t("shift.openNow")
)}
</button>
</>
)}
+278 -10
View File
@@ -2,23 +2,39 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchDrawerBalance,
fetchDrawerMovements,
fetchEvents,
fetchShift,
fetchShiftReport,
fetchShifts,
recordDrawerMovement,
reviewDrawerMovement,
type DrawerMovement,
type MovementStatus,
type ShiftSummary,
} from "./api.js";
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
import type { LedgerEvent } from "@parking/shared";
// Drawer cash movements. Operators RECORD receipts (Mandat Arkëtimi / cash_in) and
// disbursements (Mandat Pagese / cash_out) freely; admins REVIEW them after the fact
// (authorize/deny — a flag, never a cash reversal). A denial is a judgment about the
// operator, settled outside the app: the drawer balance is untouched. See
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
// answers "what's in the till and why": the CURRENT drawer balance with the open
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
// activity (every cash payment and voucher, live), the movement record/review flow
// (unchanged), and the closed-shift drawer history. All figures come from the signed
// chain — the drawer is a single site-wide till that carries across shifts. See
// wiki/concepts/shift.md.
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
/** Local midnight, ISO — the "today" window for the activity feed. */
function startOfToday(): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.toISOString();
}
function StatusBadge({ status }: { status: MovementStatus }) {
const { t } = useTranslation();
const cls =
@@ -37,6 +53,198 @@ function StatusBadge({ status }: { status: MovementStatus }) {
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const refresh = () => {
void qc.invalidateQueries({ queryKey: ["drawer"] });
// A voucher moves the open shift's added/removed figures too (the X-report).
void qc.invalidateQueries({ queryKey: ["shift"] });
};
return (
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
{/* Row 1: the till NOW + the record form. */}
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
<StatePanel />
{canCreate && <RecordPanel onDone={refresh} />}
</div>
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
<TodayPanel />
<MovementsPanel canReview={canReview} onChanged={refresh} />
<ShiftHistoryPanel />
</div>
</div>
);
}
// --- The drawer NOW ---------------------------------------------------------
// Balance from the chain + the open shift's running X-report breakdown, so the big
// number is always explainable: float + cash takings + in − out = expected = balance.
function StatePanel() {
const { t } = useTranslation();
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
const report = useQuery({
queryKey: ["shift", "xreport"],
queryFn: fetchShiftReport,
enabled: status.data?.open != null,
refetchInterval: 10_000,
});
const x = status.data?.open ? report.data : null;
const cur = balance.data?.currency ?? x?.currency ?? null;
// The current SHIFT's own balance: what this shift changed in the till
// (takings + vouchers), i.e. everything above the inherited opening float.
const shiftDelta = x ? x.expectedDrawerMinor - x.openingFloatMinor : null;
return (
<Panel title={t("drawer.stateTitle")}>
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="text-3xl font-bold text-term-cyan tabular-nums">
{balance.data ? money(balance.data.balanceMinor, cur) : "…"}
</div>
{shiftDelta != null && (
<div className="mt-0.5 text-[0.8125rem] tabular-nums">
<span className="text-term-muted">{t("drawer.thisShift")} </span>
<span className={shiftDelta < 0 ? "font-semibold text-term-red" : "font-semibold text-term-green"}>
{shiftDelta >= 0 ? "+" : ""}
{money(shiftDelta, cur)}
</span>
</div>
)}
<div className="mt-0.5 text-[0.6875rem] text-term-muted">
{status.data?.open
? t("drawer.openShift", { operator: status.data.open.operator }) +
" · " +
formatRelativeDateTime(status.data.open.startedAt, t)
: t("drawer.noShiftOpen")}
</div>
</div>
{/* The running breakdown, only while a shift is open (it's the X-report). */}
{x && (
<dl className="grid grid-cols-[max-content_max-content] gap-x-4 gap-y-0.5 text-[0.75rem] tabular-nums">
<dt className="text-term-muted">{t("shifts.openingFloat")}</dt>
<dd className="text-right text-term-text">{money(x.openingFloatMinor, cur)}</dd>
<dt className="text-term-muted">
{t("shifts.cashTaken")} · {t("shifts.payments")} {x.paymentCount}
</dt>
<dd className="text-right text-term-green">{money(x.cashTotalMinor, cur)}</dd>
<dt className="text-term-muted">{t("shifts.cashAdded")}</dt>
<dd className="text-right text-term-text">{money(x.cashAddedMinor, cur)}</dd>
<dt className="text-term-muted">{t("shifts.cashRemoved")}</dt>
<dd className="text-right text-term-red">{money(-x.cashRemovedMinor, cur)}</dd>
<dt className="border-t border-term-border pt-0.5 font-semibold text-term-muted">{t("shifts.expectedDrawer")}</dt>
<dd className="border-t border-term-border pt-0.5 text-right font-semibold text-term-text">
{money(x.expectedDrawerMinor, cur)}
</dd>
</dl>
)}
</div>
</Panel>
);
}
// --- Today's cash activity ---------------------------------------------------
// Every drawer-touching event since local midnight: cash payments (the current
// shift's incomings, live) + vouchers. Card payments never enter the till.
function TodayPanel() {
const { t } = useTranslation();
const q = useQuery({
queryKey: ["drawer", "today"],
queryFn: () => fetchEvents(1000, startOfToday()),
refetchInterval: 15_000,
});
const rows = (q.data?.events ?? []).filter((e) => {
if (e.type === "cash_in" || e.type === "cash_out") return true;
if (e.type !== "payment") return false;
return (e.payload as { tender?: string } | null)?.tender !== "card";
});
let cashIn = 0;
let vouchersNet = 0;
let payments = 0;
let cur: string | null = null;
for (const e of rows) {
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
const amt = pl.amountMinor ?? 0;
if (pl.currency) cur = pl.currency;
if (e.type === "payment") {
cashIn += amt;
payments++;
} else {
vouchersNet += e.type === "cash_in" ? Math.abs(amt) : -Math.abs(amt);
}
}
return (
<Panel
title={t("drawer.todayTitle")}
right={
rows.length > 0 ? (
<span className="text-[0.6875rem] tabular-nums text-term-muted">
{t("drawer.todayPayments", { count: payments })} · <span className="text-term-green">{money(cashIn, cur)}</span>
{vouchersNet !== 0 && (
<>
{" "}
· <span className={vouchersNet < 0 ? "text-term-red" : "text-term-green"}>{money(vouchersNet, cur)}</span>
</>
)}
</span>
) : null
}
className="min-h-0"
>
<div className="h-full min-h-0 overflow-y-auto pr-1">
{q.isError ? (
<div className="text-[0.75rem] text-term-red">{(q.error as Error).message}</div>
) : q.isLoading ? (
<div className="text-term-muted">{t("common.loading")}</div>
) : rows.length === 0 ? (
<div className="text-term-muted">{t("drawer.noActivity")}</div>
) : (
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{rows.map((e) => (
<TodayRow key={e.id} e={e} />
))}
</tbody>
</table>
)}
</div>
</Panel>
);
}
function TodayRow({ e }: { e: LedgerEvent }) {
const { t } = useTranslation();
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string };
const amt = pl.amountMinor ?? 0;
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
const time = new Date(e.occurredAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const label =
e.type === "payment"
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
return (
<tr className="border-t border-term-border/40">
<td className="whitespace-nowrap py-1 pr-2 text-term-muted">{time}</td>
<td className="max-w-0 truncate py-1 pr-2 text-term-text" title={pl.reason || undefined}>
{label}
</td>
<td className={`whitespace-nowrap py-1 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
{money(signed, pl.currency ?? null)}
</td>
</tr>
);
}
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
const { t } = useTranslation();
// Reviewers can filter the list (the pending queue); operators always see their own, all.
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
const q = useQuery({
@@ -47,9 +255,6 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
const pendingCount = movements.filter((m) => m.status === "pending").length;
return (
<div className="flex h-full flex-col gap-3 p-3">
{canCreate && <RecordPanel onDone={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />}
<Panel
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
right={
@@ -59,9 +264,9 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
</span>
) : null
}
className="min-h-0 flex-1"
className="min-h-0"
>
<div className="flex h-full flex-col">
<div className="flex h-full min-h-0 flex-col">
{canReview && (
<div className="mb-2 flex items-center gap-1.5">
{(["", "pending", "authorized", "denied"] as const).map((s) => (
@@ -97,7 +302,7 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
</thead>
<tbody>
{movements.map((m) => (
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={onChanged} />
))}
</tbody>
</table>
@@ -105,10 +310,73 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
</div>
</div>
</Panel>
);
}
// --- Closed shifts, drawer-focused -------------------------------------------
// Scope follows /api/shifts: operators see their own, admins all.
function ShiftHistoryPanel() {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
const shifts = (q.data?.shifts ?? []).slice(0, 50);
const showOperator = q.data?.scope === "all";
return (
<Panel title={t("drawer.historyTitle")} className="min-h-0">
<div className="h-full min-h-0 overflow-y-auto pr-1">
{q.isLoading ? (
<div className="text-term-muted">{t("common.loading")}</div>
) : shifts.length === 0 ? (
<div className="text-term-muted">{t("drawer.noShifts")}</div>
) : (
<div className="flex flex-col gap-1.5">
{shifts.map((s) => (
<ShiftDrawerCard key={s.id} s={s} showOperator={showOperator} />
))}
</div>
)}
</div>
</Panel>
);
}
function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: boolean }) {
const { t } = useTranslation();
const cur = s.currency;
return (
<div className="card p-2.5 text-[0.75rem]">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-term-text">
{showOperator ? `${s.operator} · ` : ""}
{formatRelativeDateTime(s.startedAt, t)}
</span>
<span className="font-semibold text-term-text tabular-nums" title={t("shifts.expectedDrawer")}>
{money(s.expectedDrawerMinor, cur)}
</span>
</div>
<div className="mt-0.5 flex flex-wrap gap-x-3 text-term-muted tabular-nums">
<span title={t("shifts.openingFloat")}>{money(s.openingFloatMinor, cur)} →</span>
<span className="text-term-green" title={t("shifts.cashTaken")}>
+{money(s.cashTotalMinor, cur)}
</span>
{s.cashAddedMinor > 0 && (
<span className="text-term-green" title={t("shifts.cashAdded")}>
+{money(s.cashAddedMinor, cur)}
</span>
)}
{s.cashRemovedMinor > 0 && (
<span className="text-term-red" title={t("shifts.cashRemoved")}>
−{money(s.cashRemovedMinor, cur)}
</span>
)}
</div>
</div>
);
}
// --- Record form (unchanged from the pre-redesign feature) ------------------
function RecordPanel({ onDone }: { onDone: () => void }) {
const { t } = useTranslation();
const [amount, setAmount] = useState("");
+9 -2
View File
@@ -15,6 +15,7 @@ import {
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js";
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
import type { LedgerEvent } from "@parking/shared";
@@ -176,7 +177,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
)}
{isAdmin && (
<div className="field">
<span className="label">{t("shifts.operator")}</span>
{/* <span className="label">{t("shifts.operator")}</span> */}
{/* A select over operators that HAVE shifts — the server filter is an
exact username match, so free text could only miss. */}
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
@@ -244,7 +245,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
<span className="flex items-center gap-2">
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
{busy ? t("shift.starting") : t("shift.startShift")}
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.starting")}
</span>
) : (
t("shift.startShift")
)}
</button>
</span>
);
+6
View File
@@ -1118,6 +1118,12 @@ export function fetchDrawerMovements(status?: MovementStatus): Promise<{
return apiFetch(`/api/drawer/movements${qs}`);
}
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
* the amount that carries across shifts). */
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
return apiFetch("/api/drawer/balance");
}
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
export function reviewDrawerMovement(args: {
refId: string;
+10
View File
@@ -66,6 +66,16 @@ export const en: Catalog = {
profile: "Profile",
},
drawer: {
stateTitle: "Drawer now",
openShift: "Open shift: {{operator}}",
noShiftOpen: "No shift open — the drawer carries the last shift's closing balance.",
thisShift: "This shift:",
todayTitle: "Today's cash activity",
todayPayments: "{{count}} payments",
payment: "Payment",
noActivity: "No cash activity today.",
historyTitle: "Closed shifts",
noShifts: "No closed shifts yet.",
recordTitle: "Record a cash movement",
amount: "amount",
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
+14 -4
View File
@@ -68,6 +68,16 @@ export const sq = {
profile: "Profili",
},
drawer: {
stateTitle: "Arka tani",
openShift: "Turn i hapur: {{operator}}",
noShiftOpen: "Asnjë turn i hapur — arka mban gjendjen e mbylljes së turnit të fundit.",
thisShift: "Ky turn:",
todayTitle: "Aktiviteti i arkës sot",
todayPayments: "{{count}} pagesa",
payment: "Pagesë",
noActivity: "Pa lëvizje arke sot.",
historyTitle: "Turne të mbyllura",
noShifts: "Ende pa turne të mbyllura.",
recordTitle: "Regjistro një lëvizje arke",
amount: "shuma",
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
@@ -499,10 +509,10 @@ export const sq = {
// Anashkalimi i portës së pranisë (radar/kamera me defekt) — admini heq një sinjal si kusht.
presenceGateTitle: "Porta e pranisë në hyrje",
presenceGateHint:
"Butoni i hyrjes normalisht kërkon edhe radarin/lakun edhe një zbulim nga kamera për të konfirmuar një automjet real. Nëse një pajisje ka defekt, anashkaloje që kalimtarët të mund të hyjnë derisa ta rregullojë ekipi i mbështetjes. Çdo ndryshim regjistrohet në ledger, dhe biletat e lëshuara gjatë anashkalimit shënohen.",
presenceBypassRadar: "Anashkalo radarin / lakun (sensor prania me defekt)",
presenceBypassCamera: "Anashkalo kamerën (zbulim automjeti me defekt)",
presenceBypassActive: "Anashkalimi i pranisë aktiv — porta e hyrjes është dobësuar. Fike sapo pajisja të rregullohet.",
"Butoni i hyrjes normalisht kërkon edhe radarin edhe një event nga kamera për të konfirmuar një automjet në hyrje. Nëse një pajisje ka defekt, anashkaloje që kalimtarët të mund të hyjnë derisa ta rregullohet/ndërrohet. Çdo ndryshim regjistrohet në ledger, dhe biletat e lëshuara gjatë anashkalimit shënohen.",
presenceBypassRadar: "Anashkalo radarin (radari me defekt)",
presenceBypassCamera: "Anashkalo kamerën (kamera me defekt)",
presenceBypassActive: "Anashkalimi i pranisë aktiv — siguria e hyrjes është dobësuar.",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Shfaq fjalëkalimin",
hideSecret: "Fshih fjalëkalimin",
+16 -3
View File
@@ -25,6 +25,7 @@ import {
} from "./api.js";
import { qk, queryClient } from "./lib/query.js";
import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
@@ -335,9 +336,15 @@ function ShiftButton() {
disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={onClick}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
>
{busy ? t("shift.opening") : label}
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
</span>
) : (
label
)}
</button>
{!isOpen && (
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
@@ -409,7 +416,13 @@ function CloseShiftConfirm({
{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")}
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.ending")}
</span>
) : (
t("shift.endShift")
)}
</button>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
// Inline busy indicator for buttons whose action can take a moment (opening a
// shift signs an event over the whole chain; printing waits on hardware). A label
// swap alone ("Opening…") proved too subtle on the booth — operators re-clicked or
// assumed the click was lost, so busy buttons pair the text with this spinner.
// Inherits the button's text colour via border-current.
export function Spinner() {
return (
<span
aria-hidden
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent align-[-1px]"
/>
);
}
+37 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-07-01
updated: 2026-07-05
status: open
---
@@ -237,6 +237,35 @@ after**. This removes the friction while keeping accountability.
+ own movements; admin: the review queue + all movements). `/shifts` is now just open/close +
Z-report. Server: `routes/drawer.ts` (lifted out of `routes/shift.ts`); UI: `DrawerManager.tsx`.
## Drawer hub — the page answers "what's in the till and why" (2026-07-05)
Operator feedback: `/drawer` was **too simple** — record + review only, with no current balance, no
sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub of five
panels, all reads over data that already existed:
- **Drawer now** — the running balance (new `GET /api/drawer/balance`, `shift:read`; a passthrough to
the service's existing `drawerBalance()`, which was never exposed). While a shift is open, the
X-report breakdown sits beside it so the number is always explainable: *opening float + cash takings
(with payment count — the "current shift incomings") + vouchers in − out = expected = balance*, and
a **"This shift: ±X"** figure under the balance shows the shift's OWN contribution (expected −
opening float), separating what this operator moved from what they inherited. With no shift open it
reads as the carried-forward closing balance.
- **Today's cash activity** — every drawer-touching signed event since local midnight (cash payments +
vouchers; card never enters the till), live (15s), with day totals. Source: the existing
`/api/events` window query, filtered client-side (`event:read`).
- **Record** + **movements/review** — the 2026-07-01 flow, unchanged.
- **Closed shifts** — drawer-focused history via the existing scope-aware `/api/shifts`
(operators: own; admins: all): float → takings ± vouchers, expected drawer per shift.
Visibility note: the balance endpoint is `shift:read` on purpose — the drawer is a **single site-wide
till**, the same exposure the open shift's X-report already had, not per-operator data.
**Busy feedback on shift buttons (2026-07-05).** Opening a shift can take seconds (see the open item
below), and the buttons' only feedback was a label swap ("Opening…") — subtle enough that operators
read a slow open as a dead click. Every shift open/close button (header, /shifts, the pay-modal's
"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
(`ui/Spinner.tsx`, reusable) and dims while disabled.
## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
@@ -253,6 +282,13 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
## Open
- **Drawer/shift reads fold the WHOLE chain — O(chain) growth (flagged 2026-07-05).**
`#drawerBalanceAt`, `currentOpenShift`, and `listShifts` select every ledger event and fold in JS.
Fine pre-opening; after months of operation this is a linearly growing pause on every shift open,
X-report, and drawer-balance read (the observed "opening a shift is slow"). Clean fix when it
bites: fold **from the last `shift_z_report` forward** — its `expectedDrawerMinor` is already the
signed balance snapshot at that point — instead of from genesis. Not built; the UI got busy
spinners in the meantime.
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20; review reworked
2026-07-01):** opening float auto-inherits the prior shift's expected drawer; drawer movements are the
**`cash_in` / `cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type),
+22
View File
@@ -2320,3 +2320,25 @@ Follow-up to the lab redesign (same session): the sidebar now also lists the PUB
the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew
an optional version-name field (never prefilled — republishing a tweak under last season's name
would mislabel history). Details on [[tariff]] (Tariff Lab section).
## [2026-07-05] update | Drawer hub: balance now, live daily activity, shift history
Operator: "/drawer is too simple — no daily activity, current shift incomings not reflected, closed
shifts history missing, drawer current state not shown." Rebuilt DrawerManager as a five-panel hub
(details on [[shift]] §Drawer hub): Drawer-now panel (new GET /api/drawer/balance exposing the
service's existing drawerBalance(); open-shift X-report breakdown alongside so float + takings +
vouchers = expected = balance is explicit), today's cash feed (existing /api/events since local
midnight, cash-only, live), the unchanged record/review flow, and a drawer-focused closed-shifts
list (existing scope-aware /api/shifts). No new ledger surface — one read-only endpoint, everything
else composes what the chain already records. RBAC test added (shift:read 200 / other 403 / anon 401).
## [2026-07-05] update | Drawer "this shift" figure + spinners on shift buttons; O(chain) flagged
Follow-ups to the drawer hub (same session): the Drawer-now panel gained "This shift: ±X"
(expected − opening float — the shift's own contribution vs what it inherited), and every shift
open/close button got an animated spinner + dim while busy (ui/Spinner.tsx) — the old label-swap
read as a dead click on a slow open. Root cause of the slowness recorded as an open item on
[[shift]]: drawer/shift reads fold the WHOLE chain (O(chain) — grows forever); fix when it bites =
fold from the last z-report's signed expectedDrawerMinor forward. Also from this session: dev-DB
migrations are MANUAL (pnpm db:migrate in packages/db) — a 500 "no such column" after pulling a
migration means it was skipped; booth containers migrate on boot and are immune.