feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.
- New `drawer` resource: drawer:create (operator records; admin-revocable per
role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
touches the drawer balance (the correction is settled outside the app). This
is what keeps a late review from leaking into the next operator's inherited
drawer — a denial that lands after the reviewed shift closed moves no cash.
Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
(operator: record + own; admin: review queue + all). routes/drawer.ts lifted
from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
for its other job = admin-sees-all-shifts). New DrawerManager.tsx.
Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
on-site), matching the card-tender gate.
shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,27 +1,6 @@
|
|||||||
import bcrypt from "bcrypt";
|
|
||||||
import { eq, users, type Db } from "@parking/db";
|
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
import {
|
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShiftsQuery {
|
interface ShiftsQuery {
|
||||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
/** 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
|
// 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.
|
// 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<void> {
|
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||||
// Reading the shift state vs. opening/closing one's own shift.
|
// Reading the shift state vs. opening/closing one's own shift.
|
||||||
const readGuard = requirePermission("shift:read");
|
const readGuard = requirePermission("shift:read");
|
||||||
const guard = requirePermission("shift:create");
|
const guard = requirePermission("shift:create");
|
||||||
@@ -87,49 +66,8 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db:
|
|||||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||||
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { subscriptionRoutes } from "./routes/subscriptions.js";
|
|||||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
import { shiftRoutes } from "./routes/shift.js";
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
|
import { drawerRoutes } from "./routes/drawer.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
@@ -265,8 +266,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||||
await subscriptionPlanRoutes(app, db);
|
await subscriptionPlanRoutes(app, db);
|
||||||
|
|
||||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
// Shift open/close (shiftService constructed above).
|
||||||
await shiftRoutes(app, shiftService, db);
|
await shiftRoutes(app, shiftService);
|
||||||
|
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||||
|
await drawerRoutes(app, shiftService);
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|||||||
@@ -117,27 +117,100 @@ describe("drawer carry-forward", () => {
|
|||||||
expect(next.openingFloatMinor).toBe(25000); // inherited
|
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.open("alice");
|
||||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" });
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", 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_out", operator: "alice", amountMinor: 30000, reason: "bank drop" });
|
||||||
const r = shift.currentReport()!;
|
const r = shift.currentReport()!;
|
||||||
expect(r.cashAddedMinor).toBe(100000);
|
expect(r.cashAddedMinor).toBe(100000);
|
||||||
expect(r.cashRemovedMinor).toBe(30000);
|
expect(r.cashRemovedMinor).toBe(30000);
|
||||||
expect(r.expectedDrawerMinor).toBe(70000);
|
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 shift.open("alice");
|
||||||
await expect(
|
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);
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
await expect(
|
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);
|
).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", () => {
|
describe("close signs a Z-report; listShifts reads it back", () => {
|
||||||
it("a closed shift appears in history with its split figures", async () => {
|
it("a closed shift appears in history with its split figures", async () => {
|
||||||
await shift.open("alice");
|
await shift.open("alice");
|
||||||
|
|||||||
@@ -90,6 +90,27 @@ export interface ShiftReport {
|
|||||||
readonly printed: boolean;
|
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 {
|
export class InvalidCashMovementError extends Error {
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super(msg);
|
super(msg);
|
||||||
@@ -281,24 +302,24 @@ export class ShiftService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
|
* Record a drawer cash MOVEMENT — the direction is the event TYPE, not the sign of an
|
||||||
* an amount (a receipt and a disbursement are different financial documents):
|
* amount (a receipt and a disbursement are different financial documents):
|
||||||
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
||||||
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
||||||
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
|
* `amountMinor` is always a POSITIVE magnitude. The movement is OPERATOR-RECORDED FREELY
|
||||||
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
|
* (no admin sign-off at creation — 2026-07-01); an admin REVIEWS it after the fact via
|
||||||
* route). Returns the new drawer balance + the assigned voucher number, and prints
|
* `reviewMovement` (authorize/deny — a flag that never moves cash). It counts in the
|
||||||
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
|
* 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: {
|
async recordVoucher(args: {
|
||||||
type: "cash_in" | "cash_out";
|
type: "cash_in" | "cash_out";
|
||||||
operator: string;
|
operator: string;
|
||||||
authorizedBy: string;
|
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
}): 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) {
|
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||||
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||||
}
|
}
|
||||||
@@ -308,25 +329,122 @@ export class ShiftService {
|
|||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type,
|
type,
|
||||||
source: "manual",
|
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: {
|
payload: {
|
||||||
amountMinor, // positive magnitude — direction is the type
|
amountMinor, // positive magnitude — direction is the type
|
||||||
...(reason ? { reason } : {}),
|
...(reason ? { reason } : {}),
|
||||||
...(args.currency ? { currency: args.currency } : {}),
|
...(args.currency ? { currency: args.currency } : {}),
|
||||||
operator,
|
operator,
|
||||||
authorizedBy,
|
|
||||||
voucherNo,
|
voucherNo,
|
||||||
},
|
},
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
const { balanceMinor, currency } = this.#drawerBalanceAt(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(
|
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 };
|
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<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||||
|
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-
|
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||||
* inherited from the chain = the drawer balance at the start instant. */
|
* inherited from the chain = the drawer balance at the start instant. */
|
||||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||||
@@ -578,7 +696,6 @@ export class ShiftService {
|
|||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
authorizedBy: string;
|
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
at: string;
|
at: string;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
@@ -597,8 +714,7 @@ export class ShiftService {
|
|||||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||||
`Arsyeja: ${v.reason || "-"}`,
|
`Arsyeja: ${v.reason || "-"}`,
|
||||||
"",
|
"",
|
||||||
`Hapur nga: ${v.operator}`,
|
`Regjistroi: ${v.operator}`,
|
||||||
`Autorizoi: ${v.authorizedBy}`,
|
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await printer.printReport({ title, lines });
|
await printer.printReport({ title, lines });
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<span className={`rounded border px-1 text-[0.625rem] uppercase tracking-wider ${cls}`}>
|
||||||
|
{t(`drawer.status.${status}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MovementStatus | "">("");
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{canCreate && <RecordPanel onDone={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />}
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||||
|
right={
|
||||||
|
canReview && pendingCount > 0 ? (
|
||||||
|
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||||
|
{t("drawer.pendingCount", { count: pendingCount })}
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{canReview && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
|
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s || "all"}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(s)}
|
||||||
|
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||||
|
>
|
||||||
|
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : movements.length === 0 ? (
|
||||||
|
<div className="text-term-muted">{t("drawer.empty")}</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
|
||||||
|
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
|
||||||
|
{canReview && <th className="px-2 py-1.5" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{movements.map((m) => (
|
||||||
|
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Panel title={t("drawer.recordTitle")}>
|
||||||
|
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input w-28"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder={t("drawer.amount")}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input min-w-40 flex-1"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder={t("drawer.reasonPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.6875rem] text-term-muted">{t("drawer.recordHint")}</div>
|
||||||
|
{msg && (
|
||||||
|
<div className={`text-[0.75rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-go btn-sm" disabled={record.isPending} onClick={() => submit("cash_in")}>
|
||||||
|
{t("drawer.mandatArketimi")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" disabled={record.isPending} onClick={() => submit("cash_out")}>
|
||||||
|
{t("drawer.mandatPagese")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr className="border-t border-term-border/50 align-top">
|
||||||
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">{formatRelativeDateTime(m.at, t)}</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<span className={m.type === "cash_in" ? "text-term-green" : "text-term-red"}>
|
||||||
|
{m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}
|
||||||
|
</span>
|
||||||
|
{m.voucherNo && <span className="ml-1 text-[0.625rem] text-term-muted">{m.voucherNo}</span>}
|
||||||
|
</td>
|
||||||
|
<td className={`whitespace-nowrap px-2 py-1.5 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
|
||||||
|
{money(signed, m.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-term-text">{m.reason || "—"}</td>
|
||||||
|
{canReview && <td className="px-2 py-1.5 text-term-muted">{m.operator}</td>}
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<StatusBadge status={m.status} />
|
||||||
|
{m.status !== "pending" && m.reviewedBy && (
|
||||||
|
<div className="mt-0.5 text-[0.5625rem] text-term-muted">
|
||||||
|
{m.reviewedBy}
|
||||||
|
{m.reviewNote ? ` · ${m.reviewNote}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{canReview && (
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{m.status === "pending" ? (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button type="button" className="btn btn-go btn-sm" disabled={review.isPending} onClick={() => review.mutate("authorize")}>
|
||||||
|
{t("drawer.authorize")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
disabled={review.isPending}
|
||||||
|
onClick={() => (noteOpen ? review.mutate("deny") : setNoteOpen(true))}
|
||||||
|
>
|
||||||
|
{t("drawer.deny")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{noteOpen && (
|
||||||
|
<input
|
||||||
|
className="input w-44 text-[0.6875rem]"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder={t("drawer.denyNotePlaceholder")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{review.isError && <span className="text-[0.625rem] text-term-red">{(review.error as Error).message}</span>}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
fetchShifts,
|
fetchShifts,
|
||||||
openShift,
|
openShift,
|
||||||
recordCashVoucher,
|
|
||||||
type ShiftReport,
|
type ShiftReport,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
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 { t } = useTranslation();
|
||||||
const [preset, setPreset] = useState<Preset>("week");
|
const [preset, setPreset] = useState<Preset>("week");
|
||||||
const [operator, setOperator] = useState("");
|
const [operator, setOperator] = useState("");
|
||||||
@@ -198,7 +198,6 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
isMine={isMine}
|
isMine={isMine}
|
||||||
showOperator={isAdmin}
|
showOperator={isAdmin}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
canVoucher={canVoucher}
|
|
||||||
onChanged={refreshAll}
|
onChanged={refreshAll}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -257,7 +256,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
|||||||
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
||||||
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
||||||
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
||||||
<span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>
|
{CARD_PAYMENTS_ENABLED && <span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>}
|
||||||
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -270,7 +269,6 @@ function ShiftActivityLog({
|
|||||||
isMine,
|
isMine,
|
||||||
showOperator,
|
showOperator,
|
||||||
canManage,
|
canManage,
|
||||||
canVoucher,
|
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
shift: ShiftSummary;
|
shift: ShiftSummary;
|
||||||
@@ -278,11 +276,10 @@ function ShiftActivityLog({
|
|||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
showOperator: boolean;
|
showOperator: boolean;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
canVoucher: boolean;
|
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
const [modal, setModal] = useState<null | "end" | "takings">(null);
|
||||||
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
||||||
// (full signed payload + snapshots + chain provenance).
|
// (full signed payload + snapshots + chain provenance).
|
||||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
@@ -311,7 +308,6 @@ function ShiftActivityLog({
|
|||||||
{isCurrent && isMine && canManage && (
|
{isCurrent && isMine && canManage && (
|
||||||
<span className="flex flex-wrap gap-1.5">
|
<span className="flex flex-wrap gap-1.5">
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
||||||
{canVoucher && <button type="button" className="btn btn-sm" onClick={() => setModal("voucher")}>{t("shift.drawerVoucher")}</button>}
|
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -325,7 +321,7 @@ function ShiftActivityLog({
|
|||||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
||||||
<Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||||
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +336,6 @@ function ShiftActivityLog({
|
|||||||
|
|
||||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||||
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
|
||||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -385,7 +380,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||||
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />}
|
||||||
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
||||||
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
||||||
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
||||||
@@ -411,7 +406,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||||
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||||
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
||||||
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
<span />
|
<span />
|
||||||
@@ -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<string | null>(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 (
|
|
||||||
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
|
||||||
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
|
|
||||||
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
|
|
||||||
</div>
|
|
||||||
<div className="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" />
|
|
||||||
</div>
|
|
||||||
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
|
|
||||||
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
|
|
||||||
<div className="mt-1 flex justify-end gap-2">
|
|
||||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
|
|
||||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => submit("cash_out")}>{t("shift.mandatPagese")}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TakingsModal({ onClose }: { onClose: () => void }) {
|
function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
||||||
@@ -500,7 +447,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||||
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />}
|
||||||
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
||||||
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
||||||
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
||||||
|
|||||||
+49
-10
@@ -1019,15 +1019,38 @@ export async function fetchShiftReport(): Promise<XReport | null> {
|
|||||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||||||
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
// Redesigned 2026-07-01: an operator RECORDS a receipt/disbursement freely; an admin
|
||||||
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
// REVIEWS it after the fact (authorize/deny — a flag, never a cash reversal). See
|
||||||
export function recordCashVoucher(args: {
|
// 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";
|
type: "cash_in" | "cash_out";
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
authorizedBy: string;
|
currency?: string;
|
||||||
authorizerPassword: string;
|
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
type: "cash_in" | "cash_out";
|
type: "cash_in" | "cash_out";
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
@@ -1035,10 +1058,26 @@ export function recordCashVoucher(args: {
|
|||||||
balanceMinor: number;
|
balanceMinor: number;
|
||||||
printed: boolean;
|
printed: boolean;
|
||||||
}> {
|
}> {
|
||||||
return apiFetch("/api/cash-voucher", {
|
return apiFetch("/api/drawer/movement", { method: "POST", body: JSON.stringify(args) });
|
||||||
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). */
|
/** A completed shift (reconstructed from its signed Z-report). */
|
||||||
|
|||||||
@@ -58,12 +58,42 @@ export const en: Catalog = {
|
|||||||
users: "Users",
|
users: "Users",
|
||||||
roles: "Roles",
|
roles: "Roles",
|
||||||
shifts: "Shifts",
|
shifts: "Shifts",
|
||||||
|
drawer: "Drawer",
|
||||||
reports: "Reports",
|
reports: "Reports",
|
||||||
recycleBin: "Recycle bin",
|
recycleBin: "Recycle bin",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
backup: "Backup",
|
backup: "Backup",
|
||||||
profile: "Profile",
|
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: {
|
profile: {
|
||||||
title: "My profile",
|
title: "My profile",
|
||||||
accountSection: "Account",
|
accountSection: "Account",
|
||||||
@@ -183,6 +213,8 @@ export const en: Catalog = {
|
|||||||
evtCashMovement: "CASH",
|
evtCashMovement: "CASH",
|
||||||
evtCashIn: "PAY-IN",
|
evtCashIn: "PAY-IN",
|
||||||
evtCashOut: "PAY-OUT",
|
evtCashOut: "PAY-OUT",
|
||||||
|
evtCashReview: "REVIEW",
|
||||||
|
decision: { authorize: "authorized", deny: "denied" },
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
evtRefused: "REFUSED",
|
evtRefused: "REFUSED",
|
||||||
// live-feed event detail line + classification badges (computed from payload)
|
// live-feed event detail line + classification badges (computed from payload)
|
||||||
@@ -224,6 +256,10 @@ export const en: Catalog = {
|
|||||||
edPlate: "Plate",
|
edPlate: "Plate",
|
||||||
edCategory: "Category",
|
edCategory: "Category",
|
||||||
edOperator: "Operator",
|
edOperator: "Operator",
|
||||||
|
edDecision: "Review decision",
|
||||||
|
edReviewedBy: "Reviewed by",
|
||||||
|
edReviewNote: "Note",
|
||||||
|
edReviewRef: "Movement ref",
|
||||||
edTariffVersion: "Tariff version",
|
edTariffVersion: "Tariff version",
|
||||||
edRawPayload: "Raw signed payload",
|
edRawPayload: "Raw signed payload",
|
||||||
edOccurrence: "Occurrence id",
|
edOccurrence: "Occurrence id",
|
||||||
@@ -709,9 +745,9 @@ export const en: Catalog = {
|
|||||||
srcSubWindow: "out-of-window",
|
srcSubWindow: "out-of-window",
|
||||||
drawerSection: "— Drawer —",
|
drawerSection: "— Drawer —",
|
||||||
openingFloat: "Opening cash:",
|
openingFloat: "Opening cash:",
|
||||||
cashTaken: "Cash taken:",
|
cashTaken: "Daily takings:",
|
||||||
cashAdded: "Cash added:",
|
cashAdded: "Receipts:",
|
||||||
cashRemoved: "Cash removed:",
|
cashRemoved: "Disbursements:",
|
||||||
expectedDrawer: "Expected drawer:",
|
expectedDrawer: "Expected drawer:",
|
||||||
printedToReceipt: "Printed to booth receipt.",
|
printedToReceipt: "Printed to booth receipt.",
|
||||||
recordedNoPrinter: "Recorded (no printer to print to).",
|
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||||
@@ -760,9 +796,9 @@ export const en: Catalog = {
|
|||||||
current: "current",
|
current: "current",
|
||||||
drawerSection: "Drawer",
|
drawerSection: "Drawer",
|
||||||
openingFloat: "Opening cash",
|
openingFloat: "Opening cash",
|
||||||
cashTaken: "Cash taken",
|
cashTaken: "Daily takings",
|
||||||
cashAdded: "Cash added",
|
cashAdded: "Receipts",
|
||||||
cashRemoved: "Cash removed",
|
cashRemoved: "Disbursements",
|
||||||
loadFailed: "Failed to load shifts.",
|
loadFailed: "Failed to load shifts.",
|
||||||
},
|
},
|
||||||
reports: {
|
reports: {
|
||||||
|
|||||||
@@ -60,12 +60,42 @@ export const sq = {
|
|||||||
users: "Përdoruesit",
|
users: "Përdoruesit",
|
||||||
roles: "Rolet",
|
roles: "Rolet",
|
||||||
shifts: "Turnet",
|
shifts: "Turnet",
|
||||||
|
drawer: "Arka",
|
||||||
reports: "Raportet",
|
reports: "Raportet",
|
||||||
recycleBin: "Koshi",
|
recycleBin: "Koshi",
|
||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
backup: "Kopje rezervë",
|
backup: "Kopje rezervë",
|
||||||
profile: "Profili",
|
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: {
|
profile: {
|
||||||
title: "Profili im",
|
title: "Profili im",
|
||||||
accountSection: "Llogaria",
|
accountSection: "Llogaria",
|
||||||
@@ -187,6 +217,8 @@ export const sq = {
|
|||||||
evtCashMovement: "ARKË",
|
evtCashMovement: "ARKË",
|
||||||
evtCashIn: "ARKËTIM",
|
evtCashIn: "ARKËTIM",
|
||||||
evtCashOut: "PAGESË",
|
evtCashOut: "PAGESË",
|
||||||
|
evtCashReview: "SHQYRTIM",
|
||||||
|
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
evtRefused: "REFUZUAR",
|
evtRefused: "REFUZUAR",
|
||||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||||
@@ -228,6 +260,10 @@ export const sq = {
|
|||||||
edPlate: "Targa",
|
edPlate: "Targa",
|
||||||
edCategory: "Kategoria",
|
edCategory: "Kategoria",
|
||||||
edOperator: "Operatori",
|
edOperator: "Operatori",
|
||||||
|
edDecision: "Vendimi i shqyrtimit",
|
||||||
|
edReviewedBy: "Shqyrtuar nga",
|
||||||
|
edReviewNote: "Shënim",
|
||||||
|
edReviewRef: "Ref. lëvizjes",
|
||||||
edTariffVersion: "Versioni i tarifës",
|
edTariffVersion: "Versioni i tarifës",
|
||||||
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
|
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
|
||||||
edOccurrence: "ID e hyrjes",
|
edOccurrence: "ID e hyrjes",
|
||||||
@@ -722,9 +758,9 @@ export const sq = {
|
|||||||
srcSubWindow: "jashtë orarit",
|
srcSubWindow: "jashtë orarit",
|
||||||
drawerSection: "— Arka —",
|
drawerSection: "— Arka —",
|
||||||
openingFloat: "Arka fillestare:",
|
openingFloat: "Arka fillestare:",
|
||||||
cashTaken: "Para të marra:",
|
cashTaken: "Xhiro ditore:",
|
||||||
cashAdded: "Para të shtuara:",
|
cashAdded: "Arkëtime:",
|
||||||
cashRemoved: "Para të hequra:",
|
cashRemoved: "Pagesa:",
|
||||||
expectedDrawer: "Gjëndje Arke:",
|
expectedDrawer: "Gjëndje Arke:",
|
||||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||||
@@ -775,9 +811,9 @@ export const sq = {
|
|||||||
// Expanded drawer detail.
|
// Expanded drawer detail.
|
||||||
drawerSection: "Arka",
|
drawerSection: "Arka",
|
||||||
openingFloat: "Arka fillestare",
|
openingFloat: "Arka fillestare",
|
||||||
cashTaken: "Para të marra",
|
cashTaken: "Xhiro ditore",
|
||||||
cashAdded: "Para të shtuara",
|
cashAdded: "Arkëtime",
|
||||||
cashRemoved: "Para të hequra",
|
cashRemoved: "Pagesa",
|
||||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
loadFailed: "Ngarkimi i turneve dështoi.",
|
||||||
},
|
},
|
||||||
reports: {
|
reports: {
|
||||||
|
|||||||
+32
-8
@@ -41,6 +41,8 @@ import { SiteSettings } from "./SiteSettings.js";
|
|||||||
import { UsersManager } from "./UsersManager.js";
|
import { UsersManager } from "./UsersManager.js";
|
||||||
import { RolesManager } from "./RolesManager.js";
|
import { RolesManager } from "./RolesManager.js";
|
||||||
import { ShiftsHistory } from "./ShiftsHistory.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 { LogsViewer } from "./LogsViewer.js";
|
||||||
import { BackupSettings } from "./BackupSettings.js";
|
import { BackupSettings } from "./BackupSettings.js";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
@@ -379,7 +381,7 @@ function CloseShiftConfirm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||||
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
|
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||||
<span />
|
<span />
|
||||||
@@ -430,6 +432,11 @@ function RootLayout() {
|
|||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<NavLink to="/booth" label={t("nav.booth")} />
|
<NavLink to="/booth" label={t("nav.booth")} />
|
||||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
<NavLink to="/shifts" label={t("nav.shifts")} />
|
||||||
|
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||||
|
user can do either. See wiki/concepts/shift.md. */}
|
||||||
|
{(show("drawer:create") || show("drawer:review")) && (
|
||||||
|
<NavLink to="/drawer" label={t("nav.drawer")} />
|
||||||
|
)}
|
||||||
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
||||||
Shown if the user can reach ANY of its tabs. */}
|
Shown if the user can reach ANY of its tabs. */}
|
||||||
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
||||||
@@ -548,14 +555,30 @@ const shiftRoute = createRoute({
|
|||||||
component: function ShiftRoute() {
|
component: function ShiftRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
||||||
// The CURRENT shift's pane carries the actions (open/close, drawer voucher, takings),
|
// The CURRENT shift's pane carries the actions (open/close, takings), each opening a
|
||||||
// each opening a modal. `canManage` = shift:create (start/end + raise vouchers); a
|
// modal. `canManage` = shift:create (start/end). Drawer cash movements moved to /drawer
|
||||||
// voucher additionally needs an admin's password sign-off server-side.
|
// (2026-07-01).
|
||||||
|
return <ShiftsHistory user={user} canManage={can(user, "shift:create")} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawerRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/drawer",
|
||||||
|
// Reachable by anyone who can record OR review; the component shows the right view per
|
||||||
|
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
||||||
|
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
||||||
|
throw redirect({ to: "/booth" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: function DrawerRoute() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
return (
|
return (
|
||||||
<ShiftsHistory
|
<DrawerManager
|
||||||
user={user}
|
canCreate={can(user, "drawer:create")}
|
||||||
canManage={can(user, "shift:create")}
|
canReview={can(user, "drawer:review")}
|
||||||
canVoucher={can(user, "shift:create")}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -723,6 +746,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
|
drawerRoute,
|
||||||
reportsRoute,
|
reportsRoute,
|
||||||
subscriptionsRoute.addChildren([
|
subscriptionsRoute.addChildren([
|
||||||
subscriptionsIndexRoute,
|
subscriptionsIndexRoute,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
||||||
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
||||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||||
|
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,6 +188,12 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
const category = typeof p?.category === "string" ? p.category : null;
|
const category = typeof p?.category === "string" ? p.category : null;
|
||||||
const operator = typeof p?.operator === "string" ? p.operator : null;
|
const operator = typeof p?.operator === "string" ? p.operator : null;
|
||||||
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
||||||
|
// cash_review fields: the admin's decision on a drawer movement (+ who / note / the
|
||||||
|
// reviewed movement id). A flag only — it never moves cash. See wiki/concepts/shift.md.
|
||||||
|
const decision = p?.decision === "authorize" || p?.decision === "deny" ? p.decision : null;
|
||||||
|
const reviewedBy = typeof p?.reviewedBy === "string" ? p.reviewedBy : null;
|
||||||
|
const reviewNote = typeof p?.note === "string" ? p.note : null;
|
||||||
|
const refId = typeof p?.refId === "string" ? p.refId : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||||
@@ -244,6 +251,21 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||||
|
{/* cash_review: the admin's decision + who + why (for a denial). */}
|
||||||
|
{decision && (
|
||||||
|
<DetailRow label={t("booth.edDecision")}>
|
||||||
|
<span className={decision === "authorize" ? "text-term-green" : "text-term-red"}>
|
||||||
|
{t(`booth.decision.${decision}`)}
|
||||||
|
</span>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
{reviewedBy && <DetailRow label={t("booth.edReviewedBy")}>{reviewedBy}</DetailRow>}
|
||||||
|
{reviewNote && <DetailRow label={t("booth.edReviewNote")}>{reviewNote}</DetailRow>}
|
||||||
|
{refId && (
|
||||||
|
<DetailRow label={t("booth.edReviewRef")}>
|
||||||
|
<code className="text-[0.6875rem] text-term-muted">{refId}</code>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
{sessionRef && sessionRef !== e.identity && (
|
{sessionRef && sessionRef !== e.identity && (
|
||||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Drawer redesign (2026-07-01): operators RECORD cash movements freely; admins REVIEW them
|
||||||
|
-- after the fact (authorize/deny — a flag, not a reversal). New `drawer` resource with two
|
||||||
|
-- permissions in @parking/shared: drawer:create + drawer:review.
|
||||||
|
--
|
||||||
|
-- The built-in `admin` role gets ALL permissions in code (auth.ts ADMIN_PERMS = new
|
||||||
|
-- Set(PERMISSIONS)), so it needs NO seed row here. This grants the default `operator` role
|
||||||
|
-- the ability to record movements (drawer:create) — matching the prior behaviour where an
|
||||||
|
-- operator could raise a voucher. An admin can revoke it per-role in the Roles UI (it's just
|
||||||
|
-- data). drawer:review is admin-only, so it is NOT granted to operator.
|
||||||
|
--
|
||||||
|
-- Idempotent: role_permissions has a UNIQUE(role_id, permission) index, so re-running is a
|
||||||
|
-- no-op via OR IGNORE. See wiki/concepts/shift.md.
|
||||||
|
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||||
|
('operator','drawer:create');
|
||||||
@@ -127,6 +127,13 @@
|
|||||||
"when": 1781886000000,
|
"when": 1781886000000,
|
||||||
"tag": "0017_backup_retention",
|
"tag": "0017_backup_retention",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886100000,
|
||||||
|
"tag": "0018_drawer_permissions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,8 @@ export const RESOURCES = [
|
|||||||
"subscription", // the subscription registry
|
"subscription", // the subscription registry
|
||||||
"site", // site_config + device setup/assign
|
"site", // site_config + device setup/assign
|
||||||
"device", // device status / printers / snapshots / catalog
|
"device", // device status / printers / snapshots / catalog
|
||||||
"shift", // open/close own shift; move the drawer float
|
"shift", // open/close own shift
|
||||||
|
"drawer", // record cash receipts/disbursements (operator); review them (admin)
|
||||||
"payment", // take payment, quote, voucher/receipt, exit, reopen
|
"payment", // take payment, quote, voucher/receipt, exit, reopen
|
||||||
"session", // active sessions, lookup
|
"session", // active sessions, lookup
|
||||||
"event", // the signed ledger feed + void
|
"event", // the signed ledger feed + void
|
||||||
@@ -33,9 +34,11 @@ export const RESOURCES = [
|
|||||||
export type Resource = (typeof RESOURCES)[number];
|
export type Resource = (typeof RESOURCES)[number];
|
||||||
|
|
||||||
/** CRUD plus domain verbs where CRUD doesn't fit: `void` (append a void event, NOT a
|
/** CRUD plus domain verbs where CRUD doesn't fit: `void` (append a void event, NOT a
|
||||||
* delete), `cash` (move the drawer float — admin-grade shift action), and `plan`
|
* delete), `cash` (admin-grade shift scope — see all operators' shifts), `plan` (compose
|
||||||
* (compose the subscription plan catalog — admin-grade; selling stays `create`). */
|
* the subscription plan catalog — admin-grade; selling stays `create`), and `review`
|
||||||
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan";
|
* (admin authorizes/denies a drawer movement an operator recorded — a flag, not a
|
||||||
|
* reversal; see wiki/concepts/shift.md). */
|
||||||
|
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan" | "review";
|
||||||
|
|
||||||
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
|
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
|
||||||
export type Permission = `${Resource}:${Action}`;
|
export type Permission = `${Resource}:${Action}`;
|
||||||
@@ -53,6 +56,11 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
"site:read", "site:update",
|
"site:read", "site:update",
|
||||||
"device:read",
|
"device:read",
|
||||||
"shift:read", "shift:create", "shift:cash",
|
"shift:read", "shift:create", "shift:cash",
|
||||||
|
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
|
||||||
|
// admin sign-off at creation; admin-revocable per role) and review (admin AUTHORIZES or
|
||||||
|
// DENIES a recorded movement after the fact — a flag, never a cash reversal). A denial is
|
||||||
|
// a judgment about the operator, settled outside the app. See wiki/concepts/shift.md.
|
||||||
|
"drawer:create", "drawer:review",
|
||||||
"payment:read", "payment:create",
|
"payment:read", "payment:create",
|
||||||
"session:read",
|
"session:read",
|
||||||
"event:read", "event:void",
|
"event:read", "event:void",
|
||||||
@@ -241,11 +249,19 @@ export type LedgerEventType =
|
|||||||
// financial documents — the direction is the TYPE, not the sign of an amount):
|
// financial documents — the direction is the TYPE, not the sign of an amount):
|
||||||
// cash_in = Mandat Arkëtimi (receipt / pay-IN): cash enters the drawer.
|
// cash_in = Mandat Arkëtimi (receipt / pay-IN): cash enters the drawer.
|
||||||
// cash_out = Mandat Pagese (disbursement / pay-OUT): cash leaves the drawer.
|
// cash_out = Mandat Pagese (disbursement / pay-OUT): cash leaves the drawer.
|
||||||
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised
|
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised by),
|
||||||
// by), authorizedBy (admin who signed off), voucherNo }. Operator-raised /
|
// voucherNo }. OPERATOR-RECORDED (freely; no admin sign-off at creation — 2026-07-01).
|
||||||
// admin-authorized. Folds into the drawer balance. See wiki/concepts/shift.md.
|
// Folds into the drawer balance. Reviewed after the fact via cash_review (below).
|
||||||
|
// See wiki/concepts/shift.md.
|
||||||
| "cash_in"
|
| "cash_in"
|
||||||
| "cash_out"
|
| "cash_out"
|
||||||
|
// Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Payload: { refId (the
|
||||||
|
// reviewed movement's event id), decision: "authorize"|"deny", reviewedBy, note?,
|
||||||
|
// currency? }. A FLAG only — it NEVER moves cash: a denial is a judgment about the
|
||||||
|
// operator (settled outside the app), so it does NOT reverse the movement and does NOT
|
||||||
|
// touch the drawer balance. Append-only, signed, so the decision is itself auditable.
|
||||||
|
// See wiki/concepts/shift.md.
|
||||||
|
| "cash_review"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
/** How money was tendered (for payment events + the shift Z-report). */
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
@@ -292,12 +308,23 @@ export interface LedgerPayload {
|
|||||||
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
|
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
|
||||||
* pricing reprices identically at exit. Absent on legacy entries (= default). */
|
* pricing reprices identically at exit. Absent on legacy entries (= default). */
|
||||||
readonly category?: string;
|
readonly category?: string;
|
||||||
/** cash_in / cash_out voucher: the admin who AUTHORIZED the drawer movement (the
|
|
||||||
* operator in `operator` raised it). Operator-raised / admin-authorized. */
|
|
||||||
readonly authorizedBy?: string;
|
|
||||||
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
|
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
|
||||||
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
|
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
|
||||||
readonly voucherNo?: string;
|
readonly voucherNo?: string;
|
||||||
|
/** LEGACY cash_in / cash_out (pre-2026-07-01): the admin who AUTHORIZED the movement
|
||||||
|
* at creation. The current flow records movements freely and reviews them AFTER via a
|
||||||
|
* cash_review event, so new movements do NOT carry this. Kept so historical events
|
||||||
|
* still verify + display. See wiki/concepts/shift.md. */
|
||||||
|
readonly authorizedBy?: string;
|
||||||
|
/** cash_review: the id of the cash_in/cash_out event this review decides on. */
|
||||||
|
readonly refId?: string;
|
||||||
|
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
||||||
|
* neither value moves cash or touches the drawer balance. */
|
||||||
|
readonly decision?: "authorize" | "deny";
|
||||||
|
/** cash_review: the admin (username) who made the decision. */
|
||||||
|
readonly reviewedBy?: string;
|
||||||
|
/** cash_review: optional free-text admin note (e.g. why a movement was denied). */
|
||||||
|
readonly note?: string;
|
||||||
/** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window,
|
/** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window,
|
||||||
* so the minutes actually parked out-of-window are charged at the transient tariff and
|
* so the minutes actually parked out-of-window are charged at the transient tariff and
|
||||||
* collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long
|
* collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long
|
||||||
|
|||||||
+52
-15
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, shifts, anti-fraud]
|
tags: [parking, domain, business, shifts, anti-fraud]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-20
|
updated: 2026-07-01
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,21 +152,20 @@ lives in the **event type**, not the sign of an amount:
|
|||||||
a **positive magnitude**. Voucher no. `AR-NNNN`.
|
a **positive magnitude**. Voucher no. `AR-NNNN`.
|
||||||
- **`cash_out`** (*Mandat Pagese* — a **disbursement / pay-OUT**): cash leaves the drawer.
|
- **`cash_out`** (*Mandat Pagese* — a **disbursement / pay-OUT**): cash leaves the drawer.
|
||||||
`amountMinor` positive; the fold subtracts it. Voucher no. `PA-NNNN`.
|
`amountMinor` positive; the fold subtracts it. Voucher no. `PA-NNNN`.
|
||||||
- Payload: `{ amountMinor (positive), reason, currency, operator (who raised), authorizedBy (admin who
|
- Payload: `{ amountMinor (positive), reason, currency, operator (who recorded), voucherNo }`. Each
|
||||||
signed off), voucherNo }`. Each prints a **slip** (Albanian, like every operator-facing paper).
|
prints a **slip** (Albanian, like every operator-facing paper).
|
||||||
- **Authorization changed: operator-RAISED, admin-AUTHORIZED.** Previously admin-only. Now any holder
|
- **Authorization model (redesigned 2026-07-01): operator RECORDS freely → admin REVIEWS after.** See
|
||||||
of `shift:create` (operator-grade) may *raise* a voucher, but the route only commits it if
|
"Drawer review" below. (Superseded the 2026-06-20 *operator-raised / admin-authorized-at-creation*
|
||||||
`authorizedBy` is a real **admin** (`shift:cash`) who **re-enters their password**. This keeps the
|
scheme, where the operator typed an admin's password inline — that blocked the operator until an
|
||||||
float control — an operator can't move the float alone — while letting them do the paperwork at the
|
admin stood at the booth, and it lived on `/shifts`.)
|
||||||
booth. (`POST /api/cash-voucher`, guarded `shift:create` + server-side authorizer password+grade check.)
|
|
||||||
- **Legacy `cash_movement` stays valid.** The type is retained; historical signed events on the live
|
- **Legacy `cash_movement` stays valid.** The type is retained; historical signed events on the live
|
||||||
chain still verify and still fold into the drawer (signed-± as before). Only *new* movements use the
|
chain still verify and still fold into the drawer (signed-± as before). Only *new* movements use the
|
||||||
voucher pair. The append-only chain is never rewritten.
|
voucher pair. The append-only chain is never rewritten.
|
||||||
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
|
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
|
||||||
the drawer).
|
the drawer).
|
||||||
|
|
||||||
**The math — drawer is a fold over the chain BY TIME, not by operator** (a drawer voucher is the
|
**The math — drawer is a fold over the chain BY TIME, not by operator** (whoever holds the drawer at a
|
||||||
admin's authorization, not the shift operator's takings, so it can't key off `identity`):
|
given instant is accountable for its running balance, regardless of who recorded each movement):
|
||||||
|
|
||||||
```
|
```
|
||||||
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
||||||
@@ -175,6 +174,9 @@ expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
|||||||
+ Σ cash_movement amounts (legacy, signed) up to `at`
|
+ Σ cash_movement amounts (legacy, signed) up to `at`
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`cash_review` is NOT in this fold.** A review decision never moves cash — so it's excluded from the
|
||||||
|
drawer math by construction (see "Drawer review").
|
||||||
|
|
||||||
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
|
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
|
||||||
before this shift's start mark. It is **auto-inherited from the chain** (no operator entry). The
|
before this shift's start mark. It is **auto-inherited from the chain** (no operator entry). The
|
||||||
first shift ever opens at **0**; the admin's load makes it 5000.
|
first shift ever opens at **0**; the admin's load makes it 5000.
|
||||||
@@ -201,6 +203,40 @@ Card payments are excluded from the drawer (they settle to the bank, not the til
|
|||||||
is **expected**, not counted — the optional blind-count enhancement below would record the *variance*
|
is **expected**, not counted — the optional blind-count enhancement below would record the *variance*
|
||||||
against it.
|
against it.
|
||||||
|
|
||||||
|
## Drawer review — operator records freely, admin reviews after (2026-07-01)
|
||||||
|
|
||||||
|
The drawer feature was reworked from **synchronous admin-authorization-at-creation** (an admin had to
|
||||||
|
type their password at the booth for every receipt/disbursement) to **operator-records → admin-reviews-
|
||||||
|
after**. This removes the friction while keeping accountability.
|
||||||
|
|
||||||
|
- **Record (`drawer:create`).** An operator RECORDS a `cash_in`/`cash_out` freely — no admin sign-off
|
||||||
|
at creation. It **counts in the drawer immediately** (the cash physically moved). The permission is
|
||||||
|
**per-role and admin-revocable** in the Roles UI: an admin can turn off an operator's ability to
|
||||||
|
record at all. `POST /api/drawer/movement`.
|
||||||
|
- **Review (`drawer:review`, admin-grade).** Each movement is `pending` until an admin **authorizes**
|
||||||
|
or **denies** it. The decision is a new **signed `cash_review`** event `{ refId, decision, reviewedBy,
|
||||||
|
note? }` — append-only, so the decision itself is auditable. `GET /api/drawer/movements` (operators
|
||||||
|
see only their own; reviewers see all + a status filter = the pending queue) and
|
||||||
|
`POST /api/drawer/review`. One decision per movement (re-review rejected).
|
||||||
|
- **A denial is a FLAG, not a reversal — this is the load-bearing design choice.** Denying a movement
|
||||||
|
does **NOT** append a reversing cash event and does **NOT** touch the drawer balance. It's a judgment
|
||||||
|
about the operator ("this disbursement wasn't genuine"); crediting/debiting them is the **admin's/
|
||||||
|
accountant's job, outside this system**. We deliberately do **not** build accounting here — just a
|
||||||
|
simple running balance.
|
||||||
|
|
||||||
|
> **Why deny ≠ reversal (the cross-shift argument).** The drawer folds BY TIME across shifts. If a
|
||||||
|
> denial appended a reversal, it would land in whatever shift is open **when the admin clicks** — which
|
||||||
|
> can be a **later** operator's shift, after the reviewed shift already closed and Z-reported. That
|
||||||
|
> would make operator 2 accountable for correcting operator 1's mistake. By making review a pure flag,
|
||||||
|
> the correction never enters the ledger, so it **cannot leak into the next operator's drawer**. The
|
||||||
|
> next operator simply inherits the real physical balance (which they count at shift open) and carries
|
||||||
|
> on. This is verified by a regression test (`shift-service.test.ts`: op1 disburses → closes → op2
|
||||||
|
> inherits → admin denies → op2's drawer unchanged).
|
||||||
|
|
||||||
|
- **Home.** The feature moved OFF `/shifts` to its own top-level **`/drawer`** route (operator: record
|
||||||
|
+ own movements; admin: the review queue + all movements). `/shifts` is now just open/close +
|
||||||
|
Z-report. Server: `routes/drawer.ts` (lifted out of `routes/shift.ts`); UI: `DrawerManager.tsx`.
|
||||||
|
|
||||||
## Where the fraud control actually lives
|
## Where the fraud control actually lives
|
||||||
|
|
||||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||||
@@ -217,11 +253,12 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20):** opening float
|
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20; review reworked
|
||||||
auto-inherits the prior shift's expected drawer; drawer movements are now the **`cash_in` /
|
2026-07-01):** opening float auto-inherits the prior shift's expected drawer; drawer movements are the
|
||||||
`cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type, operator-raised
|
**`cash_in` / `cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type),
|
||||||
& admin-authorized), superseding the signed-± `cash_movement` (kept for history). Z-report reports
|
superseding the signed-± `cash_movement` (kept for history). As of 2026-07-01 an operator RECORDS them
|
||||||
the full drawer picture. See the Drawer balance section above.
|
freely and an admin REVIEWS after (signed `cash_review`, a flag not a reversal) — see "Drawer review".
|
||||||
|
Z-report reports the full drawer picture. See the Drawer balance section above.
|
||||||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||||||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||||||
the money. Confirm that's the intended accountability (vs. by entry).
|
the money. Confirm that's the intended accountability (vs. by entry).
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
|
|||||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||||
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
||||||
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
||||||
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts.
|
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts. Drawer cash movements (operator records, admin reviews via signed cash_review — a flag, not a reversal) live at the /drawer route (2026-07-01).
|
||||||
- [[card-payments]] — card tender DISABLED (no P2PE POS on-site yet, 2026-07-01); cash-only UI gate (`CARD_PAYMENTS_ENABLED`); future POS keeps PCI scope out of the app; how to re-enable.
|
- [[card-payments]] — card tender DISABLED (no P2PE POS on-site yet, 2026-07-01); cash-only UI gate (`CARD_PAYMENTS_ENABLED`); future POS keeps PCI scope out of the app; how to re-enable.
|
||||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||||
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
||||||
|
|||||||
+20
@@ -2088,3 +2088,23 @@ modal shows Total + "Pay + open barrier" with NO tender/cash/card row. Re-enable
|
|||||||
a bank-certified P2PE terminal is provisioned (PCI scope stays out of the app — the terminal captures
|
a bank-certified P2PE terminal is provisioned (PCI scope stays out of the app — the terminal captures
|
||||||
card data, not the app). New page concepts/card-payments.md documents current state + future-POS
|
card data, not the app). New page concepts/card-payments.md documents current state + future-POS
|
||||||
device requirements + re-enable path; linked from index, parking-session, open-questions #3.
|
device requirements + re-enable path; linked from index, parking-session, open-questions #3.
|
||||||
|
|
||||||
|
## [2026-07-01] feat | Drawer redesign — operator records freely, admin reviews after; moved to /drawer
|
||||||
|
|
||||||
|
Reworked drawer cash movements from synchronous admin-authorization-at-creation (operator typed an
|
||||||
|
admin's password inline at the booth for every receipt/disbursement) to operator-records → admin-
|
||||||
|
reviews-after. An operator with drawer:create RECORDS a cash_in/cash_out freely; it counts in the
|
||||||
|
drawer immediately. An admin with drawer:review AUTHORIZES/DENIES it after via a new signed cash_review
|
||||||
|
event { refId, decision, reviewedBy, note? }. THE LOAD-BEARING CHOICE (settled with user): a denial is
|
||||||
|
a FLAG, not a reversal — it never appends reversing cash and never touches the drawer balance (the
|
||||||
|
correction is the admin's/accountant's job outside the app; we are NOT building accounting). This kills
|
||||||
|
the cross-shift-leak problem the user raised: a denial that lands after the reviewed shift closed can't
|
||||||
|
pollute the next operator's inherited drawer, because it moves no cash. New `drawer` resource +
|
||||||
|
drawer:create (per-role revocable) / drawer:review permissions; migration 0018 grants operator
|
||||||
|
drawer:create. Feature moved OFF the polluted /shifts route to a top-level /drawer (operator: record +
|
||||||
|
own; admin: review queue + all). New routes/drawer.ts (lifted from routes/shift.ts, retired the
|
||||||
|
authorizer-password gate; kept shift:cash for its other job = admin-sees-all-shifts scope),
|
||||||
|
DrawerManager.tsx, drawer.* i18n (sq+en). Verified: full monorepo build/lint/test green (225 server
|
||||||
|
tests incl. the op1-denied → op2-drawer-unchanged regression); Playwright end-to-end on /drawer
|
||||||
|
(record disbursement → pending → authorize → status flips, ledger shows cash_out + cash_review with no
|
||||||
|
authorizedBy). Recorded in shift.md "Drawer review".
|
||||||
|
|||||||
Reference in New Issue
Block a user