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:
2026-06-20 16:18:26 +02:00
parent a20400c2c5
commit 2835f78635
13 changed files with 335 additions and 78 deletions
+47 -11
View File
@@ -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 });
+1 -1
View File
@@ -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.
+115 -28
View File
@@ -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();
+2
View File
@@ -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" },
};
+52 -12
View File
@@ -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
View File
@@ -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),
});
}
+10
View File
@@ -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",
+10
View File
@@ -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",
+3 -1
View File
@@ -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 });
}
+3 -2
View File
@@ -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")} />;
},
});