feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.
- New `drawer` resource: drawer:create (operator records; admin-revocable per
role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
touches the drawer balance (the correction is settled outside the app). This
is what keeps a late review from leaking into the next operator's inherited
drawer — a denial that lands after the reviewed shift closed moves no cash.
Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
(operator: record + own; admin: review queue + all). routes/drawer.ts lifted
from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
for its other job = admin-sees-all-shifts). New DrawerManager.tsx.
Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
on-site), matching the card-tender gate.
shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchDrawerMovements,
|
||||
recordDrawerMovement,
|
||||
reviewDrawerMovement,
|
||||
type DrawerMovement,
|
||||
type MovementStatus,
|
||||
} from "./api.js";
|
||||
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
|
||||
// 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
|
||||
// wiki/concepts/shift.md.
|
||||
|
||||
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
||||
|
||||
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();
|
||||
// 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 (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
fetchShiftReport,
|
||||
fetchShifts,
|
||||
openShift,
|
||||
recordCashVoucher,
|
||||
type ShiftReport,
|
||||
type ShiftSummary,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
@@ -88,7 +88,7 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
||||
};
|
||||
}
|
||||
|
||||
export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { user: SessionUser | null; canManage?: boolean; canVoucher?: boolean }) {
|
||||
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [preset, setPreset] = useState<Preset>("week");
|
||||
const [operator, setOperator] = useState("");
|
||||
@@ -198,7 +198,6 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
isMine={isMine}
|
||||
showOperator={isAdmin}
|
||||
canManage={canManage}
|
||||
canVoucher={canVoucher}
|
||||
onChanged={refreshAll}
|
||||
/>
|
||||
) : (
|
||||
@@ -257,7 +256,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
||||
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
||||
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
||||
<span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>
|
||||
{CARD_PAYMENTS_ENABLED && <span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>}
|
||||
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -270,7 +269,6 @@ function ShiftActivityLog({
|
||||
isMine,
|
||||
showOperator,
|
||||
canManage,
|
||||
canVoucher,
|
||||
onChanged,
|
||||
}: {
|
||||
shift: ShiftSummary;
|
||||
@@ -278,11 +276,10 @@ function ShiftActivityLog({
|
||||
isMine: boolean;
|
||||
showOperator: boolean;
|
||||
canManage: boolean;
|
||||
canVoucher: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
||||
const [modal, setModal] = useState<null | "end" | "takings">(null);
|
||||
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
||||
// (full signed payload + snapshots + chain provenance).
|
||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||
@@ -311,7 +308,6 @@ function ShiftActivityLog({
|
||||
{isCurrent && isMine && canManage && (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
||||
{canVoucher && <button type="button" className="btn btn-sm" onClick={() => setModal("voucher")}>{t("shift.drawerVoucher")}</button>}
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
||||
</span>
|
||||
)}
|
||||
@@ -325,7 +321,7 @@ function ShiftActivityLog({
|
||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
||||
<Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +336,6 @@ function ShiftActivityLog({
|
||||
|
||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
@@ -385,7 +380,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
||||
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />}
|
||||
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
||||
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
||||
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
||||
@@ -411,7 +406,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
||||
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||
<span />
|
||||
@@ -430,54 +425,6 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
);
|
||||
}
|
||||
|
||||
function VoucherModal({ currency, onClose, onDone }: { currency: string | null; onClose: () => void; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [amount, setAmount] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [authName, setAuthName] = useState("");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
async function submit(type: "cash_in" | "cash_out") {
|
||||
setMsg(null);
|
||||
const major = Number(amount);
|
||||
if (!Number.isFinite(major) || major <= 0) return setMsg(t("shift.enterPositive"));
|
||||
if (!authName.trim() || !authPassword) return setMsg(t("shift.authRequired"));
|
||||
try {
|
||||
const r = await recordCashVoucher({ type, amountMinor: Math.round(major * 100), reason: reason.trim(), authorizedBy: authName.trim(), authorizerPassword: authPassword });
|
||||
setMsg(t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }));
|
||||
setAmount("");
|
||||
setReason("");
|
||||
setAuthPassword("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
||||
<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("shift.amount")} inputMode="decimal" />
|
||||
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
|
||||
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
|
||||
</div>
|
||||
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
|
||||
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => submit("cash_out")}>{t("shift.mandatPagese")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
||||
@@ -500,7 +447,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
||||
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />}
|
||||
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
||||
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
||||
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
||||
|
||||
+49
-10
@@ -1019,15 +1019,38 @@ export async function fetchShiftReport(): Promise<XReport | null> {
|
||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||
}
|
||||
|
||||
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
||||
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
||||
export function recordCashVoucher(args: {
|
||||
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||||
// Redesigned 2026-07-01: an operator RECORDS a receipt/disbursement freely; an admin
|
||||
// REVIEWS it after the fact (authorize/deny — a flag, never a cash reversal). See
|
||||
// wiki/concepts/shift.md.
|
||||
|
||||
export type MovementStatus = "pending" | "authorized" | "denied";
|
||||
|
||||
/** A drawer movement with its admin-review status. */
|
||||
export interface DrawerMovement {
|
||||
id: string;
|
||||
type: "cash_in" | "cash_out";
|
||||
/** Positive magnitude; direction is the type. */
|
||||
amountMinor: number;
|
||||
currency: string | null;
|
||||
reason: string | null;
|
||||
operator: string;
|
||||
voucherNo: string | null;
|
||||
at: string;
|
||||
status: MovementStatus;
|
||||
reviewedBy: string | null;
|
||||
reviewNote: string | null;
|
||||
reviewedAt: string | null;
|
||||
}
|
||||
|
||||
/** Operator RECORDS a drawer movement — cash_in (Mandat Arkëtimi / pay-IN) or cash_out
|
||||
* (Mandat Pagese / pay-OUT). Direction is the TYPE; amountMinor a positive magnitude.
|
||||
* No admin sign-off at creation — it's reviewed afterward. */
|
||||
export function recordDrawerMovement(args: {
|
||||
type: "cash_in" | "cash_out";
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
authorizedBy: string;
|
||||
authorizerPassword: string;
|
||||
currency?: string;
|
||||
}): Promise<{
|
||||
type: "cash_in" | "cash_out";
|
||||
amountMinor: number;
|
||||
@@ -1035,10 +1058,26 @@ export function recordCashVoucher(args: {
|
||||
balanceMinor: number;
|
||||
printed: boolean;
|
||||
}> {
|
||||
return apiFetch("/api/cash-voucher", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
return apiFetch("/api/drawer/movement", { method: "POST", body: JSON.stringify(args) });
|
||||
}
|
||||
|
||||
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
||||
* and may filter by status (the pending review queue). */
|
||||
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
||||
movements: DrawerMovement[];
|
||||
scope: "all" | "self";
|
||||
}> {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
return apiFetch(`/api/drawer/movements${qs}`);
|
||||
}
|
||||
|
||||
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||
export function reviewDrawerMovement(args: {
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
note?: string;
|
||||
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||||
return apiFetch("/api/drawer/review", { method: "POST", body: JSON.stringify(args) });
|
||||
}
|
||||
|
||||
/** A completed shift (reconstructed from its signed Z-report). */
|
||||
|
||||
@@ -58,12 +58,42 @@ export const en: Catalog = {
|
||||
users: "Users",
|
||||
roles: "Roles",
|
||||
shifts: "Shifts",
|
||||
drawer: "Drawer",
|
||||
reports: "Reports",
|
||||
recycleBin: "Recycle bin",
|
||||
logs: "Logs",
|
||||
backup: "Backup",
|
||||
profile: "Profile",
|
||||
},
|
||||
drawer: {
|
||||
recordTitle: "Record a cash movement",
|
||||
amount: "amount",
|
||||
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
|
||||
recordHint: "Recorded to the drawer immediately. An admin reviews it afterward.",
|
||||
mandatArketimi: "Receipt (in) +",
|
||||
mandatPagese: "Disbursement (out) −",
|
||||
enterPositive: "Enter a positive amount.",
|
||||
recorded: "{{no}} recorded. Drawer now {{amount}}.",
|
||||
myTitle: "My cash movements",
|
||||
allTitle: "Cash movements",
|
||||
pendingCount: "{{count}} pending",
|
||||
filterAll: "All",
|
||||
empty: "No cash movements yet.",
|
||||
colWhen: "When",
|
||||
colType: "Type",
|
||||
colAmount: "Amount",
|
||||
colReason: "Reason",
|
||||
colOperator: "Operator",
|
||||
colStatus: "Status",
|
||||
status: {
|
||||
pending: "pending",
|
||||
authorized: "authorized",
|
||||
denied: "denied",
|
||||
},
|
||||
authorize: "Authorize",
|
||||
deny: "Deny",
|
||||
denyNotePlaceholder: "reason for denial (optional)",
|
||||
},
|
||||
profile: {
|
||||
title: "My profile",
|
||||
accountSection: "Account",
|
||||
@@ -183,6 +213,8 @@ export const en: Catalog = {
|
||||
evtCashMovement: "CASH",
|
||||
evtCashIn: "PAY-IN",
|
||||
evtCashOut: "PAY-OUT",
|
||||
evtCashReview: "REVIEW",
|
||||
decision: { authorize: "authorized", deny: "denied" },
|
||||
evtAnomaly: "ANOMALY",
|
||||
evtRefused: "REFUSED",
|
||||
// live-feed event detail line + classification badges (computed from payload)
|
||||
@@ -224,6 +256,10 @@ export const en: Catalog = {
|
||||
edPlate: "Plate",
|
||||
edCategory: "Category",
|
||||
edOperator: "Operator",
|
||||
edDecision: "Review decision",
|
||||
edReviewedBy: "Reviewed by",
|
||||
edReviewNote: "Note",
|
||||
edReviewRef: "Movement ref",
|
||||
edTariffVersion: "Tariff version",
|
||||
edRawPayload: "Raw signed payload",
|
||||
edOccurrence: "Occurrence id",
|
||||
@@ -709,9 +745,9 @@ export const en: Catalog = {
|
||||
srcSubWindow: "out-of-window",
|
||||
drawerSection: "— Drawer —",
|
||||
openingFloat: "Opening cash:",
|
||||
cashTaken: "Cash taken:",
|
||||
cashAdded: "Cash added:",
|
||||
cashRemoved: "Cash removed:",
|
||||
cashTaken: "Daily takings:",
|
||||
cashAdded: "Receipts:",
|
||||
cashRemoved: "Disbursements:",
|
||||
expectedDrawer: "Expected drawer:",
|
||||
printedToReceipt: "Printed to booth receipt.",
|
||||
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||
@@ -760,9 +796,9 @@ export const en: Catalog = {
|
||||
current: "current",
|
||||
drawerSection: "Drawer",
|
||||
openingFloat: "Opening cash",
|
||||
cashTaken: "Cash taken",
|
||||
cashAdded: "Cash added",
|
||||
cashRemoved: "Cash removed",
|
||||
cashTaken: "Daily takings",
|
||||
cashAdded: "Receipts",
|
||||
cashRemoved: "Disbursements",
|
||||
loadFailed: "Failed to load shifts.",
|
||||
},
|
||||
reports: {
|
||||
|
||||
@@ -60,12 +60,42 @@ export const sq = {
|
||||
users: "Përdoruesit",
|
||||
roles: "Rolet",
|
||||
shifts: "Turnet",
|
||||
drawer: "Arka",
|
||||
reports: "Raportet",
|
||||
recycleBin: "Koshi",
|
||||
logs: "Loget",
|
||||
backup: "Kopje rezervë",
|
||||
profile: "Profili",
|
||||
},
|
||||
drawer: {
|
||||
recordTitle: "Regjistro një lëvizje arke",
|
||||
amount: "shuma",
|
||||
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
|
||||
recordHint: "Regjistrohet menjëherë në arkë. Një admin e shqyrton më pas.",
|
||||
mandatArketimi: "Arkëtim (hyrje) +",
|
||||
mandatPagese: "Pagesë (dalje) −",
|
||||
enterPositive: "Fut një shumë pozitive.",
|
||||
recorded: "{{no}} u regjistrua. Arka tani {{amount}}.",
|
||||
myTitle: "Lëvizjet e mia të arkës",
|
||||
allTitle: "Lëvizjet e arkës",
|
||||
pendingCount: "{{count}} në pritje",
|
||||
filterAll: "Të gjitha",
|
||||
empty: "Asnjë lëvizje arke ende.",
|
||||
colWhen: "Kur",
|
||||
colType: "Lloji",
|
||||
colAmount: "Shuma",
|
||||
colReason: "Arsyeja",
|
||||
colOperator: "Operatori",
|
||||
colStatus: "Statusi",
|
||||
status: {
|
||||
pending: "në pritje",
|
||||
authorized: "autorizuar",
|
||||
denied: "refuzuar",
|
||||
},
|
||||
authorize: "Autorizo",
|
||||
deny: "Refuzo",
|
||||
denyNotePlaceholder: "arsyeja e refuzimit (opsionale)",
|
||||
},
|
||||
profile: {
|
||||
title: "Profili im",
|
||||
accountSection: "Llogaria",
|
||||
@@ -187,6 +217,8 @@ export const sq = {
|
||||
evtCashMovement: "ARKË",
|
||||
evtCashIn: "ARKËTIM",
|
||||
evtCashOut: "PAGESË",
|
||||
evtCashReview: "SHQYRTIM",
|
||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||
evtAnomaly: "ANOMALI",
|
||||
evtRefused: "REFUZUAR",
|
||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||
@@ -228,6 +260,10 @@ export const sq = {
|
||||
edPlate: "Targa",
|
||||
edCategory: "Kategoria",
|
||||
edOperator: "Operatori",
|
||||
edDecision: "Vendimi i shqyrtimit",
|
||||
edReviewedBy: "Shqyrtuar nga",
|
||||
edReviewNote: "Shënim",
|
||||
edReviewRef: "Ref. lëvizjes",
|
||||
edTariffVersion: "Versioni i tarifës",
|
||||
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
|
||||
edOccurrence: "ID e hyrjes",
|
||||
@@ -722,9 +758,9 @@ export const sq = {
|
||||
srcSubWindow: "jashtë orarit",
|
||||
drawerSection: "— Arka —",
|
||||
openingFloat: "Arka fillestare:",
|
||||
cashTaken: "Para të marra:",
|
||||
cashAdded: "Para të shtuara:",
|
||||
cashRemoved: "Para të hequra:",
|
||||
cashTaken: "Xhiro ditore:",
|
||||
cashAdded: "Arkëtime:",
|
||||
cashRemoved: "Pagesa:",
|
||||
expectedDrawer: "Gjëndje Arke:",
|
||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||
@@ -775,9 +811,9 @@ export const sq = {
|
||||
// Expanded drawer detail.
|
||||
drawerSection: "Arka",
|
||||
openingFloat: "Arka fillestare",
|
||||
cashTaken: "Para të marra",
|
||||
cashAdded: "Para të shtuara",
|
||||
cashRemoved: "Para të hequra",
|
||||
cashTaken: "Xhiro ditore",
|
||||
cashAdded: "Arkëtime",
|
||||
cashRemoved: "Pagesa",
|
||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
||||
},
|
||||
reports: {
|
||||
|
||||
+32
-8
@@ -41,6 +41,8 @@ import { SiteSettings } from "./SiteSettings.js";
|
||||
import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||
import { DrawerManager } from "./DrawerManager.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
import { BackupSettings } from "./BackupSettings.js";
|
||||
import { RecycleBin } from "./RecycleBin.js";
|
||||
@@ -379,7 +381,7 @@ function CloseShiftConfirm({
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
|
||||
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||
<span />
|
||||
@@ -430,6 +432,11 @@ function RootLayout() {
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
||||
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||
user can do either. See wiki/concepts/shift.md. */}
|
||||
{(show("drawer:create") || show("drawer:review")) && (
|
||||
<NavLink to="/drawer" label={t("nav.drawer")} />
|
||||
)}
|
||||
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
||||
Shown if the user can reach ANY of its tabs. */}
|
||||
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
||||
@@ -548,14 +555,30 @@ const shiftRoute = createRoute({
|
||||
component: function ShiftRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
||||
// The CURRENT shift's pane carries the actions (open/close, drawer voucher, takings),
|
||||
// each opening a modal. `canManage` = shift:create (start/end + raise vouchers); a
|
||||
// voucher additionally needs an admin's password sign-off server-side.
|
||||
// The CURRENT shift's pane carries the actions (open/close, takings), each opening a
|
||||
// modal. `canManage` = shift:create (start/end). Drawer cash movements moved to /drawer
|
||||
// (2026-07-01).
|
||||
return <ShiftsHistory user={user} canManage={can(user, "shift:create")} />;
|
||||
},
|
||||
});
|
||||
|
||||
const drawerRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/drawer",
|
||||
// Reachable by anyone who can record OR review; the component shows the right view per
|
||||
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
||||
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
||||
throw redirect({ to: "/booth" });
|
||||
}
|
||||
},
|
||||
component: function DrawerRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return (
|
||||
<ShiftsHistory
|
||||
user={user}
|
||||
canManage={can(user, "shift:create")}
|
||||
canVoucher={can(user, "shift:create")}
|
||||
<DrawerManager
|
||||
canCreate={can(user, "drawer:create")}
|
||||
canReview={can(user, "drawer:review")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -723,6 +746,7 @@ const routeTree = rootRoute.addChildren([
|
||||
...legacyRedirects,
|
||||
profileRoute,
|
||||
shiftRoute,
|
||||
drawerRoute,
|
||||
reportsRoute,
|
||||
subscriptionsRoute.addChildren([
|
||||
subscriptionsIndexRoute,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
||||
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||
};
|
||||
|
||||
@@ -187,6 +188,12 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
const category = typeof p?.category === "string" ? p.category : null;
|
||||
const operator = typeof p?.operator === "string" ? p.operator : null;
|
||||
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
||||
// cash_review fields: the admin's decision on a drawer movement (+ who / note / the
|
||||
// reviewed movement id). A flag only — it never moves cash. See wiki/concepts/shift.md.
|
||||
const decision = p?.decision === "authorize" || p?.decision === "deny" ? p.decision : null;
|
||||
const reviewedBy = typeof p?.reviewedBy === "string" ? p.reviewedBy : null;
|
||||
const reviewNote = typeof p?.note === "string" ? p.note : null;
|
||||
const refId = typeof p?.refId === "string" ? p.refId : null;
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||
@@ -244,6 +251,21 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||
{/* cash_review: the admin's decision + who + why (for a denial). */}
|
||||
{decision && (
|
||||
<DetailRow label={t("booth.edDecision")}>
|
||||
<span className={decision === "authorize" ? "text-term-green" : "text-term-red"}>
|
||||
{t(`booth.decision.${decision}`)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{reviewedBy && <DetailRow label={t("booth.edReviewedBy")}>{reviewedBy}</DetailRow>}
|
||||
{reviewNote && <DetailRow label={t("booth.edReviewNote")}>{reviewNote}</DetailRow>}
|
||||
{refId && (
|
||||
<DetailRow label={t("booth.edReviewRef")}>
|
||||
<code className="text-[0.6875rem] text-term-muted">{refId}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{sessionRef && sessionRef !== e.identity && (
|
||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user