diff --git a/apps/server/src/routes/drawer.ts b/apps/server/src/routes/drawer.ts new file mode 100644 index 0000000..e420eeb --- /dev/null +++ b/apps/server/src/routes/drawer.ts @@ -0,0 +1,94 @@ +import type { FastifyInstance } from "fastify"; +import { requirePermission, roleHasPermissions } from "../auth.js"; +import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js"; + +// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a +// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after +// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md. +// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create) +// - GET /api/drawer/movements: list with review status. Operators see (shift:read) +// only their own; reviewers see all + can filter status. +// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review) +// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a +// judgment about the operator settled outside the app, never a cash reversal. + +interface MovementBody { + /** 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; +} + +interface ReviewBody { + /** The cash_in/cash_out event id being decided on. */ + refId: string; + decision: "authorize" | "deny"; + /** Optional admin note (e.g. why denied). */ + note?: string; +} + +interface MovementsQuery { + /** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */ + status?: MovementStatus; +} + +export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise { + const createGuard = requirePermission("drawer:create"); + const reviewGuard = requirePermission("drawer:review"); + const readGuard = requirePermission("shift:read"); + + // Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once. + app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => { + const b = req.body ?? ({} as MovementBody); + if (b.type !== "cash_in" && b.type !== "cash_out") { + return reply.code(400).send({ error: "type must be cash_in or cash_out" }); + } + try { + return await shift.recordVoucher({ + type: b.type, + operator: req.user.username, + 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 }); + } + }); + + // List movements + review status. Operators are hard-scoped to their OWN movements; a + // reviewer sees ALL and may filter by status (the pending review queue). + app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => { + const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]); + const q = req.query ?? {}; + const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined; + const movements = shift.movementsWithStatus({ + operator: canReview ? undefined : req.user.username, + status, + }); + return { movements, scope: canReview ? "all" : "self" }; + }); + + // Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal. + app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => { + const b = req.body ?? ({} as ReviewBody); + if (!b.refId || (b.decision !== "authorize" && b.decision !== "deny")) { + return reply.code(400).send({ error: "refId and decision (authorize|deny) are required" }); + } + try { + return await shift.reviewMovement({ + refId: b.refId, + decision: b.decision, + reviewedBy: req.user.username, + note: b.note, + }); + } catch (err) { + if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message }); + return reply.code(500).send({ error: (err as Error).message }); + } + }); +} diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index 1e6f5fc..9b0c428 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -1,27 +1,6 @@ -import bcrypt from "bcrypt"; -import { eq, users, type Db } from "@parking/db"; import type { FastifyInstance } from "fastify"; import { requirePermission, roleHasPermissions } from "../auth.js"; -import { - InvalidCashMovementError, - NoOpenShiftError, - ShiftAlreadyOpenError, - type ShiftService, -} from "../shift-service.js"; - -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; -} +import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js"; interface ShiftsQuery { /** Filter to one operator (admin-only; non-admins are forced to themselves). */ @@ -35,7 +14,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, db: Db): Promise { +export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise { // Reading the shift state vs. opening/closing one's own shift. const readGuard = requirePermission("shift:read"); const guard = requirePermission("shift:create"); @@ -87,49 +66,8 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: return { shifts, scope: canSeeAll ? "all" : "self" }; }); - // 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 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.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 }); - } - }, - ); + // NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the + // feature is no longer part of the shift route. See wiki/concepts/shift.md. app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => { try { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 385ddbb..48c934f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -42,6 +42,7 @@ import { subscriptionRoutes } from "./routes/subscriptions.js"; import { subscriptionPlanRoutes } from "./routes/subscription-plans.js"; import { qrReaderRoutes } from "./routes/qr-reader.js"; import { shiftRoutes } from "./routes/shift.js"; +import { drawerRoutes } from "./routes/drawer.js"; import { siteRoutes } from "./routes/site.js"; import { snapshotRoutes } from "./routes/snapshots.js"; import { tariffRoutes } from "./routes/tariffs.js"; @@ -265,8 +266,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise { expect(next.openingFloatMinor).toBe(25000); // inherited }); - it("cash_in / cash_out vouchers adjust the drawer", async () => { + it("cash_in / cash_out movements adjust the drawer", async () => { await shift.open("alice"); - await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" }); - await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: 30000, reason: "bank drop" }); + await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float load" }); + await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 30000, reason: "bank drop" }); const r = shift.currentReport()!; expect(r.cashAddedMinor).toBe(100000); expect(r.cashRemovedMinor).toBe(30000); expect(r.expectedDrawerMinor).toBe(70000); }); - it("rejects a non-positive voucher amount", async () => { + it("rejects a non-positive movement amount", async () => { await shift.open("alice"); await expect( - shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }), + shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 0, reason: "x" }), ).rejects.toBeInstanceOf(InvalidCashMovementError); await expect( - shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }), + shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: -5, reason: "x" }), ).rejects.toBeInstanceOf(InvalidCashMovementError); }); }); +describe("drawer review (operator records, admin reviews after)", () => { + it("a new movement starts pending; review sets authorized/denied", async () => { + await shift.open("alice"); + const m = await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 5000, reason: "supplies" }); + // Find the movement's ledger id via the status list. + let list = shift.movementsWithStatus({ operator: "alice" }); + expect(list).toHaveLength(1); + expect(list[0].status).toBe("pending"); + expect(list[0].voucherNo).toBe(m.voucherNo); + + await shift.reviewMovement({ refId: list[0].id, decision: "deny", reviewedBy: "admin", note: "not genuine" }); + list = shift.movementsWithStatus({ operator: "alice" }); + expect(list[0].status).toBe("denied"); + expect(list[0].reviewedBy).toBe("admin"); + expect(list[0].reviewNote).toBe("not genuine"); + }); + + it("DENY is a flag only — it does NOT reverse the movement or touch the drawer", async () => { + await shift.open("alice"); + await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 10000, reason: "x" }); + const before = shift.drawerBalance().balanceMinor; + expect(before).toBe(-10000); // the disbursement counted immediately + const id = shift.movementsWithStatus({ operator: "alice" })[0].id; + await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }); + // Balance UNCHANGED by the denial — the correction is settled outside the app. + expect(shift.drawerBalance().balanceMinor).toBe(-10000); + }); + + it("a denied movement in a CLOSED shift never leaks into the next operator's drawer", async () => { + // The regression that motivated the redesign: op1 disburses, shift closes, op2 + // inherits; op1's disbursement is later DENIED. op2's drawer must be untouched. + await shift.open("op1"); + await shift.recordVoucher({ type: "cash_out", operator: "op1", amountMinor: 10000, reason: "questionable" }); + const closed = await shift.close("op1"); + expect(closed.expectedDrawerMinor).toBe(-10000); + + const next = await shift.open("op2"); + expect(next.openingFloatMinor).toBe(-10000); // op2 inherits the real till balance + + const id = shift.movementsWithStatus({ operator: "op1" })[0].id; + await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }); + + // op2's drawer is STILL -10000 — the denial added no reversing cash. + expect(shift.drawerBalance().balanceMinor).toBe(-10000); + expect(shift.currentReport()!.openingFloatMinor).toBe(-10000); + }); + + it("rejects reviewing a non-movement or an already-reviewed movement", async () => { + await shift.open("alice"); + await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 5000, reason: "x" }); + const id = shift.movementsWithStatus({ operator: "alice" })[0].id; + await expect( + shift.reviewMovement({ refId: "not-a-real-id", decision: "authorize", reviewedBy: "admin" }), + ).rejects.toBeInstanceOf(InvalidCashMovementError); + await shift.reviewMovement({ refId: id, decision: "authorize", reviewedBy: "admin" }); + await expect( + shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }), + ).rejects.toBeInstanceOf(InvalidCashMovementError); // already reviewed + }); + + it("scopes movements by operator", async () => { + await shift.open("alice"); + await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 1000, reason: "a" }); + await shift.close("alice"); + await shift.open("bob"); + await shift.recordVoucher({ type: "cash_out", operator: "bob", amountMinor: 2000, reason: "b" }); + expect(shift.movementsWithStatus({ operator: "alice" })).toHaveLength(1); + expect(shift.movementsWithStatus({ operator: "bob" })).toHaveLength(1); + expect(shift.movementsWithStatus()).toHaveLength(2); // reviewer sees all + expect(shift.movementsWithStatus({ status: "pending" })).toHaveLength(2); + }); +}); + describe("close signs a Z-report; listShifts reads it back", () => { it("a closed shift appears in history with its split figures", async () => { await shift.open("alice"); diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index 83df760..dde17f3 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -90,6 +90,27 @@ export interface ShiftReport { readonly printed: boolean; } +/** A drawer movement's admin-review status, derived from its latest `cash_review`. */ +export type MovementStatus = "pending" | "authorized" | "denied"; + +/** One drawer cash movement (cash_in/cash_out) with its review status — the row shape for + * the operator's own list and the admin review queue. `status` is derived, not stored. */ +export interface DrawerMovement { + readonly id: string; + readonly type: "cash_in" | "cash_out"; + /** Positive magnitude; direction is the `type`. */ + readonly amountMinor: number; + readonly currency: string | null; + readonly reason: string | null; + readonly operator: string; + readonly voucherNo: string | null; + readonly at: string; + readonly status: MovementStatus; + readonly reviewedBy: string | null; + readonly reviewNote: string | null; + readonly reviewedAt: string | null; +} + export class InvalidCashMovementError extends Error { constructor(msg: string) { super(msg); @@ -281,24 +302,24 @@ export class ShiftService { } /** - * 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): + * Record a drawer cash MOVEMENT — 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. + * `amountMinor` is always a POSITIVE magnitude. The movement is OPERATOR-RECORDED FREELY + * (no admin sign-off at creation — 2026-07-01); an admin REVIEWS it after the fact via + * `reviewMovement` (authorize/deny — a flag that never moves cash). It counts in the + * drawer immediately (the cash physically moved). Returns the new drawer balance + the + * assigned voucher number, and prints a slip best-effort. See wiki/concepts/shift.md. */ 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; + const { type, operator, reason } = args; if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) { throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)"); } @@ -308,25 +329,122 @@ export class ShiftService { await this.#log.append({ type, source: "manual", - identity: operator, // who RAISED the voucher (the operator at the booth) + identity: operator, // who RECORDED the movement (the operator at the booth) payload: { amountMinor, // positive magnitude — direction is the type ...(reason ? { reason } : {}), ...(args.currency ? { currency: args.currency } : {}), operator, - authorizedBy, voucherNo, }, occurredAt: now, }); const { balanceMinor, currency } = this.#drawerBalanceAt(now); - const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now }); + const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now }); this.#logger.info( - `${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`, + `${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`, ); return { type, amountMinor, voucherNo, balanceMinor, printed }; } + /** + * Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Appends a signed `cash_review` + * referencing the movement. This is a FLAG ONLY — a `deny` does NOT reverse the movement + * and does NOT touch the drawer balance (a denial is a judgment about the operator, + * settled outside the app). Rejects an unknown/ non-movement refId, and a movement that + * was already decided (one decision per movement; a clean audit trail). Idempotent by + * design: the drawer fold never reads `cash_review`. See wiki/concepts/shift.md. + */ + async reviewMovement(args: { + refId: string; + decision: "authorize" | "deny"; + reviewedBy: string; + note?: string; + }): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> { + const { refId, decision, reviewedBy } = args; + if (decision !== "authorize" && decision !== "deny") { + throw new InvalidCashMovementError("decision must be authorize or deny"); + } + const movement = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.id, refId)).get(); + if (!movement || (movement.type !== "cash_in" && movement.type !== "cash_out")) { + throw new InvalidCashMovementError("refId is not a cash movement"); + } + // One decision per movement — reject a re-review so the audit stays unambiguous. + const already = this.#db + .select() + .from(ledgerEvents) + .where(eq(ledgerEvents.type, "cash_review")) + .all() + .some((r) => (r.payload as LedgerPayload | null)?.refId === refId); + if (already) throw new InvalidCashMovementError("movement already reviewed"); + + const now = new Date().toISOString(); + await this.#log.append({ + type: "cash_review", + source: "manual", + identity: reviewedBy, // the admin who decided + payload: { + refId, + decision, + reviewedBy, + ...(args.note ? { note: args.note } : {}), + }, + occurredAt: now, + }); + this.#logger.info(`cash_review ${decision} of ${movement.type} ${refId} by ${reviewedBy}`); + return { refId, decision, reviewedBy, at: now }; + } + + /** + * All drawer cash movements (cash_in/cash_out) with their review STATUS, newest first. + * Status is derived from the latest `cash_review` referencing each movement: none → + * `pending`, else `authorized`/`denied`. Powers the operator's own list and the admin + * review queue. `operator` (optional) scopes to one operator's movements (an operator + * sees only their own; a reviewer sees all). See wiki/concepts/shift.md. + */ + movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] { + const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); + // Latest review decision per movement id. + const reviewByRef = new Map(); + for (const r of rows) { + if (r.type !== "cash_review") continue; + const pl = (r.payload ?? {}) as LedgerPayload; + if (!pl.refId || (pl.decision !== "authorize" && pl.decision !== "deny")) continue; + reviewByRef.set(pl.refId, { + decision: pl.decision, + reviewedBy: pl.reviewedBy ?? "", + ...(pl.note ? { note: pl.note } : {}), + at: r.occurredAt, + }); + } + const out: DrawerMovement[] = []; + for (const r of rows) { + if (r.type !== "cash_in" && r.type !== "cash_out") continue; + const pl = (r.payload ?? {}) as LedgerPayload; + const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? ""; + if (filter?.operator && operator !== filter.operator) continue; + const review = reviewByRef.get(r.id); + const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending"; + if (filter?.status && status !== filter.status) continue; + out.push({ + id: r.id, + type: r.type, + amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0, + currency: pl.currency ?? null, + reason: pl.reason ?? null, + operator, + voucherNo: pl.voucherNo ?? null, + at: r.occurredAt, + status, + reviewedBy: review?.reviewedBy ?? null, + reviewNote: review?.note ?? null, + reviewedAt: review?.at ?? null, + }); + } + // Newest first. + return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)); + } + /** Open a shift for the operator (explicit start). The opening float is auto- * inherited from the chain = the drawer balance at the start instant. */ async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> { @@ -578,7 +696,6 @@ export class ShiftService { amountMinor: number; reason: string; operator: string; - authorizedBy: string; currency: string | null; at: string; }): Promise { @@ -597,8 +714,7 @@ export class ShiftService { `Shuma: ${money(v.amountMinor)} ${cur}`, `Arsyeja: ${v.reason || "-"}`, "", - `Hapur nga: ${v.operator}`, - `Autorizoi: ${v.authorizedBy}`, + `Regjistroi: ${v.operator}`, ]; try { await printer.printReport({ title, lines }); diff --git a/apps/web/src/DrawerManager.tsx b/apps/web/src/DrawerManager.tsx new file mode 100644 index 0000000..56994dd --- /dev/null +++ b/apps/web/src/DrawerManager.tsx @@ -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 ( + + {t(`drawer.status.${status}`)} + + ); +} + +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(""); + 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 ( +
+ {canCreate && void qc.invalidateQueries({ queryKey: ["drawer"] })} />} + + 0 ? ( + + {t("drawer.pendingCount", { count: pendingCount })} + + ) : null + } + className="min-h-0 flex-1" + > +
+ {canReview && ( +
+ {(["", "pending", "authorized", "denied"] as const).map((s) => ( + + ))} +
+ )} + +
+ {q.isLoading ? ( +
{t("common.loading")}
+ ) : movements.length === 0 ? ( +
{t("drawer.empty")}
+ ) : ( + + + + + + + + {canReview && } + + {canReview && + + + {movements.map((m) => ( + void qc.invalidateQueries({ queryKey: ["drawer"] })} /> + ))} + +
{t("drawer.colWhen")}{t("drawer.colType")}{t("drawer.colAmount")}{t("drawer.colReason")}{t("drawer.colOperator")}{t("drawer.colStatus")}} +
+ )} +
+
+
+
+ ); +} + +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 ( + +
+
+ setAmount(e.target.value)} + placeholder={t("drawer.amount")} + inputMode="decimal" + /> + setReason(e.target.value)} + placeholder={t("drawer.reasonPlaceholder")} + /> +
+
{t("drawer.recordHint")}
+ {msg && ( +
{msg.text}
+ )} +
+ + +
+
+
+ ); +} + +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 ( + + {formatRelativeDateTime(m.at, t)} + + + {m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")} + + {m.voucherNo && {m.voucherNo}} + + + {money(signed, m.currency)} + + {m.reason || "—"} + {canReview && {m.operator}} + + + {m.status !== "pending" && m.reviewedBy && ( +
+ {m.reviewedBy} + {m.reviewNote ? ` · ${m.reviewNote}` : ""} +
+ )} + + {canReview && ( + + {m.status === "pending" ? ( +
+
+ + +
+ {noteOpen && ( + setNote(e.target.value)} + placeholder={t("drawer.denyNotePlaceholder")} + /> + )} + {review.isError && {(review.error as Error).message}} +
+ ) : null} + + )} + + ); +} diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index 4fff06b..81828c3 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -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("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
{t("shifts.payments")} {s.paymentCount} {money(s.cashTotalMinor, cur)} - {money(s.cardTotalMinor, cur)} + {CARD_PAYMENTS_ENABLED && {money(s.cardTotalMinor, cur)}} {money(s.expectedDrawerMinor, cur)}
@@ -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); + const [modal, setModal] = useState(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(null); @@ -311,7 +308,6 @@ function ShiftActivityLog({ {isCurrent && isMine && canManage && ( - {canVoucher && } )} @@ -325,7 +321,7 @@ function ShiftActivityLog({
-
+ {CARD_PAYMENTS_ENABLED &&
}
@@ -340,7 +336,6 @@ function ShiftActivityLog({ {detailEvent && setDetailEvent(null)} />} {modal === "end" && setModal(null)} onDone={onChanged} />} - {modal === "voucher" && setModal(null)} onDone={onChanged} />} {modal === "takings" && setModal(null)} />} ); @@ -385,7 +380,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
-
+ {CARD_PAYMENTS_ENABLED &&
}
@@ -411,7 +406,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
-
+ {CARD_PAYMENTS_ENABLED &&
} {/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
@@ -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(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 ( - -
-
- setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" /> - setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} /> -
-
- setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" /> - setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" /> -
-
{t("shift.voucherHint")}
- {msg &&
{msg}
} -
- - - -
-
-
- ); -} - 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 }) {
-
+ {CARD_PAYMENTS_ENABLED &&
}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 350f080..91580fc 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1019,15 +1019,38 @@ export async function fetchShiftReport(): Promise { return (await apiFetch("/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). */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 4206c9c..c8ac358 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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: { diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 332657e..c8ed5d4 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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: { diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index fc8a6e3..384d1b0 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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({
- + {CARD_PAYMENTS_ENABLED && } {/* Drawer math made explicit: opening float + cash taken = expected drawer. */} @@ -430,6 +432,11 @@ function RootLayout() {