@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.
- package/dist/auth-auth.js +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-MDTRBOPQ.js → chunk-2C3VCTYJ.js} +1 -1
- package/dist/chunk-3YKVH4Y7.js +126 -0
- package/dist/chunk-6YFZLXFP.js +84 -0
- package/dist/{chunk-3NZ2XUBO.js → chunk-AJ2RZTVX.js} +9 -2
- package/dist/chunk-BI4EGLPG.js +298 -0
- package/dist/{chunk-3KIDW4LT.js → chunk-BVI5XDDA.js} +1 -1
- package/dist/chunk-DJMUW5T2.js +298 -0
- package/dist/{chunk-BIQ2J75Y.js → chunk-GLIK5BHP.js} +2 -2
- package/dist/{chunk-JEYT63LE.js → chunk-IBZVIUNI.js} +1 -1
- package/dist/{chunk-4SZXBT56.js → chunk-NT72CQAI.js} +2 -2
- package/dist/{chunk-E2D7QT6N.js → chunk-TJSNVTVB.js} +1 -1
- package/dist/{chunk-5Z2IAD5I.js → chunk-TLG4C2XI.js} +2 -2
- package/dist/chunk-V7VQVDWS.js +237 -0
- package/dist/chunk-WUNTHINH.js +98 -0
- package/dist/{chunk-IF5UAVIE.js → chunk-YC7G2IOZ.js} +1 -1
- package/dist/{chunk-IB4JVGKJ.js → chunk-YGYA7KEG.js} +47 -3
- package/dist/{chunk-GDOCD7LT.js → chunk-ZUMEOZ22.js} +5 -5
- package/dist/core-i18n.d.ts +2 -2
- package/dist/core-i18n.js +1 -1
- package/dist/core.d.ts +61 -1
- package/dist/core.js +1 -1
- package/dist/data-mock-store.js +330 -12
- package/dist/data-prisma-store.js +319 -9
- package/dist/data-seed-runner.js +18 -15
- package/dist/data.d.ts +64 -3
- package/dist/generated/client/edge.js +31 -10
- package/dist/generated/client/index-browser.js +28 -7
- package/dist/generated/client/index.d.ts +3583 -577
- package/dist/generated/client/index.js +31 -10
- package/dist/generated/client/package.json +1 -1
- package/dist/generated/client/schema.prisma +48 -9
- package/dist/generated/client/wasm.js +31 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/offline.d.ts +34 -1
- package/dist/offline.js +2 -2
- package/dist/types-B8MopM4b.d.ts +281 -0
- package/dist/types.d.ts +104 -1
- package/dist/types.js +1 -1
- package/dist/ui-components-admin/accounts-manager.d.ts +52 -0
- package/dist/ui-components-admin/accounts-manager.js +471 -0
- package/dist/ui-components-admin/admin-nav.d.ts +15 -1
- package/dist/ui-components-admin/admin-nav.js +388 -60
- package/dist/ui-components-admin/issuance-form.js +72 -13
- package/dist/ui-components-admin/logs-viewer.d.ts +13 -0
- package/dist/ui-components-admin/logs-viewer.js +102 -0
- package/dist/ui-components-admin/notifications-list.d.ts +5 -0
- package/dist/ui-components-admin/notifications-list.js +70 -0
- package/dist/ui-components-admin/officers-manager.d.ts +27 -0
- package/dist/ui-components-admin/officers-manager.js +271 -0
- package/dist/ui-components-admin/roles-manager.d.ts +37 -0
- package/dist/ui-components-admin/roles-manager.js +406 -0
- package/dist/ui-components-admin/ticket-preview.js +7 -7
- package/dist/ui-components-admin/tickets-table.js +56 -33
- package/dist/ui-components-admin/violations-manager.d.ts +32 -0
- package/dist/ui-components-admin/violations-manager.js +385 -0
- package/dist/ui-components-citizen/citizen-nav.js +2 -2
- package/dist/ui-components-citizen/payment-form.js +5 -5
- package/dist/ui-components-citizen/payment-qr-dialog.js +4 -4
- package/dist/ui-components-citizen/ticket-not-found.js +2 -2
- package/dist/ui-components-citizen/violation-history-table.js +3 -3
- package/dist/ui-components-shared/amount-summary.js +4 -4
- package/dist/ui-components-shared/money.js +3 -3
- package/dist/ui-components-shared/municipal-seal.js +3 -3
- package/dist/ui-components-shared/official-header.js +4 -4
- package/dist/ui-components-shared/site-header.js +4 -4
- package/dist/ui-components-shared/sonner.js +2 -2
- package/dist/ui-components-shared/theme-toggle.js +3 -3
- package/dist/ui-components-shared/ticket-receipt.js +13 -6
- package/dist/ui-components-shared/violations-table.js +4 -4
- package/dist/ui-components-ui/badge.d.ts +1 -1
- package/dist/ui-components-ui/button.d.ts +1 -1
- package/dist/ui-components-ui/dropdown-menu.js +2 -237
- package/dist/ui-components-ui/sheet.js +3 -126
- package/dist/ui-config.d.ts +1 -1
- package/dist/ui-config.js +2 -2
- package/dist/ui-server.d.ts +1 -1
- package/dist/ui-server.js +2 -2
- package/package.json +6 -6
- package/prisma/migrations/20260622010000_add_super_admin_role/migration.sql +3 -0
- package/prisma/migrations/20260622020000_add_apprehending_enforcer/migration.sql +4 -0
- package/prisma/migrations/20260622030000_custom_roles/migration.sql +30 -0
- package/prisma/migrations/20260622040000_add_activity_log/migration.sql +18 -0
- package/prisma/migrations/20260622050000_violation_catalog_management/migration.sql +5 -0
- package/prisma/schema.prisma +48 -9
- package/dist/chunk-5YYR37CF.js +0 -146
- package/dist/chunk-B634JHKZ.js +0 -181
- package/dist/types-CtBC5-TW.d.ts +0 -129
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { makeOvrTicketNo, addDays, makeBillNo, makeOrderOfPaymentNo, enrich } from './chunk-
|
|
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-
|
|
2
|
-
import { AmountSummary } from './chunk-
|
|
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 {
|
|
6
|
-
import {
|
|
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 }) {
|
package/dist/core-i18n.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as CopyOverrides, D as Dictionary } from './types-
|
|
2
|
-
export { b as baseCopy } from './types-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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';
|
package/dist/data-mock-store.js
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
122
|
+
const data = await readStore();
|
|
123
|
+
return structuredClone(data.officers);
|
|
46
124
|
},
|
|
47
125
|
async getOfficer(id) {
|
|
48
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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;
|