@7365admin1/layer-common 3.0.7 → 3.0.9

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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/components/CameraMain.vue +17 -1
  3. package/components/DashboardMain.vue +1854 -848
  4. package/components/EntryPassInformation.vue +60 -0
  5. package/components/HidAccessLogDashboard.vue +1691 -0
  6. package/components/HidEnabledGate.vue +51 -0
  7. package/components/HidIdentityMapping.vue +975 -0
  8. package/components/HidQrCodeConfiguration.vue +618 -0
  9. package/components/HidReaderForm.vue +246 -0
  10. package/components/HidReaderManagement.vue +1233 -0
  11. package/components/HidServiceSettingsPanel.vue +150 -0
  12. package/components/HidUserEnrollment.vue +1274 -0
  13. package/components/Layout/NavigationDrawer.vue +31 -1
  14. package/components/MemberInformation.vue +23 -15
  15. package/components/NavigationItem.vue +11 -4
  16. package/components/OnlineFormFill.vue +34 -0
  17. package/components/PassInformation.vue +73 -34
  18. package/components/SiteSettings.vue +60 -116
  19. package/components/VisitorForm.vue +815 -290
  20. package/components/VisitorFormSelection.vue +12 -2
  21. package/components/VisitorManagement.vue +652 -205
  22. package/composables/useCommonPermission.ts +59 -0
  23. package/composables/useDashboard.ts +46 -1
  24. package/composables/useHidAmico.ts +152 -0
  25. package/composables/useHidNavigation.ts +106 -0
  26. package/composables/useMember.ts +7 -1
  27. package/composables/useOptionalServices.ts +118 -0
  28. package/composables/useSettingsPermission.ts +138 -0
  29. package/composables/useSiteSettings.ts +1 -1
  30. package/composables/useUser.ts +1 -2
  31. package/nuxt.config.ts +1 -1
  32. package/package.json +3 -2
  33. package/pages/[org]/[site]/access-mgmt/access-logs/index.vue +21 -0
  34. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +21 -0
  35. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +21 -0
  36. package/pages/[org]/[site]/access-mgmt/hid-users/index.vue +21 -0
  37. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +21 -0
  38. package/types/local.d.ts +2 -1
  39. package/types/visitor.d.ts +4 -2
