@gelabs/ovr 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/auth-auth.js +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-MDTRBOPQ.js → chunk-2C3VCTYJ.js} +1 -1
  4. package/dist/chunk-3YKVH4Y7.js +126 -0
  5. package/dist/chunk-6YFZLXFP.js +84 -0
  6. package/dist/{chunk-3NZ2XUBO.js → chunk-AJ2RZTVX.js} +9 -2
  7. package/dist/chunk-BI4EGLPG.js +298 -0
  8. package/dist/{chunk-3KIDW4LT.js → chunk-BVI5XDDA.js} +1 -1
  9. package/dist/chunk-DJMUW5T2.js +298 -0
  10. package/dist/{chunk-BIQ2J75Y.js → chunk-GLIK5BHP.js} +2 -2
  11. package/dist/{chunk-JEYT63LE.js → chunk-IBZVIUNI.js} +1 -1
  12. package/dist/{chunk-4SZXBT56.js → chunk-NT72CQAI.js} +2 -2
  13. package/dist/{chunk-E2D7QT6N.js → chunk-TJSNVTVB.js} +1 -1
  14. package/dist/{chunk-5Z2IAD5I.js → chunk-TLG4C2XI.js} +2 -2
  15. package/dist/chunk-V7VQVDWS.js +237 -0
  16. package/dist/chunk-WUNTHINH.js +98 -0
  17. package/dist/{chunk-IF5UAVIE.js → chunk-YC7G2IOZ.js} +1 -1
  18. package/dist/{chunk-IB4JVGKJ.js → chunk-YGYA7KEG.js} +47 -3
  19. package/dist/{chunk-GDOCD7LT.js → chunk-ZUMEOZ22.js} +5 -5
  20. package/dist/core-i18n.d.ts +2 -2
  21. package/dist/core-i18n.js +1 -1
  22. package/dist/core.d.ts +61 -1
  23. package/dist/core.js +1 -1
  24. package/dist/data-mock-store.js +330 -12
  25. package/dist/data-prisma-store.js +319 -9
  26. package/dist/data-seed-runner.js +18 -15
  27. package/dist/data.d.ts +64 -3
  28. package/dist/generated/client/edge.js +31 -10
  29. package/dist/generated/client/index-browser.js +28 -7
  30. package/dist/generated/client/index.d.ts +3583 -577
  31. package/dist/generated/client/index.js +31 -10
  32. package/dist/generated/client/package.json +1 -1
  33. package/dist/generated/client/schema.prisma +48 -9
  34. package/dist/generated/client/wasm.js +31 -10
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +1 -1
  37. package/dist/offline.d.ts +34 -1
  38. package/dist/offline.js +2 -2
  39. package/dist/types-B8MopM4b.d.ts +281 -0
  40. package/dist/types.d.ts +104 -1
  41. package/dist/types.js +1 -1
  42. package/dist/ui-components-admin/accounts-manager.d.ts +52 -0
  43. package/dist/ui-components-admin/accounts-manager.js +471 -0
  44. package/dist/ui-components-admin/admin-nav.d.ts +15 -1
  45. package/dist/ui-components-admin/admin-nav.js +388 -60
  46. package/dist/ui-components-admin/issuance-form.js +72 -13
  47. package/dist/ui-components-admin/logs-viewer.d.ts +13 -0
  48. package/dist/ui-components-admin/logs-viewer.js +102 -0
  49. package/dist/ui-components-admin/notifications-list.d.ts +5 -0
  50. package/dist/ui-components-admin/notifications-list.js +70 -0
  51. package/dist/ui-components-admin/officers-manager.d.ts +27 -0
  52. package/dist/ui-components-admin/officers-manager.js +271 -0
  53. package/dist/ui-components-admin/roles-manager.d.ts +37 -0
  54. package/dist/ui-components-admin/roles-manager.js +406 -0
  55. package/dist/ui-components-admin/ticket-preview.js +7 -7
  56. package/dist/ui-components-admin/tickets-table.js +56 -33
  57. package/dist/ui-components-admin/violations-manager.d.ts +32 -0
  58. package/dist/ui-components-admin/violations-manager.js +385 -0
  59. package/dist/ui-components-citizen/citizen-nav.js +2 -2
  60. package/dist/ui-components-citizen/payment-form.js +5 -5
  61. package/dist/ui-components-citizen/payment-qr-dialog.js +4 -4
  62. package/dist/ui-components-citizen/ticket-not-found.js +2 -2
  63. package/dist/ui-components-citizen/violation-history-table.js +3 -3
  64. package/dist/ui-components-shared/amount-summary.js +4 -4
  65. package/dist/ui-components-shared/money.js +3 -3
  66. package/dist/ui-components-shared/municipal-seal.js +3 -3
  67. package/dist/ui-components-shared/official-header.js +4 -4
  68. package/dist/ui-components-shared/site-header.js +4 -4
  69. package/dist/ui-components-shared/sonner.js +2 -2
  70. package/dist/ui-components-shared/theme-toggle.js +3 -3
  71. package/dist/ui-components-shared/ticket-receipt.js +13 -6
  72. package/dist/ui-components-shared/violations-table.js +4 -4
  73. package/dist/ui-components-ui/badge.d.ts +1 -1
  74. package/dist/ui-components-ui/button.d.ts +1 -1
  75. package/dist/ui-components-ui/dropdown-menu.js +2 -237
  76. package/dist/ui-components-ui/sheet.js +3 -126
  77. package/dist/ui-config.d.ts +1 -1
  78. package/dist/ui-config.js +2 -2
  79. package/dist/ui-server.d.ts +1 -1
  80. package/dist/ui-server.js +2 -2
  81. package/package.json +6 -6
  82. package/prisma/migrations/20260622010000_add_super_admin_role/migration.sql +3 -0
  83. package/prisma/migrations/20260622020000_add_apprehending_enforcer/migration.sql +4 -0
  84. package/prisma/migrations/20260622030000_custom_roles/migration.sql +30 -0
  85. package/prisma/migrations/20260622040000_add_activity_log/migration.sql +18 -0
  86. package/prisma/migrations/20260622050000_violation_catalog_management/migration.sql +5 -0
  87. package/prisma/schema.prisma +48 -9
  88. package/dist/chunk-5YYR37CF.js +0 -146
  89. package/dist/chunk-B634JHKZ.js +0 -181
  90. package/dist/types-CtBC5-TW.d.ts +0 -129
