feat(drawer): drawer hub — balance now, this-shift figure, daily activity, shift history; busy spinners
/drawer was record + review only: no current balance, no sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub: - Drawer now: the till's running balance (new GET /api/drawer/balance, shift:read — exposes the service's existing drawerBalance(); the drawer is one site-wide till, same exposure the X-report already had) with the open shift's X-report breakdown alongside (float + takings + vouchers = expected = balance) and a "This shift: ±X" figure (expected − opening float — the shift's own contribution vs what it inherited). - Today's cash activity: every cash payment + voucher since local midnight from the signed chain, live, with day totals (card never enters the till). - Record + movements/review: the 2026-07-01 flow, unchanged. - Closed shifts: drawer-focused history via the scope-aware /api/shifts (float → takings ± vouchers → expected per shift). Also: every shift open/close button (header, /shifts, pay modal, end- shift confirm) now shows an animated spinner + dims while busy — the old label-swap-only feedback read as a dead click when a shift open ran slow. The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain)) is recorded as an open item in wiki/concepts/shift.md with the fix sketch: fold from the last z-report's signed expectedDrawerMinor forward. No new ledger surface — one read-only endpoint; RBAC test added. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -9,6 +9,9 @@ import { InvalidCashMovementError, type MovementStatus, type ShiftService } from
|
||||
// - 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)
|
||||
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
|
||||
// payments + vouchers over the whole chain — the
|
||||
// amount that carries across shifts).
|
||||
// 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.
|
||||
|
||||
@@ -73,6 +76,10 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
|
||||
return { movements, scope: canReview ? "all" : "self" };
|
||||
});
|
||||
|
||||
// The physical drawer balance now. Same visibility as the open shift's X-report
|
||||
// (shift:read) — the drawer is a single site-wide till, not per-operator data.
|
||||
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -101,3 +101,21 @@ describe("CSRF double-submit on mutations", () => {
|
||||
expect(put.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drawer balance (the till NOW)", () => {
|
||||
it("shift:read gets the balance; a role without it is 403; no auth 401", async () => {
|
||||
const anon = await app.inject({ method: "GET", url: "/api/drawer/balance" });
|
||||
expect(anon.statusCode).toBe(401);
|
||||
|
||||
const viewer = await seedUser(db, { username: "till", roleId: "till", permissions: ["shift:read"] });
|
||||
const { cookie } = await login(app, viewer.username, viewer.password);
|
||||
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
|
||||
expect(ok.statusCode).toBe(200);
|
||||
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null });
|
||||
|
||||
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
||||
const other = await login(app, outsider.username, outsider.password);
|
||||
const denied = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie: other.cookie } });
|
||||
expect(denied.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -694,7 +694,7 @@ export class ShiftService {
|
||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
"",
|
||||
"-- Arka --",
|
||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Fillimi: ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
|
||||
Reference in New Issue
Block a user