feat: re-model drawer cash as directional vouchers (Mandat Arkëtimi / Pagese)
Replace the single signed-± cash_movement with two distinct financial documents — the direction is the event TYPE, not the sign of an amount: cash_in = Mandat Arkëtimi (receipt / pay-IN, +) voucher AR-NNNN cash_out = Mandat Pagese (disbursement / pay-OUT, −) voucher PA-NNNN Each carries a positive magnitude, voucher number, reason, the operator who raised it and the admin who authorized it, and prints an Albanian slip. Authorization changes from admin-only to operator-RAISED / admin-AUTHORIZED: any shift:create holder raises the voucher, but POST /api/cash-voucher only commits when authorizedBy is a real admin (shift:cash) re-entering their password (verified server-side). Keeps the float control while letting the operator do the booth paperwork. Legacy cash_movement events are kept — they still verify and still fold into the drawer (signed-±); the append-only chain is never rewritten. The drawer fold and the Z-report window now sum all three types. Verified against a copy of the live DB with the real signing modules: cash_in 3000 + cash_out 5000 → drawer −2000, hash-chain verifies OK. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
|
||||
import { closeShift, fetchShift, openShift, recordCashVoucher, type ShiftReport } from "./api.js";
|
||||
|
||||
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
||||
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
||||
// totals + the DRAWER picture (opening float carried from the prior shift, cash
|
||||
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
|
||||
// taken/added/removed, expected drawer). Operators RAISE a drawer cash voucher
|
||||
// (Mandat Arkëtimi / Mandat Pagese); an admin AUTHORIZES it with their password.
|
||||
// Available to cashier/operator/admin (readonly has no shift).
|
||||
|
||||
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
||||
|
||||
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [startedAt, setStartedAt] = useState<string | null>(null);
|
||||
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
||||
@@ -19,9 +20,11 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// Cash-movement form (admin only).
|
||||
// Drawer-voucher form. Operator raises; an admin authorizes (name + password).
|
||||
const [moveAmount, setMoveAmount] = useState("");
|
||||
const [moveReason, setMoveReason] = useState("");
|
||||
const [authName, setAuthName] = useState("");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
||||
|
||||
function refresh() {
|
||||
@@ -66,18 +69,31 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function move(sign: 1 | -1) {
|
||||
async function voucher(type: "cash_in" | "cash_out") {
|
||||
setMoveMsg(null);
|
||||
const major = Number(moveAmount);
|
||||
if (!Number.isFinite(major) || major <= 0) {
|
||||
setMoveMsg(t("shift.enterPositive"));
|
||||
return;
|
||||
}
|
||||
if (!authName.trim() || !authPassword) {
|
||||
setMoveMsg(t("shift.authRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
|
||||
const r = await recordCashVoucher({
|
||||
type,
|
||||
amountMinor: Math.round(major * 100),
|
||||
reason: moveReason.trim(),
|
||||
authorizedBy: authName.trim(),
|
||||
authorizerPassword: authPassword,
|
||||
});
|
||||
setMoveAmount("");
|
||||
setMoveReason("");
|
||||
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
|
||||
setAuthPassword("");
|
||||
setMoveMsg(
|
||||
t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }),
|
||||
);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setMoveMsg((e as Error).message);
|
||||
@@ -115,11 +131,12 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
|
||||
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
||||
|
||||
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
||||
{isAdmin && (
|
||||
{/* Drawer cash voucher: operator RAISES, an admin AUTHORIZES (name + password).
|
||||
cash_in = Mandat Arkëtimi (pay-IN), cash_out = Mandat Pagese (pay-OUT). */}
|
||||
{canVoucher && (
|
||||
<div className="mt-4 border-t border-term-border pt-3">
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{t("shift.drawerCashAdmin")}
|
||||
{t("shift.drawerVoucher")}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
@@ -135,9 +152,32 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
onChange={(e) => setMoveReason(e.target.value)}
|
||||
placeholder={t("shift.reasonPlaceholder")}
|
||||
/>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => move(1)}>{t("shift.load")}</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
||||
</div>
|
||||
{/* Admin sign-off — the float can only move with an admin's authorization. */}
|
||||
<div className="mt-2 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"
|
||||
/>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => voucher("cash_in")}>
|
||||
{t("shift.mandatArketimi")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => voucher("cash_out")}>
|
||||
{t("shift.mandatPagese")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
||||
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user