feat(drawer): drawer hub — balance now, this-shift figure, daily activity, shift history; busy spinners
/drawer was record + review only: no current balance, no sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub: - Drawer now: the till's running balance (new GET /api/drawer/balance, shift:read — exposes the service's existing drawerBalance(); the drawer is one site-wide till, same exposure the X-report already had) with the open shift's X-report breakdown alongside (float + takings + vouchers = expected = balance) and a "This shift: ±X" figure (expected − opening float — the shift's own contribution vs what it inherited). - Today's cash activity: every cash payment + voucher since local midnight from the signed chain, live, with day totals (card never enters the till). - Record + movements/review: the 2026-07-01 flow, unchanged. - Closed shifts: drawer-focused history via the scope-aware /api/shifts (float → takings ± vouchers → expected per shift). Also: every shift open/close button (header, /shifts, pay modal, end- shift confirm) now shows an animated spinner + dims while busy — the old label-swap-only feedback read as a dead click when a shift open ran slow. The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain)) is recorded as an open item in wiki/concepts/shift.md with the fix sketch: fold from the last z-report's signed expectedDrawerMinor forward. No new ledger surface — one read-only endpoint; RBAC test added. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -9,6 +9,9 @@ import { InvalidCashMovementError, type MovementStatus, type ShiftService } from
|
|||||||
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
||||||
// only their own; reviewers see all + can filter status.
|
// only their own; reviewers see all + can filter status.
|
||||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
// - 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
|
// 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.
|
// 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" };
|
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.
|
// 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) => {
|
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||||
const b = req.body ?? ({} as ReviewBody);
|
const b = req.body ?? ({} as ReviewBody);
|
||||||
|
|||||||
@@ -101,3 +101,21 @@ describe("CSRF double-submit on mutations", () => {
|
|||||||
expect(put.statusCode).toBe(403);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ export class ShiftService {
|
|||||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||||
"",
|
"",
|
||||||
"-- Arka --",
|
"-- Arka --",
|
||||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
`Fillimi: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useShift } from "./lib/use-shift.js";
|
|||||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.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
|
// 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
|
// 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}
|
disabled={openingShift}
|
||||||
className="btn btn-go btn-sm mt-2"
|
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>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+278
-10
@@ -2,23 +2,39 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
fetchDrawerBalance,
|
||||||
fetchDrawerMovements,
|
fetchDrawerMovements,
|
||||||
|
fetchEvents,
|
||||||
|
fetchShift,
|
||||||
|
fetchShiftReport,
|
||||||
|
fetchShifts,
|
||||||
recordDrawerMovement,
|
recordDrawerMovement,
|
||||||
reviewDrawerMovement,
|
reviewDrawerMovement,
|
||||||
type DrawerMovement,
|
type DrawerMovement,
|
||||||
type MovementStatus,
|
type MovementStatus,
|
||||||
|
type ShiftSummary,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { Panel } from "./ui/Panel.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
|
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
||||||
// disbursements (Mandat Pagese / cash_out) freely; admins REVIEW them after the fact
|
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
||||||
// (authorize/deny — a flag, never a cash reversal). A denial is a judgment about the
|
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
|
||||||
// operator, settled outside the app: the drawer balance is untouched. See
|
// 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.
|
// wiki/concepts/shift.md.
|
||||||
|
|
||||||
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
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 }) {
|
function StatusBadge({ status }: { status: MovementStatus }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cls =
|
const cls =
|
||||||
@@ -37,6 +53,198 @@ function StatusBadge({ status }: { status: MovementStatus }) {
|
|||||||
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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.
|
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
||||||
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
@@ -47,9 +255,6 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3 p-3">
|
|
||||||
{canCreate && <RecordPanel onDone={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />}
|
|
||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||||
right={
|
right={
|
||||||
@@ -59,9 +264,9 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
</span>
|
</span>
|
||||||
) : null
|
) : 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 && (
|
{canReview && (
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||||
@@ -97,7 +302,7 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{movements.map((m) => (
|
{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -105,10 +310,73 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
||||||
|
|
||||||
function RecordPanel({ onDone }: { onDone: () => void }) {
|
function RecordPanel({ onDone }: { onDone: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<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
|
{/* A select over operators that HAVE shifts — the server filter is an
|
||||||
exact username match, so free text could only miss. */}
|
exact username match, so free text could only miss. */}
|
||||||
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
|
<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">
|
<span className="flex items-center gap-2">
|
||||||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
<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>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1118,6 +1118,12 @@ export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
|||||||
return apiFetch(`/api/drawer/movements${qs}`);
|
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). */
|
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||||
export function reviewDrawerMovement(args: {
|
export function reviewDrawerMovement(args: {
|
||||||
refId: string;
|
refId: string;
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ export const en: Catalog = {
|
|||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
},
|
},
|
||||||
drawer: {
|
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",
|
recordTitle: "Record a cash movement",
|
||||||
amount: "amount",
|
amount: "amount",
|
||||||
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
|
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ export const sq = {
|
|||||||
profile: "Profili",
|
profile: "Profili",
|
||||||
},
|
},
|
||||||
drawer: {
|
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",
|
recordTitle: "Regjistro një lëvizje arke",
|
||||||
amount: "shuma",
|
amount: "shuma",
|
||||||
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
|
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.
|
// Anashkalimi i portës së pranisë (radar/kamera me defekt) — admini heq një sinjal si kusht.
|
||||||
presenceGateTitle: "Porta e pranisë në hyrje",
|
presenceGateTitle: "Porta e pranisë në hyrje",
|
||||||
presenceGateHint:
|
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.",
|
"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 / lakun (sensor prania me defekt)",
|
presenceBypassRadar: "Anashkalo radarin (radari me defekt)",
|
||||||
presenceBypassCamera: "Anashkalo kamerën (zbulim automjeti me defekt)",
|
presenceBypassCamera: "Anashkalo kamerën (kamera me defekt)",
|
||||||
presenceBypassActive: "Anashkalimi i pranisë aktiv — porta e hyrjes është dobësuar. Fike sapo pajisja të rregullohet.",
|
presenceBypassActive: "Anashkalimi i pranisë aktiv — siguria e hyrjes është dobësuar.",
|
||||||
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
revealSecret: "Shfaq fjalëkalimin",
|
revealSecret: "Shfaq fjalëkalimin",
|
||||||
hideSecret: "Fshih fjalëkalimin",
|
hideSecret: "Fshih fjalëkalimin",
|
||||||
|
|||||||
+16
-3
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { qk, queryClient } from "./lib/query.js";
|
import { qk, queryClient } from "./lib/query.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
@@ -335,9 +336,15 @@ function ShiftButton() {
|
|||||||
disabled={busy || blockedByOther}
|
disabled={busy || blockedByOther}
|
||||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||||
onClick={onClick}
|
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>
|
</button>
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||||
@@ -409,7 +416,13 @@ function CloseShiftConfirm({
|
|||||||
{t("subs.cancel")}
|
{t("subs.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, shifts, anti-fraud]
|
tags: [parking, domain, business, shifts, anti-fraud]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-01
|
updated: 2026-07-05
|
||||||
status: open
|
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 +
|
+ 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`.
|
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
|
## Where the fraud control actually lives
|
||||||
|
|
||||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
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
|
## 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
|
- **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
|
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),
|
**`cash_in` / `cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type),
|
||||||
|
|||||||
+22
@@ -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
|
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
|
an optional version-name field (never prefilled — republishing a tweak under last season's name
|
||||||
would mislabel history). Details on [[tariff]] (Tariff Lab section).
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user