a9ccf9e20c
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
531 lines
22 KiB
TypeScript
531 lines
22 KiB
TypeScript
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,
|
||
type TillId,
|
||
} from "./api.js";
|
||
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||
import { shiftKey } from "./lib/use-shift.js";
|
||
import { Panel } from "./ui/Panel.js";
|
||
import { tillOf, 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. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub
|
||
// shows one till at a time — a switch appears when the site has more than one — and
|
||
// every panel below is scoped to it. See wiki/concepts/shift.md "Tills".
|
||
|
||
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 [till, setTill] = useState<TillId>("booth");
|
||
// Which tills exist here (the booth + effective money-taking modules') — from the
|
||
// booth's status read, which every till answer carries.
|
||
const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") });
|
||
const tills = status.data?.tills ?? ["booth"];
|
||
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">
|
||
{/* Till switch — only when there is more than one drawer to look at. */}
|
||
{tills.length > 1 && (
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
{tills.map((x) => (
|
||
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setTill(x)}>
|
||
{t(`till.${x}Long`)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Row 1: the till NOW + the record form. */}
|
||
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
||
<StatePanel till={till} />
|
||
{canCreate && <RecordPanel till={till} 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 till={till} />
|
||
<MovementsPanel till={till} canReview={canReview} onChanged={refresh} />
|
||
<ShiftHistoryPanel till={till} />
|
||
</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({ till }: { till: TillId }) {
|
||
const { t } = useTranslation();
|
||
const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 });
|
||
const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
|
||
const report = useQuery({
|
||
queryKey: ["shift", "xreport", till],
|
||
queryFn: () => fetchShiftReport(till),
|
||
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({ till }: { till: TillId }) {
|
||
const { t } = useTranslation();
|
||
const q = useQuery({
|
||
queryKey: ["drawer", "today"],
|
||
queryFn: () => fetchEvents(1000, startOfToday()),
|
||
refetchInterval: 15_000,
|
||
});
|
||
|
||
// This till's drawer-touching events only (a bay payment is wash-till money; a
|
||
// parking payment is booth money — tillOf() is the one shared rule).
|
||
const rows = (q.data?.events ?? []).filter((e) => {
|
||
if (tillOf(e.payload) !== till) return false;
|
||
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
||
if (e.type !== "payment" && e.type !== "carwash_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" || e.type === "carwash_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" || e.type === "carwash_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({ till, canReview, onChanged }: { till: TillId; 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 : "", till],
|
||
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till),
|
||
});
|
||
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({ till }: { till: TillId }) {
|
||
const { t } = useTranslation();
|
||
const q = useQuery({ queryKey: ["shifts", "drawer-history", till], queryFn: () => fetchShifts({ till }) });
|
||
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({ till, onDone }: { till: TillId; 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(), till }),
|
||
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>
|
||
);
|
||
}
|