@gelabs/ovr 0.1.1 → 0.2.1

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 (101) hide show
  1. package/dist/chunk-3KIDW4LT.js +13 -0
  2. package/dist/chunk-4SZXBT56.js +26 -0
  3. package/dist/chunk-55FQP2DO.js +46 -0
  4. package/dist/{chunk-JV7QQ22Q.js → chunk-5YYR37CF.js} +10 -1
  5. package/dist/chunk-5Z2IAD5I.js +72 -0
  6. package/dist/chunk-BBQBKQA4.js +31 -0
  7. package/dist/chunk-BIQ2J75Y.js +54 -0
  8. package/dist/chunk-E2D7QT6N.js +92 -0
  9. package/dist/chunk-EGKFELO3.js +15 -0
  10. package/dist/chunk-EYFZWQ4J.js +74 -0
  11. package/dist/chunk-GDOCD7LT.js +46 -0
  12. package/dist/chunk-IF5UAVIE.js +25 -0
  13. package/dist/chunk-JEYT63LE.js +25 -0
  14. package/dist/chunk-K3KIBHJF.js +20 -0
  15. package/dist/chunk-M35R6JLA.js +142 -0
  16. package/dist/chunk-MDTRBOPQ.js +22 -0
  17. package/dist/chunk-NSCIBSCW.js +24 -0
  18. package/dist/chunk-OE525ZER.js +31 -0
  19. package/dist/chunk-OWCGEEAZ.js +107 -0
  20. package/dist/chunk-QCRVT2SS.js +18 -0
  21. package/dist/chunk-SETIN6XP.js +95 -0
  22. package/dist/chunk-WOPU6DI7.js +77 -0
  23. package/dist/chunk-XQTVSNHC.js +18 -0
  24. package/dist/core-i18n.d.ts +2 -2
  25. package/dist/core-i18n.js +1 -1
  26. package/dist/data-db.d.ts +1 -4
  27. package/dist/data-mock-store.js +53 -1
  28. package/dist/data-prisma-store.d.ts +3 -1
  29. package/dist/data-prisma-store.js +84 -1
  30. package/dist/data-seed-runner.d.ts +2 -1
  31. package/dist/data.d.ts +13 -1
  32. package/dist/offline.d.ts +126 -0
  33. package/dist/offline.js +376 -0
  34. package/dist/{types-DrrNO_Ak.d.ts → types-CtBC5-TW.d.ts} +8 -0
  35. package/dist/ui-components-admin/admin-nav.d.ts +7 -0
  36. package/dist/ui-components-admin/admin-nav.js +83 -0
  37. package/dist/ui-components-admin/issuance-form.d.ts +15 -0
  38. package/dist/ui-components-admin/issuance-form.js +367 -0
  39. package/dist/ui-components-admin/stat-card.d.ts +10 -0
  40. package/dist/ui-components-admin/stat-card.js +29 -0
  41. package/dist/ui-components-admin/ticket-preview.d.ts +9 -0
  42. package/dist/ui-components-admin/ticket-preview.js +13 -0
  43. package/dist/ui-components-admin/tickets-table.d.ts +8 -0
  44. package/dist/ui-components-admin/tickets-table.js +50 -0
  45. package/dist/ui-components-citizen/citizen-nav.d.ts +5 -0
  46. package/dist/ui-components-citizen/citizen-nav.js +33 -0
  47. package/dist/ui-components-citizen/payment-form.d.ts +14 -0
  48. package/dist/ui-components-citizen/payment-form.js +168 -0
  49. package/dist/ui-components-citizen/payment-qr-dialog.d.ts +30 -0
  50. package/dist/ui-components-citizen/payment-qr-dialog.js +9 -0
  51. package/dist/ui-components-citizen/ticket-not-found.d.ts +5 -0
  52. package/dist/ui-components-citizen/ticket-not-found.js +21 -0
  53. package/dist/ui-components-citizen/violation-history-table.d.ts +16 -0
  54. package/dist/ui-components-citizen/violation-history-table.js +77 -0
  55. package/dist/ui-components-shared/amount-summary.d.ts +8 -0
  56. package/dist/ui-components-shared/amount-summary.js +7 -0
  57. package/dist/ui-components-shared/money.d.ts +9 -0
  58. package/dist/ui-components-shared/money.js +5 -0
  59. package/dist/ui-components-shared/municipal-seal.d.ts +11 -0
  60. package/dist/ui-components-shared/municipal-seal.js +6 -0
  61. package/dist/ui-components-shared/official-header.d.ts +5 -0
  62. package/dist/ui-components-shared/official-header.js +7 -0
  63. package/dist/ui-components-shared/print-button.d.ts +8 -0
  64. package/dist/ui-components-shared/print-button.js +17 -0
  65. package/dist/ui-components-shared/seal.d.ts +12 -0
  66. package/dist/ui-components-shared/seal.js +2 -0
  67. package/dist/ui-components-shared/site-header.d.ts +13 -0
  68. package/dist/ui-components-shared/site-header.js +40 -0
  69. package/dist/ui-components-shared/sonner.d.ts +6 -0
  70. package/dist/ui-components-shared/sonner.js +38 -0
  71. package/dist/ui-components-shared/status-badge.d.ts +9 -0
  72. package/dist/ui-components-shared/status-badge.js +3 -0
  73. package/dist/ui-components-shared/theme-toggle.d.ts +7 -0
  74. package/dist/ui-components-shared/theme-toggle.js +6 -0
  75. package/dist/ui-components-shared/ticket-receipt.d.ts +12 -0
  76. package/dist/ui-components-shared/ticket-receipt.js +158 -0
  77. package/dist/ui-components-shared/violations-table.d.ts +8 -0
  78. package/dist/ui-components-shared/violations-table.js +7 -0
  79. package/dist/ui-components-ui/alert.js +2 -74
  80. package/dist/ui-components-ui/badge.d.ts +1 -1
  81. package/dist/ui-components-ui/badge.js +2 -46
  82. package/dist/ui-components-ui/button.d.ts +2 -2
  83. package/dist/ui-components-ui/card.js +2 -95
  84. package/dist/ui-components-ui/checkbox.js +2 -31
  85. package/dist/ui-components-ui/dialog.js +3 -142
  86. package/dist/ui-components-ui/input.js +2 -20
  87. package/dist/ui-components-ui/label.js +2 -18
  88. package/dist/ui-components-ui/separator.js +2 -24
  89. package/dist/ui-components-ui/skeleton.js +2 -15
  90. package/dist/ui-components-ui/table.js +2 -107
  91. package/dist/ui-components-ui/textarea.js +2 -18
  92. package/dist/ui-config.d.ts +19 -2
  93. package/dist/ui-config.js +2 -36
  94. package/dist/ui-server.d.ts +1 -1
  95. package/dist/ui-server.js +1 -1
  96. package/package.json +24 -4
  97. package/prisma/migrations/20260611112111_init/migration.sql +142 -0
  98. package/prisma/migrations/20260611112121_add_ticket_sequence/migration.sql +4 -0
  99. package/prisma/migrations/20260612000000_split_violator_address/migration.sql +26 -0
  100. package/prisma/migrations/20260612000100_barangay_optional/migration.sql +2 -0
  101. package/prisma/migrations/migration_lock.toml +3 -0
