@7365admin1/layer-common 3.2.1-staging.62 → 3.2.1-staging.63

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.
@@ -327,6 +327,8 @@ type AccessTab = "resident" | "visitor" | "administrator";
327
327
  type AccessRow = {
328
328
  key: string;
329
329
  identityKey: string;
330
+ identityType?: string;
331
+ visitorId?: string;
330
332
  name: string;
331
333
  facialData: string;
332
334
  faceImage?: string;
@@ -351,6 +353,7 @@ const { getVisitors } = useVisitor();
351
353
 
352
354
  const readers = ref<Record<string, any>[]>([]);
353
355
  const identities = ref<Record<string, any>[]>([]);
356
+ const allIdentities = ref<Record<string, any>[]>([]);
354
357
  const visitorRecords = ref<Record<string, any>>({});
355
358
  const logs = ref<Record<string, any>[]>([]);
356
359
  const liveAccessLogs = ref<Record<string, any>[]>([]);
@@ -556,12 +559,10 @@ async function loadDashboard() {
556
559
  resetDashboardData();
557
560
  try {
558
561
  const isAdministratorTab = activeTab.value === "administrator";
559
- const isVisitorTab = activeTab.value === "visitor";
560
562
  const [identityResponse, logResponse, hidAccessLogs, configurationResponse, roleResponse, visitorResponse, readerUserResponse] = await Promise.all([
561
563
  getIdentities(selectedReaderId.value, {
562
564
  page: 1,
563
565
  limit: 500,
564
- ...(!isAdministratorTab ? { type: getIdentityType(activeTab.value) } : {}),
565
566
  }),
566
567
  getLogs(selectedReaderId.value, {
567
568
  page: 1,
@@ -570,7 +571,7 @@ async function loadDashboard() {
570
571
  loadLiveAccessLogs(selectedReaderId.value),
571
572
  loadReaderConfiguration(selectedReaderId.value),
572
573
  isAdministratorTab ? loadAdministratorRoles(selectedReaderId.value) : Promise.resolve(null),
573
- isVisitorTab && props.org
574
+ props.org
574
575
  ? getVisitors({
575
576
  org: props.org,
576
577
  site: props.site,
@@ -587,18 +588,19 @@ async function loadDashboard() {
587
588
  const loadedIdentities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
588
589
  const mergedIdentities = mergeReaderUsersWithIdentities(readerUserResponse, loadedIdentities);
589
590
  const loadedVisitors = visitorResponse?.items ?? visitorResponse?.data?.items ?? visitorResponse?.data?.visitors ?? [];
590
- visitorRecords.value = isVisitorTab
591
- ? Object.fromEntries(loadedVisitors.map((visitor: Record<string, any>) => [String(visitor._id), visitor]))
592
- : {};
591
+ visitorRecords.value = Object.fromEntries(
592
+ loadedVisitors.map((visitor: Record<string, any>) => [String(visitor._id), visitor]),
593
+ );
593
594
  administratorUserIds.value = getAdministratorRoleUserIds(roleResponse);
595
+ allIdentities.value = mergedIdentities;
594
596
  identities.value = isAdministratorTab
595
597
  ? mergedIdentities.filter((identity: Record<string, any>) => {
596
598
  const hidUserId = toHidNumericId(identity.hidUserId);
597
599
  return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
598
600
  })
599
- : isVisitorTab
600
- ? mergedIdentities.filter((identity: Record<string, any>) => Boolean(getVisitorRecord(identity)))
601
- : mergedIdentities;
601
+ : activeTab.value === "visitor"
602
+ ? mergedIdentities.filter(isVisitorIdentity)
603
+ : mergedIdentities.filter((identity: Record<string, any>) => !isVisitorIdentity(identity));
602
604
  logs.value = logResponse?.items ?? logResponse?.data?.items ?? logResponse?.data?.logs ?? [];
603
605
  liveAccessLogs.value = hidAccessLogs;
604
606
  readerConfiguration.value = normalizeReaderConfiguration(configurationResponse);
@@ -775,6 +777,7 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
775
777
  function resetDashboardData() {
776
778
  selectedHistoryRow.value = undefined;
777
779
  identities.value = [];
780
+ allIdentities.value = [];
778
781
  visitorRecords.value = {};
779
782
  logs.value = [];
780
783
  liveAccessLogs.value = [];
@@ -782,15 +785,11 @@ function resetDashboardData() {
782
785
  administratorUserIds.value = new Set();
783
786
  }
784
787
 
785
- function getIdentityType(tab: AccessTab) {
786
- if (tab === "administrator") return "admin";
787
- return tab;
788
- }
789
-
790
788
  function isVisibleForActiveTab(row: AccessRow) {
791
789
  const identity = findIdentityByKey(row.identityKey);
790
+ const isVisitor = row.identityType === "visitor" || Boolean(row.visitorId) || isVisitorIdentity(identity);
792
791
  if (activeTab.value === "visitor") {
793
- return Boolean(identity && getVisitorRecord(identity));
792
+ return isVisitor;
794
793
  }
795
794
 
796
795
  if (activeTab.value === "administrator") {
@@ -798,7 +797,11 @@ function isVisibleForActiveTab(row: AccessRow) {
798
797
  return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
799
798
  }
800
799
 
801
- return !identity || !getVisitorRecord(identity);
800
+ return !isVisitor;
801
+ }
802
+
803
+ function isVisitorIdentity(identity: Record<string, unknown> | undefined) {
804
+ return Boolean(identity && (identity.type === "visitor" || identity.visitor));
802
805
  }
803
806
 
804
807
  function loadAdministratorRoles(readerId: string) {
@@ -850,7 +853,7 @@ function getIdentityKey(identity: Record<string, any>) {
850
853
 
851
854
  function findIdentityByKey(identityKey: string) {
852
855
  if (!identityKey) return undefined;
853
- return identities.value.find((identity) => getIdentityKey(identity) === identityKey);
856
+ return allIdentities.value.find((identity) => getIdentityKey(identity) === identityKey);
854
857
  }
855
858
 
856
859
  function getLogIdentityKey(log: Record<string, any>) {
@@ -1154,12 +1157,15 @@ function getRawAccessLogs(event: Record<string, any>) {
1154
1157
 
1155
1158
  function mapAccessLogToRow(rawLog: Record<string, any>, event: Record<string, any>, index: string): AccessRow {
1156
1159
  const identity = findIdentityForAccessLog(rawLog, event);
1160
+ const eventIdentity = event.payload?.identity || event.payload?.identityLookup || {};
1157
1161
  const rawAccessTime = getAccessLogTime(rawLog, event);
1158
1162
  const identityKey = identity ? getIdentityKey(identity) : getLogIdentityKey({ ...event, payload: { ...event.payload, rawAccessLog: rawLog } });
1159
1163
 
1160
1164
  return {
1161
1165
  key: `${event._id || event.id || "log"}-${rawLog.id || rawLog.time || index}`,
1162
1166
  identityKey,
1167
+ identityType: String(identity?.type || eventIdentity.type || "").toLowerCase() || undefined,
1168
+ visitorId: String(identity?.visitor || eventIdentity.visitor || "") || undefined,
1163
1169
  name: identity ? getName(identity) : getRawLogName(rawLog, event),
1164
1170
  facialData: identity ? getFacialData(identity) : "N/A",
1165
1171
  faceImage: identity ? getUserImageSrc(identity) : "",
@@ -1175,7 +1181,7 @@ function mapAccessLogToRow(rawLog: Record<string, any>, event: Record<string, an
1175
1181
  function findIdentityForAccessLog(rawLog: Record<string, any>, event: Record<string, any>) {
1176
1182
  const eventIdentityId = event.payload?.identity?._id;
1177
1183
  if (eventIdentityId) {
1178
- const byId = identities.value.find((identity) => String(identity._id) === String(eventIdentityId));
1184
+ const byId = allIdentities.value.find((identity) => String(identity._id) === String(eventIdentityId));
1179
1185
  if (byId) return byId;
1180
1186
  }
1181
1187
 
@@ -1184,7 +1190,7 @@ function findIdentityForAccessLog(rawLog: Record<string, any>, event: Record<str
1184
1190
  const cardNo = rawLog.card_value || rawLog.cardNo || event.payload?.identity?.cardNo || event.payload?.identityLookup?.cardNo;
1185
1191
  const qrCodeValue = rawLog.qrcode_value || rawLog.qrCode || rawLog.qrcode;
1186
1192
 
1187
- return identities.value.find((identity) => {
1193
+ return allIdentities.value.find((identity) => {
1188
1194
  return (
1189
1195
  (hidUserId !== undefined && hidUserId !== null && String(identity.hidUserId) === String(hidUserId)) ||
1190
1196
  (registration && identity.registration === registration) ||
@@ -0,0 +1,199 @@
1
+ <template>
2
+ <v-dialog
3
+ :model-value="modelValue"
4
+ max-width="520"
5
+ persistent
6
+ @update:model-value="emit('update:modelValue', $event)"
7
+ >
8
+ <v-card class="camera-dialog" rounded="lg">
9
+ <v-card-title class="d-flex align-center justify-space-between pa-4">
10
+ <span class="text-subtitle-1 font-weight-bold">Take Facial Photo</span>
11
+ <v-btn
12
+ icon="mdi-close"
13
+ variant="text"
14
+ size="small"
15
+ aria-label="Close camera"
16
+ @click="close"
17
+ />
18
+ </v-card-title>
19
+
20
+ <v-divider />
21
+
22
+ <v-card-text class="pa-4">
23
+ <div class="camera-viewport">
24
+ <video
25
+ ref="videoElement"
26
+ class="camera-preview"
27
+ autoplay
28
+ muted
29
+ playsinline
30
+ />
31
+
32
+ <div v-if="starting" class="camera-state">
33
+ <v-progress-circular indeterminate color="primary" />
34
+ <span class="mt-3">Starting camera...</span>
35
+ </div>
36
+
37
+ <div v-else-if="cameraError" class="camera-state px-6 text-center">
38
+ <v-icon icon="mdi-camera-off-outline" size="42" color="error" />
39
+ <span class="mt-3 text-error">{{ cameraError }}</span>
40
+ </div>
41
+ </div>
42
+
43
+ <canvas ref="canvasElement" class="d-none" />
44
+
45
+ <div class="text-caption text-medium-emphasis mt-3 text-center">
46
+ Keep your face centered and make sure the area is well lit.
47
+ </div>
48
+ </v-card-text>
49
+
50
+ <v-divider />
51
+
52
+ <v-card-actions class="pa-4">
53
+ <v-btn variant="text" @click="close">Cancel</v-btn>
54
+ <v-spacer />
55
+ <v-btn
56
+ color="primary"
57
+ variant="flat"
58
+ prepend-icon="mdi-camera"
59
+ :disabled="starting || !!cameraError || !stream"
60
+ @click="capture"
61
+ >
62
+ Take Photo
63
+ </v-btn>
64
+ </v-card-actions>
65
+ </v-card>
66
+ </v-dialog>
67
+ </template>
68
+
69
+ <script setup lang="ts">
70
+ const props = defineProps<{ modelValue: boolean }>();
71
+ const emit = defineEmits<{
72
+ "update:modelValue": [value: boolean];
73
+ capture: [dataUrl: string];
74
+ }>();
75
+
76
+ const videoElement = ref<HTMLVideoElement | null>(null);
77
+ const canvasElement = ref<HTMLCanvasElement | null>(null);
78
+ const stream = shallowRef<MediaStream | null>(null);
79
+ const starting = ref(false);
80
+ const cameraError = ref("");
81
+
82
+ function stopCamera() {
83
+ stream.value?.getTracks().forEach((track) => track.stop());
84
+ stream.value = null;
85
+ if (videoElement.value) videoElement.value.srcObject = null;
86
+ }
87
+
88
+ function getCameraErrorMessage(caught: unknown) {
89
+ if (caught instanceof DOMException) {
90
+ if (caught.name === "NotAllowedError") {
91
+ return "Camera permission was denied. Allow camera access and try again.";
92
+ }
93
+ if (caught.name === "NotFoundError") {
94
+ return "No camera was found on this device.";
95
+ }
96
+ if (caught.name === "NotReadableError") {
97
+ return "The camera is currently being used by another application.";
98
+ }
99
+ }
100
+ return "Unable to open the camera. Use HTTPS or check the browser camera permission.";
101
+ }
102
+
103
+ async function startCamera() {
104
+ stopCamera();
105
+ cameraError.value = "";
106
+ starting.value = true;
107
+
108
+ try {
109
+ if (!navigator.mediaDevices?.getUserMedia) {
110
+ throw new Error("Camera API is unavailable");
111
+ }
112
+
113
+ stream.value = await navigator.mediaDevices.getUserMedia({
114
+ audio: false,
115
+ video: {
116
+ facingMode: "user",
117
+ width: { ideal: 1280 },
118
+ height: { ideal: 960 },
119
+ },
120
+ });
121
+
122
+ await nextTick();
123
+ if (!videoElement.value) throw new Error("Camera preview is unavailable");
124
+ videoElement.value.srcObject = stream.value;
125
+ await videoElement.value.play();
126
+ } catch (caught: unknown) {
127
+ stopCamera();
128
+ cameraError.value = getCameraErrorMessage(caught);
129
+ } finally {
130
+ starting.value = false;
131
+ }
132
+ }
133
+
134
+ function capture() {
135
+ const video = videoElement.value;
136
+ const canvas = canvasElement.value;
137
+ if (!video || !canvas || !video.videoWidth || !video.videoHeight) return;
138
+
139
+ canvas.width = video.videoWidth;
140
+ canvas.height = video.videoHeight;
141
+ const context = canvas.getContext("2d");
142
+ if (!context) return;
143
+
144
+ context.translate(canvas.width, 0);
145
+ context.scale(-1, 1);
146
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
147
+
148
+ emit("capture", canvas.toDataURL("image/jpeg", 0.9));
149
+ close();
150
+ }
151
+
152
+ function close() {
153
+ stopCamera();
154
+ emit("update:modelValue", false);
155
+ }
156
+
157
+ watch(
158
+ () => props.modelValue,
159
+ (isOpen) => {
160
+ if (isOpen) void startCamera();
161
+ else stopCamera();
162
+ }
163
+ );
164
+
165
+ onBeforeUnmount(stopCamera);
166
+ </script>
167
+
168
+ <style scoped>
169
+ .camera-dialog {
170
+ overflow: hidden;
171
+ }
172
+
173
+ .camera-viewport {
174
+ position: relative;
175
+ width: 100%;
176
+ overflow: hidden;
177
+ aspect-ratio: 3 / 4;
178
+ border-radius: 8px;
179
+ background: #111722;
180
+ }
181
+
182
+ .camera-preview {
183
+ width: 100%;
184
+ height: 100%;
185
+ object-fit: cover;
186
+ transform: scaleX(-1);
187
+ }
188
+
189
+ .camera-state {
190
+ position: absolute;
191
+ inset: 0;
192
+ display: flex;
193
+ flex-direction: column;
194
+ align-items: center;
195
+ justify-content: center;
196
+ background: #111722;
197
+ color: #ffffff;
198
+ }
199
+ </style>
@@ -237,7 +237,7 @@
237
237
  <div class="sip-switch-row sip-section-switch">
238
238
  <div>
239
239
  <span>Enable video</span>
240
- <small>Allow this reader to send and receive video during SIP calls.</small>
240
+ <small>Allow this reader to send its camera feed during SIP intercom calls.</small>
241
241
  </div>
242
242
  <v-switch v-model="sipForm.videoEnabled" :disabled="!sipForm.enabled" color="success" density="compact" hide-details />
243
243
  </div>
@@ -417,7 +417,10 @@
417
417
  <span class="sip-account-address">{{ sipAccount.sipAddress }}</span>
418
418
  </div>
419
419
  <div class="call-toggle-row web-video-toggle">
420
- <span>Enable video</span>
420
+ <div>
421
+ <span>Receive HID video</span>
422
+ <small>Show the HID camera feed without sharing this device's camera.</small>
423
+ </div>
421
424
  <v-switch
422
425
  v-model="webPhoneForm.video"
423
426
  color="success"
@@ -457,12 +460,10 @@
457
460
  class="web-phone-media"
458
461
  :class="{
459
462
  'has-video': webPhoneForm.video && webPhone.callState.value === 'active',
460
- 'has-local-video': webPhone.localVideoAvailable.value,
461
463
  'has-remote-video': webPhone.remoteVideoAvailable.value,
462
464
  }"
463
465
  >
464
466
  <video ref="remoteVideoEl" autoplay playsinline class="remote-video" />
465
- <video ref="localVideoEl" autoplay muted playsinline class="local-video" />
466
467
  <audio ref="remoteAudioEl" autoplay />
467
468
  <div
468
469
  v-if="!webPhoneForm.video || webPhone.callState.value !== 'active' || !webPhone.remoteVideoAvailable.value"
@@ -472,7 +473,7 @@
472
473
  <strong>{{ webPhoneCallLabel }}</strong>
473
474
  <span v-if="webPhone.currentTarget.value">{{ webPhone.currentTarget.value }}</span>
474
475
  <span v-if="webPhoneForm.video && webPhone.callState.value === 'active' && !webPhone.remoteVideoAvailable.value">
475
- Waiting for video from the other device
476
+ Waiting for video from the HID device
476
477
  </span>
477
478
  </div>
478
479
  </div>
@@ -529,14 +530,6 @@
529
530
  :title="webPhone.muted.value ? 'Unmute' : 'Mute'"
530
531
  @click="toggleWebMute"
531
532
  />
532
- <v-btn
533
- v-if="webPhoneForm.video"
534
- :icon="webPhone.cameraEnabled.value ? 'mdi-video' : 'mdi-video-off'"
535
- variant="outlined"
536
- :disabled="webPhone.callState.value !== 'active' || !webPhone.localVideoAvailable.value"
537
- :title="webPhone.cameraEnabled.value ? 'Turn camera off' : 'Turn camera on'"
538
- @click="toggleWebCamera"
539
- />
540
533
  <v-btn
541
534
  icon="mdi-phone-hangup"
542
535
  class="hangup-web-call"
@@ -723,7 +716,6 @@ const webPhoneActionLoading = ref(false);
723
716
  const sipAccount = ref<SipAccount | null>(null);
724
717
  const sipAccountLoading = ref(false);
725
718
  const sipAccountError = ref("");
726
- const localVideoEl = ref<HTMLVideoElement | null>(null);
727
719
  const remoteVideoEl = ref<HTMLVideoElement | null>(null);
728
720
  const remoteAudioEl = ref<HTMLAudioElement | null>(null);
729
721
  const webPhone = useSipWebPhone();
@@ -1249,7 +1241,6 @@ async function connectWebPhone() {
1249
1241
  displayName: "iService365 Web",
1250
1242
  video: webPhoneForm.video,
1251
1243
  }, {
1252
- localVideo: localVideoEl.value,
1253
1244
  remoteVideo: remoteVideoEl.value,
1254
1245
  remoteAudio: remoteAudioEl.value,
1255
1246
  });
@@ -1315,14 +1306,6 @@ async function hangupWebCall() {
1315
1306
  }
1316
1307
  }
1317
1308
 
1318
- function toggleWebCamera() {
1319
- try {
1320
- webPhone.toggleCamera();
1321
- } catch (error: unknown) {
1322
- showMessage(getErrorMessage(error), "error");
1323
- }
1324
- }
1325
-
1326
1309
  function toggleWebMute() {
1327
1310
  try {
1328
1311
  webPhone.toggleMute();
@@ -2462,23 +2445,6 @@ export default {
2462
2445
  display: block;
2463
2446
  }
2464
2447
 
2465
- .local-video {
2466
- position: absolute;
2467
- right: 12px;
2468
- bottom: 12px;
2469
- display: none;
2470
- width: 108px;
2471
- aspect-ratio: 3 / 4;
2472
- border: 2px solid #fff;
2473
- border-radius: 4px;
2474
- background: #263b49;
2475
- object-fit: cover;
2476
- }
2477
-
2478
- .web-phone-media.has-local-video .local-video {
2479
- display: block;
2480
- }
2481
-
2482
2448
  .web-phone-media.has-remote-video .audio-call-state {
2483
2449
  display: none;
2484
2450
  }
@@ -50,7 +50,13 @@
50
50
 
51
51
  <v-card flat border class="table-card">
52
52
  <div class="table-refresh">
53
- <v-btn icon="mdi-refresh" variant="text" density="comfortable" :loading="loading" @click="loadUsers" />
53
+ <v-btn
54
+ icon="mdi-refresh"
55
+ variant="text"
56
+ density="comfortable"
57
+ :loading="loading"
58
+ @click="loadUsers"
59
+ />
54
60
  <div class="page-count">{{ pageRange }}</div>
55
61
  </div>
56
62
 
@@ -66,10 +72,18 @@
66
72
  </tr>
67
73
  </thead>
68
74
  <tbody>
69
- <tr v-for="user in users" :key="user._rowKey || user._id || user.hidUserId">
75
+ <tr
76
+ v-for="user in users"
77
+ :key="user._rowKey || user._id || user.hidUserId"
78
+ >
70
79
  <td>{{ getName(user) }}</td>
71
80
  <td>
72
- <v-avatar v-if="getUserImageSrc(user)" size="34" rounded="lg" class="hid-face-avatar">
81
+ <v-avatar
82
+ v-if="getUserImageSrc(user)"
83
+ size="34"
84
+ rounded="lg"
85
+ class="hid-face-avatar"
86
+ >
73
87
  <v-img :src="getUserImageSrc(user)" cover />
74
88
  </v-avatar>
75
89
  <span v-else>{{ getFacialData(user) }}</span>
@@ -88,13 +102,22 @@
88
102
  <td class="text-right">
89
103
  <v-menu>
90
104
  <template #activator="{ props: menuProps }">
91
- <v-btn v-bind="menuProps" icon="mdi-dots-vertical" variant="text" density="comfortable" />
105
+ <v-btn
106
+ v-bind="menuProps"
107
+ icon="mdi-dots-vertical"
108
+ variant="text"
109
+ density="comfortable"
110
+ />
92
111
  </template>
93
112
 
94
113
  <v-list density="compact" min-width="150">
95
114
  <v-list-item title="View" @click="openView(user)" />
96
115
  <v-list-item title="Edit" @click="openEdit(user)" />
97
- <v-list-item title="Delete" class="text-error" @click="openDelete(user)" />
116
+ <v-list-item
117
+ title="Delete"
118
+ class="text-error"
119
+ @click="openDelete(user)"
120
+ />
98
121
  </v-list>
99
122
  </v-menu>
100
123
  </td>
@@ -109,8 +132,26 @@
109
132
 
110
133
  <div class="table-footer">
111
134
  <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()" />
135
+ <v-btn
136
+ icon="mdi-chevron-left"
137
+ variant="text"
138
+ density="comfortable"
139
+ :disabled="page <= 1"
140
+ @click="
141
+ page--;
142
+ loadUsers();
143
+ "
144
+ />
145
+ <v-btn
146
+ icon="mdi-chevron-right"
147
+ variant="text"
148
+ density="comfortable"
149
+ :disabled="page >= pages"
150
+ @click="
151
+ page++;
152
+ loadUsers();
153
+ "
154
+ />
114
155
  </div>
115
156
  </v-card>
116
157
 
@@ -125,7 +166,12 @@
125
166
  <v-menu>
126
167
  <template #activator="{ props: menuProps }">
127
168
  <button v-bind="menuProps" class="photo-button" type="button">
128
- <v-img v-if="form.photoPreview" :src="form.photoPreview" cover class="photo-preview" />
169
+ <v-img
170
+ v-if="form.photoPreview"
171
+ :src="form.photoPreview"
172
+ cover
173
+ class="photo-preview"
174
+ />
129
175
  <span v-if="form.photoPreview" class="photo-edit-icon">
130
176
  <v-icon icon="mdi-pencil" size="16" />
131
177
  </span>
@@ -137,7 +183,10 @@
137
183
  </template>
138
184
  <v-list density="compact" min-width="190">
139
185
  <v-list-item title="Browse" @click="triggerPhotoInput" />
140
- <v-list-item title="Take Photo using Camera" @click="triggerCameraInput" />
186
+ <v-list-item
187
+ title="Take Photo using Camera"
188
+ @click="triggerCameraInput"
189
+ />
141
190
  <v-list-item
142
191
  v-if="form.photoPreview"
143
192
  title="Remove Photo"
@@ -146,12 +195,10 @@
146
195
  />
147
196
  </v-list>
148
197
  </v-menu>
149
- <input ref="photoInput" type="file" accept="image/*" class="hidden-input" @change="onPhotoChange" />
150
198
  <input
151
- ref="cameraInput"
199
+ ref="photoInput"
152
200
  type="file"
153
201
  accept="image/*"
154
- capture="user"
155
202
  class="hidden-input"
156
203
  @change="onPhotoChange"
157
204
  />
@@ -217,31 +264,61 @@
217
264
  </v-card-text>
218
265
 
219
266
  <v-card-actions class="pa-0">
220
- <v-btn class="text-none action-cancel" variant="flat" height="44" @click="formDialog = false">Cancel</v-btn>
221
- <v-btn class="text-none action-submit" variant="flat" height="44" :loading="saving" @click="saveUser">
267
+ <v-btn
268
+ class="text-none action-cancel"
269
+ variant="flat"
270
+ height="44"
271
+ @click="formDialog = false"
272
+ >Cancel</v-btn
273
+ >
274
+ <v-btn
275
+ class="text-none action-submit"
276
+ variant="flat"
277
+ height="44"
278
+ :loading="saving"
279
+ @click="saveUser"
280
+ >
222
281
  Save
223
282
  </v-btn>
224
283
  </v-card-actions>
225
284
  </v-card>
226
285
  </v-dialog>
227
286
 
287
+ <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
288
+
228
289
  <v-dialog v-model="viewDialog" max-width="430">
229
290
  <v-card rounded="lg">
230
291
  <v-card-title class="small-title">User Information</v-card-title>
231
292
  <v-card-text>
232
293
  <div class="detail-grid">
233
294
  <span>Name</span><strong>{{ getName(selectedUser) }}</strong>
234
- <span>User ID</span><strong>{{ selectedUser ? formatHidUid(selectedUser.hidUserId) : "N/A" }}</strong>
235
- <span>Registration No.</span><strong>{{ selectedUser?.registration || "N/A" }}</strong>
236
- <span>Last access time</span><strong>{{ formatDate(selectedUser?.metadata?.lastAccessAt) }}</strong>
237
- <span>Status</span><strong>{{ selectedUser ? getMappingStatus(selectedUser) : "N/A" }}</strong>
238
- <span>Panic Pin</span><strong>{{ selectedUser?.cardNo || "N/A" }}</strong>
239
- <span>Private Password</span><strong>{{ selectedUser?.metadata?.pinPassword ? "********" : "N/A" }}</strong>
295
+ <span>User ID</span
296
+ ><strong>{{
297
+ selectedUser ? formatHidUid(selectedUser.hidUserId) : "N/A"
298
+ }}</strong>
299
+ <span>Registration No.</span
300
+ ><strong>{{ selectedUser?.registration || "N/A" }}</strong>
301
+ <span>Last access time</span
302
+ ><strong>{{
303
+ formatDate(selectedUser?.metadata?.lastAccessAt)
304
+ }}</strong>
305
+ <span>Status</span
306
+ ><strong>{{
307
+ selectedUser ? getMappingStatus(selectedUser) : "N/A"
308
+ }}</strong>
309
+ <span>Panic Pin</span
310
+ ><strong>{{ selectedUser?.cardNo || "N/A" }}</strong>
311
+ <span>Private Password</span
312
+ ><strong>{{
313
+ selectedUser?.metadata?.pinPassword ? "********" : "N/A"
314
+ }}</strong>
240
315
  </div>
241
316
  </v-card-text>
242
317
  <v-card-actions>
243
318
  <v-spacer />
244
- <v-btn class="text-none" variant="text" @click="viewDialog = false">Close</v-btn>
319
+ <v-btn class="text-none" variant="text" @click="viewDialog = false"
320
+ >Close</v-btn
321
+ >
245
322
  </v-card-actions>
246
323
  </v-card>
247
324
  </v-dialog>
@@ -252,12 +329,26 @@
252
329
  <v-card-text class="text-body-2">
253
330
  Are you sure you want to permanently delete this HID user?
254
331
  <div class="text-caption text-medium-emphasis mt-2">
255
- This action will remove the user from the HID reader and mark the identity as deleted.
332
+ This action will remove the user from the HID reader and mark the
333
+ identity as deleted.
256
334
  </div>
257
335
  </v-card-text>
258
336
  <v-card-actions class="pa-0">
259
- <v-btn class="text-none action-cancel" variant="flat" height="44" @click="deleteDialog = false">Cancel</v-btn>
260
- <v-btn class="text-none action-submit" variant="flat" height="44" :loading="deleting" @click="deleteUser">Delete</v-btn>
337
+ <v-btn
338
+ class="text-none action-cancel"
339
+ variant="flat"
340
+ height="44"
341
+ @click="deleteDialog = false"
342
+ >Cancel</v-btn
343
+ >
344
+ <v-btn
345
+ class="text-none action-submit"
346
+ variant="flat"
347
+ height="44"
348
+ :loading="deleting"
349
+ @click="deleteUser"
350
+ >Delete</v-btn
351
+ >
261
352
  </v-card-actions>
262
353
  </v-card>
263
354
  </v-dialog>
@@ -312,7 +403,7 @@ const deleteDialog = ref(false);
312
403
  const selectedUser = ref<Record<string, any> | null>(null);
313
404
  const showPassword = ref(false);
314
405
  const photoInput = ref<HTMLInputElement | null>(null);
315
- const cameraInput = ref<HTMLInputElement | null>(null);
406
+ const cameraDialog = ref(false);
316
407
  const selectedPhotoFile = ref<File | null>(null);
317
408
  const removePhotoRequested = ref(false);
318
409
  const snackbar = reactive({
@@ -340,7 +431,7 @@ const readerOptions = computed(() =>
340
431
  readers.value.map((reader) => ({
341
432
  title: reader.name || reader.deviceId || reader._id,
342
433
  value: reader._id,
343
- })),
434
+ }))
344
435
  );
345
436
 
346
437
  const pageRange = computed(() => {
@@ -354,7 +445,7 @@ const pageRange = computed(() => {
354
445
  watch(
355
446
  () => props.site,
356
447
  () => init(),
357
- { immediate: true },
448
+ { immediate: true }
358
449
  );
359
450
 
360
451
  async function init() {
@@ -365,7 +456,8 @@ async function init() {
365
456
  async function loadReaders() {
366
457
  if (!props.site) return;
367
458
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
368
- readers.value = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
459
+ readers.value =
460
+ response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
369
461
  if (!selectedReaderId.value && readers.value.length) {
370
462
  selectedReaderId.value = readers.value[0]._id;
371
463
  }
@@ -394,10 +486,16 @@ async function loadUsers() {
394
486
  limit: 500,
395
487
  offset: 0,
396
488
  }),
397
- isAdministratorIdentity() ? loadAdministratorRoles(selectedReaderId.value) : Promise.resolve(null),
489
+ isAdministratorIdentity()
490
+ ? loadAdministratorRoles(selectedReaderId.value)
491
+ : Promise.resolve(null),
398
492
  ]);
399
493
 
400
- const identities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
494
+ const identities =
495
+ identityResponse?.items ??
496
+ identityResponse?.data?.items ??
497
+ identityResponse?.data?.identities ??
498
+ [];
401
499
  const adminRoleIds = getAdministratorRoleUserIds(roleResponse);
402
500
  const readerUsers = normalizeHidUsers(readerResponse).filter((user) => {
403
501
  if (Number(user.userTypeId) === 1) return false;
@@ -429,21 +527,23 @@ async function loadUsers() {
429
527
  async function loadUserImages(items: Record<string, any>[]) {
430
528
  if (!selectedReaderId.value) return;
431
529
 
432
- await Promise.all(items.map(async (user) => {
433
- const hidUserId = toHidNumericId(user.hidUserId);
434
- const key = String(hidUserId || "");
435
- if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
436
-
437
- try {
438
- const response = await getUserImage(selectedReaderId.value, hidUserId);
439
- const image = response?.data || response;
440
- userImages.value[key] = image?.base64
441
- ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
442
- : "";
443
- } catch {
444
- userImages.value[key] = "";
445
- }
446
- }));
530
+ await Promise.all(
531
+ items.map(async (user) => {
532
+ const hidUserId = toHidNumericId(user.hidUserId);
533
+ const key = String(hidUserId || "");
534
+ if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
535
+
536
+ try {
537
+ const response = await getUserImage(selectedReaderId.value, hidUserId);
538
+ const image = response?.data || response;
539
+ userImages.value[key] = image?.base64
540
+ ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
541
+ : "";
542
+ } catch {
543
+ userImages.value[key] = "";
544
+ }
545
+ })
546
+ );
447
547
  }
448
548
 
449
549
  function resetForm() {
@@ -512,7 +612,28 @@ function triggerPhotoInput() {
512
612
  }
513
613
 
514
614
  function triggerCameraInput() {
515
- cameraInput.value?.click();
615
+ cameraDialog.value = true;
616
+ }
617
+
618
+ function onCameraCapture(dataUrl: string) {
619
+ const [header, encodedData] = dataUrl.split(",", 2);
620
+ if (!header || !encodedData) {
621
+ showToast("Unable to capture the facial image.", "error");
622
+ return;
623
+ }
624
+
625
+ const mimeType = header.match(/^data:([^;]+);base64$/)?.[1] || "image/jpeg";
626
+ const decodedData = atob(encodedData);
627
+ const bytes = new Uint8Array(decodedData.length);
628
+ for (let index = 0; index < decodedData.length; index += 1) {
629
+ bytes[index] = decodedData.charCodeAt(index);
630
+ }
631
+
632
+ selectedPhotoFile.value = new File([bytes], `hid-facial-${Date.now()}.jpg`, {
633
+ type: mimeType,
634
+ });
635
+ form.photoPreview = dataUrl;
636
+ removePhotoRequested.value = false;
516
637
  }
517
638
 
518
639
  function removePhoto() {
@@ -545,7 +666,8 @@ function onPhotoChange(event: Event) {
545
666
  }
546
667
 
547
668
  async function saveUser() {
548
- const readerId = selectedReaderId.value || form.reader || readers.value[0]?._id;
669
+ const readerId =
670
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
549
671
  if (!readerId || !form.name || !form.hidUserId || !form.registration) {
550
672
  showToast("Please complete the required HID user fields.", "error");
551
673
  return;
@@ -567,7 +689,13 @@ async function saveUser() {
567
689
  hidUserId: String(hidUser.id),
568
690
  registration: form.registration,
569
691
  cardNo: form.cardNo,
570
- type: props.identityType as "resident" | "staff" | "contractor" | "visitor" | "admin" | "unknown",
692
+ type: props.identityType as
693
+ | "resident"
694
+ | "staff"
695
+ | "contractor"
696
+ | "visitor"
697
+ | "admin"
698
+ | "unknown",
571
699
  status: "active" as const,
572
700
  metadata: {
573
701
  name: form.name,
@@ -604,7 +732,11 @@ async function saveUser() {
604
732
 
605
733
  formDialog.value = false;
606
734
  await loadUsers();
607
- showToast(selectedUser.value?._id ? "HID user updated on reader." : "HID user enrolled on reader.");
735
+ showToast(
736
+ selectedUser.value?._id
737
+ ? "HID user updated on reader."
738
+ : "HID user enrolled on reader."
739
+ );
608
740
  } catch (error: any) {
609
741
  console.error("Unable to save HID user:", error);
610
742
  showToast(getHidErrorMessage(error), "error");
@@ -619,13 +751,21 @@ type FacialSyncResult = {
619
751
  scores?: Record<string, unknown>;
620
752
  };
621
753
 
622
- async function syncFacialImage(readerId: string, hidUserId: number): Promise<FacialSyncResult> {
754
+ async function syncFacialImage(
755
+ readerId: string,
756
+ hidUserId: number
757
+ ): Promise<FacialSyncResult> {
623
758
  if (selectedPhotoFile.value) {
624
759
  const timestamp = Math.floor(Date.now() / 1000);
625
- const response = await setUserImage(readerId, hidUserId, selectedPhotoFile.value, {
626
- timestamp,
627
- match: true,
628
- });
760
+ const response = await setUserImage(
761
+ readerId,
762
+ hidUserId,
763
+ selectedPhotoFile.value,
764
+ {
765
+ timestamp,
766
+ match: true,
767
+ }
768
+ );
629
769
  const result = response?.data;
630
770
  userImages.value[String(hidUserId)] = form.photoPreview;
631
771
  return { action: "enrolled", timestamp, scores: result?.scores };
@@ -640,7 +780,10 @@ async function syncFacialImage(readerId: string, hidUserId: number): Promise<Fac
640
780
  return { action: "none" };
641
781
  }
642
782
 
643
- function applyFacialResult(metadata: Record<string, unknown>, result: FacialSyncResult) {
783
+ function applyFacialResult(
784
+ metadata: Record<string, unknown>,
785
+ result: FacialSyncResult
786
+ ) {
644
787
  if (result.action === "enrolled") {
645
788
  metadata.facialData = form.hidUserId;
646
789
  metadata.imageTimestamp = String(result.timestamp || "");
@@ -658,7 +801,11 @@ async function deleteUser() {
658
801
 
659
802
  deleting.value = true;
660
803
  try {
661
- await syncHidUserRole(selectedReaderId.value, toHidNumericId(selectedUser.value.hidUserId), false);
804
+ await syncHidUserRole(
805
+ selectedReaderId.value,
806
+ toHidNumericId(selectedUser.value.hidUserId),
807
+ false
808
+ );
662
809
  await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
663
810
  if (selectedUser.value._id) {
664
811
  await deleteIdentity(selectedUser.value._id);
@@ -674,8 +821,14 @@ async function deleteUser() {
674
821
  }
675
822
  }
676
823
 
677
- async function saveIdentityOnReader(readerId: string, payload: Record<string, any>) {
678
- const existingIdentity = await findExistingIdentityOnReader(readerId, payload);
824
+ async function saveIdentityOnReader(
825
+ readerId: string,
826
+ payload: Record<string, any>
827
+ ) {
828
+ const existingIdentity = await findExistingIdentityOnReader(
829
+ readerId,
830
+ payload
831
+ );
679
832
  if (existingIdentity?._id) {
680
833
  await updateIdentity(existingIdentity._id, payload);
681
834
  return;
@@ -687,31 +840,47 @@ async function saveIdentityOnReader(readerId: string, payload: Record<string, an
687
840
  const message = getHidErrorMessage(error);
688
841
  if (!message.toLowerCase().includes("already exists")) throw error;
689
842
 
690
- const duplicatedIdentity = await findExistingIdentityOnReader(readerId, payload);
843
+ const duplicatedIdentity = await findExistingIdentityOnReader(
844
+ readerId,
845
+ payload
846
+ );
691
847
  if (!duplicatedIdentity?._id) throw error;
692
848
  await updateIdentity(duplicatedIdentity._id, payload);
693
849
  }
694
850
  }
695
851
 
696
- async function findExistingIdentityOnReader(readerId: string, payload: Record<string, any>) {
852
+ async function findExistingIdentityOnReader(
853
+ readerId: string,
854
+ payload: Record<string, any>
855
+ ) {
697
856
  const response = await getIdentities(readerId, {
698
857
  page: 1,
699
858
  limit: 500,
700
859
  search: payload.hidUserId || payload.registration || payload.cardNo || "",
701
860
  });
702
- const identities = response?.items ?? response?.data?.items ?? response?.data?.identities ?? [];
861
+ const identities =
862
+ response?.items ??
863
+ response?.data?.items ??
864
+ response?.data?.identities ??
865
+ [];
703
866
  const hidUserId = String(payload.hidUserId || "");
704
867
  const registration = String(payload.registration || "");
705
868
  const cardNo = String(payload.cardNo || "");
706
869
 
707
- return identities.find((identity: Record<string, any>) =>
708
- String(identity.hidUserId || "") === hidUserId ||
709
- Boolean(registration && String(identity.registration || "") === registration) ||
710
- Boolean(cardNo && String(identity.cardNo || "") === cardNo),
870
+ return identities.find(
871
+ (identity: Record<string, any>) =>
872
+ String(identity.hidUserId || "") === hidUserId ||
873
+ Boolean(
874
+ registration && String(identity.registration || "") === registration
875
+ ) ||
876
+ Boolean(cardNo && String(identity.cardNo || "") === cardNo)
711
877
  );
712
878
  }
713
879
 
714
- async function createHidUserOnReader(readerId: string, user: Record<string, any>) {
880
+ async function createHidUserOnReader(
881
+ readerId: string,
882
+ user: Record<string, any>
883
+ ) {
715
884
  const existing = await getHidUserFromReader(readerId, user.id);
716
885
  if (existing) {
717
886
  await updateHidUserOnReader(readerId, user);
@@ -741,7 +910,10 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
741
910
  return normalizeHidUsers(response)[0];
742
911
  }
743
912
 
744
- async function updateHidUserOnReader(readerId: string, user: Record<string, any>) {
913
+ async function updateHidUserOnReader(
914
+ readerId: string,
915
+ user: Record<string, any>
916
+ ) {
745
917
  const { id, ...values } = user;
746
918
  await runObjectOperation(readerId, {
747
919
  operation: "modify",
@@ -755,7 +927,10 @@ async function updateHidUserOnReader(readerId: string, user: Record<string, any>
755
927
  });
756
928
  }
757
929
 
758
- async function deleteHidUserFromReader(readerId: string, user: Record<string, any>) {
930
+ async function deleteHidUserFromReader(
931
+ readerId: string,
932
+ user: Record<string, any>
933
+ ) {
759
934
  const id = toHidNumericId(user.hidUserId);
760
935
  if (!readerId || !id) return;
761
936
 
@@ -770,7 +945,11 @@ async function deleteHidUserFromReader(readerId: string, user: Record<string, an
770
945
  });
771
946
  }
772
947
 
773
- async function syncHidUserRole(readerId: string, hidUserId: unknown, isAdministrator: boolean) {
948
+ async function syncHidUserRole(
949
+ readerId: string,
950
+ hidUserId: unknown,
951
+ isAdministrator: boolean
952
+ ) {
774
953
  const userId = toHidNumericId(hidUserId);
775
954
  if (!readerId || !userId) return;
776
955
 
@@ -864,14 +1043,17 @@ function getAdministratorRoleUserIds(response: Record<string, any> | null) {
864
1043
  return new Set(
865
1044
  (Array.isArray(roles) ? roles : [])
866
1045
  .map((role) => toHidNumericId(role.user_id))
867
- .filter((userId): userId is number => Boolean(userId)),
1046
+ .filter((userId): userId is number => Boolean(userId))
868
1047
  );
869
1048
  }
870
1049
 
871
1050
  function toHidUserObject(identity: Record<string, any>) {
872
1051
  return {
873
1052
  id: toHidNumericId(identity.hidUserId),
874
- name: identity.metadata?.name || identity.name || `User ${identity.hidUserId || ""}`.trim(),
1053
+ name:
1054
+ identity.metadata?.name ||
1055
+ identity.name ||
1056
+ `User ${identity.hidUserId || ""}`.trim(),
875
1057
  registration: identity.registration || "",
876
1058
  };
877
1059
  }
@@ -900,23 +1082,40 @@ function getName(user?: Record<string, any> | null) {
900
1082
  }
901
1083
 
902
1084
  function getUnit(user?: Record<string, any> | null) {
903
- return user?.metadata?.unitLabel || [user?.metadata?.block, user?.metadata?.level, user?.metadata?.unit].filter(Boolean).join("/") || "N/A";
1085
+ return (
1086
+ user?.metadata?.unitLabel ||
1087
+ [user?.metadata?.block, user?.metadata?.level, user?.metadata?.unit]
1088
+ .filter(Boolean)
1089
+ .join("/") ||
1090
+ "N/A"
1091
+ );
904
1092
  }
905
1093
 
906
1094
  function getFacialData(user: Record<string, any>) {
907
- return user?.metadata?.facialData || user?.metadata?.imageTimestamp || (user?.metadata?.photo ? user.hidUserId : "N/A");
1095
+ return (
1096
+ user?.metadata?.facialData ||
1097
+ user?.metadata?.imageTimestamp ||
1098
+ (user?.metadata?.photo ? user.hidUserId : "N/A")
1099
+ );
908
1100
  }
909
1101
 
910
1102
  function getUserImageSrc(user: Record<string, any>) {
911
1103
  const hidUserId = toHidNumericId(user?.hidUserId);
912
- return user?.metadata?.photo || (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
1104
+ return (
1105
+ user?.metadata?.photo ||
1106
+ (hidUserId ? userImages.value[String(hidUserId)] : "") ||
1107
+ ""
1108
+ );
913
1109
  }
914
1110
 
915
1111
  async function getNextUid() {
916
1112
  const readerIds = await getExistingHidReaderIds();
917
1113
  const maxNumber = users.value.reduce((max, user) => {
918
1114
  const match = String(user.hidUserId ?? "").match(/^UID(\d+)$/i);
919
- return Math.max(max, match ? Number(match[1]) : toHidNumericId(user.hidUserId) || 0);
1115
+ return Math.max(
1116
+ max,
1117
+ match ? Number(match[1]) : toHidNumericId(user.hidUserId) || 0
1118
+ );
920
1119
  }, 0);
921
1120
  const nextNumber = Math.max(maxNumber, ...readerIds, 0) + 1;
922
1121
  return `UID${String(nextNumber).padStart(6, "0")}`;
@@ -930,10 +1129,15 @@ async function getNextRegistration() {
930
1129
  const numericRegistrations = registrations
931
1130
  .map((registration) => {
932
1131
  const match = registration.match(/^(\d+)$/);
933
- return match ? { value: Number(match[1]), width: match[1].length } : undefined;
1132
+ return match
1133
+ ? { value: Number(match[1]), width: match[1].length }
1134
+ : undefined;
934
1135
  })
935
1136
  .filter((item): item is { value: number; width: number } => Boolean(item));
936
- const maxRegistration = numericRegistrations.reduce((max, item) => Math.max(max, item.value), 0);
1137
+ const maxRegistration = numericRegistrations.reduce(
1138
+ (max, item) => Math.max(max, item.value),
1139
+ 0
1140
+ );
937
1141
  const width = Math.max(6, ...numericRegistrations.map((item) => item.width));
938
1142
 
939
1143
  return String(maxRegistration + 1).padStart(width, "0");
@@ -980,7 +1184,9 @@ function normalizeHidUsers(response: Record<string, any>) {
980
1184
  name: user.name,
981
1185
  metadata: {
982
1186
  name: user.name,
983
- imageTimestamp: user.image_timestamp ? String(user.image_timestamp) : "",
1187
+ imageTimestamp: user.image_timestamp
1188
+ ? String(user.image_timestamp)
1189
+ : "",
984
1190
  lastAccessAt: fromUnixSeconds(user.last_access),
985
1191
  hidReaderSource: true,
986
1192
  },
@@ -988,7 +1194,10 @@ function normalizeHidUsers(response: Record<string, any>) {
988
1194
  : [];
989
1195
  }
990
1196
 
991
- function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], identities: Record<string, any>[]) {
1197
+ function mergeReaderUsersWithIdentities(
1198
+ readerUsers: Record<string, any>[],
1199
+ identities: Record<string, any>[]
1200
+ ) {
992
1201
  const identityById = new Map<string, Record<string, any>>();
993
1202
  identities.forEach((identity) => {
994
1203
  const id = toHidNumericId(identity.hidUserId);
@@ -996,7 +1205,9 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
996
1205
  });
997
1206
 
998
1207
  const merged = readerUsers.map((readerUser) => {
999
- const identity = identityById.get(String(toHidNumericId(readerUser.hidUserId)));
1208
+ const identity = identityById.get(
1209
+ String(toHidNumericId(readerUser.hidUserId))
1210
+ );
1000
1211
  if (!identity) return readerUser;
1001
1212
 
1002
1213
  return {
@@ -1011,8 +1222,11 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
1011
1222
  metadata: {
1012
1223
  ...readerUser.metadata,
1013
1224
  ...identity.metadata,
1014
- imageTimestamp: identity.metadata?.imageTimestamp || readerUser.metadata?.imageTimestamp,
1015
- lastAccessAt: identity.metadata?.lastAccessAt || readerUser.metadata?.lastAccessAt,
1225
+ imageTimestamp:
1226
+ identity.metadata?.imageTimestamp ||
1227
+ readerUser.metadata?.imageTimestamp,
1228
+ lastAccessAt:
1229
+ identity.metadata?.lastAccessAt || readerUser.metadata?.lastAccessAt,
1016
1230
  },
1017
1231
  };
1018
1232
  });
@@ -1023,13 +1237,16 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
1023
1237
  function filterUsers(items: Record<string, any>[]) {
1024
1238
  const searchText = search.value.trim().toLowerCase();
1025
1239
  return items.filter((item) => {
1026
- const matchesSearch = !searchText || [
1027
- getName(item),
1028
- item.hidUserId,
1029
- item.registration,
1030
- item.cardNo,
1031
- ].some((value) => String(value || "").toLowerCase().includes(searchText));
1032
- const matchesStatus = status.value === "Status" || getMappingStatus(item) === status.value;
1240
+ const matchesSearch =
1241
+ !searchText ||
1242
+ [getName(item), item.hidUserId, item.registration, item.cardNo].some(
1243
+ (value) =>
1244
+ String(value || "")
1245
+ .toLowerCase()
1246
+ .includes(searchText)
1247
+ );
1248
+ const matchesStatus =
1249
+ status.value === "Status" || getMappingStatus(item) === status.value;
1033
1250
  return matchesSearch && matchesStatus;
1034
1251
  });
1035
1252
  }
@@ -1089,7 +1306,7 @@ function getHidErrorMessage(error: any) {
1089
1306
  data?.statusMessage ||
1090
1307
  error?.statusMessage ||
1091
1308
  error?.message ||
1092
- "Unable to save HID user.",
1309
+ "Unable to save HID user."
1093
1310
  );
1094
1311
  }
1095
1312
  </script>
@@ -1,5 +1,6 @@
1
1
  import { computed, onScopeDispose, readonly, ref, shallowRef } from "vue";
2
2
  import type { SimpleUser, SimpleUserDelegate, SimpleUserOptions } from "sip.js/lib/platform/web";
3
+ import type { SessionDescriptionHandlerOptions } from "sip.js/lib/platform/web/session-description-handler/session-description-handler-options";
3
4
 
4
5
  export type SipWebPhoneConfig = {
5
6
  webSocketServer: string;
@@ -11,7 +12,6 @@ export type SipWebPhoneConfig = {
11
12
  };
12
13
 
13
14
  export type SipWebPhoneMedia = {
14
- localVideo?: HTMLVideoElement | null;
15
15
  remoteAudio?: HTMLAudioElement | null;
16
16
  remoteVideo?: HTMLVideoElement | null;
17
17
  };
@@ -25,8 +25,6 @@ export default function useSipWebPhone() {
25
25
  const callState = ref<CallState>("idle");
26
26
  const errorMessage = ref("");
27
27
  const muted = ref(false);
28
- const cameraEnabled = ref(false);
29
- const localVideoAvailable = ref(false);
30
28
  const remoteVideoAvailable = ref(false);
31
29
  const currentTarget = ref("");
32
30
  const activeConfig = shallowRef<SipWebPhoneConfig>();
@@ -106,8 +104,7 @@ export default function useSipWebPhone() {
106
104
  aor: normalizeSipUri(config.aor),
107
105
  delegate,
108
106
  media: {
109
- constraints: { audio: true, video: Boolean(config.video) },
110
- local: { video: media.localVideo || undefined },
107
+ constraints: { audio: true, video: false },
111
108
  remote: {
112
109
  audio: media.remoteAudio || undefined,
113
110
  video: media.remoteVideo || undefined,
@@ -169,7 +166,14 @@ export default function useSipWebPhone() {
169
166
  currentTarget.value = destination;
170
167
  callState.value = "calling";
171
168
  try {
172
- await user.call(destination);
169
+ const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
170
+ constraints: { audio: true, video: false },
171
+ offerOptions: {
172
+ offerToReceiveAudio: true,
173
+ offerToReceiveVideo: Boolean(activeConfig.value?.video),
174
+ },
175
+ };
176
+ await user.call(destination, { sessionDescriptionHandlerOptions });
173
177
  } catch (error) {
174
178
  resetCall();
175
179
  errorMessage.value = getErrorMessage(error);
@@ -179,7 +183,10 @@ export default function useSipWebPhone() {
179
183
 
180
184
  async function answer() {
181
185
  const user = requireRegisteredUser();
182
- await user.answer();
186
+ const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
187
+ constraints: { audio: true, video: false },
188
+ };
189
+ await user.answer({ sessionDescriptionHandlerOptions });
183
190
  }
184
191
 
185
192
  async function decline() {
@@ -207,42 +214,19 @@ export default function useSipWebPhone() {
207
214
  muted.value = !enable;
208
215
  }
209
216
 
210
- function toggleCamera() {
211
- const user = requireUser();
212
- const tracks = user.localMediaStream?.getVideoTracks() || [];
213
- if (!tracks.length) throw new Error("No camera track is available. Reconnect the web phone with video enabled.");
214
- const enable = !cameraEnabled.value;
215
- tracks.forEach((track) => {
216
- track.enabled = enable;
217
- });
218
- cameraEnabled.value = enable;
219
- }
220
-
221
217
  function syncVideoState() {
222
218
  const user = simpleUser.value;
223
- const localStream = user?.localMediaStream;
224
219
  const remoteStream = user?.remoteMediaStream;
225
- const localTrack = localStream?.getVideoTracks()[0];
226
220
  const remoteTrack = remoteStream?.getVideoTracks()[0];
227
221
 
228
222
  const updateState = () => {
229
- const currentLocalTrack = localStream?.getVideoTracks()[0];
230
223
  const currentRemoteTrack = remoteStream?.getVideoTracks()[0];
231
- localVideoAvailable.value = Boolean(currentLocalTrack && currentLocalTrack.readyState === "live");
232
224
  remoteVideoAvailable.value = Boolean(currentRemoteTrack && currentRemoteTrack.readyState === "live");
233
- cameraEnabled.value = Boolean(currentLocalTrack?.enabled);
234
225
  };
235
226
 
236
227
  updateState();
237
- if (localStream) localStream.onaddtrack = updateState;
238
228
  if (remoteStream) remoteStream.onaddtrack = updateState;
239
229
 
240
- if (localTrack) {
241
- localTrack.onended = () => {
242
- localVideoAvailable.value = false;
243
- cameraEnabled.value = false;
244
- };
245
- }
246
230
  if (remoteTrack) {
247
231
  remoteTrack.onended = () => {
248
232
  remoteVideoAvailable.value = false;
@@ -254,8 +238,6 @@ export default function useSipWebPhone() {
254
238
  callState.value = "idle";
255
239
  currentTarget.value = "";
256
240
  muted.value = false;
257
- cameraEnabled.value = false;
258
- localVideoAvailable.value = false;
259
241
  remoteVideoAvailable.value = false;
260
242
  }
261
243
 
@@ -279,8 +261,6 @@ export default function useSipWebPhone() {
279
261
  callState: readonly(callState),
280
262
  errorMessage: readonly(errorMessage),
281
263
  muted: readonly(muted),
282
- cameraEnabled: readonly(cameraEnabled),
283
- localVideoAvailable: readonly(localVideoAvailable),
284
264
  remoteVideoAvailable: readonly(remoteVideoAvailable),
285
265
  currentTarget: readonly(currentTarget),
286
266
  isRegistered,
@@ -292,7 +272,6 @@ export default function useSipWebPhone() {
292
272
  decline,
293
273
  hangup,
294
274
  toggleMute,
295
- toggleCamera,
296
275
  };
297
276
  }
298
277
 
@@ -323,10 +302,10 @@ function normalizeDestination(target: string, aor: string) {
323
302
 
324
303
  function getErrorMessage(error: unknown) {
325
304
  if (error instanceof DOMException && error.name === "NotAllowedError") {
326
- return "Camera or microphone permission was denied. Allow media access in the browser and reconnect the web phone.";
305
+ return "Microphone permission was denied. Allow microphone access in the browser and reconnect the web phone.";
327
306
  }
328
307
  if (error instanceof DOMException && error.name === "NotFoundError") {
329
- return "No camera or microphone was found for this video call.";
308
+ return "No microphone was found for this call.";
330
309
  }
331
310
  return error instanceof Error ? error.message : "SIP connection failed.";
332
311
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.1-staging.62",
5
+ "version": "3.2.1-staging.63",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {