diff --git a/apps/server/src/routes/drawer.ts b/apps/server/src/routes/drawer.ts index e420eeb..f59a6e4 100644 --- a/apps/server/src/routes/drawer.ts +++ b/apps/server/src/routes/drawer.ts @@ -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); diff --git a/apps/server/src/routes/routes.test.ts b/apps/server/src/routes/routes.test.ts index 536a815..70f5a53 100644 --- a/apps/server/src/routes/routes.test.ts +++ b/apps/server/src/routes/routes.test.ts @@ -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); + }); +}); diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index 246cfb5..34cb748 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -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}`, diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 310d44c..a7d6737 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -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 ? ( + + {t("shift.opening")} + + ) : ( + t("shift.openNow") + )} )} diff --git a/apps/web/src/DrawerManager.tsx b/apps/web/src/DrawerManager.tsx index 56994dd..4b7ec4d 100644 --- a/apps/web/src/DrawerManager.tsx +++ b/apps/web/src/DrawerManager.tsx @@ -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 ( +
+ {/* Row 1: the till NOW + the record form. */} +
+ + {canCreate && } +
+ + {/* Row 2: today's cash feed · the movement review queue · closed shifts. */} +
+ + + +
+
+ ); +} + +// --- 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 ( + +
+
+
+ {balance.data ? money(balance.data.balanceMinor, cur) : "…"} +
+ {shiftDelta != null && ( +
+ {t("drawer.thisShift")} + + {shiftDelta >= 0 ? "+" : ""} + {money(shiftDelta, cur)} + +
+ )} +
+ {status.data?.open + ? t("drawer.openShift", { operator: status.data.open.operator }) + + " · " + + formatRelativeDateTime(status.data.open.startedAt, t) + : t("drawer.noShiftOpen")} +
+
+ {/* The running breakdown, only while a shift is open (it's the X-report). */} + {x && ( +
+
{t("shifts.openingFloat")}
+
{money(x.openingFloatMinor, cur)}
+
+ {t("shifts.cashTaken")} · {t("shifts.payments")} {x.paymentCount} +
+
{money(x.cashTotalMinor, cur)}
+
{t("shifts.cashAdded")}
+
{money(x.cashAddedMinor, cur)}
+
{t("shifts.cashRemoved")}
+
{money(-x.cashRemovedMinor, cur)}
+
{t("shifts.expectedDrawer")}
+
+ {money(x.expectedDrawerMinor, cur)} +
+
+ )} +
+
+ ); +} + +// --- 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 ( + 0 ? ( + + {t("drawer.todayPayments", { count: payments })} · {money(cashIn, cur)} + {vouchersNet !== 0 && ( + <> + {" "} + · {money(vouchersNet, cur)} + + )} + + ) : null + } + className="min-h-0" + > +
+ {q.isError ? ( +
{(q.error as Error).message}
+ ) : q.isLoading ? ( +
{t("common.loading")}
+ ) : rows.length === 0 ? ( +
{t("drawer.noActivity")}
+ ) : ( + + + {rows.map((e) => ( + + ))} + +
+ )} +
+
+ ); +} + +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 ( + + {time} + + {label} + + + {money(signed, pl.currency ?? null)} + + + ); +} + +// --- 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(""); const q = useQuery({ @@ -47,68 +255,128 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca const pendingCount = movements.filter((m) => m.status === "pending").length; return ( -
- {canCreate && void qc.invalidateQueries({ queryKey: ["drawer"] })} />} - - 0 ? ( - - {t("drawer.pendingCount", { count: pendingCount })} - - ) : null - } - className="min-h-0 flex-1" - > -
- {canReview && ( -
- {(["", "pending", "authorized", "denied"] as const).map((s) => ( - - ))} -
- )} - -
- {q.isLoading ? ( -
{t("common.loading")}
- ) : movements.length === 0 ? ( -
{t("drawer.empty")}
- ) : ( - - - - - - - - {canReview && } - - {canReview && - - - {movements.map((m) => ( - void qc.invalidateQueries({ queryKey: ["drawer"] })} /> - ))} - -
{t("drawer.colWhen")}{t("drawer.colType")}{t("drawer.colAmount")}{t("drawer.colReason")}{t("drawer.colOperator")}{t("drawer.colStatus")}} -
- )} + 0 ? ( + + {t("drawer.pendingCount", { count: pendingCount })} + + ) : null + } + className="min-h-0" + > +
+ {canReview && ( +
+ {(["", "pending", "authorized", "denied"] as const).map((s) => ( + + ))}
+ )} + +
+ {q.isLoading ? ( +
{t("common.loading")}
+ ) : movements.length === 0 ? ( +
{t("drawer.empty")}
+ ) : ( + + + + + + + + {canReview && } + + {canReview && + + + {movements.map((m) => ( + + ))} + +
{t("drawer.colWhen")}{t("drawer.colType")}{t("drawer.colAmount")}{t("drawer.colReason")}{t("drawer.colOperator")}{t("drawer.colStatus")}} +
+ )}
- +
+
+ ); +} + +// --- 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 ( + +
+ {q.isLoading ? ( +
{t("common.loading")}
+ ) : shifts.length === 0 ? ( +
{t("drawer.noShifts")}
+ ) : ( +
+ {shifts.map((s) => ( + + ))} +
+ )} +
+
+ ); +} + +function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: boolean }) { + const { t } = useTranslation(); + const cur = s.currency; + return ( +
+
+ + {showOperator ? `${s.operator} · ` : ""} + {formatRelativeDateTime(s.startedAt, t)} + + + {money(s.expectedDrawerMinor, cur)} + +
+
+ {money(s.openingFloatMinor, cur)} → + + +{money(s.cashTotalMinor, cur)} + + {s.cashAddedMinor > 0 && ( + + +{money(s.cashAddedMinor, cur)} + + )} + {s.cashRemovedMinor > 0 && ( + + −{money(s.cashRemovedMinor, cur)} + + )} +
); } +// --- Record form (unchanged from the pre-redesign feature) ------------------ + function RecordPanel({ onDone }: { onDone: () => void }) { const { t } = useTranslation(); const [amount, setAmount] = useState(""); diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index e66a4a4..875eb37 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -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 && (
- {t("shifts.operator")} + {/* {t("shifts.operator")} */} {/* A select over operators that HAVE shifts — the server filter is an exact username match, so free text could only miss. */}