@7365admin1/layer-common 3.0.8 → 3.0.10

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 (32) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/components/DashboardMain.vue +1864 -803
  3. package/components/EntryPassInformation.vue +60 -0
  4. package/components/HidAccessLogDashboard.vue +1691 -0
  5. package/components/HidEnabledGate.vue +51 -0
  6. package/components/HidIdentityMapping.vue +975 -0
  7. package/components/HidQrCodeConfiguration.vue +618 -0
  8. package/components/HidReaderForm.vue +246 -0
  9. package/components/HidReaderManagement.vue +1233 -0
  10. package/components/HidServiceSettingsPanel.vue +150 -0
  11. package/components/HidUserEnrollment.vue +1274 -0
  12. package/components/Layout/NavigationDrawer.vue +31 -1
  13. package/components/MemberInformation.vue +1 -1
  14. package/components/NavigationItem.vue +11 -4
  15. package/components/SiteSettings.vue +24 -1
  16. package/components/VisitorForm.vue +195 -4
  17. package/components/VisitorFormSelection.vue +12 -2
  18. package/composables/useDashboard.ts +31 -4
  19. package/composables/useHidAmico.ts +152 -0
  20. package/composables/useHidNavigation.ts +106 -0
  21. package/composables/useMember.ts +8 -3
  22. package/composables/useOptionalServices.ts +118 -0
  23. package/composables/useSiteSettings.ts +1 -1
  24. package/composables/useUser.ts +1 -2
  25. package/nuxt.config.ts +1 -1
  26. package/package.json +2 -1
  27. package/pages/[org]/[site]/access-mgmt/access-logs/index.vue +21 -0
  28. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +21 -0
  29. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +21 -0
  30. package/pages/[org]/[site]/access-mgmt/hid-users/index.vue +21 -0
  31. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +21 -0
  32. package/types/local.d.ts +2 -1
