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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
+328
-60
@@ -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,68 +255,128 @@ 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={
|
||||
canReview && pendingCount > 0 ? (
|
||||
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{t("drawer.pendingCount", { count: pendingCount })}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{canReview && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||
<button
|
||||
key={s || "all"}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : movements.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.empty")}</div>
|
||||
) : (
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
|
||||
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
|
||||
{canReview && <th className="px-2 py-1.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m) => (
|
||||
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Panel
|
||||
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||
right={
|
||||
canReview && pendingCount > 0 ? (
|
||||
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{t("drawer.pendingCount", { count: pendingCount })}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
className="min-h-0"
|
||||
>
|
||||
<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) => (
|
||||
<button
|
||||
key={s || "all"}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : movements.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.empty")}</div>
|
||||
) : (
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
|
||||
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
|
||||
{canReview && <th className="px-2 py-1.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m) => (
|
||||
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={onChanged} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</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("");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user