@@ -0,0 +1,1274 @@
1
+ <template>
2
+ <section class="hid-user-enrollment">
3
+ <div class="toolbar">
4
+ <v-btn
5
+ rounded="xl"
6
+ class="text-none enroll-button"
7
+ variant="flat"
8
+ prepend-icon="mdi-account-plus"
9
+ @click="openEnroll"
10
+ >
11
+ Enroll
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="loadUsers"
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
+ prepend-inner-icon="mdi-magnify"
37
+ @keyup.enter="loadUsers"
38
+ />
39
+
40
+ <v-select
41
+ v-model="status"
42
+ :items="statusOptions"
43
+ variant="outlined"
44
+ density="compact"
45
+ hide-details
46
+ class="status-input"
47
+ @update:model-value="loadUsers"
48
+ />
49
+ </div>
50
+
51
+ <v-card flat border class="table-card">
52
+ <div class="table-refresh">
53
+ <v-btn icon="mdi-refresh" variant="text" density="comfortable" :loading="loading" @click="loadUsers" />
54
+ <div class="page-count">{{ pageRange }}</div>
55
+ </div>
56
+
57
+ <div class="table-body">
58
+ <v-table>
59
+ <thead>
60
+ <tr>
61
+ <th>Name</th>
62
+ <th>Facial Data</th>
63
+ <th>UID</th>
64
+ <th>Status</th>
65
+ <th class="text-right"></th>
66
+ </tr>
67
+ </thead>
68
+ <tbody>
69
+ <tr v-for="user in users" :key="user._rowKey || user._id || user.hidUserId">
70
+ <td>{{ getName(user) }}</td>
71
+ <td>
72
+ <v-avatar v-if="getUserImageSrc(user)" size="34" rounded="lg" class="hid-face-avatar">
73
+ <v-img :src="getUserImageSrc(user)" cover />
74
+ </v-avatar>
75
+ <span v-else>{{ getFacialData(user) }}</span>
76
+ </td>
77
+ <td>{{ formatHidUid(user.hidUserId) || "N/A" }}</td>
78
+ <td>
79
+ <v-chip
80
+ size="small"
81
+ variant="flat"
82
+ class="status-chip"
83
+ :class="getMappingStatusClass(user)"
84
+ >
85
+ {{ getMappingStatus(user) }}
86
+ </v-chip>
87
+ </td>
88
+ <td class="text-right">
89
+ <v-menu>
90
+ <template #activator="{ props: menuProps }">
91
+ <v-btn v-bind="menuProps" icon="mdi-dots-vertical" variant="text" density="comfortable" />
92
+ </template>
93
+
94
+ <v-list density="compact" min-width="150">
95
+ <v-list-item title="View" @click="openView(user)" />
96
+ <v-list-item title="Edit" @click="openEdit(user)" />
97
+ <v-list-item title="Delete" class="text-error" @click="openDelete(user)" />
98
+ </v-list>
99
+ </v-menu>
100
+ </td>
101
+ </tr>
102
+ </tbody>
103
+ </v-table>
104
+
105
+ <div v-if="!users.length" class="empty-state">
106
+ No HID users enrolled yet.
107
+ </div>
108
+ </div>
109
+
110
+ <div class="table-footer">
111
+ <span>{{ pageRange }}</span>
112
+ <v-btn icon="mdi-chevron-left" variant="text" density="comfortable" :disabled="page <= 1" @click="page--; loadUsers()" />
113
+ <v-btn icon="mdi-chevron-right" variant="text" density="comfortable" :disabled="page >= pages" @click="page++; loadUsers()" />
114
+ </div>
115
+ </v-card>
116
+
117
+ <v-dialog v-model="formDialog" max-width="420" persistent>
118
+ <v-card rounded="lg">
119
+ <v-card-title class="small-title">
120
+ {{ selectedUser ? "Edit User Information" : "Enroll" }}
121
+ </v-card-title>
122
+
123
+ <v-card-text>
124
+ <div class="photo-wrap">
125
+ <v-menu>
126
+ <template #activator="{ props: menuProps }">
127
+ <button v-bind="menuProps" class="photo-button" type="button">
128
+ <v-img v-if="form.photoPreview" :src="form.photoPreview" cover class="photo-preview" />
129
+ <span v-if="form.photoPreview" class="photo-edit-icon">
130
+ <v-icon icon="mdi-pencil" size="16" />
131
+ </span>
132
+ <template v-else>
133
+ <v-icon icon="mdi-account-plus" size="40" color="#a4acb5" />
134
+ <span>Add Photo</span>
135
+ </template>
136
+ </button>
137
+ </template>
138
+ <v-list density="compact" min-width="190">
139
+ <v-list-item title="Browse" @click="triggerPhotoInput" />
140
+ <v-list-item title="Take Photo using Camera" @click="triggerPhotoInput" />
141
+ </v-list>
142
+ </v-menu>
143
+ <input ref="photoInput" type="file" accept="image/*" class="hidden-input" @change="onPhotoChange" />
144
+ </div>
145
+
146
+ <div class="field-label">Name <span>*</span></div>
147
+ <v-text-field
148
+ v-model="form.name"
149
+ aria-label="Name"
150
+ placeholder="Enter name"
151
+ density="compact"
152
+ variant="outlined"
153
+ hide-details="auto"
154
+ class="mb-3"
155
+ />
156
+
157
+ <div class="field-label">ID</div>
158
+ <v-text-field
159
+ v-model="form.hidUserId"
160
+ aria-label="ID"
161
+ density="compact"
162
+ variant="outlined"
163
+ hide-details="auto"
164
+ readonly
165
+ class="mb-3"
166
+ />
167
+
168
+ <div class="field-label">Registration No. <span>*</span></div>
169
+ <v-text-field
170
+ v-model="form.registration"
171
+ aria-label="Registration No."
172
+ placeholder="Enter registration no."
173
+ density="compact"
174
+ variant="outlined"
175
+ hide-details="auto"
176
+ :readonly="!selectedUser"
177
+ class="mb-3"
178
+ />
179
+
180
+ <div class="field-label">Panic Pin (optional)</div>
181
+ <v-text-field
182
+ v-model="form.cardNo"
183
+ aria-label="Panic Pin"
184
+ placeholder="Enter panic pin"
185
+ density="compact"
186
+ variant="outlined"
187
+ hide-details="auto"
188
+ class="mb-3"
189
+ />
190
+
191
+ <div class="field-label">Panic Password (optional)</div>
192
+ <v-text-field
193
+ v-model="form.pinPassword"
194
+ aria-label="Panic Password"
195
+ placeholder="Enter password"
196
+ density="compact"
197
+ variant="outlined"
198
+ hide-details="auto"
199
+ :type="showPassword ? 'text' : 'password'"
200
+ :append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
201
+ @click:append-inner="showPassword = !showPassword"
202
+ />
203
+ </v-card-text>
204
+
205
+ <v-card-actions class="pa-0">
206
+ <v-btn class="text-none action-cancel" variant="flat" height="44" @click="formDialog = false">Cancel</v-btn>
207
+ <v-btn class="text-none action-submit" variant="flat" height="44" :loading="saving" @click="saveUser">
208
+ Save
209
+ </v-btn>
210
+ </v-card-actions>
211
+ </v-card>
212
+ </v-dialog>
213
+
214
+ <v-dialog v-model="viewDialog" max-width="430">
215
+ <v-card rounded="lg">
216
+ <v-card-title class="small-title">User Information</v-card-title>
217
+ <v-card-text>
218
+ <div class="detail-grid">
219
+ <span>Name</span><strong>{{ getName(selectedUser) }}</strong>
220
+ <span>User ID</span><strong>{{ selectedUser ? formatHidUid(selectedUser.hidUserId) : "N/A" }}</strong>
221
+ <span>Registration No.</span><strong>{{ selectedUser?.registration || "N/A" }}</strong>
222
+ <span>Last access time</span><strong>{{ formatDate(selectedUser?.metadata?.lastAccessAt) }}</strong>
223
+ <span>Status</span><strong>{{ selectedUser ? getMappingStatus(selectedUser) : "N/A" }}</strong>
224
+ <span>Panic Pin</span><strong>{{ selectedUser?.cardNo || "N/A" }}</strong>
225
+ <span>Private Password</span><strong>{{ selectedUser?.metadata?.pinPassword ? "********" : "N/A" }}</strong>
226
+ </div>
227
+ </v-card-text>
228
+ <v-card-actions>
229
+ <v-spacer />
230
+ <v-btn class="text-none" variant="text" @click="viewDialog = false">Close</v-btn>
231
+ </v-card-actions>
232
+ </v-card>
233
+ </v-dialog>
234
+
235
+ <v-dialog v-model="deleteDialog" max-width="360" persistent>
236
+ <v-card rounded="lg">
237
+ <v-card-title class="small-title">Delete</v-card-title>
238
+ <v-card-text class="text-body-2">
239
+ Are you sure you want to permanently delete this HID user?
240
+ <div class="text-caption text-medium-emphasis mt-2">
241
+ This action will remove the user from the HID reader and mark the identity as deleted.
242
+ </div>
243
+ </v-card-text>
244
+ <v-card-actions class="pa-0">
245
+ <v-btn class="text-none action-cancel" variant="flat" height="44" @click="deleteDialog = false">Cancel</v-btn>
246
+ <v-btn class="text-none action-submit" variant="flat" height="44" :loading="deleting" @click="deleteUser">Delete</v-btn>
247
+ </v-card-actions>
248
+ </v-card>
249
+ </v-dialog>
250
+
251
+ <v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3500">
252
+ {{ snackbar.message }}
253
+ </v-snackbar>
254
+ </section>
255
+ </template>
256
+
257
+ <script setup lang="ts">
258
+ const props = defineProps({
259
+ site: {
260
+ type: String,
261
+ required: true,
262
+ },
263
+ identityType: {
264
+ type: String,
265
+ default: "resident",
266
+ },
267
+ });
268
+
269
+ const {
270
+ getReaders,
271
+ getIdentities,
272
+ getUserImage,
273
+ runObjectOperation,
274
+ createIdentity,
275
+ updateIdentity,
276
+ deleteIdentity,
277
+ } = useHidAmico();
278
+
279
+ const readers = ref<Record<string, any>[]>([]);
280
+ const users = ref<Record<string, any>[]>([]);
281
+ const userImages = ref<Record<string, string>>({});
282
+ const selectedReaderId = ref("");
283
+ const search = ref("");
284
+ const status = ref("Status");
285
+ const page = ref(1);
286
+ const limit = ref(10);
287
+ const pages = ref(1);
288
+ const total = ref(0);
289
+ const serverPageRange = ref("");
290
+ const loading = ref(false);
291
+ const saving = ref(false);
292
+ const deleting = ref(false);
293
+ const formDialog = ref(false);
294
+ const viewDialog = ref(false);
295
+ const deleteDialog = ref(false);
296
+ const selectedUser = ref<Record<string, any> | null>(null);
297
+ const showPassword = ref(false);
298
+ const photoInput = ref<HTMLInputElement | null>(null);
299
+ const snackbar = reactive({
300
+ show: false,
301
+ color: "success",
302
+ message: "",
303
+ });
304
+
305
+ const form = reactive({
306
+ reader: "",
307
+ block: "",
308
+ level: "",
309
+ unit: "",
310
+ name: "",
311
+ hidUserId: "",
312
+ registration: "",
313
+ cardNo: "",
314
+ pinPassword: "",
315
+ photoPreview: "",
316
+ });
317
+
318
+ const statusOptions = ["Status", "Mapped", "Unmapped"];
319
+
320
+ const readerOptions = computed(() =>
321
+ readers.value.map((reader) => ({
322
+ title: reader.name || reader.deviceId || reader._id,
323
+ value: reader._id,
324
+ })),
325
+ );
326
+
327
+ const pageRange = computed(() => {
328
+ if (serverPageRange.value) return serverPageRange.value;
329
+ if (!total.value) return "0-0 of 0";
330
+ const start = (page.value - 1) * limit.value + 1;
331
+ const end = Math.min(page.value * limit.value, total.value);
332
+ return `${start}-${end} of ${total.value}`;
333
+ });
334
+
335
+ watch(
336
+ () => props.site,
337
+ () => init(),
338
+ { immediate: true },
339
+ );
340
+
341
+ async function init() {
342
+ await loadReaders();
343
+ await loadUsers();
344
+ }
345
+
346
+ async function loadReaders() {
347
+ if (!props.site) return;
348
+ const response = await getReaders({ site: props.site, page: 1, limit: 100 });
349
+ readers.value = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
350
+ if (!selectedReaderId.value && readers.value.length) {
351
+ selectedReaderId.value = readers.value[0]._id;
352
+ }
353
+ }
354
+
355
+ async function loadUsers() {
356
+ if (!selectedReaderId.value) {
357
+ users.value = [];
358
+ return;
359
+ }
360
+
361
+ loading.value = true;
362
+ try {
363
+ const identityQuery: Record<string, any> = {
364
+ page: page.value,
365
+ limit: 500,
366
+ type: props.identityType,
367
+ search: search.value,
368
+ };
369
+
370
+ const [identityResponse, readerResponse, roleResponse] = await Promise.all([
371
+ getIdentities(selectedReaderId.value, identityQuery),
372
+ runObjectOperation(selectedReaderId.value, {
373
+ operation: "load",
374
+ object: "users",
375
+ limit: 500,
376
+ offset: 0,
377
+ }),
378
+ isAdministratorIdentity() ? loadAdministratorRoles(selectedReaderId.value) : Promise.resolve(null),
379
+ ]);
380
+
381
+ const identities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
382
+ const adminRoleIds = getAdministratorRoleUserIds(roleResponse);
383
+ const readerUsers = normalizeHidUsers(readerResponse).filter((user) => {
384
+ if (!isAdministratorIdentity()) return true;
385
+ const hidUserId = toHidNumericId(user.hidUserId);
386
+ return Boolean(hidUserId && adminRoleIds.has(hidUserId));
387
+ });
388
+ const mergedUsers = mergeReaderUsersWithIdentities(readerUsers, identities);
389
+ const filteredUsers = filterUsers(mergedUsers);
390
+ total.value = filteredUsers.length;
391
+ pages.value = Math.max(1, Math.ceil(total.value / limit.value));
392
+ if (page.value > pages.value) page.value = pages.value;
393
+ const start = (page.value - 1) * limit.value;
394
+ users.value = filteredUsers.slice(start, start + limit.value);
395
+ serverPageRange.value = "";
396
+ await loadUserImages(users.value);
397
+ } catch (error) {
398
+ console.error("Unable to load HID reader users:", error);
399
+ showToast("Unable to load HID users from reader.", "error");
400
+ users.value = [];
401
+ total.value = 0;
402
+ pages.value = 1;
403
+ serverPageRange.value = "";
404
+ } finally {
405
+ loading.value = false;
406
+ }
407
+ }
408
+
409
+ async function loadUserImages(items: Record<string, any>[]) {
410
+ if (!selectedReaderId.value) return;
411
+
412
+ await Promise.all(items.map(async (user) => {
413
+ const hidUserId = toHidNumericId(user.hidUserId);
414
+ const key = String(hidUserId || "");
415
+ if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
416
+
417
+ try {
418
+ const response = await getUserImage(selectedReaderId.value, hidUserId);
419
+ const image = response?.data || response;
420
+ userImages.value[key] = image?.base64
421
+ ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
422
+ : "";
423
+ } catch {
424
+ userImages.value[key] = "";
425
+ }
426
+ }));
427
+ }
428
+
429
+ function resetForm() {
430
+ form.reader = selectedReaderId.value || readers.value[0]?._id || "";
431
+ form.block = "";
432
+ form.level = "";
433
+ form.unit = "";
434
+ form.name = "";
435
+ form.hidUserId = "";
436
+ form.registration = "";
437
+ form.cardNo = "";
438
+ form.pinPassword = "";
439
+ form.photoPreview = "";
440
+ }
441
+
442
+ async function openEnroll() {
443
+ selectedUser.value = null;
444
+ resetForm();
445
+ const [hidUserId, registration] = await Promise.all([
446
+ getNextUid(),
447
+ getNextRegistration(),
448
+ ]);
449
+ form.hidUserId = hidUserId;
450
+ form.registration = registration;
451
+ formDialog.value = true;
452
+ }
453
+
454
+ async function openEdit(user: Record<string, any>) {
455
+ selectedUser.value = user;
456
+ form.reader = String(user.reader ?? selectedReaderId.value);
457
+ form.block = user.metadata?.block ?? "";
458
+ form.level = user.metadata?.level ?? "";
459
+ form.unit = user.metadata?.unit ?? "";
460
+ form.name = getName(user);
461
+ form.hidUserId = formatHidUid(user.hidUserId);
462
+ form.registration = user.registration ?? "";
463
+ form.cardNo = user.cardNo ?? "";
464
+ form.pinPassword = user.metadata?.pinPassword ?? "";
465
+ form.photoPreview = getUserImageSrc(user);
466
+ formDialog.value = true;
467
+
468
+ if (!form.photoPreview) {
469
+ await loadUserImages([user]);
470
+ if (selectedUser.value?._rowKey === user._rowKey) {
471
+ form.photoPreview = getUserImageSrc(user);
472
+ }
473
+ }
474
+ }
475
+
476
+ function openView(user: Record<string, any>) {
477
+ selectedUser.value = user;
478
+ viewDialog.value = true;
479
+ }
480
+
481
+ function openDelete(user: Record<string, any>) {
482
+ selectedUser.value = user;
483
+ deleteDialog.value = true;
484
+ }
485
+
486
+ function triggerPhotoInput() {
487
+ photoInput.value?.click();
488
+ }
489
+
490
+ function onPhotoChange(event: Event) {
491
+ const file = (event.target as HTMLInputElement).files?.[0];
492
+ if (!file) return;
493
+ const reader = new FileReader();
494
+ reader.onload = () => {
495
+ form.photoPreview = String(reader.result || "");
496
+ };
497
+ reader.readAsDataURL(file);
498
+ }
499
+
500
+ async function saveUser() {
501
+ const readerId = selectedReaderId.value || form.reader || readers.value[0]?._id;
502
+ if (!readerId || !form.name || !form.hidUserId || !form.registration) {
503
+ showToast("Please complete the required HID user fields.", "error");
504
+ return;
505
+ }
506
+
507
+ saving.value = true;
508
+ try {
509
+ const hidUser = toHidUserObject({
510
+ hidUserId: form.hidUserId,
511
+ name: form.name,
512
+ registration: form.registration,
513
+ });
514
+ if (!hidUser.id) {
515
+ showToast("HID user ID must contain a valid numeric value.", "error");
516
+ return;
517
+ }
518
+
519
+ const payload = {
520
+ hidUserId: String(hidUser.id),
521
+ registration: form.registration,
522
+ cardNo: form.cardNo,
523
+ type: props.identityType as "resident" | "staff" | "contractor" | "visitor" | "admin" | "unknown",
524
+ status: "active" as const,
525
+ metadata: {
526
+ name: form.name,
527
+ block: form.block,
528
+ level: form.level,
529
+ unit: form.unit,
530
+ unitLabel: buildUnitLabel(),
531
+ facialData: form.photoPreview ? form.hidUserId : "",
532
+ photo: form.photoPreview,
533
+ pinPassword: form.pinPassword,
534
+ },
535
+ };
536
+
537
+ if (selectedUser.value) {
538
+ await updateHidUserOnReader(readerId, hidUser);
539
+ await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
540
+ if (selectedUser.value._id) {
541
+ await updateIdentity(selectedUser.value._id, payload);
542
+ } else {
543
+ await saveIdentityOnReader(readerId, payload);
544
+ }
545
+ } else {
546
+ await createHidUserOnReader(readerId, hidUser);
547
+ await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
548
+ await saveIdentityOnReader(readerId, payload);
549
+ selectedReaderId.value = readerId;
550
+ }
551
+
552
+ formDialog.value = false;
553
+ await loadUsers();
554
+ showToast(selectedUser.value?._id ? "HID user updated on reader." : "HID user enrolled on reader.");
555
+ } catch (error: any) {
556
+ console.error("Unable to save HID user:", error);
557
+ showToast(getHidErrorMessage(error), "error");
558
+ } finally {
559
+ saving.value = false;
560
+ }
561
+ }
562
+
563
+ async function deleteUser() {
564
+ if (!selectedUser.value) return;
565
+
566
+ deleting.value = true;
567
+ try {
568
+ await syncHidUserRole(selectedReaderId.value, toHidNumericId(selectedUser.value.hidUserId), false);
569
+ await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
570
+ if (selectedUser.value._id) {
571
+ await deleteIdentity(selectedUser.value._id);
572
+ }
573
+ deleteDialog.value = false;
574
+ await loadUsers();
575
+ showToast("HID user deleted from reader.");
576
+ } catch (error: any) {
577
+ console.error("Unable to delete HID user:", error);
578
+ showToast(getHidErrorMessage(error), "error");
579
+ } finally {
580
+ deleting.value = false;
581
+ }
582
+ }
583
+
584
+ async function saveIdentityOnReader(readerId: string, payload: Record<string, any>) {
585
+ const existingIdentity = await findExistingIdentityOnReader(readerId, payload);
586
+ if (existingIdentity?._id) {
587
+ await updateIdentity(existingIdentity._id, payload);
588
+ return;
589
+ }
590
+
591
+ try {
592
+ await createIdentity(readerId, { ...payload, site: props.site });
593
+ } catch (error: any) {
594
+ const message = getHidErrorMessage(error);
595
+ if (!message.toLowerCase().includes("already exists")) throw error;
596
+
597
+ const duplicatedIdentity = await findExistingIdentityOnReader(readerId, payload);
598
+ if (!duplicatedIdentity?._id) throw error;
599
+ await updateIdentity(duplicatedIdentity._id, payload);
600
+ }
601
+ }
602
+
603
+ async function findExistingIdentityOnReader(readerId: string, payload: Record<string, any>) {
604
+ const response = await getIdentities(readerId, {
605
+ page: 1,
606
+ limit: 500,
607
+ search: payload.hidUserId || payload.registration || payload.cardNo || "",
608
+ });
609
+ const identities = response?.items ?? response?.data?.items ?? response?.data?.identities ?? [];
610
+ const hidUserId = String(payload.hidUserId || "");
611
+ const registration = String(payload.registration || "");
612
+ const cardNo = String(payload.cardNo || "");
613
+
614
+ return identities.find((identity: Record<string, any>) =>
615
+ String(identity.hidUserId || "") === hidUserId ||
616
+ Boolean(registration && String(identity.registration || "") === registration) ||
617
+ Boolean(cardNo && String(identity.cardNo || "") === cardNo),
618
+ );
619
+ }
620
+
621
+ async function createHidUserOnReader(readerId: string, user: Record<string, any>) {
622
+ const existing = await getHidUserFromReader(readerId, user.id);
623
+ if (existing) {
624
+ await updateHidUserOnReader(readerId, user);
625
+ return;
626
+ }
627
+
628
+ await runObjectOperation(readerId, {
629
+ operation: "create",
630
+ object: "users",
631
+ values: [user],
632
+ });
633
+ }
634
+
635
+ async function getHidUserFromReader(readerId: string, id: unknown) {
636
+ if (!readerId || !id) return undefined;
637
+ const response = await runObjectOperation(readerId, {
638
+ operation: "load",
639
+ object: "users",
640
+ where: {
641
+ users: {
642
+ id,
643
+ },
644
+ },
645
+ limit: 1,
646
+ offset: 0,
647
+ });
648
+ return normalizeHidUsers(response)[0];
649
+ }
650
+
651
+ async function updateHidUserOnReader(readerId: string, user: Record<string, any>) {
652
+ const { id, ...values } = user;
653
+ await runObjectOperation(readerId, {
654
+ operation: "modify",
655
+ object: "users",
656
+ where: {
657
+ users: {
658
+ id,
659
+ },
660
+ },
661
+ values,
662
+ });
663
+ }
664
+
665
+ async function deleteHidUserFromReader(readerId: string, user: Record<string, any>) {
666
+ const id = toHidNumericId(user.hidUserId);
667
+ if (!readerId || !id) return;
668
+
669
+ await runObjectOperation(readerId, {
670
+ operation: "destroy",
671
+ object: "users",
672
+ where: {
673
+ users: {
674
+ id,
675
+ },
676
+ },
677
+ });
678
+ }
679
+
680
+ async function syncHidUserRole(readerId: string, hidUserId: unknown, isAdministrator: boolean) {
681
+ const userId = toHidNumericId(hidUserId);
682
+ if (!readerId || !userId) return;
683
+
684
+ const existingRole = await getHidUserRole(readerId, userId);
685
+ if (isAdministrator) {
686
+ if (existingRole) {
687
+ await runObjectOperation(readerId, {
688
+ operation: "modify",
689
+ object: "user_roles",
690
+ where: {
691
+ user_roles: {
692
+ user_id: userId,
693
+ },
694
+ },
695
+ values: {
696
+ role: 1,
697
+ },
698
+ });
699
+ return;
700
+ }
701
+
702
+ await runObjectOperation(readerId, {
703
+ operation: "create",
704
+ object: "user_roles",
705
+ values: [
706
+ {
707
+ user_id: userId,
708
+ role: 1,
709
+ },
710
+ ],
711
+ });
712
+ return;
713
+ }
714
+
715
+ if (existingRole) {
716
+ await runObjectOperation(readerId, {
717
+ operation: "destroy",
718
+ object: "user_roles",
719
+ where: {
720
+ user_roles: {
721
+ user_id: userId,
722
+ },
723
+ },
724
+ });
725
+ }
726
+ }
727
+
728
+ async function getHidUserRole(readerId: string, userId: number) {
729
+ const response = await runObjectOperation(readerId, {
730
+ operation: "load",
731
+ object: "user_roles",
732
+ where: {
733
+ user_roles: {
734
+ user_id: userId,
735
+ },
736
+ },
737
+ limit: 1,
738
+ offset: 0,
739
+ });
740
+ const data = response?.data || response || {};
741
+ const roles =
742
+ data?.user_roles ||
743
+ data?.data?.user_roles ||
744
+ data?.result?.user_roles ||
745
+ [];
746
+ return Array.isArray(roles) ? roles[0] : undefined;
747
+ }
748
+
749
+ function loadAdministratorRoles(readerId: string) {
750
+ return runObjectOperation(readerId, {
751
+ operation: "load",
752
+ object: "user_roles",
753
+ where: {
754
+ user_roles: {
755
+ role: 1,
756
+ },
757
+ },
758
+ limit: 500,
759
+ offset: 0,
760
+ });
761
+ }
762
+
763
+ function getAdministratorRoleUserIds(response: Record<string, any> | null) {
764
+ const data = response?.data || response || {};
765
+ const roles =
766
+ data?.user_roles ||
767
+ data?.data?.user_roles ||
768
+ data?.result?.user_roles ||
769
+ [];
770
+
771
+ return new Set(
772
+ (Array.isArray(roles) ? roles : [])
773
+ .map((role) => toHidNumericId(role.user_id))
774
+ .filter((userId): userId is number => Boolean(userId)),
775
+ );
776
+ }
777
+
778
+ function toHidUserObject(identity: Record<string, any>) {
779
+ return {
780
+ id: toHidNumericId(identity.hidUserId),
781
+ name: identity.metadata?.name || identity.name || `User ${identity.hidUserId || ""}`.trim(),
782
+ registration: identity.registration || "",
783
+ };
784
+ }
785
+
786
+ function toHidNumericId(value: unknown) {
787
+ const raw = String(value ?? "").trim();
788
+ if (!raw) return undefined;
789
+ const numeric = Number(raw);
790
+ if (Number.isInteger(numeric) && numeric > 0) return numeric;
791
+ const match = raw.match(/(\d+)$/);
792
+ const parsed = match ? Number(match[1]) : NaN;
793
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
794
+ }
795
+
796
+ function isAdministratorIdentity() {
797
+ return props.identityType === "admin";
798
+ }
799
+
800
+ function buildUnitLabel() {
801
+ const parts = [form.block, form.level, form.unit].filter(Boolean);
802
+ return parts.length ? `BLK ${parts.join("/")}` : "";
803
+ }
804
+
805
+ function getName(user?: Record<string, any> | null) {
806
+ return user?.metadata?.name || user?.name || "N/A";
807
+ }
808
+
809
+ function getUnit(user?: Record<string, any> | null) {
810
+ return user?.metadata?.unitLabel || [user?.metadata?.block, user?.metadata?.level, user?.metadata?.unit].filter(Boolean).join("/") || "N/A";
811
+ }
812
+
813
+ function getFacialData(user: Record<string, any>) {
814
+ return user?.metadata?.facialData || user?.metadata?.imageTimestamp || (user?.metadata?.photo ? user.hidUserId : "N/A");
815
+ }
816
+
817
+ function getUserImageSrc(user: Record<string, any>) {
818
+ const hidUserId = toHidNumericId(user?.hidUserId);
819
+ return user?.metadata?.photo || (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
820
+ }
821
+
822
+ async function getNextUid() {
823
+ const readerIds = await getExistingHidReaderIds();
824
+ const maxNumber = users.value.reduce((max, user) => {
825
+ const match = String(user.hidUserId ?? "").match(/^UID(\d+)$/i);
826
+ return Math.max(max, match ? Number(match[1]) : toHidNumericId(user.hidUserId) || 0);
827
+ }, 0);
828
+ const nextNumber = Math.max(maxNumber, ...readerIds, 0) + 1;
829
+ return `UID${String(nextNumber).padStart(6, "0")}`;
830
+ }
831
+
832
+ async function getNextRegistration() {
833
+ const existingUsers = await getExistingHidReaderUsers();
834
+ const registrations = existingUsers
835
+ .map((user) => String(user.registration || "").trim())
836
+ .filter(Boolean);
837
+ const numericRegistrations = registrations
838
+ .map((registration) => {
839
+ const match = registration.match(/^(\d+)$/);
840
+ return match ? { value: Number(match[1]), width: match[1].length } : undefined;
841
+ })
842
+ .filter((item): item is { value: number; width: number } => Boolean(item));
843
+ const maxRegistration = numericRegistrations.reduce((max, item) => Math.max(max, item.value), 0);
844
+ const width = Math.max(6, ...numericRegistrations.map((item) => item.width));
845
+
846
+ return String(maxRegistration + 1).padStart(width, "0");
847
+ }
848
+
849
+ async function getExistingHidReaderIds() {
850
+ const users = await getExistingHidReaderUsers(["id"]);
851
+ return users
852
+ .map((user) => toHidNumericId(user.hidUserId) || 0)
853
+ .filter((value) => Number.isInteger(value) && value > 0);
854
+ }
855
+
856
+ async function getExistingHidReaderUsers(fields?: string[]) {
857
+ if (!selectedReaderId.value) return [];
858
+ try {
859
+ const response = await runObjectOperation(selectedReaderId.value, {
860
+ operation: "load",
861
+ object: "users",
862
+ ...(fields ? { fields } : {}),
863
+ limit: 500,
864
+ offset: 0,
865
+ });
866
+ return normalizeHidUsers(response);
867
+ } catch {
868
+ return [];
869
+ }
870
+ }
871
+
872
+ function normalizeHidUsers(response: Record<string, any>) {
873
+ const items =
874
+ response?.data?.users ||
875
+ response?.data?.data?.users ||
876
+ response?.users ||
877
+ [];
878
+
879
+ return Array.isArray(items)
880
+ ? items.map((user) => ({
881
+ _rowKey: `reader-${user.id}`,
882
+ hidUserId: String(user.id || ""),
883
+ rawHidUserId: user.id,
884
+ registration: user.registration,
885
+ cardNo: user.card_value || user.cardNo || "",
886
+ name: user.name,
887
+ metadata: {
888
+ name: user.name,
889
+ imageTimestamp: user.image_timestamp ? String(user.image_timestamp) : "",
890
+ lastAccessAt: fromUnixSeconds(user.last_access),
891
+ hidReaderSource: true,
892
+ },
893
+ }))
894
+ : [];
895
+ }
896
+
897
+ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], identities: Record<string, any>[]) {
898
+ const identityById = new Map<string, Record<string, any>>();
899
+ identities.forEach((identity) => {
900
+ const id = toHidNumericId(identity.hidUserId);
901
+ if (id) identityById.set(String(id), identity);
902
+ });
903
+
904
+ const merged = readerUsers.map((readerUser) => {
905
+ const identity = identityById.get(String(toHidNumericId(readerUser.hidUserId)));
906
+ if (!identity) return readerUser;
907
+
908
+ return {
909
+ ...readerUser,
910
+ ...identity,
911
+ _rowKey: `mapped-${identity._id || readerUser.rawHidUserId}`,
912
+ hidUserId: identity.hidUserId || readerUser.hidUserId,
913
+ rawHidUserId: readerUser.rawHidUserId,
914
+ name: identity.metadata?.name || identity.name || readerUser.name,
915
+ registration: identity.registration || readerUser.registration,
916
+ cardNo: identity.cardNo || readerUser.cardNo,
917
+ metadata: {
918
+ ...readerUser.metadata,
919
+ ...identity.metadata,
920
+ imageTimestamp: identity.metadata?.imageTimestamp || readerUser.metadata?.imageTimestamp,
921
+ lastAccessAt: identity.metadata?.lastAccessAt || readerUser.metadata?.lastAccessAt,
922
+ },
923
+ };
924
+ });
925
+
926
+ return merged;
927
+ }
928
+
929
+ function filterUsers(items: Record<string, any>[]) {
930
+ const searchText = search.value.trim().toLowerCase();
931
+ return items.filter((item) => {
932
+ const matchesSearch = !searchText || [
933
+ getName(item),
934
+ item.hidUserId,
935
+ item.registration,
936
+ item.cardNo,
937
+ ].some((value) => String(value || "").toLowerCase().includes(searchText));
938
+ const matchesStatus = status.value === "Status" || getMappingStatus(item) === status.value;
939
+ return matchesSearch && matchesStatus;
940
+ });
941
+ }
942
+
943
+ function getTotalFromPageRange(value: string) {
944
+ const match = value.match(/\bof\s+(\d+)$/i);
945
+ return match ? Number(match[1]) : undefined;
946
+ }
947
+
948
+ function getMappingStatus(user: Record<string, any>) {
949
+ return hasMappedRecord(user) ? "Mapped" : "Unmapped";
950
+ }
951
+
952
+ function getMappingStatusClass(user: Record<string, any>) {
953
+ return hasMappedRecord(user) ? "status-success" : "status-warning";
954
+ }
955
+
956
+ function hasMappedRecord(user: Record<string, any>) {
957
+ return Boolean(user?.person || user?.user || user?.member || user?.visitor);
958
+ }
959
+
960
+ function formatDate(value?: string) {
961
+ if (!value) return "N/A";
962
+ const date = new Date(value);
963
+ return Number.isNaN(date.getTime()) ? "N/A" : date.toLocaleString();
964
+ }
965
+
966
+ function formatHidUid(value: unknown) {
967
+ const id = toHidNumericId(value);
968
+ return id ? `UID${String(id).padStart(6, "0")}` : String(value || "");
969
+ }
970
+
971
+ function fromUnixSeconds(value: unknown) {
972
+ const numeric = Number(value);
973
+ if (!Number.isFinite(numeric) || numeric <= 0) return "";
974
+ return new Date(numeric * 1000).toISOString();
975
+ }
976
+
977
+ function showToast(message: string, color = "success") {
978
+ snackbar.message = message;
979
+ snackbar.color = color;
980
+ snackbar.show = true;
981
+ }
982
+
983
+ function getHidErrorMessage(error: any) {
984
+ const data =
985
+ error?.data ||
986
+ error?.response?._data ||
987
+ error?.response?.data ||
988
+ error?.cause?.data ||
989
+ error?.cause?.response?._data ||
990
+ {};
991
+
992
+ return String(
993
+ data?.message ||
994
+ data?.error ||
995
+ data?.statusMessage ||
996
+ error?.statusMessage ||
997
+ error?.message ||
998
+ "Unable to save HID user.",
999
+ );
1000
+ }
1001
+ </script>
1002
+
1003
+ <style scoped>
1004
+ .hid-user-enrollment {
1005
+ padding: 24px 0;
1006
+ width: 100%;
1007
+ min-width: 0;
1008
+ }
1009
+
1010
+ .toolbar {
1011
+ display: flex;
1012
+ flex-wrap: wrap;
1013
+ gap: 12px;
1014
+ align-items: center;
1015
+ margin-bottom: 16px;
1016
+ }
1017
+
1018
+ .toolbar-spacer {
1019
+ flex: 1;
1020
+ }
1021
+
1022
+ .enroll-button {
1023
+ min-width: 170px;
1024
+ background: #e4e4e4;
1025
+ color: #0a2638;
1026
+ }
1027
+
1028
+ .reader-input,
1029
+ .search-input {
1030
+ max-width: 240px;
1031
+ }
1032
+
1033
+ .status-input {
1034
+ max-width: 160px;
1035
+ }
1036
+
1037
+ .table-card {
1038
+ display: flex;
1039
+ flex-direction: column;
1040
+ min-height: 430px;
1041
+ overflow: hidden;
1042
+ }
1043
+
1044
+ .table-card :deep(table) {
1045
+ table-layout: fixed;
1046
+ width: 100%;
1047
+ min-width: 820px;
1048
+ }
1049
+
1050
+ .table-card :deep(th),
1051
+ .table-card :deep(td) {
1052
+ white-space: nowrap;
1053
+ }
1054
+
1055
+ .table-card :deep(th:nth-child(1)),
1056
+ .table-card :deep(td:nth-child(1)) {
1057
+ width: 24%;
1058
+ }
1059
+
1060
+ .table-card :deep(th:nth-child(2)),
1061
+ .table-card :deep(td:nth-child(2)) {
1062
+ width: 20%;
1063
+ }
1064
+
1065
+ .table-card :deep(th:nth-child(3)),
1066
+ .table-card :deep(td:nth-child(3)) {
1067
+ width: 12%;
1068
+ }
1069
+
1070
+ .table-card :deep(th:nth-child(4)),
1071
+ .table-card :deep(td:nth-child(4)) {
1072
+ width: 20%;
1073
+ }
1074
+
1075
+ .table-card :deep(th:nth-child(5)),
1076
+ .table-card :deep(td:nth-child(5)) {
1077
+ width: 16%;
1078
+ }
1079
+
1080
+ .table-card :deep(th:nth-child(6)),
1081
+ .table-card :deep(td:nth-child(6)) {
1082
+ width: 8%;
1083
+ }
1084
+
1085
+ .table-body {
1086
+ flex: 1;
1087
+ overflow-x: auto;
1088
+ }
1089
+
1090
+ .table-refresh,
1091
+ .table-footer {
1092
+ display: flex;
1093
+ align-items: center;
1094
+ gap: 8px;
1095
+ padding: 8px 10px;
1096
+ border-bottom: 1px solid #eeeeee;
1097
+ }
1098
+
1099
+ .table-footer {
1100
+ justify-content: flex-end;
1101
+ border-top: 1px solid #eeeeee;
1102
+ border-bottom: 0;
1103
+ background: #f6f6f6;
1104
+ min-height: 44px;
1105
+ padding: 4px 10px;
1106
+ }
1107
+
1108
+ .page-count {
1109
+ margin-left: auto;
1110
+ color: #475467;
1111
+ font-size: 12px;
1112
+ }
1113
+
1114
+ .empty-state {
1115
+ min-height: 280px;
1116
+ display: grid;
1117
+ place-items: center;
1118
+ color: #687382;
1119
+ font-size: 13px;
1120
+ }
1121
+
1122
+ .status-chip {
1123
+ min-width: 86px;
1124
+ justify-content: center;
1125
+ font-weight: 600;
1126
+ }
1127
+
1128
+ .status-success {
1129
+ background: #dff7e7 !important;
1130
+ color: #1b7f3a !important;
1131
+ }
1132
+
1133
+ .status-warning {
1134
+ background: #fff0cc !important;
1135
+ color: #9a6700 !important;
1136
+ }
1137
+
1138
+ .status-error {
1139
+ background: #fde2e1 !important;
1140
+ color: #b42318 !important;
1141
+ }
1142
+
1143
+ .small-title {
1144
+ font-size: 14px;
1145
+ font-weight: 700;
1146
+ }
1147
+
1148
+ .field-label {
1149
+ color: #4f5a66;
1150
+ font-size: 12px;
1151
+ font-weight: 600;
1152
+ margin-bottom: 6px;
1153
+ }
1154
+
1155
+ .field-label span {
1156
+ color: #e53935;
1157
+ }
1158
+
1159
+ .photo-wrap {
1160
+ display: grid;
1161
+ place-items: center;
1162
+ margin-bottom: 14px;
1163
+ }
1164
+
1165
+ .photo-button {
1166
+ width: 120px;
1167
+ height: 120px;
1168
+ border: 1px solid #d8dde4;
1169
+ border-radius: 8px;
1170
+ background: #ffffff;
1171
+ display: grid;
1172
+ place-items: center;
1173
+ color: #98a2b3;
1174
+ font-size: 12px;
1175
+ overflow: hidden;
1176
+ position: relative;
1177
+ }
1178
+
1179
+ .photo-preview {
1180
+ width: 100%;
1181
+ height: 100%;
1182
+ }
1183
+
1184
+ .photo-edit-icon {
1185
+ position: absolute;
1186
+ right: 8px;
1187
+ bottom: 8px;
1188
+ width: 28px;
1189
+ height: 28px;
1190
+ border-radius: 50%;
1191
+ background: #ffffff;
1192
+ color: #00324a;
1193
+ display: grid;
1194
+ place-items: center;
1195
+ box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
1196
+ }
1197
+
1198
+ .hid-face-avatar {
1199
+ border: 1px solid #d8dde4;
1200
+ background: #f2f4f7;
1201
+ }
1202
+
1203
+ .hidden-input {
1204
+ display: none;
1205
+ }
1206
+
1207
+ .detail-grid {
1208
+ display: grid;
1209
+ grid-template-columns: 1fr 1fr;
1210
+ gap: 8px 16px;
1211
+ font-size: 13px;
1212
+ }
1213
+
1214
+ .detail-grid span {
1215
+ color: #667085;
1216
+ }
1217
+
1218
+ .action-cancel,
1219
+ .action-submit {
1220
+ flex: 1;
1221
+ border-radius: 0;
1222
+ }
1223
+
1224
+ .action-submit {
1225
+ background: #062d42;
1226
+ color: #ffffff;
1227
+ }
1228
+
1229
+ @media (max-width: 960px) {
1230
+ .toolbar {
1231
+ align-items: stretch;
1232
+ }
1233
+
1234
+ .toolbar-spacer {
1235
+ display: none;
1236
+ }
1237
+
1238
+ .enroll-button,
1239
+ .reader-input,
1240
+ .search-input,
1241
+ .status-input {
1242
+ max-width: none;
1243
+ width: 100%;
1244
+ }
1245
+
1246
+ .table-card {
1247
+ min-height: 360px;
1248
+ }
1249
+ }
1250
+
1251
+ @media (max-width: 600px) {
1252
+ .hid-user-enrollment {
1253
+ padding: 12px 0;
1254
+ }
1255
+
1256
+ .detail-grid {
1257
+ grid-template-columns: 1fr;
1258
+ }
1259
+
1260
+ .photo-button {
1261
+ width: 104px;
1262
+ height: 104px;
1263
+ }
1264
+
1265
+ .table-footer {
1266
+ justify-content: center;
1267
+ }
1268
+
1269
+ :global(.v-dialog > .v-overlay__content) {
1270
+ max-width: calc(100vw - 24px) !important;
1271
+ margin: 12px !important;
1272
+ }
1273
+ }
1274
+ </style>