@@ -0,0 +1,1691 @@
1
+ <template>
2
+ <section class="hid-access-log-dashboard">
3
+ <div class="toolbar">
4
+ <v-btn
5
+ rounded="xl"
6
+ class="text-none export-button"
7
+ variant="flat"
8
+ prepend-icon="mdi-download"
9
+ @click="openExportDialog"
10
+ >
11
+ Export
12
+ </v-btn>
13
+
14
+ <v-select
15
+ v-if="readers.length > 1"
16
+ v-model="selectedReaderId"
17
+ :items="readerOptions"
18
+ item-title="title"
19
+ item-value="value"
20
+ variant="outlined"
21
+ density="compact"
22
+ hide-details
23
+ class="reader-input"
24
+ @update:model-value="loadDashboard"
25
+ />
26
+
27
+ <div class="toolbar-spacer" />
28
+
29
+ <v-text-field
30
+ v-model="search"
31
+ placeholder="Search"
32
+ variant="outlined"
33
+ density="compact"
34
+ hide-details
35
+ class="search-input"
36
+ />
37
+
38
+ <v-select
39
+ v-model="status"
40
+ :items="statusOptions"
41
+ variant="outlined"
42
+ density="compact"
43
+ hide-details
44
+ class="status-input"
45
+ />
46
+ </div>
47
+
48
+ <v-card flat border class="table-card">
49
+ <v-tabs
50
+ v-model="activeTab"
51
+ class="access-tabs"
52
+ density="compact"
53
+ @update:model-value="onTabChange"
54
+ >
55
+ <v-tab value="resident" class="text-none">Resident</v-tab>
56
+ <v-tab value="visitor" class="text-none">Visitor</v-tab>
57
+ <v-tab value="administrator" class="text-none">Administrator</v-tab>
58
+ </v-tabs>
59
+
60
+ <div class="table-body">
61
+ <v-table>
62
+ <thead>
63
+ <tr>
64
+ <th>Name</th>
65
+ <th>Facial Data</th>
66
+ <th>Unit</th>
67
+ <th>Last Access Method</th>
68
+ <th>Last access time</th>
69
+ <th>Status</th>
70
+ <th class="text-right"></th>
71
+ </tr>
72
+ </thead>
73
+ <tbody>
74
+ <tr v-for="row in paginatedRows" :key="row.key">
75
+ <td>{{ row.name }}</td>
76
+ <td>
77
+ <v-avatar v-if="row.faceImage" size="34" rounded="lg" class="hid-face-avatar">
78
+ <v-img :src="row.faceImage" cover />
79
+ </v-avatar>
80
+ <span v-else>{{ row.facialData }}</span>
81
+ </td>
82
+ <td>{{ row.unit }}</td>
83
+ <td>{{ row.method }}</td>
84
+ <td>{{ row.lastAccessTime }}</td>
85
+ <td>
86
+ <v-chip
87
+ size="small"
88
+ variant="flat"
89
+ class="status-chip"
90
+ :class="row.status === 'Mapped' ? 'mapped-chip' : 'unmapped-chip'"
91
+ >
92
+ {{ row.status }}
93
+ </v-chip>
94
+ </td>
95
+ <td class="text-right">
96
+ <v-btn
97
+ icon="mdi-history"
98
+ variant="text"
99
+ density="comfortable"
100
+ @click="openHistoryDialog(row)"
101
+ />
102
+ </td>
103
+ </tr>
104
+ </tbody>
105
+ </v-table>
106
+
107
+ <div v-if="!filteredRows.length" class="empty-state">
108
+ No access logs found.
109
+ </div>
110
+ </div>
111
+
112
+ <div class="table-footer">
113
+ <span>{{ pageRange }}</span>
114
+ <v-btn
115
+ icon="mdi-chevron-left"
116
+ variant="text"
117
+ density="comfortable"
118
+ :disabled="page <= 1"
119
+ @click="page--"
120
+ />
121
+ <v-btn
122
+ icon="mdi-chevron-right"
123
+ variant="text"
124
+ density="comfortable"
125
+ :disabled="page >= pages"
126
+ @click="page++"
127
+ />
128
+ </div>
129
+ </v-card>
130
+
131
+ <v-dialog v-model="historyDialog" max-width="980">
132
+ <v-card class="history-dialog" rounded="lg">
133
+ <v-card-title class="history-title">Access Log</v-card-title>
134
+
135
+ <v-card-text class="history-body">
136
+ <div class="history-toolbar">
137
+ <v-btn
138
+ rounded="xl"
139
+ class="text-none export-button"
140
+ variant="flat"
141
+ prepend-icon="mdi-download"
142
+ @click="openExportDialog('history')"
143
+ >
144
+ Export Log
145
+ </v-btn>
146
+ <div class="toolbar-spacer" />
147
+ <v-btn icon="mdi-close" variant="text" density="comfortable" @click="historyDialog = false" />
148
+ </div>
149
+
150
+ <v-card flat border class="history-table-card">
151
+ <div class="table-refresh">
152
+ <v-icon size="16">mdi-refresh</v-icon>
153
+ </div>
154
+ <v-table>
155
+ <thead>
156
+ <tr>
157
+ <th>Name</th>
158
+ <th>Unit No.</th>
159
+ <th>Reader Location</th>
160
+ <th>Access Method</th>
161
+ <th>Access Date and Time</th>
162
+ </tr>
163
+ </thead>
164
+ <tbody>
165
+ <tr v-for="row in selectedHistoryRows" :key="row.key">
166
+ <td>{{ row.name }}</td>
167
+ <td>{{ row.unit }}</td>
168
+ <td>{{ row.readerLocation }}</td>
169
+ <td>{{ row.method }}</td>
170
+ <td>{{ row.lastAccessTime }}</td>
171
+ </tr>
172
+ </tbody>
173
+ </v-table>
174
+
175
+ <div v-if="!selectedHistoryRows.length" class="empty-state">
176
+ No access logs found.
177
+ </div>
178
+ </v-card>
179
+ </v-card-text>
180
+ </v-card>
181
+ </v-dialog>
182
+
183
+ <v-dialog v-model="exportDialog" max-width="430" persistent>
184
+ <v-card class="export-dialog" rounded="lg">
185
+ <v-card-title class="dialog-title">Export</v-card-title>
186
+
187
+ <v-card-text class="dialog-body">
188
+ <div class="field-label">Start Date <span>*</span></div>
189
+ <v-menu
190
+ v-model="startDateMenu"
191
+ :close-on-content-click="false"
192
+ location="bottom"
193
+ content-class="compact-date-menu"
194
+ >
195
+ <template #activator="{ props: menuProps }">
196
+ <v-text-field
197
+ v-bind="menuProps"
198
+ v-model="exportForm.startDate"
199
+ aria-label="Start Date"
200
+ prepend-inner-icon="mdi-calendar"
201
+ placeholder="DD/MM/YYYY"
202
+ density="compact"
203
+ variant="outlined"
204
+ hide-details="auto"
205
+ readonly
206
+ class="mb-3"
207
+ />
208
+ </template>
209
+ <v-date-picker
210
+ :model-value="parseExportDate(exportForm.startDate, false)"
211
+ hide-header
212
+ width="320"
213
+ class="compact-date-picker"
214
+ @update:model-value="setExportDate('startDate', $event)"
215
+ />
216
+ </v-menu>
217
+
218
+ <div class="field-label">End Date <span>*</span></div>
219
+ <v-menu
220
+ v-model="endDateMenu"
221
+ :close-on-content-click="false"
222
+ location="bottom"
223
+ content-class="compact-date-menu"
224
+ >
225
+ <template #activator="{ props: menuProps }">
226
+ <v-text-field
227
+ v-bind="menuProps"
228
+ v-model="exportForm.endDate"
229
+ aria-label="End Date"
230
+ prepend-inner-icon="mdi-calendar"
231
+ placeholder="DD/MM/YYYY"
232
+ density="compact"
233
+ variant="outlined"
234
+ hide-details="auto"
235
+ readonly
236
+ class="mb-3"
237
+ />
238
+ </template>
239
+ <v-date-picker
240
+ :model-value="parseExportDate(exportForm.endDate, false)"
241
+ hide-header
242
+ width="320"
243
+ class="compact-date-picker"
244
+ @update:model-value="setExportDate('endDate', $event)"
245
+ />
246
+ </v-menu>
247
+
248
+ <div class="field-label">Users</div>
249
+ <v-select
250
+ v-model="exportForm.users"
251
+ :items="userOptions"
252
+ item-title="title"
253
+ item-value="value"
254
+ aria-label="Users"
255
+ placeholder="Select"
256
+ density="compact"
257
+ variant="outlined"
258
+ hide-details="auto"
259
+ multiple
260
+ chips
261
+ closable-chips
262
+ class="mb-3"
263
+ />
264
+
265
+ <div class="field-label">Access Method</div>
266
+ <v-select
267
+ v-model="exportForm.accessMethod"
268
+ :items="accessMethodOptions"
269
+ aria-label="Access Method"
270
+ placeholder="Select"
271
+ density="compact"
272
+ variant="outlined"
273
+ hide-details="auto"
274
+ class="mb-3"
275
+ />
276
+
277
+ <div class="field-label">Reader Location</div>
278
+ <v-select
279
+ v-model="exportForm.readerLocation"
280
+ :items="readerLocationOptions"
281
+ aria-label="Reader Location"
282
+ placeholder="Select"
283
+ density="compact"
284
+ variant="outlined"
285
+ hide-details="auto"
286
+ class="mb-3"
287
+ />
288
+
289
+ <div class="field-label">Status (Optional)</div>
290
+ <v-select
291
+ v-model="exportForm.status"
292
+ :items="statusOptions"
293
+ aria-label="Status"
294
+ placeholder="Select"
295
+ density="compact"
296
+ variant="outlined"
297
+ hide-details="auto"
298
+ />
299
+ </v-card-text>
300
+
301
+ <v-card-actions class="dialog-actions pa-0">
302
+ <v-btn class="text-none action-cancel" variant="flat" height="48" @click="exportDialog = false">
303
+ Cancel
304
+ </v-btn>
305
+ <v-btn class="text-none action-submit" variant="flat" height="48" @click="exportRows">
306
+ Export
307
+ </v-btn>
308
+ </v-card-actions>
309
+ </v-card>
310
+ </v-dialog>
311
+ </section>
312
+ </template>
313
+
314
+ <script setup lang="ts">
315
+ const props = defineProps({
316
+ site: {
317
+ type: String,
318
+ required: true,
319
+ },
320
+ });
321
+
322
+ type AccessTab = "resident" | "visitor" | "administrator";
323
+ type AccessRow = {
324
+ key: string;
325
+ identityKey: string;
326
+ name: string;
327
+ facialData: string;
328
+ faceImage?: string;
329
+ unit: string;
330
+ method: string;
331
+ lastAccessTime: string;
332
+ lastAccessDate?: Date;
333
+ readerLocation: string;
334
+ status: "Mapped" | "Unmapped";
335
+ };
336
+
337
+ type PdfAccessRow = {
338
+ name: string;
339
+ unit: string;
340
+ readerLocation: string;
341
+ method: string;
342
+ accessDateTime: string;
343
+ };
344
+
345
+ const { getReaders, getIdentities, getLogs, getUserImage, runObjectOperation, getReaderConfiguration } = useHidAmico();
346
+ const { getVisitors } = useVisitor();
347
+
348
+ const readers = ref<Record<string, any>[]>([]);
349
+ const identities = ref<Record<string, any>[]>([]);
350
+ const visitorRecords = ref<Record<string, any>>({});
351
+ const logs = ref<Record<string, any>[]>([]);
352
+ const liveAccessLogs = ref<Record<string, any>[]>([]);
353
+ const readerConfiguration = ref<Record<string, any>>({});
354
+ const administratorUserIds = ref<Set<number>>(new Set());
355
+ const userImages = ref<Record<string, string>>({});
356
+ const selectedReaderId = ref("");
357
+ const activeTab = ref<AccessTab>("resident");
358
+ const search = ref("");
359
+ const status = ref("Status");
360
+ const page = ref(1);
361
+ const limit = 20;
362
+ const loading = ref(false);
363
+ const exportDialog = ref(false);
364
+ const historyDialog = ref(false);
365
+ const selectedHistoryRow = ref<AccessRow>();
366
+ const exportScope = ref<"dashboard" | "history">("dashboard");
367
+ const startDateMenu = ref(false);
368
+ const endDateMenu = ref(false);
369
+ const exportForm = reactive({
370
+ startDate: "",
371
+ endDate: "",
372
+ users: [] as string[],
373
+ accessMethod: "All",
374
+ readerLocation: "All",
375
+ status: "Status",
376
+ });
377
+
378
+ const statusOptions = ["Status", "Mapped", "Unmapped"];
379
+ const accessMethodOptions = ["All", "Facial", "QR Code", "ID and Password", "PIN", "Card"];
380
+
381
+ const readerOptions = computed(() =>
382
+ readers.value.map((reader) => ({
383
+ title: reader.name || reader.deviceId || reader._id,
384
+ value: reader._id,
385
+ })),
386
+ );
387
+
388
+ const selectedReader = computed(() =>
389
+ readers.value.find((reader) => reader._id === selectedReaderId.value),
390
+ );
391
+
392
+ const readerLocationOptions = computed(() => {
393
+ const locations = readers.value
394
+ .map((reader) => reader.location)
395
+ .filter((location) => !!location);
396
+ return ["All", ...Array.from(new Set(locations))];
397
+ });
398
+
399
+ const userOptions = computed(() => [
400
+ { title: "All", value: "all" },
401
+ ...identities.value.map((identity) => ({
402
+ title: getName(identity),
403
+ value: getIdentityKey(identity),
404
+ })),
405
+ ]);
406
+
407
+ const rows = computed<AccessRow[]>(() => {
408
+ const logRows = normalizeAccessRows(normalizeAccessLogs()).filter(isVisibleForActiveTab);
409
+ return logRows.sort(sortAccessRowsByDateDesc);
410
+ });
411
+
412
+ const selectedHistoryRows = computed<AccessRow[]>(() => {
413
+ if (!selectedHistoryRow.value) return [];
414
+
415
+ const selectedKey = selectedHistoryRow.value.identityKey;
416
+ const matchedLogs = logs.value.filter((log) => getLogIdentityKey(log) === selectedKey);
417
+ const matchedLiveLogs = liveAccessLogs.value.filter((log) => getAccessLogIdentityKey(log) === selectedKey);
418
+
419
+ if (!matchedLogs.length && !matchedLiveLogs.length) {
420
+ return rows.value.filter((row) => row.identityKey === selectedKey);
421
+ }
422
+
423
+ const historyRows = matchedLogs.map((log, index) => ({
424
+ key: `${selectedHistoryRow.value?.key || "history"}-${log._id || log.id || index}`,
425
+ identityKey: selectedKey,
426
+ name: selectedHistoryRow.value?.name || "N/A",
427
+ facialData: selectedHistoryRow.value?.facialData || "N/A",
428
+ faceImage: selectedHistoryRow.value?.faceImage,
429
+ unit: selectedHistoryRow.value?.unit || "N/A",
430
+ method: getAccessMethod({}, log),
431
+ lastAccessTime: formatDate(getLogAccessDateValue(log)),
432
+ lastAccessDate: parseDate(getLogAccessDateValue(log)),
433
+ readerLocation: getLogReaderLocation(log),
434
+ status: selectedHistoryRow.value?.status || "Mapped",
435
+ }));
436
+
437
+ const liveRows = matchedLiveLogs.map((log, index) => ({
438
+ key: `${selectedHistoryRow.value?.key || "history"}-live-${log.id || log.time || index}`,
439
+ identityKey: selectedKey,
440
+ name: selectedHistoryRow.value?.name || "N/A",
441
+ facialData: selectedHistoryRow.value?.facialData || "N/A",
442
+ faceImage: selectedHistoryRow.value?.faceImage,
443
+ unit: selectedHistoryRow.value?.unit || "N/A",
444
+ method: getAccessMethod({}, { payload: { rawAccessLog: log } }),
445
+ lastAccessTime: formatDate(getAccessLogTime(log, {})),
446
+ lastAccessDate: parseDate(getAccessLogTime(log, {})),
447
+ readerLocation: selectedReader.value?.location || "N/A",
448
+ status: selectedHistoryRow.value?.status || "Mapped",
449
+ }));
450
+
451
+ return [...historyRows, ...liveRows].sort((a, b) => {
452
+ const aTime = a.lastAccessDate?.getTime() || 0;
453
+ const bTime = b.lastAccessDate?.getTime() || 0;
454
+ return bTime - aTime;
455
+ });
456
+ });
457
+
458
+ const filteredRows = computed(() => {
459
+ const query = search.value.trim().toLowerCase();
460
+ return rows.value.filter((row) => {
461
+ const matchesSearch = !query || [
462
+ row.name,
463
+ row.facialData,
464
+ row.unit,
465
+ row.method,
466
+ row.lastAccessTime,
467
+ row.status,
468
+ ].some((value) => String(value ?? "").toLowerCase().includes(query));
469
+
470
+ const matchesStatus = status.value === "Status" || row.status === status.value;
471
+
472
+ return matchesSearch && matchesStatus;
473
+ });
474
+ });
475
+
476
+ const pages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / limit)));
477
+
478
+ const paginatedRows = computed(() => {
479
+ const currentPage = Math.min(page.value, pages.value);
480
+ const start = (currentPage - 1) * limit;
481
+ return filteredRows.value.slice(start, start + limit);
482
+ });
483
+
484
+ const pageRange = computed(() => {
485
+ if (!filteredRows.value.length) return "0-0 of 0";
486
+ const currentPage = Math.min(page.value, pages.value);
487
+ const start = (currentPage - 1) * limit + 1;
488
+ const end = Math.min(currentPage * limit, filteredRows.value.length);
489
+ return `${start}-${end} of ${filteredRows.value.length}`;
490
+ });
491
+
492
+ const exportRowsData = computed(() =>
493
+ getExportSourceRows().filter((row) => {
494
+ const selectedUsers = exportForm.users.filter((user) => user !== "all");
495
+ const matchesUsers = !selectedUsers.length || selectedUsers.includes(row.key) || selectedUsers.includes(row.identityKey);
496
+ const matchesMethod = exportForm.accessMethod === "All" || row.method === exportForm.accessMethod;
497
+ const matchesLocation = exportForm.readerLocation === "All" || row.readerLocation === exportForm.readerLocation;
498
+ const matchesStatus = exportForm.status === "Status" || row.status === exportForm.status;
499
+ const matchesDate = isWithinExportRange(row.lastAccessDate);
500
+
501
+ return matchesUsers && matchesMethod && matchesLocation && matchesStatus && matchesDate;
502
+ }),
503
+ );
504
+
505
+ watch(
506
+ () => props.site,
507
+ () => init(),
508
+ { immediate: true },
509
+ );
510
+
511
+ watch([search, status, activeTab], () => {
512
+ page.value = 1;
513
+ });
514
+
515
+ watch(pages, () => {
516
+ if (page.value > pages.value) page.value = pages.value;
517
+ });
518
+
519
+ async function init() {
520
+ await loadReaders();
521
+ await loadDashboard();
522
+ }
523
+
524
+ async function loadReaders() {
525
+ if (!props.site) return;
526
+ const response = await getReaders({ site: props.site, page: 1, limit: 100 });
527
+ readers.value = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
528
+ if (!selectedReaderId.value && readers.value.length) {
529
+ selectedReaderId.value = readers.value[0]._id;
530
+ }
531
+ }
532
+
533
+ async function loadDashboard() {
534
+ if (!selectedReaderId.value) {
535
+ identities.value = [];
536
+ visitorRecords.value = {};
537
+ logs.value = [];
538
+ liveAccessLogs.value = [];
539
+ return;
540
+ }
541
+
542
+ loading.value = true;
543
+ try {
544
+ const isAdministratorTab = activeTab.value === "administrator";
545
+ const isVisitorTab = activeTab.value === "visitor";
546
+ const [identityResponse, logResponse, hidAccessLogs, configurationResponse, roleResponse, visitorResponse] = await Promise.all([
547
+ getIdentities(selectedReaderId.value, {
548
+ page: 1,
549
+ limit: 500,
550
+ ...(!isAdministratorTab ? { type: getIdentityType(activeTab.value) } : {}),
551
+ }),
552
+ getLogs(selectedReaderId.value, {
553
+ page: 1,
554
+ limit: 500,
555
+ }),
556
+ loadLiveAccessLogs(selectedReaderId.value),
557
+ loadReaderConfiguration(selectedReaderId.value),
558
+ isAdministratorTab ? loadAdministratorRoles(selectedReaderId.value) : Promise.resolve(null),
559
+ isVisitorTab ? getVisitors({ site: props.site, page: 1, limit: 500, order: "desc" }) : Promise.resolve(null),
560
+ ]);
561
+
562
+ const loadedIdentities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
563
+ const loadedVisitors = visitorResponse?.items ?? visitorResponse?.data?.items ?? visitorResponse?.data?.visitors ?? [];
564
+ visitorRecords.value = isVisitorTab
565
+ ? Object.fromEntries(loadedVisitors.map((visitor: Record<string, any>) => [String(visitor._id), visitor]))
566
+ : {};
567
+ administratorUserIds.value = getAdministratorRoleUserIds(roleResponse);
568
+ identities.value = isAdministratorTab
569
+ ? loadedIdentities.filter((identity: Record<string, any>) => {
570
+ const hidUserId = toHidNumericId(identity.hidUserId);
571
+ return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
572
+ })
573
+ : isVisitorTab
574
+ ? loadedIdentities.filter((identity: Record<string, any>) => Boolean(getVisitorRecord(identity)))
575
+ : loadedIdentities;
576
+ logs.value = logResponse?.items ?? logResponse?.data?.items ?? logResponse?.data?.logs ?? [];
577
+ liveAccessLogs.value = hidAccessLogs;
578
+ readerConfiguration.value = normalizeReaderConfiguration(configurationResponse);
579
+ await loadUserImages(identities.value);
580
+ } finally {
581
+ loading.value = false;
582
+ }
583
+ }
584
+
585
+ async function loadUserImages(items: Record<string, any>[]) {
586
+ if (!selectedReaderId.value) return;
587
+
588
+ await Promise.all(items.map(async (identity) => {
589
+ const hidUserId = identity.hidUserId;
590
+ const key = String(hidUserId || "");
591
+ if (!hidUserId || userImages.value[key] || identity?.metadata?.photo) return;
592
+
593
+ try {
594
+ const response = await getUserImage(selectedReaderId.value, hidUserId);
595
+ const image = response?.data || response;
596
+ userImages.value[key] = image?.base64
597
+ ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
598
+ : "";
599
+ } catch {
600
+ userImages.value[key] = "";
601
+ }
602
+ }));
603
+ }
604
+
605
+ async function loadLiveAccessLogs(readerId: string) {
606
+ const allLogs: Record<string, any>[] = [];
607
+ const limit = 500;
608
+ const maxPages = 20;
609
+
610
+ for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
611
+ try {
612
+ const response = await runObjectOperation(readerId, {
613
+ operation: "load",
614
+ object: "access_logs",
615
+ where: {
616
+ access_logs: {},
617
+ },
618
+ limit,
619
+ offset: pageIndex * limit,
620
+ });
621
+
622
+ const pageLogs = normalizeHidAccessLogs(response);
623
+ allLogs.push(...pageLogs);
624
+
625
+ if (pageLogs.length < limit) break;
626
+ } catch (error) {
627
+ console.warn("Unable to load HID live access logs", error);
628
+ break;
629
+ }
630
+ }
631
+
632
+ return dedupeAccessLogs(allLogs).sort((a, b) => {
633
+ const aTime = parseDate(getAccessLogTime(a, {}))?.getTime() || 0;
634
+ const bTime = parseDate(getAccessLogTime(b, {}))?.getTime() || 0;
635
+ return bTime - aTime;
636
+ });
637
+ }
638
+
639
+ async function loadReaderConfiguration(readerId: string) {
640
+ try {
641
+ return await getReaderConfiguration(readerId, {
642
+ access_logs: [],
643
+ event_types: [],
644
+ log_types: [],
645
+ identification_rules: [],
646
+ portals: [],
647
+ users: [],
648
+ });
649
+ } catch (error) {
650
+ console.warn("Unable to load HID configuration", error);
651
+ return {};
652
+ }
653
+ }
654
+
655
+ function onTabChange() {
656
+ page.value = 1;
657
+ loadDashboard();
658
+ }
659
+
660
+ function getIdentityType(tab: AccessTab) {
661
+ if (tab === "administrator") return "admin";
662
+ return tab;
663
+ }
664
+
665
+ function isVisibleForActiveTab(row: AccessRow) {
666
+ if (activeTab.value !== "administrator") return true;
667
+ const hidUserId = toHidNumericId(row.identityKey.split("|")[0]);
668
+ return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
669
+ }
670
+
671
+ function loadAdministratorRoles(readerId: string) {
672
+ return runObjectOperation(readerId, {
673
+ operation: "load",
674
+ object: "user_roles",
675
+ where: {
676
+ user_roles: {
677
+ role: 1,
678
+ },
679
+ },
680
+ limit: 500,
681
+ offset: 0,
682
+ });
683
+ }
684
+
685
+ function getAdministratorRoleUserIds(response: Record<string, any> | null) {
686
+ const data = response?.data || response || {};
687
+ const roles =
688
+ data?.user_roles ||
689
+ data?.data?.user_roles ||
690
+ data?.result?.user_roles ||
691
+ [];
692
+
693
+ return new Set(
694
+ (Array.isArray(roles) ? roles : [])
695
+ .map((role) => toHidNumericId(role.user_id))
696
+ .filter((userId): userId is number => Boolean(userId)),
697
+ );
698
+ }
699
+
700
+ function toHidNumericId(value: unknown) {
701
+ const raw = String(value ?? "").trim();
702
+ if (!raw) return undefined;
703
+ const numeric = Number(raw);
704
+ if (Number.isInteger(numeric) && numeric > 0) return numeric;
705
+ const match = raw.match(/(\d+)$/);
706
+ const parsed = match ? Number(match[1]) : NaN;
707
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
708
+ }
709
+
710
+ function getIdentityKey(identity: Record<string, any>) {
711
+ return [
712
+ identity.hidUserId,
713
+ identity.registration,
714
+ identity.cardNo,
715
+ ].filter(Boolean).join("|");
716
+ }
717
+
718
+ function getLogIdentityKey(log: Record<string, any>) {
719
+ const identity = log.payload?.identity || {};
720
+ const lookup = log.payload?.identityLookup || {};
721
+ const rawLog = log.payload?.rawAccessLog || log;
722
+ return [
723
+ identity.hidUserId || lookup.hidUserId || rawLog.user_id || rawLog.userId || rawLog.hidUserId,
724
+ identity.registration || lookup.registration,
725
+ identity.cardNo || lookup.cardNo || rawLog.card_value || rawLog.cardNo,
726
+ ].filter(Boolean).join("|");
727
+ }
728
+
729
+ function getName(identity: Record<string, any>) {
730
+ return getVisitorRecord(identity)?.name || identity.metadata?.name || identity.name || "N/A";
731
+ }
732
+
733
+ function getFacialData(identity: Record<string, any>) {
734
+ return identity.metadata?.facialData || (identity.metadata?.photo ? identity.hidUserId : "N/A");
735
+ }
736
+
737
+ function getUserImageSrc(identity: Record<string, any>) {
738
+ return identity?.metadata?.photo || userImages.value[String(identity?.hidUserId || "")] || "";
739
+ }
740
+
741
+ function getUnit(identity: Record<string, any>) {
742
+ const visitor = getVisitorRecord(identity);
743
+ if (visitor) {
744
+ return visitor.unitName || [visitor.block, visitor.level, visitor.unit].filter(Boolean).join("/") || "N/A";
745
+ }
746
+
747
+ return identity.metadata?.unitLabel || [identity.metadata?.block, identity.metadata?.level, identity.metadata?.unit].filter(Boolean).join("/") || "N/A";
748
+ }
749
+
750
+ function getVisitorRecord(identity: Record<string, any>) {
751
+ const visitorId = identity?.visitor;
752
+ return visitorId ? visitorRecords.value[String(visitorId)] : undefined;
753
+ }
754
+
755
+ function getAccessMethod(identity: Record<string, any>, log?: Record<string, any>) {
756
+ const payload = log?.payload?.rawAccessLog || log?.payload || log || {};
757
+ const configuredLogType = getConfigurationLabel("log_types", payload.log_type_id || payload.logTypeId);
758
+ if (configuredLogType) return configuredLogType;
759
+ const configuredRule = getConfigurationLabel("identification_rules", payload.identification_rule_id || payload.identificationRuleId);
760
+ if (configuredRule) return configuredRule;
761
+ if (payload.qrcode_value || payload.qrCode || payload.qrcode) return "QR Code";
762
+ if (payload.card_value || payload.cardNo || identity.cardNo) return "Card";
763
+ if (payload.pin_value) return "PIN";
764
+ if (payload.password_value || payload.password || payload.identifier_id) return "ID and Password";
765
+ if (payload.confidence || payload.user_id || payload.userId || payload.hidUserId) return "Facial";
766
+ if (identity.metadata?.pinPassword) return "ID and Password";
767
+ return identity.metadata?.photo || identity.metadata?.facialData ? "Facial" : "N/A";
768
+ }
769
+
770
+ function formatDate(value?: string | number) {
771
+ if (!value) return "N/A";
772
+ const date = typeof value === "number" ? new Date(value * 1000) : new Date(value);
773
+ return Number.isNaN(date.getTime()) ? "N/A" : date.toLocaleString();
774
+ }
775
+
776
+ function parseDate(value?: string | number) {
777
+ if (!value) return undefined;
778
+ const date = typeof value === "number" ? new Date(value * 1000) : new Date(value);
779
+ return Number.isNaN(date.getTime()) ? undefined : date;
780
+ }
781
+
782
+ function openHistoryDialog(row: AccessRow) {
783
+ selectedHistoryRow.value = row;
784
+ historyDialog.value = true;
785
+ }
786
+
787
+ function openExportDialog(scope: "dashboard" | "history" = "dashboard") {
788
+ exportScope.value = scope;
789
+
790
+ if (scope === "history" && selectedHistoryRow.value) {
791
+ exportForm.users = [selectedHistoryRow.value.identityKey];
792
+ } else {
793
+ exportForm.users = ["all"];
794
+ }
795
+
796
+ const dateRange = getDefaultExportDateRange();
797
+ const today = toDisplayDate(new Date());
798
+ exportForm.startDate = dateRange.startDate || today;
799
+ exportForm.endDate = dateRange.endDate || today;
800
+ exportDialog.value = true;
801
+ }
802
+
803
+ function getDefaultExportDateRange() {
804
+ const dates = getExportSourceRows()
805
+ .map((row) => row.lastAccessDate)
806
+ .filter((date): date is Date => Boolean(date && !Number.isNaN(date.getTime())))
807
+ .sort((a, b) => a.getTime() - b.getTime());
808
+
809
+ return {
810
+ startDate: dates[0] ? toDisplayDate(dates[0]) : "",
811
+ endDate: dates[dates.length - 1] ? toDisplayDate(dates[dates.length - 1]) : "",
812
+ };
813
+ }
814
+
815
+ function toDisplayDate(date: Date) {
816
+ const day = String(date.getDate()).padStart(2, "0");
817
+ const month = String(date.getMonth() + 1).padStart(2, "0");
818
+ return `${day}/${month}/${date.getFullYear()}`;
819
+ }
820
+
821
+ function isWithinExportRange(value?: Date) {
822
+ if (!value || (!exportForm.startDate && !exportForm.endDate)) return true;
823
+
824
+ const start = parseExportDate(exportForm.startDate, false);
825
+ const end = parseExportDate(exportForm.endDate, true);
826
+
827
+ if (start && value < start) return false;
828
+ if (end && value > end) return false;
829
+ return true;
830
+ }
831
+
832
+ function parseExportDate(value: string, endOfDay: boolean) {
833
+ if (!value) return undefined;
834
+ const match = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
835
+ if (!match) return undefined;
836
+ const [, day, month, year] = match;
837
+ const date = new Date(Number(year), Number(month) - 1, Number(day));
838
+ if (Number.isNaN(date.getTime())) return undefined;
839
+ if (endOfDay) {
840
+ date.setHours(23, 59, 59, 999);
841
+ } else {
842
+ date.setHours(0, 0, 0, 0);
843
+ }
844
+ return date;
845
+ }
846
+
847
+ function setExportDate(field: "startDate" | "endDate", value: unknown) {
848
+ const date = normalizePickerDate(value);
849
+ if (!date) return;
850
+
851
+ exportForm[field] = toDisplayDate(date);
852
+ if (field === "startDate") {
853
+ startDateMenu.value = false;
854
+ } else {
855
+ endDateMenu.value = false;
856
+ }
857
+ }
858
+
859
+ function normalizePickerDate(value: unknown) {
860
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
861
+ return value;
862
+ }
863
+ if (typeof value === "string") {
864
+ const [year, month, day] = value.split("-").map(Number);
865
+ if (year && month && day) {
866
+ return new Date(year, month - 1, day);
867
+ }
868
+ const date = new Date(value);
869
+ return Number.isNaN(date.getTime()) ? undefined : date;
870
+ }
871
+ return undefined;
872
+ }
873
+
874
+ function normalizeAccessLogs() {
875
+ const persistedRows = logs.value.flatMap((event, eventIndex) => {
876
+ const rawLogs = getRawAccessLogs(event);
877
+ if (rawLogs.length) {
878
+ return rawLogs.map((rawLog, rawIndex) => mapAccessLogToRow(rawLog, event, `${eventIndex}-${rawIndex}`));
879
+ }
880
+
881
+ if (event.payload?.identity || event.payload?.identityLookup) {
882
+ return [mapAccessLogToRow(event.payload, event, `${eventIndex}`)];
883
+ }
884
+
885
+ return [];
886
+ });
887
+
888
+ const liveRows = liveAccessLogs.value.map((rawLog, rawIndex) =>
889
+ mapAccessLogToRow(rawLog, { id: "hid-live", payload: { reader: selectedReader.value } }, `live-${rawIndex}`),
890
+ );
891
+
892
+ return [...persistedRows, ...liveRows];
893
+ }
894
+
895
+ function normalizeAccessRows(rows: AccessRow[]) {
896
+ const seen = new Set<string>();
897
+ return rows.filter((row) => {
898
+ const key = [
899
+ row.identityKey,
900
+ row.lastAccessDate?.getTime() || row.lastAccessTime,
901
+ row.method,
902
+ row.readerLocation,
903
+ ].join("|");
904
+
905
+ if (seen.has(key)) return false;
906
+ seen.add(key);
907
+ return true;
908
+ });
909
+ }
910
+
911
+ function sortAccessRowsByDateDesc(a: AccessRow, b: AccessRow) {
912
+ const aTime = a.lastAccessDate?.getTime() || 0;
913
+ const bTime = b.lastAccessDate?.getTime() || 0;
914
+ return bTime - aTime;
915
+ }
916
+
917
+ function dedupeAccessLogs(items: Record<string, any>[]) {
918
+ const seen = new Set<string>();
919
+ return items.filter((item) => {
920
+ const key = [
921
+ item.id,
922
+ item.time,
923
+ item.user_id ?? item.userId ?? item.hidUserId,
924
+ item.card_value ?? item.cardNo,
925
+ ].join("|");
926
+
927
+ if (seen.has(key)) return false;
928
+ seen.add(key);
929
+ return true;
930
+ });
931
+ }
932
+
933
+ function normalizeHidAccessLogs(response: Record<string, any>) {
934
+ const data = response?.data || response || {};
935
+ const accessLogs =
936
+ data.access_logs ||
937
+ data.data?.access_logs ||
938
+ data.accessLogs ||
939
+ data.data?.accessLogs ||
940
+ data.result?.access_logs ||
941
+ data.result?.data?.access_logs;
942
+
943
+ return Array.isArray(accessLogs) ? accessLogs : [];
944
+ }
945
+
946
+ function normalizeReaderConfiguration(response: Record<string, any>) {
947
+ const data = response?.data || response || {};
948
+ return data?.data || data?.result || data;
949
+ }
950
+
951
+ function getConfigurationLabel(section: string, id: unknown) {
952
+ if (id === undefined || id === null || id === "" || Number(id) === -1) return "";
953
+ const entries = readerConfiguration.value?.[section];
954
+ if (!entries) return "";
955
+
956
+ const items = Array.isArray(entries)
957
+ ? entries
958
+ : typeof entries === "object"
959
+ ? Object.values(entries)
960
+ : [];
961
+
962
+ const match = items.find((item: any) => {
963
+ if (!item || typeof item !== "object") return false;
964
+ return String(item.id ?? item.value ?? item.key ?? item.code) === String(id);
965
+ }) as Record<string, any> | undefined;
966
+
967
+ return match?.name || match?.label || match?.description || match?.title || "";
968
+ }
969
+
970
+ function getRawAccessLogs(event: Record<string, any>) {
971
+ const payload = event.payload || {};
972
+ const result = payload.result || payload.data || {};
973
+ const accessLogs =
974
+ result.access_logs ||
975
+ result.data?.access_logs ||
976
+ result.accessLogs ||
977
+ payload.access_logs ||
978
+ payload.accessLogs;
979
+
980
+ return Array.isArray(accessLogs) ? accessLogs : [];
981
+ }
982
+
983
+ function mapAccessLogToRow(rawLog: Record<string, any>, event: Record<string, any>, index: string): AccessRow {
984
+ const identity = findIdentityForAccessLog(rawLog, event);
985
+ const rawAccessTime = getAccessLogTime(rawLog, event);
986
+ const identityKey = identity ? getIdentityKey(identity) : getLogIdentityKey({ ...event, payload: { ...event.payload, rawAccessLog: rawLog } });
987
+
988
+ return {
989
+ key: `${event._id || event.id || "log"}-${rawLog.id || rawLog.time || index}`,
990
+ identityKey,
991
+ name: identity ? getName(identity) : getRawLogName(rawLog, event),
992
+ facialData: identity ? getFacialData(identity) : "N/A",
993
+ faceImage: identity ? getUserImageSrc(identity) : "",
994
+ unit: identity ? getUnit(identity) : "N/A",
995
+ method: getAccessMethod(identity || {}, { ...event, payload: { ...event.payload, rawAccessLog: rawLog } }),
996
+ lastAccessTime: formatDate(rawAccessTime),
997
+ lastAccessDate: parseDate(rawAccessTime),
998
+ readerLocation: getLogReaderLocation(event),
999
+ status: identity ? "Mapped" : "Unmapped",
1000
+ };
1001
+ }
1002
+
1003
+ function findIdentityForAccessLog(rawLog: Record<string, any>, event: Record<string, any>) {
1004
+ const eventIdentityId = event.payload?.identity?._id;
1005
+ if (eventIdentityId) {
1006
+ const byId = identities.value.find((identity) => String(identity._id) === String(eventIdentityId));
1007
+ if (byId) return byId;
1008
+ }
1009
+
1010
+ const hidUserId = rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId ?? event.payload?.identity?.hidUserId ?? event.payload?.identityLookup?.hidUserId;
1011
+ const registration = rawLog.registration ?? event.payload?.identity?.registration ?? event.payload?.identityLookup?.registration;
1012
+ const cardNo = rawLog.card_value || rawLog.cardNo || event.payload?.identity?.cardNo || event.payload?.identityLookup?.cardNo;
1013
+
1014
+ return identities.value.find((identity) => {
1015
+ return (
1016
+ (hidUserId !== undefined && hidUserId !== null && String(identity.hidUserId) === String(hidUserId)) ||
1017
+ (registration && identity.registration === registration) ||
1018
+ (cardNo && String(identity.cardNo) === String(cardNo))
1019
+ );
1020
+ });
1021
+ }
1022
+
1023
+ function getLatestAccessLogForIdentity(identity: Record<string, any>) {
1024
+ const identityKey = getIdentityKey(identity);
1025
+ const accessLogs = [
1026
+ ...liveAccessLogs.value,
1027
+ ...logs.value.flatMap((event) => getRawAccessLogs(event)),
1028
+ ];
1029
+
1030
+ return accessLogs
1031
+ .filter((log) => getAccessLogIdentityKey(log) === identityKey)
1032
+ .sort((a, b) => {
1033
+ const aTime = parseDate(getAccessLogTime(a, {}))?.getTime() || 0;
1034
+ const bTime = parseDate(getAccessLogTime(b, {}))?.getTime() || 0;
1035
+ return bTime - aTime;
1036
+ })[0];
1037
+ }
1038
+
1039
+ function getAccessLogIdentityKey(rawLog: Record<string, any>) {
1040
+ return [
1041
+ rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId,
1042
+ rawLog.registration,
1043
+ rawLog.card_value || rawLog.cardNo,
1044
+ ].filter(Boolean).join("|");
1045
+ }
1046
+
1047
+ function getAccessLogTime(rawLog: Record<string, any>, event: Record<string, any>) {
1048
+ return rawLog.time || rawLog.createdAt || rawLog.accessedAt || event.createdAt;
1049
+ }
1050
+
1051
+ function getRawLogName(rawLog: Record<string, any>, event: Record<string, any>) {
1052
+ return rawLog.name || event.payload?.identity?.name || event.payload?.name || "Unmapped";
1053
+ }
1054
+
1055
+ function exportRows() {
1056
+ exportRowsAsPdf();
1057
+ exportDialog.value = false;
1058
+ }
1059
+
1060
+ function getExportSourceRows() {
1061
+ return exportScope.value === "history" ? selectedHistoryRows.value : filteredRows.value;
1062
+ }
1063
+
1064
+ function getLogAccessDateValue(log: Record<string, any>) {
1065
+ const payload = log.payload || {};
1066
+ return log.createdAt || payload.createdAt || payload.accessedAt || payload.time || payload.eventTime;
1067
+ }
1068
+
1069
+ function getLogReaderLocation(log: Record<string, any>) {
1070
+ return log.payload?.reader?.location || log.payload?.readerLocation || selectedReader.value?.location || "N/A";
1071
+ }
1072
+
1073
+ async function exportRowsAsPdf() {
1074
+ if (typeof window === "undefined") return;
1075
+
1076
+ const { jsPDF } = await import("jspdf");
1077
+ const pdf = new jsPDF({ unit: "mm", format: "a4", orientation: "portrait" });
1078
+ const rows = exportRowsData.value.map((row) => ({
1079
+ name: row.name,
1080
+ unit: row.unit,
1081
+ readerLocation: row.readerLocation,
1082
+ method: row.method,
1083
+ accessDateTime: row.lastAccessTime,
1084
+ }));
1085
+
1086
+ drawAccessLogPdf(pdf, rows);
1087
+ pdf.save(`hid-access-log-${activeTab.value}.pdf`);
1088
+ }
1089
+
1090
+ function drawAccessLogPdf(pdf: any, rows: PdfAccessRow[]) {
1091
+ const margin = 12;
1092
+ const pageWidth = pdf.internal.pageSize.getWidth();
1093
+ const pageHeight = pdf.internal.pageSize.getHeight();
1094
+ const tableWidth = pageWidth - margin * 2;
1095
+ const columns = [
1096
+ { title: "Name", key: "name", width: 38 },
1097
+ { title: "Unit No.", key: "unit", width: 26 },
1098
+ { title: "Reader Location", key: "readerLocation", width: 42 },
1099
+ { title: "Access Method", key: "method", width: 36 },
1100
+ { title: "Access Date and Time", key: "accessDateTime", width: tableWidth - 142 },
1101
+ ];
1102
+ let y = margin;
1103
+
1104
+ const addHeader = () => {
1105
+ pdf.setFillColor(6, 45, 66);
1106
+ pdf.rect(margin, y, tableWidth, 14, "F");
1107
+ pdf.setTextColor(255, 255, 255);
1108
+ pdf.setFont("helvetica", "bold");
1109
+ pdf.setFontSize(13);
1110
+ pdf.text("Access Log", margin + 4, y + 9);
1111
+ y += 20;
1112
+
1113
+ pdf.setTextColor(10, 38, 56);
1114
+ pdf.setFontSize(9);
1115
+ pdf.setFont("helvetica", "bold");
1116
+ pdf.text("Date", margin, y);
1117
+ pdf.text("Site", margin + 72, y);
1118
+ pdf.setFont("helvetica", "normal");
1119
+ pdf.text(`${exportForm.startDate || "-"} - ${exportForm.endDate || "-"}`, margin, y + 6);
1120
+ pdf.text(getPdfSiteName(), margin + 72, y + 6);
1121
+ y += 16;
1122
+ };
1123
+
1124
+ const addTableHeader = () => {
1125
+ pdf.setFillColor(241, 243, 245);
1126
+ pdf.rect(margin, y, tableWidth, 9, "F");
1127
+ pdf.setDrawColor(218, 224, 231);
1128
+ pdf.setTextColor(10, 38, 56);
1129
+ pdf.setFont("helvetica", "bold");
1130
+ pdf.setFontSize(8);
1131
+
1132
+ let x = margin;
1133
+ columns.forEach((column) => {
1134
+ pdf.rect(x, y, column.width, 9);
1135
+ pdf.text(column.title, x + 2, y + 6);
1136
+ x += column.width;
1137
+ });
1138
+ y += 9;
1139
+ };
1140
+
1141
+ const ensureSpace = (height: number) => {
1142
+ if (y + height <= pageHeight - margin) return;
1143
+ pdf.addPage();
1144
+ y = margin;
1145
+ addHeader();
1146
+ addTableHeader();
1147
+ };
1148
+
1149
+ addHeader();
1150
+ addTableHeader();
1151
+
1152
+ if (!rows.length) {
1153
+ pdf.setFont("helvetica", "normal");
1154
+ pdf.setFontSize(10);
1155
+ pdf.setTextColor(104, 115, 130);
1156
+ pdf.text("No access logs found.", margin, y + 8);
1157
+ return;
1158
+ }
1159
+
1160
+ rows.forEach((row) => {
1161
+ const cellLines = columns.map((column) => {
1162
+ const value = String(row[column.key as keyof PdfAccessRow] ?? "N/A");
1163
+ return pdf.splitTextToSize(value, column.width - 4);
1164
+ });
1165
+ const rowHeight = Math.max(9, Math.max(...cellLines.map((lines: string[]) => lines.length)) * 4.2 + 5);
1166
+ ensureSpace(rowHeight);
1167
+
1168
+ pdf.setFont("helvetica", "normal");
1169
+ pdf.setFontSize(8);
1170
+ pdf.setTextColor(10, 38, 56);
1171
+
1172
+ let x = margin;
1173
+ columns.forEach((column, index) => {
1174
+ pdf.setDrawColor(230, 233, 237);
1175
+ pdf.rect(x, y, column.width, rowHeight);
1176
+ pdf.text(cellLines[index], x + 2, y + 5);
1177
+ x += column.width;
1178
+ });
1179
+ y += rowHeight;
1180
+ });
1181
+ }
1182
+
1183
+ function buildPdfHtml() {
1184
+ const rows = exportRowsData.value.map((row) => ({
1185
+ name: row.name,
1186
+ unit: row.unit,
1187
+ readerLocation: row.readerLocation,
1188
+ method: row.method,
1189
+ accessDateTime: row.lastAccessTime,
1190
+ }));
1191
+ const groupedRows = groupRowsByName(rows);
1192
+ const dateRange = `${exportForm.startDate || "-"} - ${exportForm.endDate || "-"}`;
1193
+ const siteName = getPdfSiteName();
1194
+
1195
+ return `
1196
+ <div class="hid-access-log-pdf">
1197
+ <style>
1198
+ .hid-access-log-pdf {
1199
+ width: 190mm;
1200
+ min-height: 277mm;
1201
+ padding: 10mm;
1202
+ background: #ffffff;
1203
+ color: #0a2638;
1204
+ font-family: Arial, Helvetica, sans-serif;
1205
+ font-size: 9px;
1206
+ }
1207
+
1208
+ .pdf-summary {
1209
+ display: grid;
1210
+ grid-template-columns: 64mm 64mm 1fr;
1211
+ border: 1px solid #d9dee5;
1212
+ margin-bottom: 8mm;
1213
+ }
1214
+
1215
+ .pdf-title {
1216
+ display: flex;
1217
+ align-items: center;
1218
+ justify-content: center;
1219
+ background: #062d42;
1220
+ color: #ffffff;
1221
+ font-size: 12px;
1222
+ font-weight: 600;
1223
+ }
1224
+
1225
+ .pdf-summary-cell {
1226
+ min-height: 18mm;
1227
+ padding: 4mm 3mm;
1228
+ border-left: 1px solid #d9dee5;
1229
+ }
1230
+
1231
+ .pdf-label {
1232
+ display: block;
1233
+ margin-bottom: 2mm;
1234
+ color: #314155;
1235
+ font-size: 7px;
1236
+ font-weight: 700;
1237
+ }
1238
+
1239
+ .pdf-value {
1240
+ font-size: 10px;
1241
+ line-height: 1.35;
1242
+ }
1243
+
1244
+ .pdf-table {
1245
+ width: 100%;
1246
+ border-collapse: collapse;
1247
+ margin-bottom: 8mm;
1248
+ page-break-inside: avoid;
1249
+ }
1250
+
1251
+ .pdf-table th {
1252
+ background: #f1f1f1;
1253
+ color: #0a2638;
1254
+ font-size: 8px;
1255
+ font-weight: 700;
1256
+ text-align: left;
1257
+ padding: 4mm 3mm;
1258
+ border: 1px solid #e2e5e9;
1259
+ }
1260
+
1261
+ .pdf-table td {
1262
+ padding: 3.2mm 3mm;
1263
+ border: 1px solid #e6e9ed;
1264
+ color: #0a2638;
1265
+ vertical-align: middle;
1266
+ }
1267
+
1268
+ .pdf-empty {
1269
+ padding: 12mm;
1270
+ border: 1px solid #e6e9ed;
1271
+ color: #687382;
1272
+ text-align: center;
1273
+ }
1274
+ </style>
1275
+
1276
+ <div class="pdf-summary">
1277
+ <div class="pdf-title">Access Log</div>
1278
+ <div class="pdf-summary-cell">
1279
+ <span class="pdf-label">Date</span>
1280
+ <div class="pdf-value">${escapeHtml(dateRange)}</div>
1281
+ </div>
1282
+ <div class="pdf-summary-cell">
1283
+ <span class="pdf-label">Site</span>
1284
+ <div class="pdf-value">${escapeHtml(siteName)}</div>
1285
+ </div>
1286
+ </div>
1287
+
1288
+ ${
1289
+ groupedRows.length
1290
+ ? groupedRows.map((group) => buildPdfTable(group.rows)).join("")
1291
+ : `<div class="pdf-empty">No access logs found.</div>`
1292
+ }
1293
+ </div>
1294
+ `;
1295
+ }
1296
+
1297
+ function groupRowsByName(rows: PdfAccessRow[]) {
1298
+ const groups = new Map<string, PdfAccessRow[]>();
1299
+ rows.forEach((row) => {
1300
+ const key = row.name || "N/A";
1301
+ const group = groups.get(key) || [];
1302
+ group.push(row);
1303
+ groups.set(key, group);
1304
+ });
1305
+
1306
+ return Array.from(groups.entries()).map(([name, groupRows]) => ({
1307
+ name,
1308
+ rows: groupRows,
1309
+ }));
1310
+ }
1311
+
1312
+ function buildPdfTable(rows: PdfAccessRow[]) {
1313
+ const body = rows
1314
+ .map(
1315
+ (row) => `
1316
+ <tr>
1317
+ <td>${escapeHtml(row.name)}</td>
1318
+ <td>${escapeHtml(row.unit)}</td>
1319
+ <td>${escapeHtml(row.readerLocation)}</td>
1320
+ <td>${escapeHtml(row.method)}</td>
1321
+ <td>${escapeHtml(row.accessDateTime)}</td>
1322
+ </tr>
1323
+ `,
1324
+ )
1325
+ .join("");
1326
+
1327
+ return `
1328
+ <table class="pdf-table">
1329
+ <thead>
1330
+ <tr>
1331
+ <th>Name</th>
1332
+ <th>Unit No.</th>
1333
+ <th>Reader Location</th>
1334
+ <th>Access Method</th>
1335
+ <th>Access Date and Time</th>
1336
+ </tr>
1337
+ </thead>
1338
+ <tbody>${body}</tbody>
1339
+ </table>
1340
+ `;
1341
+ }
1342
+
1343
+ function getPdfSiteName() {
1344
+ const readerSite = selectedReader.value?.site;
1345
+ if (typeof readerSite === "object" && readerSite?.name) return readerSite.name;
1346
+ return selectedReader.value?.siteName || selectedReader.value?.metadata?.siteName || props.site;
1347
+ }
1348
+
1349
+ function escapeHtml(value: unknown) {
1350
+ return String(value ?? "")
1351
+ .replace(/&/g, "&amp;")
1352
+ .replace(/</g, "&lt;")
1353
+ .replace(/>/g, "&gt;")
1354
+ .replace(/"/g, "&quot;")
1355
+ .replace(/'/g, "&#039;");
1356
+ }
1357
+ </script>
1358
+
1359
+ <style scoped>
1360
+ .hid-access-log-dashboard {
1361
+ padding: 24px 0;
1362
+ width: 100%;
1363
+ min-width: 0;
1364
+ }
1365
+
1366
+ .toolbar {
1367
+ display: flex;
1368
+ flex-wrap: wrap;
1369
+ gap: 12px;
1370
+ align-items: center;
1371
+ margin-bottom: 16px;
1372
+ }
1373
+
1374
+ .toolbar-spacer {
1375
+ flex: 1;
1376
+ }
1377
+
1378
+ .export-button {
1379
+ min-width: 170px;
1380
+ background: #e4e4e4;
1381
+ color: #0a2638;
1382
+ }
1383
+
1384
+ .reader-input,
1385
+ .search-input {
1386
+ max-width: 240px;
1387
+ }
1388
+
1389
+ .status-input {
1390
+ max-width: 160px;
1391
+ }
1392
+
1393
+ .table-card {
1394
+ display: flex;
1395
+ flex-direction: column;
1396
+ min-height: 430px;
1397
+ overflow: hidden;
1398
+ }
1399
+
1400
+ .table-body {
1401
+ flex: 1;
1402
+ overflow-x: auto;
1403
+ }
1404
+
1405
+ .table-card :deep(table) {
1406
+ table-layout: fixed;
1407
+ width: 100%;
1408
+ min-width: 980px;
1409
+ }
1410
+
1411
+ .table-card :deep(th),
1412
+ .table-card :deep(td) {
1413
+ white-space: nowrap;
1414
+ }
1415
+
1416
+ .table-card :deep(th:nth-child(1)),
1417
+ .table-card :deep(td:nth-child(1)) {
1418
+ width: 16%;
1419
+ }
1420
+
1421
+ .table-card :deep(th:nth-child(2)),
1422
+ .table-card :deep(td:nth-child(2)) {
1423
+ width: 14%;
1424
+ }
1425
+
1426
+ .table-card :deep(th:nth-child(3)),
1427
+ .table-card :deep(td:nth-child(3)) {
1428
+ width: 12%;
1429
+ }
1430
+
1431
+ .table-card :deep(th:nth-child(4)),
1432
+ .table-card :deep(td:nth-child(4)) {
1433
+ width: 20%;
1434
+ }
1435
+
1436
+ .table-card :deep(th:nth-child(5)),
1437
+ .table-card :deep(td:nth-child(5)) {
1438
+ width: 18%;
1439
+ }
1440
+
1441
+ .table-card :deep(th:nth-child(6)),
1442
+ .table-card :deep(td:nth-child(6)) {
1443
+ width: 14%;
1444
+ }
1445
+
1446
+ .table-card :deep(th:nth-child(7)),
1447
+ .table-card :deep(td:nth-child(7)) {
1448
+ width: 6%;
1449
+ }
1450
+
1451
+ .history-dialog {
1452
+ overflow: hidden;
1453
+ }
1454
+
1455
+ .history-title {
1456
+ color: #4d9bc2;
1457
+ font-size: 18px;
1458
+ font-weight: 600;
1459
+ padding: 16px 20px 8px;
1460
+ }
1461
+
1462
+ .history-body {
1463
+ padding: 0 20px 20px;
1464
+ }
1465
+
1466
+ .history-toolbar {
1467
+ display: flex;
1468
+ align-items: center;
1469
+ gap: 12px;
1470
+ margin-bottom: 14px;
1471
+ }
1472
+
1473
+ .history-table-card {
1474
+ overflow-x: auto;
1475
+ overflow-y: hidden;
1476
+ }
1477
+
1478
+ .history-table-card :deep(table) {
1479
+ min-width: 760px;
1480
+ }
1481
+
1482
+ .table-refresh {
1483
+ height: 34px;
1484
+ display: flex;
1485
+ align-items: center;
1486
+ padding: 0 14px;
1487
+ background: #f3f3f3;
1488
+ border-bottom: 1px solid #e7e7e7;
1489
+ color: #0a2638;
1490
+ }
1491
+
1492
+ .table-footer {
1493
+ display: flex;
1494
+ justify-content: flex-end;
1495
+ align-items: center;
1496
+ gap: 8px;
1497
+ min-height: 44px;
1498
+ padding: 4px 10px;
1499
+ border-top: 1px solid #eeeeee;
1500
+ background: #f6f6f6;
1501
+ color: #0a2638;
1502
+ font-size: 13px;
1503
+ }
1504
+
1505
+ .access-tabs {
1506
+ border-bottom: 1px solid #eeeeee;
1507
+ }
1508
+
1509
+ .access-tabs :deep(.v-slide-group__content) {
1510
+ display: grid;
1511
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1512
+ width: 100%;
1513
+ }
1514
+
1515
+ .access-tabs :deep(.v-tab) {
1516
+ flex: none;
1517
+ min-width: 0;
1518
+ width: 100%;
1519
+ max-width: none;
1520
+ color: #0a2638;
1521
+ font-size: 12px;
1522
+ font-weight: 600;
1523
+ }
1524
+
1525
+ .access-tabs :deep(.v-tab--selected) {
1526
+ font-weight: 600;
1527
+ }
1528
+
1529
+ .status-chip {
1530
+ min-width: 110px;
1531
+ justify-content: center;
1532
+ font-weight: 600;
1533
+ }
1534
+
1535
+ .mapped-chip {
1536
+ background: #dff7e7 !important;
1537
+ color: #1b7f3a !important;
1538
+ }
1539
+
1540
+ .unmapped-chip {
1541
+ background: #fff0cc !important;
1542
+ color: #9a6700 !important;
1543
+ }
1544
+
1545
+ .hid-face-avatar {
1546
+ border: 1px solid #d8dde4;
1547
+ background: #f2f4f7;
1548
+ }
1549
+
1550
+ .empty-state {
1551
+ min-height: 280px;
1552
+ display: grid;
1553
+ place-items: center;
1554
+ color: #687382;
1555
+ font-size: 13px;
1556
+ }
1557
+
1558
+ .export-dialog {
1559
+ overflow: hidden;
1560
+ }
1561
+
1562
+ .dialog-title {
1563
+ font-size: 14px;
1564
+ font-weight: 700;
1565
+ padding: 12px 16px;
1566
+ border-bottom: 1px solid #ececec;
1567
+ }
1568
+
1569
+ .dialog-body {
1570
+ padding: 18px 16px 20px;
1571
+ }
1572
+
1573
+ .field-label {
1574
+ color: #4f5a66;
1575
+ font-size: 12px;
1576
+ font-weight: 600;
1577
+ margin-bottom: 6px;
1578
+ }
1579
+
1580
+ .field-label span {
1581
+ color: #e53935;
1582
+ }
1583
+
1584
+ .dialog-actions {
1585
+ display: grid;
1586
+ grid-template-columns: 1fr 1fr;
1587
+ }
1588
+
1589
+ .action-cancel {
1590
+ background: #ffffff;
1591
+ color: #111827;
1592
+ border-radius: 0;
1593
+ }
1594
+
1595
+ .action-submit {
1596
+ background: #062d42;
1597
+ color: #ffffff;
1598
+ border-radius: 0;
1599
+ }
1600
+
1601
+ :global(.compact-date-menu) {
1602
+ max-width: 320px;
1603
+ }
1604
+
1605
+ :global(.compact-date-menu .v-overlay__content) {
1606
+ max-width: 320px;
1607
+ }
1608
+
1609
+ :global(.compact-date-picker) {
1610
+ border-radius: 8px;
1611
+ box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
1612
+ }
1613
+
1614
+ :global(.compact-date-picker .v-picker-title),
1615
+ :global(.compact-date-picker .v-date-picker-header) {
1616
+ display: none;
1617
+ }
1618
+
1619
+ :global(.compact-date-picker .v-picker__body) {
1620
+ padding-top: 4px;
1621
+ }
1622
+
1623
+ :global(.compact-date-picker .v-date-picker-month) {
1624
+ padding: 0 12px 12px;
1625
+ }
1626
+
1627
+ :global(.compact-date-picker .v-date-picker-month__day) {
1628
+ height: 34px;
1629
+ }
1630
+
1631
+ @media (max-width: 960px) {
1632
+ .toolbar,
1633
+ .history-toolbar {
1634
+ align-items: stretch;
1635
+ }
1636
+
1637
+ .toolbar-spacer {
1638
+ display: none;
1639
+ }
1640
+
1641
+ .export-button,
1642
+ .reader-input,
1643
+ .search-input,
1644
+ .status-input {
1645
+ max-width: none;
1646
+ width: 100%;
1647
+ }
1648
+
1649
+ .table-card {
1650
+ min-height: 360px;
1651
+ }
1652
+ }
1653
+
1654
+ @media (max-width: 600px) {
1655
+ .hid-access-log-dashboard {
1656
+ padding: 12px 0;
1657
+ }
1658
+
1659
+ .access-tabs :deep(.v-slide-group__content) {
1660
+ display: flex;
1661
+ }
1662
+
1663
+ .access-tabs :deep(.v-tab) {
1664
+ min-width: 132px;
1665
+ }
1666
+
1667
+ .history-body {
1668
+ padding: 0 12px 12px;
1669
+ }
1670
+
1671
+ .history-toolbar {
1672
+ flex-wrap: wrap;
1673
+ }
1674
+
1675
+ .dialog-actions {
1676
+ grid-template-columns: 1fr;
1677
+ }
1678
+
1679
+ :global(.v-dialog > .v-overlay__content) {
1680
+ max-width: calc(100vw - 24px) !important;
1681
+ margin: 12px !important;
1682
+ }
1683
+
1684
+ :global(.compact-date-menu),
1685
+ :global(.compact-date-menu .v-overlay__content),
1686
+ :global(.compact-date-picker) {
1687
+ max-width: calc(100vw - 32px) !important;
1688
+ width: calc(100vw - 32px) !important;
1689
+ }
1690
+ }
1691
+ </style>