@7365admin1/layer-common 3.2.1-staging.71 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/components/EntryPassInformation.vue +4 -4
  3. package/components/HidAccessLogDashboard.vue +44 -119
  4. package/components/HidIdentityMapping.vue +975 -0
  5. package/components/HidIntercomManagement.vue +201 -808
  6. package/components/HidQrCodeConfiguration.vue +95 -907
  7. package/components/HidServiceSettingsPanel.vue +1 -20
  8. package/components/HidUserEnrollment.vue +81 -392
  9. package/components/Layout/NavigationDrawer.vue +2 -43
  10. package/components/NavigationItem.vue +0 -9
  11. package/components/Nfc/NFCPatrolRouteMain.vue +7 -52
  12. package/components/OnlineFormConfigurationForm.vue +224 -620
  13. package/components/OnlineFormFill.vue +8 -36
  14. package/components/OnlineFormsConfiguration.vue +2 -6
  15. package/components/QrTemplate/CreditCardLandscape.vue +12 -19
  16. package/components/QrTemplate/PrintDialog.vue +196 -209
  17. package/components/SiteSettings.vue +2 -4
  18. package/components/VisitorForm.vue +78 -201
  19. package/components/VisitorManagement.vue +1 -0
  20. package/components/VisitorSocketPopUp.vue +2 -56
  21. package/composables/useHidAmico.ts +0 -127
  22. package/composables/useHidNavigation.ts +7 -0
  23. package/composables/useOnlineFormPages.ts +0 -2
  24. package/composables/useSiteSettings.ts +0 -50
  25. package/composables/useVisitorSocket.ts +0 -26
  26. package/composables/useWebUsb.ts +37 -127
  27. package/middleware/member.ts +4 -0
  28. package/package.json +1 -3
  29. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +23 -0
  30. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  31. package/plugins/secure-member.client.ts +56 -21
  32. package/plugins/vuetify.ts +9 -37
  33. package/types/site.d.ts +0 -71
  34. package/.changeset/camera-wall-gated-endpoint.md +0 -47
  35. package/.changeset/camera-wall-plain-english.md +0 -60
  36. package/.changeset/camera-wall-selection-and-guidance.md +0 -65
  37. package/.changeset/dark-primary-contrast.md +0 -39
  38. package/components/CameraWall.vue +0 -1034
  39. package/components/CameraWallTile.vue +0 -818
  40. package/components/HidCameraCaptureDialog.vue +0 -199
  41. package/composables/useSipWebPhone.ts +0 -311
  42. package/test/visitor-socket.test.mjs +0 -36
  43. package/utils/camera-wall.test.ts +0 -571
  44. package/utils/camera-wall.ts +0 -926
  45. package/utils/theme.test.ts +0 -201
  46. package/utils/theme.ts +0 -136