@@ -1,4 +1,4 @@
1
- import { makeOvrTicketNo, addDays, makeBillNo, makeOrderOfPaymentNo, enrich } from './chunk-B634JHKZ.js';
1
+ import { makeOvrTicketNo, addDays, makeBillNo, makeOrderOfPaymentNo, enrich, fullName } from './chunk-BI4EGLPG.js';
2
2
  import Dexie from 'dexie';
3
3
  import { useState, useEffect } from 'react';
4
4
  import { useLiveQuery } from 'dexie-react-hooks';
@@ -186,6 +186,10 @@ async function issueTicketOffline(input, rules) {
186
186
  record.placeOfViolation = input.placeOfViolation.trim();
187
187
  if (input.remarks?.trim()) record.remarks = input.remarks.trim();
188
188
  if (input.issuedBy?.trim()) record.issuedBy = input.issuedBy.trim();
189
+ if (input.apprehendingEnforcerId)
190
+ record.apprehendingEnforcerId = input.apprehendingEnforcerId;
191
+ if (input.apprehendingEnforcerName?.trim())
192
+ record.apprehendingEnforcerName = input.apprehendingEnforcerName.trim();
189
193
  const ticket = enrich(record, now, rules.surchargeRatePerMonth);
190
194
  await db.transaction("rw", db.tickets, db.outbox, async () => {
191
195
  await db.tickets.put(ticket);
@@ -201,7 +205,9 @@ async function issueTicketOffline(input, rules) {
201
205
  ...record.placeOfViolation ? { placeOfViolation: record.placeOfViolation } : {},
202
206
  officerId: input.officerId,
203
207
  violations: input.violations,
204
- ...record.remarks ? { remarks: record.remarks } : {}
208
+ ...record.remarks ? { remarks: record.remarks } : {},
209
+ ...record.apprehendingEnforcerId ? { apprehendingEnforcerId: record.apprehendingEnforcerId } : {},
210
+ ...record.apprehendingEnforcerName ? { apprehendingEnforcerName: record.apprehendingEnforcerName } : {}
205
211
  }
206
212
  });
207
213
  });
@@ -320,6 +326,44 @@ function useIdentity() {
320
326
  async () => (await db.meta.get("identity"))?.value
321
327
  );
322
328
  }
