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,3 +1,5 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import {
|
||||
@@ -7,11 +9,18 @@ import {
|
||||
type ShiftService,
|
||||
} from "../shift-service.js";
|
||||
|
||||
interface CashMovementBody {
|
||||
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
|
||||
interface CashVoucherBody {
|
||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||
* cash_out = Mandat Pagese (pay-OUT). */
|
||||
type: "cash_in" | "cash_out";
|
||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
|
||||
authorizedBy: string;
|
||||
/** That admin's password — re-entered to sign off on the drawer movement. */
|
||||
authorizerPassword: string;
|
||||
}
|
||||
|
||||
interface ShiftsQuery {
|
||||
@@ -26,7 +35,7 @@ interface ShiftsQuery {
|
||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||
|
||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
|
||||
// Reading the shift state vs. opening/closing one's own shift.
|
||||
const readGuard = requirePermission("shift:read");
|
||||
const guard = requirePermission("shift:create");
|
||||
@@ -68,16 +77,43 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||
});
|
||||
|
||||
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
|
||||
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
|
||||
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
|
||||
app.post<{ Body: CashMovementBody }>(
|
||||
"/api/cash-movement",
|
||||
{ preHandler: requirePermission("shift:cash") },
|
||||
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
||||
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
|
||||
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
|
||||
// (`shift:cash`) who re-enters their password. This keeps the float control —
|
||||
// an operator cannot move the float alone — while letting them raise the slip.
|
||||
// See wiki/concepts/shift.md.
|
||||
app.post<{ Body: CashVoucherBody }>(
|
||||
"/api/cash-voucher",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
|
||||
const b = req.body ?? ({} as CashVoucherBody);
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
const authName = (b.authorizedBy ?? "").trim();
|
||||
if (!authName || !b.authorizerPassword) {
|
||||
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
|
||||
}
|
||||
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
|
||||
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
|
||||
// Always run a bcrypt compare (constant-time wrt whether the user exists).
|
||||
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
|
||||
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
|
||||
if (!authUser || !passwordOk || !isAdminGrade) {
|
||||
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
|
||||
}
|
||||
try {
|
||||
return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
operator: req.user.username, // who RAISED it
|
||||
authorizedBy: authUser.username, // who signed off (canonical case)
|
||||
amountMinor: b.amountMinor,
|
||||
reason: b.reason ?? "",
|
||||
currency: b.currency,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
|
||||
@@ -204,7 +204,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
await shiftRoutes(app, shiftService, db);
|
||||
|
||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||
|
||||
@@ -199,9 +199,14 @@ export class ShiftService {
|
||||
|
||||
/**
|
||||
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
|
||||
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
|
||||
* payments add to the drawer; card payments never touch it; cash_movement amounts
|
||||
* (signed: + load, − removal) adjust it. This is what carries across shifts.
|
||||
* by operator — a drawer voucher is the admin's, not the shift operator's). Cash
|
||||
* payments add to the drawer; card payments never touch it. Drawer movements adjust
|
||||
* it via three event types kept side-by-side:
|
||||
* - `cash_in` (Mandat Arkëtimi): + amountMinor (positive magnitude)
|
||||
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
|
||||
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
|
||||
* removal) — historical chain events that still fold in unchanged.
|
||||
* This is what carries across shifts.
|
||||
*/
|
||||
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
||||
const rows = this.#db
|
||||
@@ -209,7 +214,14 @@ export class ShiftService {
|
||||
.from(ledgerEvents)
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all()
|
||||
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
|
||||
.filter(
|
||||
(r) =>
|
||||
r.occurredAt <= at &&
|
||||
(r.type === "payment" ||
|
||||
r.type === "cash_in" ||
|
||||
r.type === "cash_out" ||
|
||||
r.type === "cash_movement"),
|
||||
);
|
||||
let balanceMinor = 0;
|
||||
let currency: string | null = null;
|
||||
for (const r of rows) {
|
||||
@@ -218,8 +230,12 @@ export class ShiftService {
|
||||
if (r.type === "payment") {
|
||||
// Only CASH enters the till; card settles to the bank.
|
||||
if (pl.tender !== "card") balanceMinor += amt;
|
||||
} else if (r.type === "cash_in") {
|
||||
balanceMinor += Math.abs(amt); // receipt — direction is the type
|
||||
} else if (r.type === "cash_out") {
|
||||
balanceMinor -= Math.abs(amt); // disbursement — direction is the type
|
||||
} else {
|
||||
// cash_movement amount is signed (+ load, − removal).
|
||||
// legacy cash_movement amount is signed (+ load, − removal).
|
||||
balanceMinor += amt;
|
||||
}
|
||||
if (pl.currency) currency = pl.currency;
|
||||
@@ -227,38 +243,61 @@ export class ShiftService {
|
||||
return { balanceMinor, currency };
|
||||
}
|
||||
|
||||
/** Next voucher number for a drawer-voucher type, e.g. `AR-0007` (cash_in) /
|
||||
* `PA-0007` (cash_out). Sequential per type = count of existing events + 1. The
|
||||
* number is human-facing (printed on the slip); the signed chain is the real
|
||||
* record, so a small race only risks a duplicate label, never a lost voucher. */
|
||||
#nextVoucherNo(type: "cash_in" | "cash_out"): string {
|
||||
const prefix = type === "cash_in" ? "AR" : "PA";
|
||||
const count = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.type, type)).all().length;
|
||||
return `${prefix}-${String(count + 1).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
|
||||
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
|
||||
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
|
||||
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
|
||||
* an amount (a receipt and a disbursement are different financial documents):
|
||||
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
||||
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
||||
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
|
||||
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
|
||||
* route). Returns the new drawer balance + the assigned voucher number, and prints
|
||||
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
|
||||
*/
|
||||
async recordCashMovement(
|
||||
operator: string,
|
||||
amountMinor: number,
|
||||
reason: string,
|
||||
currency?: string,
|
||||
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||||
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
|
||||
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
|
||||
async recordVoucher(args: {
|
||||
type: "cash_in" | "cash_out";
|
||||
operator: string;
|
||||
authorizedBy: string;
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
currency?: string;
|
||||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||
const { type, operator, authorizedBy, reason } = args;
|
||||
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||
}
|
||||
const amountMinor = args.amountMinor;
|
||||
const now = new Date().toISOString();
|
||||
const voucherNo = this.#nextVoucherNo(type);
|
||||
await this.#log.append({
|
||||
type: "cash_movement",
|
||||
type,
|
||||
source: "manual",
|
||||
identity: operator, // who moved the cash (admin)
|
||||
identity: operator, // who RAISED the voucher (the operator at the booth)
|
||||
payload: {
|
||||
amountMinor,
|
||||
amountMinor, // positive magnitude — direction is the type
|
||||
...(reason ? { reason } : {}),
|
||||
...(currency ? { currency } : {}),
|
||||
...(args.currency ? { currency: args.currency } : {}),
|
||||
operator,
|
||||
authorizedBy,
|
||||
voucherNo,
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
const { balanceMinor } = this.#drawerBalanceAt(now);
|
||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
|
||||
this.#logger.info(
|
||||
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
);
|
||||
return { amountMinor, balanceMinor };
|
||||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||||
}
|
||||
|
||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||
@@ -320,20 +359,28 @@ export class ShiftService {
|
||||
? openPl.openingFloatMinor
|
||||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||||
|
||||
// Cash movements within the shift window, split into added (+) and removed (−).
|
||||
// Drawer movements within the shift window, split into added (+) and removed (−).
|
||||
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
|
||||
// cash_movement. All carry a POSITIVE magnitude except legacy, which is signed.
|
||||
const movements = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "cash_movement"))
|
||||
.all()
|
||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||||
.filter(
|
||||
(r) =>
|
||||
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||||
r.occurredAt >= startedAt &&
|
||||
r.occurredAt <= endedAt,
|
||||
);
|
||||
let cashAddedMinor = 0;
|
||||
let cashRemovedMinor = 0;
|
||||
for (const m of movements) {
|
||||
const pl = (m.payload ?? {}) as LedgerPayload;
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
if (amt >= 0) cashAddedMinor += amt;
|
||||
else cashRemovedMinor += -amt; // store as a positive magnitude
|
||||
if (m.type === "cash_in") cashAddedMinor += Math.abs(amt);
|
||||
else if (m.type === "cash_out") cashRemovedMinor += Math.abs(amt);
|
||||
else if (amt >= 0) cashAddedMinor += amt; // legacy + load
|
||||
else cashRemovedMinor += -amt; // legacy − removal, store as positive magnitude
|
||||
if (pl.currency) currency = pl.currency;
|
||||
}
|
||||
|
||||
@@ -420,6 +467,46 @@ export class ShiftService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
|
||||
* the signed event is the record; a failed print doesn't undo the voucher.
|
||||
* Albanian, like every customer/operator-facing slip (see i18n.md). */
|
||||
async #printVoucher(v: {
|
||||
type: "cash_in" | "cash_out";
|
||||
voucherNo: string;
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
operator: string;
|
||||
authorizedBy: string;
|
||||
currency: string | null;
|
||||
at: string;
|
||||
}): Promise<boolean> {
|
||||
const printer = await this.#boothPrinter();
|
||||
if (!printer) {
|
||||
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
const cur = v.currency ?? "";
|
||||
const money = (m: number) => (m / 100).toFixed(2);
|
||||
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
||||
const lines = [
|
||||
`Mandat Nr.: ${v.voucherNo}`,
|
||||
`Data: ${zStamp(v.at)}`,
|
||||
"",
|
||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||
`Arsyeja: ${v.reason || "-"}`,
|
||||
"",
|
||||
`Hapur nga: ${v.operator}`,
|
||||
`Autorizoi: ${v.authorizedBy}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title, lines });
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** First enabled booth-receipt printer, or any enabled printer. */
|
||||
async #boothPrinter(): Promise<PrinterDevice | null> {
|
||||
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||
|
||||
@@ -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