@@ -0,0 +1,975 @@
1
+ <template>
2
+ <section class="hid-identity-mapping">
3
+ <div class="toolbar">
4
+ <v-select
5
+ v-if="readers.length > 1"
6
+ v-model="selectedReaderId"
7
+ :items="readerOptions"
8
+ item-title="title"
9
+ item-value="value"
10
+ variant="outlined"
11
+ density="compact"
12
+ hide-details
13
+ class="reader-input"
14
+ @update:model-value="loadIdentities"
15
+ />
16
+
17
+ <v-btn
18
+ rounded="xl"
19
+ class="text-none refresh-button"
20
+ variant="flat"
21
+ prepend-icon="mdi-refresh"
22
+ :loading="loading"
23
+ @click="loadIdentities"
24
+ >
25
+ Refresh
26
+ </v-btn>
27
+
28
+ <div class="toolbar-spacer" />
29
+
30
+ <v-text-field
31
+ v-model="search"
32
+ placeholder="Search HID user or mapped user"
33
+ prepend-inner-icon="mdi-magnify"
34
+ variant="outlined"
35
+ density="compact"
36
+ hide-details
37
+ class="search-input"
38
+ />
39
+
40
+ <v-select
41
+ v-model="type"
42
+ :items="typeOptions"
43
+ item-title="title"
44
+ item-value="value"
45
+ variant="outlined"
46
+ density="compact"
47
+ hide-details
48
+ class="type-input"
49
+ @update:model-value="loadIdentities"
50
+ />
51
+ </div>
52
+
53
+ <v-card flat border class="table-card">
54
+ <v-tabs v-model="activeTab" class="mapping-tabs" density="compact">
55
+ <v-tab value="all" class="text-none">All</v-tab>
56
+ <v-tab value="mapped" class="text-none">Mapped</v-tab>
57
+ <v-tab value="unmapped" class="text-none">Unmapped</v-tab>
58
+ <v-tab value="review" class="text-none">Needs Review</v-tab>
59
+ </v-tabs>
60
+
61
+ <v-table>
62
+ <thead>
63
+ <tr>
64
+ <th>HID User</th>
65
+ <th>HID User ID</th>
66
+ <th>Registration No.</th>
67
+ <th>Access Method</th>
68
+ <th>Mapped To</th>
69
+ <th>Unit / Company</th>
70
+ <th>Status</th>
71
+ <th class="text-right"></th>
72
+ </tr>
73
+ </thead>
74
+ <tbody>
75
+ <tr v-for="identity in filteredIdentities" :key="identity._id">
76
+ <td>{{ getIdentityName(identity) }}</td>
77
+ <td>{{ identity.hidUserId || "N/A" }}</td>
78
+ <td>{{ identity.registration || "N/A" }}</td>
79
+ <td>{{ getAccessMethod(identity) }}</td>
80
+ <td>{{ getMappedLabel(identity) }}</td>
81
+ <td>{{ getUnitLabel(identity) }}</td>
82
+ <td>
83
+ <v-chip
84
+ size="small"
85
+ variant="flat"
86
+ :class="isMapped(identity) ? 'mapped-chip' : 'unmapped-chip'"
87
+ >
88
+ {{ isMapped(identity) ? "Mapped" : "Unmapped" }}
89
+ </v-chip>
90
+ </td>
91
+ <td class="text-right">
92
+ <v-menu>
93
+ <template #activator="{ props: menuProps }">
94
+ <v-btn v-bind="menuProps" icon="mdi-dots-vertical" variant="text" density="comfortable" />
95
+ </template>
96
+ <v-list density="compact" min-width="160">
97
+ <v-list-item @click="openMapDialog(identity)">
98
+ <v-list-item-title>{{ isMapped(identity) ? "Edit Mapping" : "Map Identity" }}</v-list-item-title>
99
+ </v-list-item>
100
+ <v-list-item :disabled="!isMapped(identity)" @click="unmapIdentity(identity)">
101
+ <v-list-item-title>Unmap</v-list-item-title>
102
+ </v-list-item>
103
+ </v-list>
104
+ </v-menu>
105
+ </td>
106
+ </tr>
107
+ </tbody>
108
+ </v-table>
109
+
110
+ <div v-if="!filteredIdentities.length" class="empty-state">
111
+ No HID identities found.
112
+ </div>
113
+ </v-card>
114
+
115
+ <v-dialog v-model="mapDialog" max-width="520" persistent>
116
+ <v-card class="mapping-dialog" rounded="lg">
117
+ <v-card-title class="dialog-title">
118
+ {{ selectedIdentity && isMapped(selectedIdentity) ? "Edit Mapping" : "Map Identity" }}
119
+ </v-card-title>
120
+
121
+ <v-card-text class="dialog-body">
122
+ <div class="identity-summary">
123
+ <div>
124
+ <span>HID User</span>
125
+ <strong>{{ selectedIdentity ? getIdentityName(selectedIdentity) : "N/A" }}</strong>
126
+ </div>
127
+ <div>
128
+ <span>HID User ID</span>
129
+ <strong>{{ selectedIdentity?.hidUserId || "N/A" }}</strong>
130
+ </div>
131
+ </div>
132
+
133
+ <div class="field-label">User Type <span>*</span></div>
134
+ <v-select
135
+ v-model="mappingForm.type"
136
+ :items="mappingTypeOptions"
137
+ item-title="title"
138
+ item-value="value"
139
+ density="compact"
140
+ variant="outlined"
141
+ hide-details="auto"
142
+ class="mb-3"
143
+ @update:model-value="onMappingTypeChange"
144
+ />
145
+
146
+ <div class="field-label">Search User <span>*</span></div>
147
+ <v-autocomplete
148
+ v-model="mappingForm.recordId"
149
+ v-model:search="recordSearch"
150
+ :items="recordOptions"
151
+ item-title="title"
152
+ item-value="value"
153
+ density="compact"
154
+ variant="outlined"
155
+ hide-details="auto"
156
+ placeholder="Search name, unit, company or email"
157
+ :loading="recordLoading"
158
+ no-filter
159
+ class="mb-3"
160
+ @update:search="debouncedSearchRecords"
161
+ @update:model-value="onRecordSelect"
162
+ />
163
+
164
+ <div class="field-label">Unit / Company</div>
165
+ <v-text-field
166
+ v-model="mappingForm.unitLabel"
167
+ density="compact"
168
+ variant="outlined"
169
+ hide-details="auto"
170
+ placeholder="Auto filled when possible"
171
+ class="mb-3"
172
+ />
173
+
174
+ <div class="field-label">Notes</div>
175
+ <v-textarea
176
+ v-model="mappingForm.notes"
177
+ density="compact"
178
+ variant="outlined"
179
+ hide-details="auto"
180
+ rows="3"
181
+ placeholder="Optional notes"
182
+ />
183
+ </v-card-text>
184
+
185
+ <v-card-actions class="dialog-actions pa-0">
186
+ <v-btn class="text-none action-cancel" variant="flat" height="48" @click="mapDialog = false">
187
+ Cancel
188
+ </v-btn>
189
+ <v-btn
190
+ class="text-none action-submit"
191
+ variant="flat"
192
+ height="48"
193
+ :loading="saving"
194
+ :disabled="!mappingForm.recordId"
195
+ @click="saveMapping"
196
+ >
197
+ Save
198
+ </v-btn>
199
+ </v-card-actions>
200
+ </v-card>
201
+ </v-dialog>
202
+
203
+ <v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="2600">
204
+ {{ snackbar.message }}
205
+ </v-snackbar>
206
+ </section>
207
+ </template>
208
+
209
+ <script setup lang="ts">
210
+ const props = defineProps({
211
+ site: {
212
+ type: String,
213
+ required: true,
214
+ },
215
+ org: {
216
+ type: String,
217
+ default: "",
218
+ },
219
+ });
220
+
221
+ type MappingType = "resident" | "visitor" | "staff";
222
+
223
+ const { getReaders, getIdentities, runObjectOperation, createIdentity, updateIdentity } = useHidAmico();
224
+ const { getAll: getMembers } = useMember();
225
+ const { getVisitors } = useVisitor();
226
+ const { getUsers } = useUser();
227
+
228
+ const readers = ref<Record<string, any>[]>([]);
229
+ const identities = ref<Record<string, any>[]>([]);
230
+ const selectedReaderId = ref("");
231
+ const activeTab = ref("all");
232
+ const search = ref("");
233
+ const type = ref("all");
234
+ const loading = ref(false);
235
+ const mapDialog = ref(false);
236
+ const selectedIdentity = ref<Record<string, any>>();
237
+ const recordSearch = ref("");
238
+ const recordOptions = ref<Array<{ title: string; value: string; raw: Record<string, any> }>>([]);
239
+ const recordLoading = ref(false);
240
+ const saving = ref(false);
241
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
242
+
243
+ const snackbar = reactive({
244
+ show: false,
245
+ message: "",
246
+ color: "success",
247
+ });
248
+
249
+ const mappingForm = reactive({
250
+ type: "resident" as MappingType,
251
+ recordId: "",
252
+ unitLabel: "",
253
+ notes: "",
254
+ record: undefined as Record<string, any> | undefined,
255
+ });
256
+
257
+ const typeOptions = [
258
+ { title: "All Types", value: "all" },
259
+ { title: "Resident", value: "resident" },
260
+ { title: "Visitor", value: "visitor" },
261
+ { title: "Administrator", value: "staff" },
262
+ ];
263
+
264
+ const mappingTypeOptions = [
265
+ { title: "Resident", value: "resident" },
266
+ { title: "Visitor", value: "visitor" },
267
+ { title: "Administrator", value: "staff" },
268
+ ];
269
+
270
+ const readerOptions = computed(() =>
271
+ readers.value.map((reader) => ({
272
+ title: reader.name || reader.deviceId || reader._id,
273
+ value: reader._id,
274
+ })),
275
+ );
276
+
277
+ const filteredIdentities = computed(() => {
278
+ const query = search.value.trim().toLowerCase();
279
+ return identities.value.filter((identity) => {
280
+ const mapped = isMapped(identity);
281
+ const matchesTab =
282
+ activeTab.value === "all" ||
283
+ (activeTab.value === "mapped" && mapped) ||
284
+ (activeTab.value === "unmapped" && !mapped) ||
285
+ (activeTab.value === "review" && !mapped);
286
+ const matchesSearch = !query || [
287
+ getIdentityName(identity),
288
+ identity.hidUserId,
289
+ identity.registration,
290
+ identity.cardNo,
291
+ getMappedLabel(identity),
292
+ getUnitLabel(identity),
293
+ getAccessMethod(identity),
294
+ ].some((value) => String(value ?? "").toLowerCase().includes(query));
295
+
296
+ return matchesTab && matchesSearch;
297
+ });
298
+ });
299
+
300
+ watch(
301
+ () => props.site,
302
+ () => init(),
303
+ { immediate: true },
304
+ );
305
+
306
+ async function init() {
307
+ await loadReaders();
308
+ await loadIdentities();
309
+ }
310
+
311
+ async function loadReaders() {
312
+ if (!props.site) return;
313
+ const response = await getReaders({ site: props.site, page: 1, limit: 100 });
314
+ readers.value = normalizeItems(response, "readers");
315
+ if (!selectedReaderId.value && readers.value.length) {
316
+ selectedReaderId.value = readers.value[0]._id;
317
+ }
318
+ }
319
+
320
+ async function loadIdentities() {
321
+ if (!selectedReaderId.value) {
322
+ identities.value = [];
323
+ return;
324
+ }
325
+
326
+ loading.value = true;
327
+ try {
328
+ const [identityResponse, readerResponse] = await Promise.all([
329
+ getIdentities(selectedReaderId.value, {
330
+ page: 1,
331
+ limit: 500,
332
+ status: "active",
333
+ }),
334
+ runObjectOperation(selectedReaderId.value, {
335
+ operation: "load",
336
+ object: "users",
337
+ limit: 500,
338
+ offset: 0,
339
+ }),
340
+ ]);
341
+ const readerUsers = normalizeHidUsers(readerResponse);
342
+ let localIdentities = normalizeItems(identityResponse, "identities");
343
+ const localUserIds = new Set(
344
+ localIdentities
345
+ .map((identity: Record<string, any>) => toHidNumericId(identity.hidUserId))
346
+ .filter((hidUserId): hidUserId is number => Boolean(hidUserId)),
347
+ );
348
+ const deviceOnlyUsers = readerUsers.filter((user) => !localUserIds.has(user.hidUserId));
349
+
350
+ if (deviceOnlyUsers.length) {
351
+ await Promise.all(deviceOnlyUsers.map(async (user) => {
352
+ try {
353
+ await createIdentity(selectedReaderId.value, {
354
+ site: props.site,
355
+ hidUserId: String(user.hidUserId),
356
+ registration: user.registration,
357
+ cardNo: user.cardNo,
358
+ type: "resident",
359
+ status: "active",
360
+ metadata: {
361
+ name: user.name,
362
+ hidImported: true,
363
+ importedAt: new Date().toISOString(),
364
+ },
365
+ });
366
+ } catch {
367
+ // A parallel sync may have already created the identity.
368
+ }
369
+ }));
370
+
371
+ const refreshedResponse = await getIdentities(selectedReaderId.value, {
372
+ page: 1,
373
+ limit: 500,
374
+ status: "active",
375
+ });
376
+ localIdentities = normalizeItems(refreshedResponse, "identities");
377
+ }
378
+
379
+ const identityByUserId = new Map<number, Record<string, any>>();
380
+ localIdentities.forEach((identity: Record<string, any>) => {
381
+ const hidUserId = toHidNumericId(identity.hidUserId);
382
+ if (hidUserId) identityByUserId.set(hidUserId, identity);
383
+ });
384
+
385
+ identities.value = readerUsers
386
+ .map((user) => {
387
+ const identity = identityByUserId.get(user.hidUserId);
388
+ if (!identity) return undefined;
389
+ return {
390
+ ...identity,
391
+ hidUserId: String(user.hidUserId),
392
+ registration: user.registration || identity.registration,
393
+ cardNo: user.cardNo || identity.cardNo,
394
+ metadata: {
395
+ ...identity.metadata,
396
+ name: identity.metadata?.name || user.name,
397
+ hidReaderSource: true,
398
+ },
399
+ };
400
+ })
401
+ .filter(Boolean)
402
+ .filter((identity: Record<string, any>) => type.value === "all" || identity.type === type.value);
403
+ } finally {
404
+ loading.value = false;
405
+ }
406
+ }
407
+
408
+ function normalizeHidUsers(response: Record<string, any>) {
409
+ const data = response?.data || response || {};
410
+ const users = data?.users || data?.data?.users || data?.result?.users || [];
411
+ return (Array.isArray(users) ? users : [])
412
+ .map((user: Record<string, any>) => ({
413
+ hidUserId: toHidNumericId(user.id),
414
+ registration: String(user.registration || ""),
415
+ cardNo: String(user.card_value || user.cardNo || ""),
416
+ name: String(user.name || ""),
417
+ }))
418
+ .filter((user) => Boolean(user.hidUserId));
419
+ }
420
+
421
+ function toHidNumericId(value: unknown) {
422
+ const raw = String(value ?? "").trim();
423
+ if (!raw) return undefined;
424
+ const numeric = Number(raw);
425
+ if (Number.isInteger(numeric) && numeric > 0) return numeric;
426
+ const match = raw.match(/(\d+)$/);
427
+ const parsed = match ? Number(match[1]) : NaN;
428
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
429
+ }
430
+
431
+ function normalizeItems(response: any, key: string) {
432
+ const candidates = [
433
+ response?.items,
434
+ response?.[key],
435
+ response?.data?.items,
436
+ response?.data?.[key],
437
+ response?.data?.data?.items,
438
+ response?.data?.data?.[key],
439
+ response?.data?.results,
440
+ response?.results,
441
+ response?.data?.data,
442
+ response?.data,
443
+ ];
444
+
445
+ return candidates.find((items) => Array.isArray(items)) ?? [];
446
+ }
447
+
448
+ function isMapped(identity: Record<string, any>) {
449
+ return Boolean(identity.person || identity.user || identity.member || identity.visitor || identity.metadata?.mappedRecordId);
450
+ }
451
+
452
+ function getIdentityName(identity: Record<string, any>) {
453
+ return identity.metadata?.name || identity.name || identity.metadata?.hidName || "N/A";
454
+ }
455
+
456
+ function getMappedLabel(identity: Record<string, any>) {
457
+ return identity.metadata?.mappedName || identity.metadata?.personName || identity.metadata?.userName || "Unmapped";
458
+ }
459
+
460
+ function getUnitLabel(identity: Record<string, any>) {
461
+ return identity.metadata?.unitLabel || identity.metadata?.company || [identity.metadata?.block, identity.metadata?.level, identity.metadata?.unit].filter(Boolean).join("/") || "N/A";
462
+ }
463
+
464
+ function getAccessMethod(identity: Record<string, any>) {
465
+ if (identity.cardNo) return "Card";
466
+ if (identity.metadata?.qrCode || identity.metadata?.qrcode) return "QR Code";
467
+ if (identity.metadata?.panicPin || identity.metadata?.pin) return "PIN";
468
+ if (identity.metadata?.pinPassword) return "ID and Password";
469
+ if (identity.metadata?.photo || identity.metadata?.facialData) return "Facial";
470
+ return "N/A";
471
+ }
472
+
473
+ function openMapDialog(identity: Record<string, any>) {
474
+ selectedIdentity.value = identity;
475
+ mappingForm.type = normalizeIdentityType(identity.type);
476
+ mappingForm.recordId = identity.metadata?.mappedRecordId || String(identity.member || identity.user || identity.person || identity.visitor || "");
477
+ mappingForm.unitLabel = getUnitLabel(identity) === "N/A" ? "" : getUnitLabel(identity);
478
+ mappingForm.notes = identity.metadata?.mappingNotes || "";
479
+ mappingForm.record = undefined;
480
+ recordSearch.value = "";
481
+ recordOptions.value = [];
482
+ mapDialog.value = true;
483
+ searchRecords();
484
+ }
485
+
486
+ function normalizeIdentityType(value: string): MappingType {
487
+ if (value === "visitor") return "visitor";
488
+ if (value === "staff") return "staff";
489
+ return "resident";
490
+ }
491
+
492
+ function onMappingTypeChange() {
493
+ mappingForm.recordId = "";
494
+ mappingForm.record = undefined;
495
+ recordOptions.value = [];
496
+ searchRecords();
497
+ }
498
+
499
+ function debouncedSearchRecords() {
500
+ if (searchTimer) clearTimeout(searchTimer);
501
+ searchTimer = setTimeout(() => searchRecords(), 300);
502
+ }
503
+
504
+ async function searchRecords() {
505
+ recordLoading.value = true;
506
+ try {
507
+ if (mappingForm.type === "visitor") {
508
+ const response = await getVisitors({
509
+ site: props.site,
510
+ search: recordSearch.value,
511
+ page: 1,
512
+ limit: 20,
513
+ order: "desc",
514
+ });
515
+ recordOptions.value = normalizeItems(response, "items").map((record: Record<string, any>) =>
516
+ toRecordOption(record, "visitor"),
517
+ );
518
+ return;
519
+ }
520
+
521
+ let records: Record<string, any>[] = [];
522
+
523
+ if (mappingForm.type === "staff") {
524
+ const response = await getMembers({
525
+ page: 1,
526
+ limit: 20,
527
+ search: recordSearch.value,
528
+ org: props.org,
529
+ type: "property_management_agency",
530
+ status: "active",
531
+ });
532
+ records = normalizeItems(response, "members");
533
+ }
534
+
535
+ if (!records.length) {
536
+ const usersResponse = await getUsers({
537
+ status: "active",
538
+ search: recordSearch.value,
539
+ page: 1,
540
+ });
541
+ records = normalizeItems(usersResponse, "users").map((user: Record<string, any>) => ({
542
+ ...user,
543
+ __mappingSource: "user",
544
+ }));
545
+ }
546
+
547
+ recordOptions.value = records.map((record: Record<string, any>) =>
548
+ toRecordOption(record, record.__mappingSource || "member"),
549
+ );
550
+ } catch {
551
+ recordOptions.value = [];
552
+ } finally {
553
+ recordLoading.value = false;
554
+ }
555
+ }
556
+
557
+ function toRecordOption(record: Record<string, any>, source = "member") {
558
+ const id = getRecordId(record, source);
559
+ const name = getRecordName(record);
560
+ const unit = getRecordUnit(record);
561
+ return {
562
+ title: unit && unit !== "N/A" ? `${name} - ${unit}` : name,
563
+ value: id,
564
+ raw: {
565
+ ...record,
566
+ __mappingSource: source,
567
+ },
568
+ };
569
+ }
570
+
571
+ function getRecordId(record: Record<string, any>, source = "member") {
572
+ if (source === "user") {
573
+ return String(record._id || record.id || record.user?._id || record.user?.id || record.user || "");
574
+ }
575
+
576
+ return String(
577
+ record._id ||
578
+ record.id ||
579
+ record.member?._id ||
580
+ record.member?.id ||
581
+ record.member ||
582
+ record.memberId ||
583
+ record.user?._id ||
584
+ record.user?.id ||
585
+ record.user ||
586
+ "",
587
+ );
588
+ }
589
+
590
+ function getRecordName(record: Record<string, any>) {
591
+ return (
592
+ record.name ||
593
+ record.userName ||
594
+ record.user?.name ||
595
+ record.memberName ||
596
+ record.fullName ||
597
+ record.firstName ||
598
+ record.email ||
599
+ record.user?.email ||
600
+ "Unnamed"
601
+ );
602
+ }
603
+
604
+ function getRecordUnit(record: Record<string, any>) {
605
+ return (
606
+ record.unitName ||
607
+ record.unitLabel ||
608
+ record.unit?.name ||
609
+ record.unitNo ||
610
+ record.company ||
611
+ record.customerSiteName ||
612
+ record.siteName ||
613
+ record.roleName ||
614
+ record.orgName ||
615
+ "N/A"
616
+ );
617
+ }
618
+
619
+ function onRecordSelect(value: string) {
620
+ const option = recordOptions.value.find((item) => item.value === value);
621
+ mappingForm.record = option?.raw;
622
+ if (option?.raw) {
623
+ mappingForm.unitLabel = getRecordUnit(option.raw) === "N/A" ? "" : getRecordUnit(option.raw);
624
+ }
625
+ }
626
+
627
+ async function saveMapping() {
628
+ if (!selectedIdentity.value || !mappingForm.recordId) return;
629
+
630
+ saving.value = true;
631
+ try {
632
+ const record = mappingForm.record || recordOptions.value.find((item) => item.value === mappingForm.recordId)?.raw || {};
633
+ const payload: Record<string, any> = {
634
+ type: mappingForm.type,
635
+ metadata: {
636
+ ...(selectedIdentity.value.metadata || {}),
637
+ mappedRecordId: mappingForm.recordId,
638
+ mappedName: getRecordName(record),
639
+ unitLabel: mappingForm.unitLabel,
640
+ mappingNotes: mappingForm.notes,
641
+ mappedAt: new Date().toISOString(),
642
+ },
643
+ };
644
+
645
+ if (mappingForm.type === "visitor") {
646
+ payload.visitor = mappingForm.recordId;
647
+ payload.member = "";
648
+ payload.user = "";
649
+ payload.person = "";
650
+ } else if (mappingForm.type === "staff") {
651
+ payload.user = getRecordUserId(record);
652
+ payload.member = getRecordMemberId(record);
653
+ payload.visitor = "";
654
+ payload.person = "";
655
+ } else {
656
+ payload.member = getRecordMemberId(record) || mappingForm.recordId;
657
+ payload.user = getRecordUserId(record);
658
+ payload.visitor = "";
659
+ payload.person = "";
660
+ }
661
+
662
+ await updateIdentity(selectedIdentity.value._id, payload);
663
+ showMessage("Identity mapped successfully.", "success");
664
+ mapDialog.value = false;
665
+ await loadIdentities();
666
+ } catch (error: any) {
667
+ showMessage(error?.data?.message || error?.message || "Unable to save mapping.", "error");
668
+ } finally {
669
+ saving.value = false;
670
+ }
671
+ }
672
+
673
+ async function unmapIdentity(identity: Record<string, any>) {
674
+ try {
675
+ await updateIdentity(identity._id, {
676
+ member: "",
677
+ user: "",
678
+ person: "",
679
+ visitor: "",
680
+ metadata: {
681
+ ...(identity.metadata || {}),
682
+ mappedRecordId: "",
683
+ mappedName: "",
684
+ mappingNotes: "",
685
+ unmappedAt: new Date().toISOString(),
686
+ },
687
+ });
688
+ showMessage("Identity unmapped successfully.", "success");
689
+ await loadIdentities();
690
+ } catch (error: any) {
691
+ showMessage(error?.data?.message || error?.message || "Unable to unmap identity.", "error");
692
+ }
693
+ }
694
+
695
+ function getRecordUserId(record: Record<string, any>) {
696
+ if (record.__mappingSource === "user") {
697
+ return String(record._id || record.id || "");
698
+ }
699
+
700
+ const user = record.user;
701
+ if (typeof user === "object" && user) {
702
+ return String(user._id || user.id || "");
703
+ }
704
+
705
+ return String(user || "");
706
+ }
707
+
708
+ function getRecordMemberId(record: Record<string, any>) {
709
+ if (record.__mappingSource === "user") return "";
710
+
711
+ const member = record.member;
712
+ if (typeof member === "object" && member) {
713
+ return String(member._id || member.id || "");
714
+ }
715
+
716
+ return String(record._id || record.id || record.memberId || member || "");
717
+ }
718
+
719
+ function showMessage(message: string, color: string) {
720
+ snackbar.message = message;
721
+ snackbar.color = color;
722
+ snackbar.show = true;
723
+ }
724
+ </script>
725
+
726
+ <style scoped>
727
+ .hid-identity-mapping {
728
+ padding: 24px 0;
729
+ width: 100%;
730
+ min-width: 0;
731
+ }
732
+
733
+ .toolbar {
734
+ display: flex;
735
+ flex-wrap: wrap;
736
+ gap: 12px;
737
+ align-items: center;
738
+ margin-bottom: 16px;
739
+ }
740
+
741
+ .toolbar-spacer {
742
+ flex: 1;
743
+ }
744
+
745
+ .reader-input,
746
+ .search-input {
747
+ max-width: 280px;
748
+ }
749
+
750
+ .type-input {
751
+ max-width: 170px;
752
+ }
753
+
754
+ .refresh-button {
755
+ min-width: 130px;
756
+ background: #e4e4e4;
757
+ color: #0a2638;
758
+ }
759
+
760
+ .table-card {
761
+ min-height: 430px;
762
+ overflow-x: auto;
763
+ overflow-y: hidden;
764
+ }
765
+
766
+ .table-card :deep(table) {
767
+ table-layout: fixed;
768
+ width: 100%;
769
+ min-width: 1040px;
770
+ }
771
+
772
+ .table-card :deep(th),
773
+ .table-card :deep(td) {
774
+ white-space: nowrap;
775
+ }
776
+
777
+ .table-card :deep(th:nth-child(1)),
778
+ .table-card :deep(td:nth-child(1)) {
779
+ width: 16%;
780
+ }
781
+
782
+ .table-card :deep(th:nth-child(2)),
783
+ .table-card :deep(td:nth-child(2)) {
784
+ width: 12%;
785
+ }
786
+
787
+ .table-card :deep(th:nth-child(3)),
788
+ .table-card :deep(td:nth-child(3)) {
789
+ width: 14%;
790
+ }
791
+
792
+ .table-card :deep(th:nth-child(4)),
793
+ .table-card :deep(td:nth-child(4)) {
794
+ width: 14%;
795
+ }
796
+
797
+ .table-card :deep(th:nth-child(5)),
798
+ .table-card :deep(td:nth-child(5)) {
799
+ width: 16%;
800
+ }
801
+
802
+ .table-card :deep(th:nth-child(6)),
803
+ .table-card :deep(td:nth-child(6)) {
804
+ width: 14%;
805
+ }
806
+
807
+ .table-card :deep(th:nth-child(7)),
808
+ .table-card :deep(td:nth-child(7)) {
809
+ width: 10%;
810
+ }
811
+
812
+ .table-card :deep(th:nth-child(8)),
813
+ .table-card :deep(td:nth-child(8)) {
814
+ width: 4%;
815
+ }
816
+
817
+ .mapping-tabs {
818
+ border-bottom: 1px solid #eeeeee;
819
+ }
820
+
821
+ .mapping-tabs :deep(.v-slide-group__content) {
822
+ display: grid;
823
+ grid-template-columns: repeat(4, minmax(0, 1fr));
824
+ width: 100%;
825
+ }
826
+
827
+ .mapping-tabs :deep(.v-tab) {
828
+ flex: none;
829
+ min-width: 0;
830
+ width: 100%;
831
+ max-width: none;
832
+ color: #0a2638;
833
+ font-size: 12px;
834
+ font-weight: 600;
835
+ }
836
+
837
+ .mapping-tabs :deep(.v-tab--selected) {
838
+ font-weight: 600;
839
+ }
840
+
841
+ .mapped-chip,
842
+ .unmapped-chip {
843
+ min-width: 96px;
844
+ justify-content: center;
845
+ font-weight: 600;
846
+ }
847
+
848
+ .mapped-chip {
849
+ background: #dff7e7 !important;
850
+ color: #1b7f3a !important;
851
+ }
852
+
853
+ .unmapped-chip {
854
+ background: #fff0cc !important;
855
+ color: #9a6700 !important;
856
+ }
857
+
858
+ .empty-state {
859
+ min-height: 280px;
860
+ display: grid;
861
+ place-items: center;
862
+ color: #687382;
863
+ font-size: 13px;
864
+ }
865
+
866
+ .mapping-dialog {
867
+ overflow: hidden;
868
+ }
869
+
870
+ .dialog-title {
871
+ font-size: 14px;
872
+ font-weight: 700;
873
+ padding: 12px 16px;
874
+ border-bottom: 1px solid #ececec;
875
+ }
876
+
877
+ .dialog-body {
878
+ padding: 18px 16px 20px;
879
+ }
880
+
881
+ .identity-summary {
882
+ display: grid;
883
+ grid-template-columns: 1fr 1fr;
884
+ gap: 12px;
885
+ padding: 12px;
886
+ margin-bottom: 16px;
887
+ background: #f5f7f9;
888
+ border: 1px solid #e2e6ea;
889
+ }
890
+
891
+ .identity-summary span {
892
+ display: block;
893
+ margin-bottom: 4px;
894
+ color: #687382;
895
+ font-size: 11px;
896
+ }
897
+
898
+ .identity-summary strong {
899
+ color: #0a2638;
900
+ font-size: 13px;
901
+ }
902
+
903
+ .field-label {
904
+ color: #4f5a66;
905
+ font-size: 12px;
906
+ font-weight: 600;
907
+ margin-bottom: 6px;
908
+ }
909
+
910
+ .field-label span {
911
+ color: #e53935;
912
+ }
913
+
914
+ .dialog-actions {
915
+ display: grid;
916
+ grid-template-columns: 1fr 1fr;
917
+ }
918
+
919
+ .action-cancel {
920
+ background: #ffffff;
921
+ color: #111827;
922
+ border-radius: 0;
923
+ }
924
+
925
+ .action-submit {
926
+ background: #062d42;
927
+ color: #ffffff;
928
+ border-radius: 0;
929
+ }
930
+
931
+ @media (max-width: 960px) {
932
+ .toolbar {
933
+ align-items: stretch;
934
+ }
935
+
936
+ .toolbar-spacer {
937
+ display: none;
938
+ }
939
+
940
+ .reader-input,
941
+ .search-input,
942
+ .type-input,
943
+ .refresh-button {
944
+ max-width: none;
945
+ width: 100%;
946
+ }
947
+
948
+ .table-card {
949
+ min-height: 360px;
950
+ }
951
+ }
952
+
953
+ @media (max-width: 600px) {
954
+ .hid-identity-mapping {
955
+ padding: 12px 0;
956
+ }
957
+
958
+ .mapping-tabs :deep(.v-slide-group__content) {
959
+ display: flex;
960
+ }
961
+
962
+ .mapping-tabs :deep(.v-tab) {
963
+ min-width: 120px;
964
+ }
965
+
966
+ .identity-summary {
967
+ grid-template-columns: 1fr;
968
+ }
969
+
970
+ :global(.v-dialog > .v-overlay__content) {
971
+ max-width: calc(100vw - 24px) !important;
972
+ margin: 12px !important;
973
+ }
974
+ }
975
+ </style>