329
+ function useNotifications() {
330
+ const tickets = useTickets();
331
+ if (!tickets) return [];
332
+ const items = [];
333
+ for (const t of tickets) {
334
+ const type = t.status === "OVERDUE" ? "overdue" : t.status === "OUTSTANDING" ? "outstanding" : null;
335
+ if (!type) continue;
336
+ items.push({
337
+ id: `${t.ovrTicketNo}:${type}`,
338
+ type,
339
+ ovrTicketNo: t.ovrTicketNo,
340
+ name: fullName(t.violator),
341
+ at: type === "overdue" ? t.dueDate : t.createdAt,
342
+ href: `/admin/tickets/${encodeURIComponent(t.ovrTicketNo)}`
343
+ });
344
+ }
345
+ const rank = (n) => n.type === "overdue" ? 0 : 1;
346
+ items.sort((a, b) => rank(a) - rank(b) || (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
347
+ return items;
348
+ }
349
+ function notifSignature(items) {
350
+ return items.map((n) => n.id).join("|");
351
+ }
352
+ function useNotificationsState() {
353
+ const items = useNotifications();
354
+ const seen = useLiveQuery(
355
+ async () => (await db.meta.get("notif_seen"))?.value
356
+ );
357
+ const signature = notifSignature(items);
358
+ return {
359
+ items,
360
+ total: items.length,
361
+ unseen: items.length > 0 && signature !== seen,
362
+ markSeen: () => {
363
+ void setMeta("notif_seen", signature);
364
+ }
365
+ };
366
+ }
323
367
  function useStats() {
324
368
  return useLiveQuery(
325
369
  async () => (await db.meta.get("stats"))?.value
@@ -376,4 +420,4 @@ function useSync() {
376
420
  return { syncing, online, error };
377
421
  }
378
422
 
379
- export { SessionExpired, cacheCredential, clearIdentity, db, ensureLease, getIdentity, getMeta, isOnline, issueTicketOffline, offlineApiBase, pullAll, pushOutbox, setMeta, setOfflineApiBase, sync, useAdminAuth, useCatalog, useIdentity, useOfficers, usePendingSync, useStats, useSync, useTicket, useTickets, verifyOffline };
423
+ export { SessionExpired, cacheCredential, clearIdentity, db, ensureLease, getIdentity, getMeta, isOnline, issueTicketOffline, offlineApiBase, pullAll, pushOutbox, setMeta, setOfflineApiBase, sync, useAdminAuth, useCatalog, useIdentity, useNotifications, useNotificationsState, useOfficers, usePendingSync, useStats, useSync, useTicket, useTickets, verifyOffline };
@@ -1,9 +1,9 @@
1
- import { ViolationsTable } from './chunk-JEYT63LE.js';
2
- import { AmountSummary } from './chunk-BIQ2J75Y.js';
3
- import { Card, CardHeader, CardTitle, CardContent } from './chunk-SETIN6XP.js';
1
+ import { ViolationsTable } from './chunk-IBZVIUNI.js';
2
+ import { AmountSummary } from './chunk-GLIK5BHP.js';
4
3
  import { StatusBadge } from './chunk-OE525ZER.js';
5
- import { useFormatters } from './chunk-E2D7QT6N.js';
6
- import { formalName, formatAddress } from './chunk-B634JHKZ.js';
4
+ import { Card, CardHeader, CardTitle, CardContent } from './chunk-SETIN6XP.js';
5
+ import { useFormatters } from './chunk-TJSNVTVB.js';
6
+ import { formalName, formatAddress } from './chunk-BI4EGLPG.js';
7
7
  import { jsxs, jsx } from 'react/jsx-runtime';
8
8
 
9
9
  function Field({ label, value }) {
@@ -1,5 +1,5 @@
1
- import { C as CopyOverrides, D as Dictionary } from './types-CtBC5-TW.js';
2
- export { b as baseCopy } from './types-CtBC5-TW.js';
1
+ import { C as CopyOverrides, D as Dictionary } from './types-B8MopM4b.js';
2
+ export { b as baseCopy } from './types-B8MopM4b.js';
3
3
  import { M as MunicipalityConfig } from './schema-CdsFQxIg.js';
4
4
  import 'zod';
5
5
 
package/dist/core-i18n.js CHANGED
@@ -1 +1 @@
1
- export { baseCopy, mergeCopy } from './chunk-5YYR37CF.js';
1
+ export { baseCopy, mergeCopy } from './chunk-DJMUW5T2.js';
package/dist/core.d.ts CHANGED
@@ -73,4 +73,64 @@ interface IssuanceDraft {
73
73
  type PreviewRules = Pick<RulesConfig, "dueWindowDays" | "surchargeRatePerMonth">;
74
74
  declare function toPreviewTicket(draft: IssuanceDraft, now: Date, rules: PreviewRules): Ticket;
75
75
 
76
- export { type ChargeInput, type ChargeResult, type IssuanceDraft, PREVIEW_PLACEHOLDER, type PreviewRules, computeCharges, enrich, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, makePaymentRef, round2, startedMonthsSince, toPreviewTicket };
76
+ /**
77
+ * Dashboard time-range presets (GE-010): 1D / 7D / MTD / prev-MTD (+ All).
78
+ *
79
+ * Ranges are computed in the LGU's timezone (config `timeZone`) so "today" / "this
80
+ * month" mean the local calendar, not the server's. Stats are derived from the
81
+ * on-device ticket list (offline-first) — no server round-trip — by ISSUE date
82
+ * (`createdAt`); each card reflects the CURRENT status of tickets issued in range.
83
+ */
84
+
85
+ type DashboardRangePreset = "1d" | "7d" | "mtd" | "prev-mtd" | "all";
86
+ interface DashboardPreset {
87
+ key: DashboardRangePreset;
88
+ label: string;
89
+ }
90
+ /** The presets, in display order. */
91
+ declare const DASHBOARD_PRESETS: DashboardPreset[];
92
+ interface DashboardRange {
93
+ /** Inclusive lower bound (ISO), or null for "all time" (no lower bound). */
94
+ fromISO: string | null;
95
+ /** Inclusive upper bound (ISO). */
96
+ toISO: string;
97
+ }
98
+ interface DashboardStats {
99
+ issued: number;
100
+ outstanding: number;
101
+ overdue: number;
102
+ paid: number;
103
+ collected: number;
104
+ }
105
+ /** Resolve a preset to a concrete [from, to] range, anchored to `now` in `timeZone`. */
106
+ declare function dashboardDateRange(preset: DashboardRangePreset, now: Date, timeZone: string): DashboardRange;
107
+ /** A custom date range from two `YYYY-MM-DD` local dates (either may be empty:
108
+ * empty `from` → no lower bound; empty `to` → up to `now`). Interpreted in
109
+ * `timeZone` as [from 00:00:00, to 23:59:59]. If from > to, they're swapped. */
110
+ declare function customDateRange(fromYMD: string | null | undefined, toYMD: string | null | undefined, now: Date, timeZone: string): DashboardRange;
111
+ /** Compute the dashboard cards from the local ticket list, scoped to `range`
112
+ * by issue date. Each card reflects each ticket's CURRENT status. */
113
+ declare function computeDashboardStats(tickets: Ticket[], range: DashboardRange): DashboardStats;
114
+
115
+ /**
116
+ * Philippine locations (PSGC) for the Issue-Ticket address dropdowns (GE-015).
117
+ *
118
+ * Provinces + their cities/municipalities ONLY (barangays omitted to keep the
119
+ * bundle small — barangay stays free-text). Bundled (no runtime API) so it works
120
+ * offline. NCR districts are flattened into a single "Metro Manila" province.
121
+ *
122
+ * Source (open data, CC0/public): flores-jacob/philippine-regions-provinces-
123
+ * cities-municipalities-barangays (2019v2, derived from PSA PSGC). Names are
124
+ * title-cased from the source's all-caps.
125
+ */
126
+ interface PhProvince {
127
+ province: string;
128
+ cities: string[];
129
+ }
130
+ declare const PH_PROVINCES: PhProvince[];
131
+ /** All province names (display order = alphabetical). */
132
+ declare function listProvinces(): string[];
133
+ /** Cities/municipalities of a province (case-insensitive match; [] if unknown). */
134
+ declare function citiesOfProvince(province: string | null | undefined): string[];
135
+
136
+ export { type ChargeInput, type ChargeResult, DASHBOARD_PRESETS, type DashboardPreset, type DashboardRange, type DashboardRangePreset, type DashboardStats, type IssuanceDraft, PH_PROVINCES, PREVIEW_PLACEHOLDER, type PhProvince, type PreviewRules, citiesOfProvince, computeCharges, computeDashboardStats, customDateRange, dashboardDateRange, enrich, listProvinces, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, makePaymentRef, round2, startedMonthsSince, toPreviewTicket };
package/dist/core.js CHANGED
@@ -1 +1 @@
1
- export { PREVIEW_PLACEHOLDER, addDays, computeCharges, createFormatters, enrich, formalName, formatAddress, fullName, localToManilaISO, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, makePaymentRef, round2, startedMonthsSince, toPreviewTicket } from './chunk-B634JHKZ.js';
1
+ export { DASHBOARD_PRESETS, PH_PROVINCES, PREVIEW_PLACEHOLDER, addDays, citiesOfProvince, computeCharges, computeDashboardStats, createFormatters, customDateRange, dashboardDateRange, enrich, formalName, formatAddress, fullName, listProvinces, localToManilaISO, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, makePaymentRef, round2, startedMonthsSince, toPreviewTicket } from './chunk-BI4EGLPG.js';
@@ -1,20 +1,37 @@
1
- import { round2, computeCharges, makePaymentRef, fullName, addDays, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, enrich } from './chunk-B634JHKZ.js';
1
+ import { SUPER_ADMIN_LOCKED_PERMISSIONS, ALL_PERMISSIONS } from './chunk-WUNTHINH.js';
2
+ import { round2, computeCharges, makePaymentRef, fullName, addDays, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, enrich } from './chunk-BI4EGLPG.js';
2
3
  import 'server-only';
3
4
  import { promises } from 'fs';
5
+ import { randomUUID } from 'crypto';
4
6
  import os from 'os';
5
7
  import path from 'path';
6
8
 
7
- var SEED_VERSION = 1;
9
+ var SEED_VERSION = 6;
8
10
  var norm = (s) => s.trim().toLowerCase();
11
+ function slugRole(label) {
12
+ return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13
+ }
14
+ function normalizePerms(perms) {
15
+ const set = new Set(perms);
16
+ return ALL_PERMISSIONS.filter((p) => set.has(p));
17
+ }
9
18
  function createMockStore(rules, seed) {
10
- const { CATALOG, OFFICERS, SEED_TICKETS, SEED_NEXT_SEQ } = seed;
19
+ const { CATALOG, OFFICERS, SEED_TICKETS, SEED_NEXT_SEQ, USERS, ROLES } = seed;
11
20
  const STORE_PATH = process.env.EOVR_STORE_FILE || path.join(os.tmpdir(), "eovr-store.json");
12
21
  const enrichRec = (rec, asOf) => enrich(rec, asOf, rules.surchargeRatePerMonth);
13
22
  function seeded() {
14
23
  return {
15
24
  version: SEED_VERSION,
16
25
  counter: SEED_NEXT_SEQ,
17
- tickets: structuredClone(SEED_TICKETS)
26
+ tickets: structuredClone(SEED_TICKETS),
27
+ users: structuredClone(USERS),
28
+ roles: structuredClone(ROLES),
29
+ activities: [],
30
+ officers: structuredClone(OFFICERS),
31
+ catalog: structuredClone(CATALOG).map((c) => ({
32
+ ...c,
33
+ active: c.active ?? true
34
+ }))
18
35
  };
19
36
  }
20
37
  async function writeStore(data) {
@@ -24,7 +41,7 @@ function createMockStore(rules, seed) {
24
41
  try {
25
42
  const raw = await promises.readFile(STORE_PATH, "utf8");
26
43
  const data = JSON.parse(raw);
27
- if (!data || data.version !== SEED_VERSION || !Array.isArray(data.tickets) || typeof data.counter !== "number") {
44
+ if (!data || data.version !== SEED_VERSION || !Array.isArray(data.tickets) || !Array.isArray(data.users) || !Array.isArray(data.roles) || !Array.isArray(data.activities) || !Array.isArray(data.officers) || !Array.isArray(data.catalog) || typeof data.counter !== "number") {
28
45
  const fresh = seeded();
29
46
  await writeStore(fresh);
30
47
  return fresh;
@@ -38,22 +55,141 @@ function createMockStore(rules, seed) {
38
55
  }
39
56
  const store = {
40
57
  async listViolationCatalog(category) {
41
- const items = category ? CATALOG.filter((c) => c.category === category) : CATALOG;
58
+ const data = await readStore();
59
+ const items = data.catalog.filter(
60
+ (c) => c.active !== false && (!category || c.category === category)
61
+ );
42
62
  return structuredClone(items);
43
63
  },
64
+ // ── Violation catalog management (GE-031) ──────────────────────────────
65
+ async listAllViolations() {
66
+ const data = await readStore();
67
+ return structuredClone(data.catalog).sort(
68
+ (a, b) => a.category === b.category ? a.code.localeCompare(b.code) : a.category.localeCompare(b.category)
69
+ );
70
+ },
71
+ async createViolation(input) {
72
+ const data = await readStore();
73
+ const code = input.code.trim();
74
+ if (!code) throw new Error("Violation code is required.");
75
+ const title = input.title.trim();
76
+ if (!title) throw new Error("Title is required.");
77
+ if (!(input.basicFine >= 0)) throw new Error("Basic fine must be \u2265 0.");
78
+ if (data.catalog.some((c) => norm(c.code) === norm(code))) {
79
+ throw new Error(`A violation with code "${code}" already exists.`);
80
+ }
81
+ const item = {
82
+ code,
83
+ title,
84
+ category: input.category,
85
+ basicFine: round2(input.basicFine),
86
+ legalText: input.legalText?.trim() || void 0,
87
+ active: true
88
+ };
89
+ data.catalog.push(item);
90
+ await writeStore(data);
91
+ return structuredClone(item);
92
+ },
93
+ async updateViolation(code, patch) {
94
+ const data = await readStore();
95
+ const c = data.catalog.find((x) => x.code === code);
96
+ if (!c) throw new Error("Violation not found.");
97
+ if (patch.title !== void 0) {
98
+ const title = patch.title.trim();
99
+ if (!title) throw new Error("Title is required.");
100
+ c.title = title;
101
+ }
102
+ if (patch.category !== void 0) c.category = patch.category;
103
+ if (patch.basicFine !== void 0) {
104
+ if (!(patch.basicFine >= 0)) throw new Error("Basic fine must be \u2265 0.");
105
+ c.basicFine = round2(patch.basicFine);
106
+ }
107
+ if (patch.legalText !== void 0) {
108
+ c.legalText = patch.legalText?.trim() || void 0;
109
+ }
110
+ if (patch.active !== void 0) c.active = patch.active;
111
+ await writeStore(data);
112
+ return structuredClone(c);
113
+ },
114
+ async deleteViolation(code) {
115
+ const data = await readStore();
116
+ const c = data.catalog.find((x) => x.code === code);
117
+ if (!c) throw new Error("Violation not found.");
118
+ c.active = false;
119
+ await writeStore(data);
120
+ },
44
121
  async listOfficers() {
45
- return structuredClone(OFFICERS);
122
+ const data = await readStore();
123
+ return structuredClone(data.officers);
46
124
  },
47
125
  async getOfficer(id) {
48
- return OFFICERS.find((o) => o.id === id) ?? null;
126
+ const data = await readStore();
127
+ return data.officers.find((o) => o.id === id) ?? null;
128
+ },
129
+ async createOfficer(input) {
130
+ const data = await readStore();
131
+ const name = input.name.trim();
132
+ if (!name) throw new Error("Officer name is required.");
133
+ const office = input.office.trim();
134
+ if (!office) throw new Error("Office is required.");
135
+ const id = `off-${slugRole(name)}`;
136
+ if (id === "off-") {
137
+ throw new Error("Officer name must include letters or numbers.");
138
+ }
139
+ if (data.officers.some((o) => o.id === id)) {
140
+ throw new Error(`An officer "${name}" already exists.`);
141
+ }
142
+ const officer = {
143
+ id,
144
+ name,
145
+ badgeNo: input.badgeNo?.trim() || void 0,
146
+ office
147
+ };
148
+ data.officers.push(officer);
149
+ await writeStore(data);
150
+ return structuredClone(officer);
151
+ },
152
+ async updateOfficer(id, patch) {
153
+ const data = await readStore();
154
+ const o = data.officers.find((x) => x.id === id);
155
+ if (!o) throw new Error("Officer not found.");
156
+ if (patch.name !== void 0) {
157
+ const name = patch.name.trim();
158
+ if (!name) throw new Error("Officer name is required.");
159
+ o.name = name;
160
+ }
161
+ if (patch.office !== void 0) {
162
+ const office = patch.office.trim();
163
+ if (!office) throw new Error("Office is required.");
164
+ o.office = office;
165
+ }
166
+ if (patch.badgeNo !== void 0) o.badgeNo = patch.badgeNo?.trim() || void 0;
167
+ await writeStore(data);
168
+ return structuredClone(o);
169
+ },
170
+ async deleteOfficer(id) {
171
+ const data = await readStore();
172
+ const o = data.officers.find((x) => x.id === id);
173
+ if (!o) throw new Error("Officer not found.");
174
+ const ticketCount = data.tickets.filter((t) => t.officer.id === id).length;
175
+ if (ticketCount > 0) {
176
+ throw new Error(
177
+ `Can't remove \u2014 this officer has ${ticketCount} issued ticket(s).`
178
+ );
179
+ }
180
+ if (data.users.some((u) => u.officerId === id)) {
181
+ throw new Error("Can't remove \u2014 this officer is linked to an account.");
182
+ }
183
+ data.officers = data.officers.filter((x) => x.id !== id);
184
+ await writeStore(data);
49
185
  },
50
186
  async createTicket(input) {
51
187
  const store2 = await readStore();
52
188
  const now = /* @__PURE__ */ new Date();
53
189
  const seq = store2.counter;
54
- const officer = OFFICERS.find((o) => o.id === input.officerId) ?? OFFICERS[0];
190
+ const officer = store2.officers.find((o) => o.id === input.officerId) ?? store2.officers[0];
55
191
  const violations = input.violations.map((v) => {
56
- const c = CATALOG.find((x) => x.code === v.catalogCode);
192
+ const c = store2.catalog.find((x) => x.code === v.catalogCode);
57
193
  if (!c) throw new Error(`Unknown violation code: ${v.catalogCode}`);
58
194
  return {
59
195
  catalogCode: c.code,
@@ -83,6 +219,8 @@ function createMockStore(rules, seed) {
83
219
  violations,
84
220
  remarks: input.remarks?.trim() || void 0,
85
221
  issuedBy: input.issuedBy?.trim() || void 0,
222
+ apprehendingEnforcerId: input.apprehendingEnforcerId || void 0,
223
+ apprehendingEnforcerName: input.apprehendingEnforcerName?.trim() || void 0,
86
224
  assessedAt,
87
225
  dueDate: addDays(now, rules.dueWindowDays).toISOString(),
88
226
  basicFinesTotal,
@@ -110,9 +248,9 @@ function createMockStore(rules, seed) {
110
248
  );
111
249
  if (existing) return enrichRec(existing, /* @__PURE__ */ new Date());
112
250
  const created = new Date(input.createdAt);
113
- const officer = OFFICERS.find((o) => o.id === input.officerId) ?? OFFICERS[0];
251
+ const officer = data.officers.find((o) => o.id === input.officerId) ?? data.officers[0];
114
252
  const violations = input.violations.map((v) => {
115
- const c = CATALOG.find((x) => x.code === v.catalogCode);
253
+ const c = data.catalog.find((x) => x.code === v.catalogCode);
116
254
  if (!c) throw new Error(`Unknown violation code: ${v.catalogCode}`);
117
255
  return {
118
256
  catalogCode: c.code,
@@ -137,6 +275,8 @@ function createMockStore(rules, seed) {
137
275
  violations,
138
276
  remarks: input.remarks?.trim() || void 0,
139
277
  issuedBy: input.issuedBy?.trim() || void 0,
278
+ apprehendingEnforcerId: input.apprehendingEnforcerId || void 0,
279
+ apprehendingEnforcerName: input.apprehendingEnforcerName?.trim() || void 0,
140
280
  assessedAt: input.createdAt,
141
281
  dueDate: addDays(created, rules.dueWindowDays).toISOString(),
142
282
  basicFinesTotal,
@@ -222,6 +362,184 @@ function createMockStore(rules, seed) {
222
362
  }) === todayKey
223
363
  ).length
224
364
  };
365
+ },
366
+ // ── Accounts (GE-013 RBAC) ──────────────────────────────────────────────
367
+ // Mock mode has no real credential store (login is the demo cookie); these
368
+ // back the accounts UI so it's fully browsable without Postgres.
369
+ async listUsers() {
370
+ const data = await readStore();
371
+ return structuredClone(data.users).sort(
372
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
373
+ );
374
+ },
375
+ async createUser(input) {
376
+ const data = await readStore();
377
+ const username = input.username.trim();
378
+ if (!username) throw new Error("Username is required.");
379
+ const role = data.roles.find((r) => r.name === input.role);
380
+ if (!role) throw new Error(`Unknown role: ${input.role}`);
381
+ if (data.users.some((u) => norm(u.username) === norm(username))) {
382
+ throw new Error(`Username "${username}" is already taken.`);
383
+ }
384
+ const officer = input.officerId ? data.officers.find((o) => o.id === input.officerId) : void 0;
385
+ const account = {
386
+ id: `usr-${randomUUID()}`,
387
+ username,
388
+ role: role.name,
389
+ roleLabel: role.label,
390
+ active: true,
391
+ officerId: input.officerId ?? null,
392
+ officerName: officer?.name,
393
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
394
+ };
395
+ data.users.unshift(account);
396
+ await writeStore(data);
397
+ return structuredClone(account);
398
+ },
399
+ async setUserActive(id, active) {
400
+ const data = await readStore();
401
+ const u = data.users.find((x) => x.id === id);
402
+ if (!u) throw new Error("Account not found.");
403
+ u.active = active;
404
+ await writeStore(data);
405
+ return structuredClone(u);
406
+ },
407
+ async setUserRole(id, role) {
408
+ const data = await readStore();
409
+ const u = data.users.find((x) => x.id === id);
410
+ if (!u) throw new Error("Account not found.");
411
+ const roleDef = data.roles.find((r) => r.name === role);
412
+ if (!roleDef) throw new Error(`Unknown role: ${role}`);
413
+ u.role = roleDef.name;
414
+ u.roleLabel = roleDef.label;
415
+ await writeStore(data);
416
+ return structuredClone(u);
417
+ },
418
+ async updateUser(id, patch) {
419
+ const data = await readStore();
420
+ const u = data.users.find((x) => x.id === id);
421
+ if (!u) throw new Error("Account not found.");
422
+ if (patch.username !== void 0) {
423
+ const username = patch.username.trim();
424
+ if (!username) throw new Error("Username is required.");
425
+ if (data.users.some((x) => x.id !== id && norm(x.username) === norm(username))) {
426
+ throw new Error(`Username "${username}" is already taken.`);
427
+ }
428
+ u.username = username;
429
+ }
430
+ if (patch.role !== void 0) {
431
+ const roleDef = data.roles.find((r) => r.name === patch.role);
432
+ if (!roleDef) throw new Error(`Unknown role: ${patch.role}`);
433
+ u.role = roleDef.name;
434
+ u.roleLabel = roleDef.label;
435
+ }
436
+ if (patch.officerId !== void 0) {
437
+ u.officerId = patch.officerId ?? null;
438
+ const officer = patch.officerId ? data.officers.find((o) => o.id === patch.officerId) : void 0;
439
+ u.officerName = officer?.name;
440
+ }
441
+ await writeStore(data);
442
+ return structuredClone(u);
443
+ },
444
+ async resetUserPassword() {
445
+ },
446
+ // ── Roles (GE-013 RBAC) ─────────────────────────────────────────────────
447
+ async listRoles() {
448
+ const data = await readStore();
449
+ return structuredClone(data.roles).sort(
450
+ (a, b) => a.isSystem === b.isSystem ? a.label.localeCompare(b.label) : a.isSystem ? -1 : 1
451
+ );
452
+ },
453
+ async getRolePermissions(name) {
454
+ const data = await readStore();
455
+ const r = data.roles.find((x) => x.name === name);
456
+ return r ? normalizePerms(r.permissions) : [];
457
+ },
458
+ async createRole(input) {
459
+ const data = await readStore();
460
+ const label = input.label.trim();
461
+ if (!label) throw new Error("Role name is required.");
462
+ const name = slugRole(label);
463
+ if (!name) throw new Error("Role name must include letters or numbers.");
464
+ if (data.roles.some((r) => r.name === name)) {
465
+ throw new Error(`A role named "${label}" already exists.`);
466
+ }
467
+ const role = {
468
+ name,
469
+ label,
470
+ isSystem: false,
471
+ permissions: normalizePerms(input.permissions)
472
+ };
473
+ data.roles.push(role);
474
+ await writeStore(data);
475
+ return structuredClone(role);
476
+ },
477
+ async updateRole(name, patch) {
478
+ const data = await readStore();
479
+ const role = data.roles.find((r) => r.name === name);
480
+ if (!role) throw new Error("Role not found.");
481
+ if (patch.label !== void 0 && !role.isSystem) {
482
+ const label = patch.label.trim();
483
+ if (!label) throw new Error("Role name is required.");
484
+ role.label = label;
485
+ }
486
+ if (patch.permissions !== void 0) {
487
+ let perms = normalizePerms(patch.permissions);
488
+ if (name === "SUPER_ADMIN") {
489
+ perms = normalizePerms([...perms, ...SUPER_ADMIN_LOCKED_PERMISSIONS]);
490
+ }
491
+ role.permissions = perms;
492
+ }
493
+ await writeStore(data);
494
+ return structuredClone(role);
495
+ },
496
+ async deleteRole(name) {
497
+ const data = await readStore();
498
+ const role = data.roles.find((r) => r.name === name);
499
+ if (!role) throw new Error("Role not found.");
500
+ if (role.isSystem) throw new Error("System roles can't be deleted.");
501
+ const inUse = data.users.filter((u) => u.role === name).length;
502
+ if (inUse > 0) {
503
+ throw new Error(
504
+ `Can't delete "${role.label}" \u2014 it's assigned to ${inUse} account(s).`
505
+ );
506
+ }
507
+ data.roles = data.roles.filter((r) => r.name !== name);
508
+ await writeStore(data);
509
+ },
510
+ // ── Activity log (GE-022) ───────────────────────────────────────────────
511
+ async logActivity(entry) {
512
+ const data = await readStore();
513
+ data.activities.push({
514
+ id: `act-${randomUUID()}`,
515
+ actorId: entry.actorId ?? null,
516
+ actorUsername: entry.actorUsername ?? null,
517
+ action: entry.action,
518
+ summary: entry.summary,
519
+ targetType: entry.targetType,
520
+ targetId: entry.targetId,
521
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
522
+ });
523
+ await writeStore(data);
524
+ },
525
+ async listActivity(filter) {
526
+ const data = await readStore();
527
+ let items = [...data.activities].sort(
528
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
529
+ );
530
+ if (filter?.action) items = items.filter((a) => a.action === filter.action);
531
+ if (filter?.actorId)
532
+ items = items.filter((a) => a.actorId === filter.actorId);
533
+ if (filter?.fromISO) {
534
+ const lo = Date.parse(filter.fromISO);
535
+ items = items.filter((a) => Date.parse(a.createdAt) >= lo);
536
+ }
537
+ if (filter?.toISO) {
538
+ const hi = Date.parse(filter.toISO);
539
+ items = items.filter((a) => Date.parse(a.createdAt) <= hi);
540
+ }
541
+ const limit = Math.min(Math.max(filter?.limit ?? 200, 1), 1e3);
542
+ return structuredClone(items.slice(0, limit));
225
543
  }
226
544
  };
227
545
  return store;