@@ -1,5 +1,5 @@
1
1
  import { prisma } from './chunk-MKALJTAU.js';
2
- import { round2, computeCharges, makePaymentRef, fullName, makeOvrTicketNo, addDays, makeBillNo, makeOrderOfPaymentNo, enrich } from './chunk-B634JHKZ.js';
2
+ import { round2, computeCharges, makePaymentRef, fullName, addDays, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, enrich } from './chunk-B634JHKZ.js';
3
3
  import 'server-only';
4
4
 
5
5
  var norm = (s) => s.trim().toLowerCase();
@@ -158,6 +158,89 @@ function createPrismaStore(rules) {
158
158
  });
159
159
  return enrichRec(toRecord(row), now);
160
160
  },
161
+ async leaseSeqs(count) {
162
+ const n = Math.max(1, Math.min(500, count));
163
+ const rows = await prisma.$queryRaw`
164
+ SELECT nextval('ovr_ticket_seq') AS nextval FROM generate_series(1, ${n})`;
165
+ return {
166
+ year: (/* @__PURE__ */ new Date()).getFullYear(),
167
+ seqs: rows.map((r) => Number(r.nextval))
168
+ };
169
+ },
170
+ async pushTicket(input) {
171
+ const existing = await prisma.ticket.findFirst({
172
+ where: {
173
+ ovrTicketNo: { equals: input.ovrTicketNo, mode: "insensitive" }
174
+ },
175
+ include: ticketInclude
176
+ });
177
+ if (existing) return enrichRec(toRecord(existing), /* @__PURE__ */ new Date());
178
+ const created = new Date(input.createdAt);
179
+ const v = input.violator;
180
+ const row = await prisma.$transaction(async (tx) => {
181
+ const officer = await tx.officer.findUnique({
182
+ where: { id: input.officerId }
183
+ });
184
+ if (!officer) throw new Error("Apprehending officer not found.");
185
+ const codes = input.violations.map((x) => x.catalogCode);
186
+ const catalog = await tx.violationCatalog.findMany({
187
+ where: { code: { in: codes } }
188
+ });
189
+ const byCode = new Map(catalog.map((c) => [c.code, c]));
190
+ const violations = input.violations.map((x) => {
191
+ const c = byCode.get(x.catalogCode);
192
+ if (!c) throw new Error(`Unknown violation code: ${x.catalogCode}`);
193
+ return {
194
+ catalogCode: c.code,
195
+ title: c.title,
196
+ basicFine: c.basicFine,
197
+ details: x.details?.trim() || null
198
+ };
199
+ });
200
+ if (violations.length === 0) {
201
+ throw new Error("A ticket must include at least one violation.");
202
+ }
203
+ const basicFinesTotal = violations.reduce(
204
+ (s, x) => s + x.basicFine.toNumber(),
205
+ 0
206
+ );
207
+ const seq = parseInt(input.ovrTicketNo.split("-").pop() ?? "", 10) || 0;
208
+ return tx.ticket.create({
209
+ data: {
210
+ ovrTicketNo: input.ovrTicketNo,
211
+ orderOfPaymentNo: makeOrderOfPaymentNo(input.ovrTicketNo),
212
+ billNo: makeBillNo(
213
+ created,
214
+ officer.office,
215
+ officer.badgeNo ?? "X000",
216
+ seq
217
+ ),
218
+ violatorFirstName: v.firstName,
219
+ violatorMiddleName: v.middleName?.trim() || null,
220
+ violatorLastName: v.lastName,
221
+ violatorStreet: v.street,
222
+ violatorBarangay: v.barangay?.trim() || null,
223
+ violatorCityMunicipality: v.cityMunicipality,
224
+ violatorProvince: v.province,
225
+ violatorLicenseNumber: v.licenseNumber,
226
+ violatorPlateNumber: v.plateNumber?.trim() || null,
227
+ violatorContactNo: v.contactNo?.trim() || null,
228
+ apprehendedAt: new Date(input.apprehendedAt),
229
+ placeOfViolation: input.placeOfViolation?.trim() || null,
230
+ remarks: input.remarks?.trim() || null,
231
+ officerId: officer.id,
232
+ assessedAt: created,
233
+ dueDate: addDays(created, rules.dueWindowDays),
234
+ basicFinesTotal,
235
+ paymentStatus: "UNPAID",
236
+ createdAt: created,
237
+ violations: { create: violations }
238
+ },
239
+ include: ticketInclude
240
+ });
241
+ });
242
+ return enrichRec(toRecord(row), /* @__PURE__ */ new Date());
243
+ },
161
244
  async searchTicket(ovrTicketNo, lastName) {
162
245
  const row = await prisma.ticket.findFirst({
163
246
  where: {
@@ -1 +1,2 @@
1
- export * from '@gelabs/ovr-data/seed-runner';
1
+ import type { SeedData } from "./data.js";
2
+ export declare function seedRunner(prisma: import("./generated/client").PrismaClient, data: SeedData): Promise<void>;
package/dist/data.d.ts CHANGED
@@ -19,6 +19,11 @@ interface NewTicketInput {
19
19
  }[];
20
20
  remarks?: string;
21
21
  }
22
+ /** An offline-issued ticket pushed to the server (client-assigned number + time). */
23
+ interface PushTicketInput extends NewTicketInput {
24
+ ovrTicketNo: string;
25
+ createdAt: string;
26
+ }
22
27
  interface NewPaymentInput {
23
28
  method: PaymentMethod;
24
29
  }
@@ -40,6 +45,13 @@ interface DataStore {
40
45
  listOfficers(): Promise<Officer[]>;
41
46
  getOfficer(id: string): Promise<Officer | null>;
42
47
  createTicket(input: NewTicketInput): Promise<Ticket>;
48
+ /** Reserve a block of global ticket-sequence numbers (offline ID leasing). */
49
+ leaseSeqs(count: number): Promise<{
50
+ year: number;
51
+ seqs: number[];
52
+ }>;
53
+ /** Idempotent insert of an offline-issued ticket (client-assigned number). */
54
+ pushTicket(input: PushTicketInput): Promise<Ticket>;
43
55
  searchTicket(ovrTicketNo: string, lastName: string): Promise<Ticket | null>;
44
56
  getTicketByNo(ovrTicketNo: string): Promise<Ticket | null>;
45
57
  listTickets(filter?: TicketFilter): Promise<Ticket[]>;
@@ -68,4 +80,4 @@ interface SeedData {
68
80
  passwordHash: string;
69
81
  }
70
82
 
71
- export type { DataStore, MockSeed, NewPaymentInput, NewTicketInput, SeedData, SeedUser, TicketFilter, TicketStats };
83
+ export type { DataStore, MockSeed, NewPaymentInput, NewTicketInput, PushTicketInput, SeedData, SeedUser, TicketFilter, TicketStats };
@@ -0,0 +1,126 @@
1
+ import Dexie, { EntityTable } from 'dexie';
2
+ import { Violator, Ticket, ViolationCatalogItem, Officer } from './types.js';
3
+ import { R as RulesConfig } from './schema-CdsFQxIg.js';
4
+ import { NewTicketInput, TicketStats } from './data.js';
5
+ import 'zod';
6
+
7
+ /**
8
+ * Client-side offline store (IndexedDB via Dexie) — offline-first.
9
+ *
10
+ * A local mirror of the data the admin/enforcer needs offline: tickets, the
11
+ * violation catalog, officers, plus an OUTBOX of tickets issued offline that
12
+ * still need to be pushed to the API. The admin pages read from HERE (not SSR) so
13
+ * they work with no internet; a sync loop keeps it fresh (see `sync.ts`).
14
+ *
15
+ * Browser-only (Dexie/IndexedDB) — import only from Client Components.
16
+ */
17
+
18
+ interface MetaRow {
19
+ key: string;
20
+ value: unknown;
21
+ }
22
+ /** A ticket issued offline, queued for an idempotent push. PK = ovrTicketNo. */
23
+ interface OutboxRow {
24
+ ovrTicketNo: string;
25
+ createdAt: string;
26
+ payload: {
27
+ ovrTicketNo: string;
28
+ createdAt: string;
29
+ violator: Violator;
30
+ apprehendedAt: string;
31
+ placeOfViolation?: string;
32
+ officerId: string;
33
+ violations: {
34
+ catalogCode: string;
35
+ details?: string;
36
+ }[];
37
+ remarks?: string;
38
+ };
39
+ status: "pending" | "error";
40
+ error?: string;
41
+ }
42
+ declare const db: Dexie & {
43
+ tickets: EntityTable<Ticket, "ovrTicketNo">;
44
+ catalog: EntityTable<ViolationCatalogItem, "code">;
45
+ officers: EntityTable<Officer, "id">;
46
+ meta: EntityTable<MetaRow, "key">;
47
+ outbox: EntityTable<OutboxRow, "ovrTicketNo">;
48
+ };
49
+ declare function getMeta<T>(key: string): Promise<T | undefined>;
50
+ declare function setMeta(key: string, value: unknown): Promise<void>;
51
+
52
+ /** Point the sync client at a different API base (default same-origin `/api`). */
53
+ declare function setOfflineApiBase(base: string): void;
54
+ /** The configured API base — so the auth client targets the same backend. */
55
+ declare function offlineApiBase(): string;
56
+ /** Thrown when an online request hits a dead session — caller must prompt re-login. */
57
+ declare class SessionExpired extends Error {
58
+ constructor();
59
+ }
60
+ declare function isOnline(): boolean;
61
+ /** Reserve more ticket numbers so offline issuance can keep minting real numbers. */
62
+ declare function ensureLease(min?: number): Promise<void>;
63
+ interface PushResult {
64
+ ovrTicketNo: string;
65
+ ok: boolean;
66
+ ticket?: Ticket;
67
+ error?: string;
68
+ }
69
+ /** Push queued offline-issued tickets. Idempotent server-side; safe to retry. */
70
+ declare function pushOutbox(): Promise<void>;
71
+ /** Pull server data into the local store — UPSERTS tickets (never clears them). */
72
+ declare function pullAll(): Promise<void>;
73
+ /** Full sync: push local writes, top up the lease, then pull fresh data. */
74
+ declare function sync(): Promise<void>;
75
+
76
+ /** Issue a ticket from the device. Returns the enriched ticket (shown inline). */
77
+ declare function issueTicketOffline(input: NewTicketInput, rules: RulesConfig): Promise<Ticket>;
78
+
79
+ /** All locally-stored tickets, newest first. `undefined` while loading. */
80
+ declare function useTickets(): Ticket[] | undefined;
81
+ declare function useCatalog(): ViolationCatalogItem[] | undefined;
82
+ declare function useOfficers(): Officer[] | undefined;
83
+ /** Cached dashboard stats (as-of last sync). `undefined` until first sync. */
84
+ declare function useStats(): TicketStats | undefined;
85
+ /** A single ticket by number: `undefined` while loading, `null` if not stored. */
86
+ declare function useTicket(ovrTicketNo: string): Ticket | undefined | null;
87
+ /** ovrTicketNos still waiting to sync (present in the outbox). Reactive. */
88
+ declare function usePendingSync(): Set<string>;
89
+ interface SyncState {
90
+ syncing: boolean;
91
+ online: boolean;
92
+ error: string | null;
93
+ }
94
+ /** Pull from the API on mount (if online) and whenever connectivity returns. */
95
+ declare function useSync(): SyncState;
96
+
97
+ interface AdminIdentity {
98
+ userId: string;
99
+ username: string;
100
+ role: string;
101
+ officerId: string | null;
102
+ }
103
+ type AuthState = {
104
+ status: "loading";
105
+ } | {
106
+ status: "authed";
107
+ user: AdminIdentity;
108
+ } | {
109
+ status: "unauthed";
110
+ };
111
+ /** The cached admin identity, or undefined. */
112
+ declare function getIdentity(): Promise<AdminIdentity | undefined>;
113
+ /** Drop the cached identity (NOT the offline credential) — used on session expiry
114
+ * and logout so a signed-out device can't pass the offline gate. */
115
+ declare function clearIdentity(): Promise<void>;
116
+ /** Client auth gate for the admin shell. Online: validate the cookie via
117
+ * /api/auth/me and refresh the cached identity. Offline: trust the cache. */
118
+ declare function useAdminAuth(): AuthState;
119
+ /** Cache an offline-login verifier after a successful ONLINE login (overwrites,
120
+ * so a server-side password change self-heals on the next online login). */
121
+ declare function cacheCredential(username: string, password: string, identity: AdminIdentity): Promise<void>;
122
+ /** OFFLINE login: verify the password against the cached verifier. On success it
123
+ * sets the active identity and returns it; otherwise null. */
124
+ declare function verifyOffline(username: string, password: string): Promise<AdminIdentity | null>;
125
+
126
+ export { type AdminIdentity, type AuthState, type MetaRow, type OutboxRow, type PushResult, SessionExpired, type SyncState, cacheCredential, clearIdentity, db, ensureLease, getIdentity, getMeta, isOnline, issueTicketOffline, offlineApiBase, pullAll, pushOutbox, setMeta, setOfflineApiBase, sync, useAdminAuth, useCatalog, useOfficers, usePendingSync, useStats, useSync, useTicket, useTickets, verifyOffline };
@@ -0,0 +1,376 @@
1
+ "use client";
2
+ import { makeOvrTicketNo, addDays, makeBillNo, makeOrderOfPaymentNo, enrich } from './chunk-B634JHKZ.js';
3
+ import Dexie from 'dexie';
4
+ import { useLiveQuery } from 'dexie-react-hooks';
5
+ import { useState, useEffect } from 'react';
6
+
7
+ var db = new Dexie("eovr-offline");
8
+ db.version(1).stores({
9
+ tickets: "ovrTicketNo, paymentStatus, createdAt",
10
+ catalog: "code, category",
11
+ officers: "id",
12
+ meta: "key"
13
+ });
14
+ db.version(2).stores({
15
+ outbox: "ovrTicketNo, status, createdAt"
16
+ });
17
+ async function getMeta(key) {
18
+ const row = await db.meta.get(key);
19
+ return row?.value;
20
+ }
21
+ async function setMeta(key, value) {
22
+ await db.meta.put({ key, value });
23
+ }
24
+
25
+ // ../ovr-offline/src/sync.ts
26
+ var API = "/api";
27
+ var LEASE_MIN = 10;
28
+ var LEASE_BATCH = 50;
29
+ function setOfflineApiBase(base) {
30
+ API = base.replace(/\/$/, "");
31
+ }
32
+ function offlineApiBase() {
33
+ return API;
34
+ }
35
+ var SessionExpired = class extends Error {
36
+ constructor() {
37
+ super("session_expired");
38
+ this.name = "SessionExpired";
39
+ }
40
+ };
41
+ function isOnline() {
42
+ return typeof navigator !== "undefined" ? navigator.onLine : true;
43
+ }
44
+ async function call(path, init) {
45
+ let res;
46
+ try {
47
+ res = await fetch(`${API}${path}`, { credentials: "include", ...init });
48
+ } catch {
49
+ return null;
50
+ }
51
+ if (res.status === 401) throw new SessionExpired();
52
+ return res;
53
+ }
54
+ var postInit = (body) => ({
55
+ method: "POST",
56
+ headers: { "content-type": "application/json" },
57
+ body: JSON.stringify(body)
58
+ });
59
+ async function ensureLease(min = LEASE_MIN) {
60
+ if (!isOnline()) return;
61
+ const seqs = await getMeta("leaseSeqs") ?? [];
62
+ if (seqs.length >= min) return;
63
+ const res = await call("/sync/lease", postInit({ count: LEASE_BATCH }));
64
+ if (!res || !res.ok) return;
65
+ const { seqs: fresh } = await res.json();
66
+ await setMeta("leaseSeqs", [...seqs, ...fresh]);
67
+ }
68
+ async function pushOutbox() {
69
+ const pending = await db.outbox.toArray();
70
+ if (!pending.length) return;
71
+ const res = await call(
72
+ "/sync/push",
73
+ postInit({ tickets: pending.map((p) => p.payload) })
74
+ );
75
+ if (!res || !res.ok) return;
76
+ const { results } = await res.json();
77
+ await db.transaction("rw", db.outbox, db.tickets, async () => {
78
+ for (const r of results) {
79
+ if (r.ok) {
80
+ await db.outbox.delete(r.ovrTicketNo);
81
+ if (r.ticket) await db.tickets.put(r.ticket);
82
+ } else {
83
+ await db.outbox.update(r.ovrTicketNo, { status: "error", error: r.error });
84
+ }
85
+ }
86
+ });
87
+ }
88
+ async function pullAll() {
89
+ const [t, c, o, s] = await Promise.all([
90
+ call("/tickets"),
91
+ call("/violations"),
92
+ call("/officers"),
93
+ call("/tickets/stats")
94
+ ]);
95
+ if (!t || !c || !o || !s) return;
96
+ if (!t.ok || !c.ok || !o.ok || !s.ok) {
97
+ throw new Error(
98
+ `sync.pullAll failed: tickets=${t.status} catalog=${c.status} officers=${o.status} stats=${s.status}`
99
+ );
100
+ }
101
+ const tickets = await t.json();
102
+ const catalog = await c.json();
103
+ const officers = await o.json();
104
+ const stats = await s.json();
105
+ await db.transaction(
106
+ "rw",
107
+ db.tickets,
108
+ db.catalog,
109
+ db.officers,
110
+ db.meta,
111
+ async () => {
112
+ await db.tickets.bulkPut(tickets);
113
+ await db.catalog.clear();
114
+ await db.catalog.bulkPut(catalog);
115
+ await db.officers.clear();
116
+ await db.officers.bulkPut(officers);
117
+ await db.meta.put({ key: "stats", value: stats });
118
+ }
119
+ );
120
+ await setMeta("lastSyncedAt", (/* @__PURE__ */ new Date()).toISOString());
121
+ }
122
+ async function sync() {
123
+ if (!isOnline()) return;
124
+ await pushOutbox();
125
+ await ensureLease();
126
+ await pullAll();
127
+ }
128
+
129
+ // ../ovr-offline/src/issue.ts
130
+ async function popSeq() {
131
+ return db.transaction("rw", db.meta, async () => {
132
+ const row = await db.meta.get("leaseSeqs");
133
+ const seqs = row?.value ?? [];
134
+ if (seqs.length === 0) return null;
135
+ await db.meta.put({ key: "leaseSeqs", value: seqs.slice(1) });
136
+ return seqs[0];
137
+ });
138
+ }
139
+ async function issueTicketOffline(input, rules) {
140
+ let seq = await popSeq();
141
+ if (seq === null && isOnline()) {
142
+ await ensureLease(50);
143
+ seq = await popSeq();
144
+ }
145
+ if (seq === null) {
146
+ throw new Error(
147
+ isOnline() ? "Couldn't reserve ticket numbers \u2014 please try again." : "No offline ticket numbers left. Reconnect to the internet to reserve more."
148
+ );
149
+ }
150
+ const now = /* @__PURE__ */ new Date();
151
+ const ovrTicketNo = makeOvrTicketNo(rules.idPrefix, now.getFullYear(), seq);
152
+ const officer = await db.officers.get(input.officerId);
153
+ if (!officer) {
154
+ const cur = await getMeta("leaseSeqs") ?? [];
155
+ await setMeta("leaseSeqs", [seq, ...cur]);
156
+ throw new Error("That officer isn't available offline. Sync and try again.");
157
+ }
158
+ const catalog = await db.catalog.toArray();
159
+ const byCode = new Map(catalog.map((c) => [c.code, c]));
160
+ const violations = input.violations.map((x) => {
161
+ const c = byCode.get(x.catalogCode);
162
+ if (!c) throw new Error(`Unknown violation: ${x.catalogCode}`);
163
+ const v = {
164
+ catalogCode: c.code,
165
+ title: c.title,
166
+ basicFine: c.basicFine
167
+ };
168
+ if (x.details?.trim()) v.details = x.details.trim();
169
+ return v;
170
+ });
171
+ const basicFinesTotal = violations.reduce((s, v) => s + v.basicFine, 0);
172
+ const record = {
173
+ ovrTicketNo,
174
+ orderOfPaymentNo: makeOrderOfPaymentNo(ovrTicketNo),
175
+ billNo: makeBillNo(now, officer.office, officer.badgeNo ?? "X000", seq),
176
+ violator: input.violator,
177
+ apprehendedAt: input.apprehendedAt,
178
+ officer,
179
+ violations,
180
+ assessedAt: now.toISOString(),
181
+ dueDate: addDays(now, rules.dueWindowDays).toISOString(),
182
+ basicFinesTotal,
183
+ paymentStatus: "UNPAID",
184
+ createdAt: now.toISOString()
185
+ };
186
+ if (input.placeOfViolation?.trim())
187
+ record.placeOfViolation = input.placeOfViolation.trim();
188
+ if (input.remarks?.trim()) record.remarks = input.remarks.trim();
189
+ const ticket = enrich(record, now, rules.surchargeRatePerMonth);
190
+ await db.transaction("rw", db.tickets, db.outbox, async () => {
191
+ await db.tickets.put(ticket);
192
+ await db.outbox.put({
193
+ ovrTicketNo,
194
+ createdAt: record.createdAt,
195
+ status: "pending",
196
+ payload: {
197
+ ovrTicketNo,
198
+ createdAt: record.createdAt,
199
+ violator: input.violator,
200
+ apprehendedAt: input.apprehendedAt,
201
+ ...record.placeOfViolation ? { placeOfViolation: record.placeOfViolation } : {},
202
+ officerId: input.officerId,
203
+ violations: input.violations,
204
+ ...record.remarks ? { remarks: record.remarks } : {}
205
+ }
206
+ });
207
+ });
208
+ if (isOnline()) void pushOutbox().catch(() => {
209
+ });
210
+ return ticket;
211
+ }
212
+ async function getIdentity() {
213
+ return await getMeta("identity") ?? void 0;
214
+ }
215
+ async function clearIdentity() {
216
+ await setMeta("identity", null);
217
+ }
218
+ function useAdminAuth() {
219
+ const [state, setState] = useState({ status: "loading" });
220
+ useEffect(() => {
221
+ let cancelled = false;
222
+ (async () => {
223
+ const cached = await getMeta("identity");
224
+ if (isOnline()) {
225
+ try {
226
+ const res = await fetch(`${offlineApiBase()}/auth/me`, {
227
+ credentials: "include"
228
+ });
229
+ if (res.ok) {
230
+ const { user } = await res.json();
231
+ await setMeta("identity", user);
232
+ if (!cancelled) setState({ status: "authed", user });
233
+ return;
234
+ }
235
+ await setMeta("identity", null);
236
+ if (!cancelled) setState({ status: "unauthed" });
237
+ return;
238
+ } catch {
239
+ }
240
+ }
241
+ if (!cancelled) {
242
+ setState(
243
+ cached ? { status: "authed", user: cached } : { status: "unauthed" }
244
+ );
245
+ }
246
+ })();
247
+ return () => {
248
+ cancelled = true;
249
+ };
250
+ }, []);
251
+ return state;
252
+ }
253
+ var PBKDF2_ITERATIONS = 21e4;
254
+ function subtle() {
255
+ return typeof crypto !== "undefined" && crypto.subtle ? crypto.subtle : null;
256
+ }
257
+ var toHex = (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(
258
+ ""
259
+ );
260
+ function fromHex(hex) {
261
+ const out = new Uint8Array(hex.length / 2);
262
+ for (let i = 0; i < out.length; i++)
263
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
264
+ return out;
265
+ }
266
+ async function deriveHashHex(password, salt, iterations) {
267
+ const s = subtle();
268
+ if (!s) return null;
269
+ const keyMaterial = await s.importKey(
270
+ "raw",
271
+ new TextEncoder().encode(password),
272
+ "PBKDF2",
273
+ false,
274
+ ["deriveBits"]
275
+ );
276
+ const bits = await s.deriveBits(
277
+ { name: "PBKDF2", salt, iterations, hash: "SHA-256" },
278
+ keyMaterial,
279
+ 256
280
+ );
281
+ return toHex(bits);
282
+ }
283
+ async function cacheCredential(username, password, identity) {
284
+ if (!subtle()) return;
285
+ const salt = crypto.getRandomValues(new Uint8Array(16));
286
+ const hashHex = await deriveHashHex(password, salt, PBKDF2_ITERATIONS);
287
+ if (!hashHex) return;
288
+ const cred = {
289
+ username: username.trim(),
290
+ saltHex: toHex(salt.buffer),
291
+ hashHex,
292
+ iterations: PBKDF2_ITERATIONS,
293
+ identity
294
+ };
295
+ await setMeta("credential", cred);
296
+ }
297
+ async function verifyOffline(username, password) {
298
+ const cred = await getMeta("credential");
299
+ if (!cred || cred.username !== username.trim()) return null;
300
+ const hashHex = await deriveHashHex(
301
+ password,
302
+ fromHex(cred.saltHex),
303
+ cred.iterations
304
+ );
305
+ if (!hashHex || hashHex !== cred.hashHex) return null;
306
+ await setMeta("identity", cred.identity);
307
+ return cred.identity;
308
+ }
309
+
310
+ // ../ovr-offline/src/hooks.ts
311
+ function useTickets() {
312
+ return useLiveQuery(() => db.tickets.orderBy("createdAt").reverse().toArray());
313
+ }
314
+ function useCatalog() {
315
+ return useLiveQuery(() => db.catalog.orderBy("category").toArray());
316
+ }
317
+ function useOfficers() {
318
+ return useLiveQuery(() => db.officers.toArray());
319
+ }
320
+ function useStats() {
321
+ return useLiveQuery(
322
+ async () => (await db.meta.get("stats"))?.value
323
+ );
324
+ }
325
+ function useTicket(ovrTicketNo) {
326
+ return useLiveQuery(
327
+ async () => await db.tickets.get(ovrTicketNo) ?? null,
328
+ [ovrTicketNo]
329
+ );
330
+ }
331
+ function usePendingSync() {
332
+ const rows = useLiveQuery(() => db.outbox.toArray());
333
+ return new Set((rows ?? []).map((r) => r.ovrTicketNo));
334
+ }
335
+ function useSync() {
336
+ const [syncing, setSyncing] = useState(false);
337
+ const [online, setOnline] = useState(true);
338
+ const [error, setError] = useState(null);
339
+ useEffect(() => {
340
+ let cancelled = false;
341
+ setOnline(isOnline());
342
+ async function runSyncCycle() {
343
+ if (!isOnline()) return;
344
+ setSyncing(true);
345
+ setError(null);
346
+ try {
347
+ await sync();
348
+ } catch (e) {
349
+ if (e instanceof SessionExpired) {
350
+ await clearIdentity();
351
+ window.location.assign("/admin/login");
352
+ return;
353
+ }
354
+ if (!cancelled) setError(e instanceof Error ? e.message : "sync failed");
355
+ } finally {
356
+ if (!cancelled) setSyncing(false);
357
+ }
358
+ }
359
+ const onOnline = () => {
360
+ setOnline(true);
361
+ void runSyncCycle();
362
+ };
363
+ const onOffline = () => setOnline(false);
364
+ void runSyncCycle();
365
+ window.addEventListener("online", onOnline);
366
+ window.addEventListener("offline", onOffline);
367
+ return () => {
368
+ cancelled = true;
369
+ window.removeEventListener("online", onOnline);
370
+ window.removeEventListener("offline", onOffline);
371
+ };
372
+ }, []);
373
+ return { syncing, online, error };
374
+ }
375
+
376
+ export { SessionExpired, cacheCredential, clearIdentity, db, ensureLease, getIdentity, getMeta, isOnline, issueTicketOffline, offlineApiBase, pullAll, pushOutbox, setMeta, setOfflineApiBase, sync, useAdminAuth, useCatalog, useOfficers, usePendingSync, useStats, useSync, useTicket, useTickets, verifyOffline };
@@ -102,6 +102,14 @@ declare const baseCopy: {
102
102
  readonly tickets: "Tickets";
103
103
  readonly issueTicket: "Issue ticket";
104
104
  readonly newTicketTitle: "Issue Violation Ticket";
105
+ readonly sync: {
106
+ readonly offline: "Offline";
107
+ readonly syncing: "Syncing…";
108
+ readonly synced: "Synced";
109
+ readonly pending: "pending sync";
110
+ readonly notSynced: "Not synced";
111
+ readonly willSync: "Saved on this device — will sync when back online.";
112
+ };
105
113
  };
106
114
  };
107
115
 
@@ -0,0 +1,7 @@
1
+ import * as React from 'react';
2
+
3
+ declare function AdminNav({ signOutAction, }: {
4
+ signOutAction: () => Promise<void>;
5
+ }): React.JSX.Element;
6
+
7
+ export { AdminNav };