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:
@@ -31,6 +31,8 @@ const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
||||
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
||||
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
||||
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" },
|
||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
+18
-7
@@ -627,14 +627,25 @@ export function closeShift(): Promise<ShiftReport> {
|
||||
return apiFetch("/api/shift/close", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
|
||||
export function recordCashMovement(
|
||||
amountMinor: number,
|
||||
reason: string,
|
||||
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||||
return apiFetch("/api/cash-movement", {
|
||||
/** 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: {
|
||||
type: "cash_in" | "cash_out";
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
authorizedBy: string;
|
||||
authorizerPassword: string;
|
||||
}): Promise<{
|
||||
type: "cash_in" | "cash_out";
|
||||
amountMinor: number;
|
||||
voucherNo: string;
|
||||
balanceMinor: number;
|
||||
printed: boolean;
|
||||
}> {
|
||||
return apiFetch("/api/cash-voucher", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ amountMinor, reason }),
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ export const en: Catalog = {
|
||||
evtShiftOpen: "SHIFT+",
|
||||
evtShiftZ: "SHIFT Z",
|
||||
evtCashMovement: "CASH",
|
||||
evtCashIn: "PAY-IN",
|
||||
evtCashOut: "PAY-OUT",
|
||||
evtAnomaly: "ANOMALY",
|
||||
// live-feed event detail line + classification badges (computed from payload)
|
||||
evtNoReason: "no reason recorded",
|
||||
@@ -511,10 +513,18 @@ export const en: Catalog = {
|
||||
drawer: "Drawer:",
|
||||
openingFloatInherited: "(opening float inherited from the prior shift)",
|
||||
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
|
||||
drawerVoucher: "Drawer voucher — operator raises, an admin authorizes",
|
||||
amount: "amount",
|
||||
reasonPlaceholder: "reason (e.g. opening float)",
|
||||
load: "Load +",
|
||||
remove: "Remove −",
|
||||
authName: "admin username",
|
||||
authPassword: "admin password",
|
||||
authRequired: "An admin must authorize: enter their username and password.",
|
||||
mandatArketimi: "Receipt (in) +",
|
||||
mandatPagese: "Disbursement (out) −",
|
||||
voucherHint: "A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.",
|
||||
voucherRecorded: "Voucher {{no}} recorded. Drawer now {{amount}}.",
|
||||
enterPositive: "Enter a positive amount.",
|
||||
drawerNow: "Drawer now {{amount}}.",
|
||||
zReport: "Z-REPORT",
|
||||
|
||||
@@ -148,6 +148,8 @@ export const sq = {
|
||||
evtShiftOpen: "TURN+",
|
||||
evtShiftZ: "TURN Z",
|
||||
evtCashMovement: "ARKË",
|
||||
evtCashIn: "ARKËTIM",
|
||||
evtCashOut: "PAGESË",
|
||||
evtAnomaly: "ANOMALI",
|
||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||
evtNoReason: "pa arsye të regjistruar",
|
||||
@@ -523,10 +525,18 @@ export const sq = {
|
||||
drawer: "Arka:",
|
||||
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||
drawerVoucher: "Mandat arke — operatori e hap, admini e autorizon",
|
||||
amount: "shuma",
|
||||
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
|
||||
load: "Shto +",
|
||||
remove: "Hiq −",
|
||||
authName: "përdoruesi i adminit",
|
||||
authPassword: "fjalëkalimi i adminit",
|
||||
authRequired: "Një admin duhet ta autorizojë: shkruaj përdoruesin dhe fjalëkalimin e tij.",
|
||||
mandatArketimi: "Arkëtim (hyrje) +",
|
||||
mandatPagese: "Pagesë (dalje) −",
|
||||
voucherHint: "Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.",
|
||||
voucherRecorded: "Mandati {{no}} u regjistrua. Arka tani {{amount}}.",
|
||||
enterPositive: "Shkruaj një shumë pozitive.",
|
||||
drawerNow: "Arka tani {{amount}}.",
|
||||
zReport: "RAPORT Z",
|
||||
|
||||
@@ -74,7 +74,9 @@ export function useLiveFeed(): void {
|
||||
if (
|
||||
msg.event.type === "shift_open" ||
|
||||
msg.event.type === "shift_z_report" ||
|
||||
msg.event.type === "cash_movement"
|
||||
msg.event.type === "cash_movement" ||
|
||||
msg.event.type === "cash_in" ||
|
||||
msg.event.type === "cash_out"
|
||||
) {
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
}
|
||||
|
||||
@@ -342,8 +342,9 @@ const shiftRoute = createRoute({
|
||||
path: "/shift",
|
||||
component: function ShiftRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
// "Admin" actions on the shift screen (drawer cash) need shift:cash.
|
||||
return <ShiftControl isAdmin={can(user, "shift:cash")} />;
|
||||
// The drawer-voucher form is operator-RAISED (shift:create); an admin still has
|
||||
// to authorize each voucher with their password server-side.
|
||||
return <ShiftControl canVoucher={can(user, "shift:create")} />;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user