Files
parking_solution/apps/web/src/DrawerManager.tsx
T
julian 5e1a885dcb feat(web): one date standard across the UI — "25 Qer 14:30"
Dates were a mix: catalog-formatted "25 Qershor 20:01" where screens used
formatRelativeDateTime, and browser-locale "7/6/2026, 9:34 AM" in ~20
places that called raw toLocaleString/-Date-/-Time-String. Unified:

- common.monthsShort in both catalogs (Jan/Shk/…/Qer/Korr/…/Dhj);
  formatDate ("25 Qer", year only when not current), formatDateTime
  ("25 Qer 14:30", optional seconds), formatClock ("HH:mm", 24h) in
  lib/format.ts. formatRelativeDateTime keeps Sot/Dje and switches its
  older-dates branch to the same short months.
- Every raw toLocale* DATE call swept: shifts X-report line, plan
  effective dates, sub version labels, drawer today feed, snapshot
  tooltips, device footer checkedAt, event-detail timestamp (keeps
  seconds — chain evidence), tariff composer active-since + version
  sidebar. Number toLocaleString (thousand separators) untouched.

The catalogs in this commit also carry the keys for the two follow-up
commits (fee breakdown, composer increment labels).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00

509 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
import type { LedgerEvent } from "@parking/shared";
// 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 =
status === "authorized"
? "border-term-green/60 text-term-green"
: status === "denied"
? "border-term-red/60 text-term-red"
: "border-term-amber/60 text-term-amber";
return (
<span className={`rounded border px-1 text-[0.625rem] uppercase tracking-wider ${cls}`}>
{t(`drawer.status.${status}`)}
</span>
);
}
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 = formatClock(e.occurredAt);
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({
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
});
const movements = q.data?.movements ?? [];
const pendingCount = movements.filter((m) => m.status === "pending").length;
return (
<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>
</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("");
const [reason, setReason] = useState("");
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const record = useMutation({
mutationFn: (type: "cash_in" | "cash_out") =>
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
onSuccess: (r) => {
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
setAmount("");
setReason("");
onDone();
},
onError: (e) => setMsg({ ok: false, text: (e as Error).message }),
});
function submit(type: "cash_in" | "cash_out") {
setMsg(null);
const major = Number(amount);
if (!Number.isFinite(major) || major <= 0) {
setMsg({ ok: false, text: t("drawer.enterPositive") });
return;
}
record.mutate(type);
}
return (
<Panel title={t("drawer.recordTitle")}>
<div className="flex flex-col gap-2 text-[0.8125rem]">
<div className="flex flex-wrap items-center gap-2">
<input
className="input w-28"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={t("drawer.amount")}
inputMode="decimal"
/>
<input
className="input min-w-40 flex-1"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t("drawer.reasonPlaceholder")}
/>
</div>
<div className="text-[0.6875rem] text-term-muted">{t("drawer.recordHint")}</div>
{msg && (
<div className={`text-[0.75rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
)}
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-go btn-sm" disabled={record.isPending} onClick={() => submit("cash_in")}>
{t("drawer.mandatArketimi")}
</button>
<button type="button" className="btn btn-danger btn-sm" disabled={record.isPending} onClick={() => submit("cash_out")}>
{t("drawer.mandatPagese")}
</button>
</div>
</div>
</Panel>
);
}
function MovementRow({ m, canReview, onReviewed }: { m: DrawerMovement; canReview: boolean; onReviewed: () => void }) {
const { t } = useTranslation();
const [note, setNote] = useState("");
const [noteOpen, setNoteOpen] = useState(false);
const review = useMutation({
mutationFn: (decision: "authorize" | "deny") =>
reviewDrawerMovement({ refId: m.id, decision, note: note.trim() || undefined }),
onSuccess: onReviewed,
});
// Direction sign for display: cash_in is +, cash_out is −.
const signed = m.type === "cash_in" ? m.amountMinor : -m.amountMinor;
return (
<tr className="border-t border-term-border/50 align-top">
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">{formatRelativeDateTime(m.at, t)}</td>
<td className="px-2 py-1.5">
<span className={m.type === "cash_in" ? "text-term-green" : "text-term-red"}>
{m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}
</span>
{m.voucherNo && <span className="ml-1 text-[0.625rem] text-term-muted">{m.voucherNo}</span>}
</td>
<td className={`whitespace-nowrap px-2 py-1.5 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
{money(signed, m.currency)}
</td>
<td className="px-2 py-1.5 text-term-text">{m.reason || "—"}</td>
{canReview && <td className="px-2 py-1.5 text-term-muted">{m.operator}</td>}
<td className="px-2 py-1.5">
<StatusBadge status={m.status} />
{m.status !== "pending" && m.reviewedBy && (
<div className="mt-0.5 text-[0.5625rem] text-term-muted">
{m.reviewedBy}
{m.reviewNote ? ` · ${m.reviewNote}` : ""}
</div>
)}
</td>
{canReview && (
<td className="px-2 py-1.5 text-right">
{m.status === "pending" ? (
<div className="flex flex-col items-end gap-1">
<div className="flex gap-1">
<button type="button" className="btn btn-go btn-sm" disabled={review.isPending} onClick={() => review.mutate("authorize")}>
{t("drawer.authorize")}
</button>
<button
type="button"
className="btn btn-danger btn-sm"
disabled={review.isPending}
onClick={() => (noteOpen ? review.mutate("deny") : setNoteOpen(true))}
>
{t("drawer.deny")}
</button>
</div>
{noteOpen && (
<input
className="input w-44 text-[0.6875rem]"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("drawer.denyNotePlaceholder")}
/>
)}
{review.isError && <span className="text-[0.625rem] text-term-red">{(review.error as Error).message}</span>}
</div>
) : null}
</td>
)}
</tr>
);
}