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:
2026-07-01 11:17:20 +02:00
parent 018328a877
commit 114a32e6f2
18 changed files with 879 additions and 206 deletions
+240
View File
@@ -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 -61
View File
@@ -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
View File
@@ -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). */
+42 -6
View File
@@ -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: {
+42 -6
View File
@@ -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
View File
@@ -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,
+22
View File
@@ -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>
)}