@7365admin1/layer-common 3.2.2-staging.107 → 3.2.2-staging.108

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.
@@ -166,8 +166,28 @@
166
166
  </template>
167
167
 
168
168
  <template v-if="passType === 'HID_QR'">
169
+ <v-card variant="tonal" color="primary" class="mt-3 pa-3" rounded="lg">
170
+ <div class="d-flex align-center ga-3">
171
+ <v-icon icon="mdi-door-open" size="28" />
172
+ <div>
173
+ <div class="text-caption text-medium-emphasis">Access Reader</div>
174
+ <div class="text-subtitle-2 font-weight-bold">{{ hidReaderName || "Reader not configured" }}</div>
175
+ <div class="text-caption mt-1">Access Portal: {{ hidPortalName || "Portal not configured" }}</div>
176
+ </div>
177
+ </div>
178
+ </v-card>
179
+ <v-alert
180
+ v-if="!hidPortalName"
181
+ type="warning"
182
+ variant="tonal"
183
+ density="compact"
184
+ class="mt-3"
185
+ icon="mdi-alert"
186
+ >
187
+ Reconnect this HID reader and select a portal before generating a visitor QR code.
188
+ </v-alert>
169
189
  <v-alert
170
- v-if="!hidQrPrinterConfigured"
190
+ v-else-if="!hidQrPrinterConfigured"
171
191
  type="info"
172
192
  variant="tonal"
173
193
  density="compact"
@@ -283,6 +303,14 @@ const props = defineProps({
283
303
  type: Boolean,
284
304
  default: false,
285
305
  },
306
+ hidReaderName: {
307
+ type: String,
308
+ default: "",
309
+ },
310
+ hidPortalName: {
311
+ type: String,
312
+ default: "",
313
+ },
286
314
  excludedCardIds: {
287
315
  type: Array as PropType<string[]>,
288
316
  default: () => [],
@@ -12,7 +12,7 @@
12
12
  </v-btn>
13
13
 
14
14
  <v-select
15
- v-if="readers.length > 1"
15
+ v-if="readers.length"
16
16
  v-model="selectedReaderId"
17
17
  :items="readerOptions"
18
18
  item-title="title"
@@ -412,7 +412,7 @@ const accessMethodOptions = ["All", "Facial", "QR Code", "ID and Password", "PIN
412
412
 
413
413
  const readerOptions = computed(() =>
414
414
  readers.value.map((reader) => ({
415
- title: reader.name || reader.deviceId || reader._id,
415
+ title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
416
416
  value: reader._id,
417
417
  })),
418
418
  );
@@ -582,7 +582,6 @@ async function loadDashboard() {
582
582
  loading.value = true;
583
583
  resetDashboardData();
584
584
  try {
585
- const isAdministratorTab = activeTab.value === "administrator";
586
585
  const isVisitorTab = activeTab.value === "visitor";
587
586
  const [identityResponse, logResponse, hidAccessLogs, configurationResponse, roleResponse, visitorResponse, readerUserResponse] = await Promise.all([
588
587
  loadDashboardSource(
@@ -603,9 +602,7 @@ async function loadDashboard() {
603
602
  ),
604
603
  loadDashboardSource(loadLiveAccessLogs(selectedReaderId.value), [], "live access logs"),
605
604
  loadDashboardSource(loadReaderConfiguration(selectedReaderId.value), {}, "reader configuration"),
606
- isAdministratorTab
607
- ? loadDashboardSource(loadAdministratorRoles(selectedReaderId.value), null, "administrator roles")
608
- : Promise.resolve(null),
605
+ loadDashboardSource(loadAdministratorRoles(selectedReaderId.value), null, "administrator roles"),
609
606
  isVisitorTab && props.org
610
607
  ? loadDashboardSource(
611
608
  getVisitors({
@@ -632,14 +629,9 @@ async function loadDashboard() {
632
629
  );
633
630
  administratorUserIds.value = getAdministratorRoleUserIds(roleResponse);
634
631
  allIdentities.value = mergedIdentities;
635
- identities.value = isAdministratorTab
636
- ? mergedIdentities.filter((identity: Record<string, any>) => {
637
- const hidUserId = toHidNumericId(identity.hidUserId);
638
- return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
639
- })
640
- : activeTab.value === "visitor"
641
- ? mergedIdentities.filter(isVisitorIdentity)
642
- : mergedIdentities.filter((identity: Record<string, any>) => !isVisitorIdentity(identity));
632
+ identities.value = mergedIdentities.filter(
633
+ (identity: Record<string, any>) => classifyIdentity(identity) === activeTab.value,
634
+ );
643
635
  logs.value = logResponse?.items ?? logResponse?.data?.items ?? logResponse?.data?.logs ?? [];
644
636
  liveAccessLogs.value = hidAccessLogs;
645
637
  readerConfiguration.value = normalizeReaderConfiguration(configurationResponse);
@@ -781,8 +773,10 @@ function normalizeHidUsers(response: Record<string, any>) {
781
773
  registration: user.registration,
782
774
  cardNo: user.card_value || user.cardNo || "",
783
775
  name: user.name,
776
+ hidUserTypeId: user.user_type_id,
784
777
  metadata: {
785
778
  name: user.name,
779
+ hidUserTypeId: user.user_type_id,
786
780
  imageTimestamp: user.image_timestamp ? String(user.image_timestamp) : "",
787
781
  hidReaderSource: true,
788
782
  },
@@ -835,21 +829,22 @@ function resetDashboardData() {
835
829
 
836
830
  function isVisibleForActiveTab(row: AccessRow) {
837
831
  const identity = findIdentityByKey(row.identityKey);
838
- const isVisitor = row.identityType === "visitor" || Boolean(row.visitorId) || isVisitorIdentity(identity);
839
- if (activeTab.value === "visitor") {
840
- return isVisitor;
841
- }
842
-
843
- if (activeTab.value === "administrator") {
844
- const hidUserId = toHidNumericId(identity?.hidUserId ?? row.identityKey.split("|")[0]);
845
- return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
846
- }
847
-
848
- return !isVisitor;
832
+ return classifyIdentity(identity, row) === activeTab.value;
849
833
  }
850
834
 
851
835
  function isVisitorIdentity(identity: Record<string, unknown> | undefined) {
852
- return Boolean(identity && (identity.type === "visitor" || identity.visitor));
836
+ return Boolean(identity && (
837
+ identity.type === "visitor"
838
+ || identity.visitor
839
+ || Number(identity.hidUserTypeId ?? (identity.metadata as Record<string, unknown> | undefined)?.hidUserTypeId) === 1
840
+ ));
841
+ }
842
+
843
+ function classifyIdentity(identity?: Record<string, any>, row?: AccessRow): AccessTab {
844
+ const hidUserId = toHidNumericId(identity?.hidUserId ?? row?.identityKey.split("|")[0]);
845
+ if (hidUserId && administratorUserIds.value.has(hidUserId)) return "administrator";
846
+ if (row?.identityType === "visitor" || row?.visitorId || isVisitorIdentity(identity)) return "visitor";
847
+ return "resident";
853
848
  }
854
849
 
855
850
  function loadAdministratorRoles(readerId: string) {
@@ -1200,7 +1195,18 @@ function getRawAccessLogs(event: Record<string, any>) {
1200
1195
  payload.access_logs ||
1201
1196
  payload.accessLogs;
1202
1197
 
1203
- return Array.isArray(accessLogs) ? accessLogs : [];
1198
+ if (Array.isArray(accessLogs)) return accessLogs;
1199
+
1200
+ const objectChanges = payload.object_changes || result.object_changes || [];
1201
+ return Array.isArray(objectChanges)
1202
+ ? objectChanges
1203
+ .filter((change: Record<string, any>) => (
1204
+ change?.object === "access_logs"
1205
+ && change?.type !== "deleted"
1206
+ && change?.values
1207
+ ))
1208
+ .map((change: Record<string, any>) => change.values)
1209
+ : [];
1204
1210
  }
1205
1211
 
1206
1212
  function mapAccessLogToRow(rawLog: Record<string, any>, event: Record<string, any>, index: string): AccessRow {
@@ -1212,7 +1218,9 @@ function mapAccessLogToRow(rawLog: Record<string, any>, event: Record<string, an
1212
1218
  return {
1213
1219
  key: `${event._id || event.id || "log"}-${rawLog.id || rawLog.time || index}`,
1214
1220
  identityKey,
1215
- identityType: String(identity?.type || eventIdentity.type || "").toLowerCase() || undefined,
1221
+ identityType: identity
1222
+ ? classifyIdentity(identity)
1223
+ : String(eventIdentity.type || "").toLowerCase() || undefined,
1216
1224
  visitorId: String(identity?.visitor || eventIdentity.visitor || "") || undefined,
1217
1225
  name: identity ? getName(identity) : getRawLogName(rawLog, event),
1218
1226
  facialData: identity ? getFacialData(identity) : "N/A",
@@ -1276,7 +1284,12 @@ function findIdentityForAccessLog(rawLog: Record<string, any>, event: Record<str
1276
1284
  const eventIdentityId = event.payload?.identity?._id;
1277
1285
  if (eventIdentityId) {
1278
1286
  const byId = allIdentities.value.find((identity) => String(identity._id) === String(eventIdentityId));
1279
- if (byId) return byId;
1287
+ const rawUserId = rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId;
1288
+ if (byId && (
1289
+ rawUserId === undefined
1290
+ || rawUserId === null
1291
+ || String(byId.hidUserId) === String(rawUserId)
1292
+ )) return byId;
1280
1293
  }
1281
1294
 
1282
1295
  const hidUserId = rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId ?? event.payload?.identity?.hidUserId ?? event.payload?.identityLookup?.hidUserId;
@@ -24,6 +24,19 @@
24
24
  {{ webPhone.callState.value === 'incoming' ? 'Incoming Call' : 'Web Call' }}
25
25
  </v-btn>
26
26
 
27
+ <v-select
28
+ v-if="readers.length"
29
+ v-model="selectedReaderId"
30
+ :items="readerOptions"
31
+ item-title="title"
32
+ item-value="value"
33
+ variant="outlined"
34
+ density="compact"
35
+ hide-details
36
+ class="reader-input"
37
+ @update:model-value="page = 1"
38
+ />
39
+
27
40
  <div class="toolbar-spacer" />
28
41
 
29
42
  <v-text-field
@@ -702,6 +715,7 @@ const {
702
715
 
703
716
  const activeTab = ref<IntercomTab>("intercom");
704
717
  const readers = ref<Record<string, any>[]>([]);
718
+ const selectedReaderId = ref("");
705
719
  const intercomRows = ref<IntercomRow[]>([]);
706
720
  const contacts = ref<ContactRow[]>([]);
707
721
  const loading = ref(false);
@@ -821,6 +835,11 @@ const contactForm = reactive({
821
835
  serverAddress: "",
822
836
  });
823
837
 
838
+ const readerOptions = computed(() => readers.value.map((reader) => ({
839
+ title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
840
+ value: reader._id,
841
+ })));
842
+
824
843
  const visibleRows = computed(() => activeTab.value === "intercom" ? filteredIntercomRows.value : filteredContacts.value);
825
844
  const pages = computed(() => Math.max(1, Math.ceil(visibleRows.value.length / limit)));
826
845
  const pageRange = computed(() => {
@@ -834,6 +853,7 @@ const pageRange = computed(() => {
834
853
  const filteredIntercomRows = computed(() => {
835
854
  const query = search.value.trim().toLowerCase();
836
855
  return intercomRows.value.filter((row) => {
856
+ const matchesReader = !selectedReaderId.value || row.reader._id === selectedReaderId.value;
837
857
  const matchesSearch = !query || [
838
858
  row.name,
839
859
  row.location,
@@ -843,18 +863,22 @@ const filteredIntercomRows = computed(() => {
843
863
  row.status,
844
864
  ].some((value) => String(value || "").toLowerCase().includes(query));
845
865
  const matchesStatus = statusFilter.value === "Status" || row.status === statusFilter.value;
846
- return matchesSearch && matchesStatus;
866
+ return matchesReader && matchesSearch && matchesStatus;
847
867
  });
848
868
  });
849
869
 
850
870
  const filteredContacts = computed(() => {
851
871
  const query = search.value.trim().toLowerCase();
852
- return contacts.value.filter((contact) => !query || [
853
- contact.name,
854
- contact.location,
855
- contact.serverAddress,
856
- contact.dialCode,
857
- ].some((value) => String(value || "").toLowerCase().includes(query)));
872
+ return contacts.value.filter((contact) => {
873
+ const matchesReader = !selectedReaderId.value || contact.readerId === selectedReaderId.value;
874
+ const matchesSearch = !query || [
875
+ contact.name,
876
+ contact.location,
877
+ contact.serverAddress,
878
+ contact.dialCode,
879
+ ].some((value) => String(value || "").toLowerCase().includes(query));
880
+ return matchesReader && matchesSearch;
881
+ });
858
882
  });
859
883
 
860
884
  const paginatedIntercomRows = computed(() => paginate(filteredIntercomRows.value));
@@ -977,6 +1001,9 @@ async function reload() {
977
1001
  try {
978
1002
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
979
1003
  readers.value = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
1004
+ if (!readers.value.some((reader) => reader._id === selectedReaderId.value)) {
1005
+ selectedReaderId.value = readers.value[0]?._id || "";
1006
+ }
980
1007
  await loadIntercomRows();
981
1008
  await loadContacts();
982
1009
  } catch (error: any) {
@@ -1328,9 +1355,10 @@ function toggleWebMute() {
1328
1355
  function openContactDialog(contact?: ContactRow) {
1329
1356
  selectedContact.value = contact || null;
1330
1357
  contactForm.id = contact?.id;
1331
- contactForm.readerId = contact?.readerId || readers.value[0]?._id || "";
1358
+ contactForm.readerId = contact?.readerId || selectedReaderId.value || readers.value[0]?._id || "";
1332
1359
  contactForm.name = contact?.name === "N/A" ? "" : contact?.name || "";
1333
- contactForm.location = contact?.location === "N/A" ? "" : contact?.location || readers.value[0]?.location || "";
1360
+ const selectedReader = readers.value.find((reader) => reader._id === contactForm.readerId);
1361
+ contactForm.location = contact?.location === "N/A" ? "" : contact?.location || selectedReader?.location || "";
1334
1362
  contactForm.serverAddress = contact?.serverAddress === "N/A" ? "" : contact?.serverAddress || "";
1335
1363
  contactDialog.value = true;
1336
1364
  }
@@ -1695,6 +1723,11 @@ export default {
1695
1723
  max-width: 220px;
1696
1724
  }
1697
1725
 
1726
+ .reader-input {
1727
+ width: 300px;
1728
+ max-width: 36vw;
1729
+ }
1730
+
1698
1731
  .status-input {
1699
1732
  max-width: 160px;
1700
1733
  }
@@ -2673,6 +2706,7 @@ export default {
2673
2706
  }
2674
2707
 
2675
2708
  .top-placeholder,
2709
+ .reader-input,
2676
2710
  .search-input,
2677
2711
  .status-input {
2678
2712
  width: 100%;
@@ -501,7 +501,6 @@ const identificationMethodOptions: Array<{
501
501
  { key: "qrCode", label: "QR Code" },
502
502
  { key: "idPassword", label: "ID Password" },
503
503
  { key: "pin", label: "PIN" },
504
- { key: "bluetooth", label: "Bluetooth" },
505
504
  ];
506
505
  const accessPermissionOptions: Array<{
507
506
  category: THidPermissionCategory;
@@ -590,7 +589,7 @@ async function loadConfig() {
590
589
  qrCode: configuredMethods?.qrCode ?? Boolean(config.enabled),
591
590
  idPassword: configuredMethods?.idPassword ?? true,
592
591
  pin: configuredMethods?.pin ?? false,
593
- bluetooth: configuredMethods?.bluetooth ?? false,
592
+ bluetooth: false,
594
593
  },
595
594
  printer: {
596
595
  vendorId: String(config.printer?.vendorId || ""),
@@ -28,17 +28,6 @@
28
28
  class="mb-3"
29
29
  />
30
30
 
31
- <div class="field-label">Device ID <span>*</span></div>
32
- <v-text-field
33
- v-model="draft.deviceId"
34
- aria-label="Device ID"
35
- placeholder="Enter device id"
36
- variant="outlined"
37
- density="compact"
38
- hide-details="auto"
39
- class="mb-3"
40
- />
41
-
42
31
  <div class="field-label">Location <span>*</span></div>
43
32
  <v-text-field
44
33
  v-model="draft.location"
@@ -79,6 +68,49 @@
79
68
  :append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
80
69
  @click:append-inner="showPassword = !showPassword"
81
70
  />
71
+
72
+ <v-btn
73
+ v-if="mode === 'add'"
74
+ block
75
+ variant="tonal"
76
+ color="primary"
77
+ prepend-icon="mdi-lan-connect"
78
+ class="text-none mt-3"
79
+ :loading="discovering"
80
+ :disabled="!canDiscover"
81
+ @click="connectReader"
82
+ >
83
+ Connect &amp; discover reader
84
+ </v-btn>
85
+ <v-alert v-if="discoveryError" type="error" density="compact" variant="tonal" class="mt-3">
86
+ {{ discoveryError }}
87
+ </v-alert>
88
+
89
+ <div class="field-label mt-3">Device ID <span>*</span></div>
90
+ <v-text-field
91
+ v-model="draft.deviceId"
92
+ aria-label="Device ID"
93
+ placeholder="Connect to detect device ID"
94
+ variant="outlined"
95
+ density="compact"
96
+ hide-details="auto"
97
+ class="mb-3"
98
+ readonly
99
+ />
100
+
101
+ <div class="field-label">HID Portal <span>*</span></div>
102
+ <v-select
103
+ v-model="draft.portalId"
104
+ :items="portals"
105
+ item-title="name"
106
+ item-value="id"
107
+ aria-label="HID Portal"
108
+ placeholder="Connect to load portals"
109
+ variant="outlined"
110
+ density="compact"
111
+ hide-details="auto"
112
+ :readonly="mode === 'edit'"
113
+ />
82
114
  </v-card-text>
83
115
 
84
116
  <v-card-actions class="form-actions pa-0">
@@ -95,6 +127,7 @@
95
127
  variant="flat"
96
128
  height="48"
97
129
  :loading="loading"
130
+ :disabled="mode === 'add' && (!draft.deviceId || !draft.portalId)"
98
131
  @click="submit"
99
132
  >
100
133
  Submit
@@ -135,11 +168,18 @@ const dialogModel = computed({
135
168
  });
136
169
 
137
170
  const showPassword = ref(false);
171
+ const discovering = ref(false);
172
+ const discoveryError = ref("");
173
+ const portals = ref<Array<{ id: number; name: string }>>([]);
174
+ const discoveredConnection = ref("");
175
+ const { discoverReader } = useHidAmico();
138
176
  const monitorEnabled = ref(true);
139
177
  const draft = reactive({
140
178
  name: "",
141
179
  baseUrl: "",
142
180
  deviceId: "",
181
+ portalId: null as number | null,
182
+ portalName: "",
143
183
  location: "",
144
184
  username: "",
145
185
  password: "",
@@ -153,6 +193,13 @@ watch(
153
193
  draft.name = props.reader?.name ?? "";
154
194
  draft.baseUrl = props.reader?.baseUrl ?? "";
155
195
  draft.deviceId = props.reader?.deviceId ?? "";
196
+ draft.portalId = props.reader?.portalId ?? null;
197
+ draft.portalName = props.reader?.portalName ?? "";
198
+ portals.value = draft.portalId
199
+ ? [{ id: draft.portalId, name: draft.portalName || `Portal ${draft.portalId}` }]
200
+ : [];
201
+ discoveryError.value = "";
202
+ discoveredConnection.value = "";
156
203
  draft.location = props.reader?.location ?? "";
157
204
  draft.username = props.reader?.username ?? "";
158
205
  draft.password = props.reader?.password ?? "";
@@ -165,9 +212,53 @@ function close() {
165
212
  emit("update:modelValue", false);
166
213
  }
167
214
 
215
+ const canDiscover = computed(() => Boolean(
216
+ draft.baseUrl.trim() && draft.username.trim() && draft.password,
217
+ ));
218
+
219
+ watch(
220
+ () => [draft.baseUrl, draft.username, draft.password],
221
+ () => {
222
+ if (props.mode !== "add" || !discoveredConnection.value) return;
223
+ const current = [draft.baseUrl.trim(), draft.username.trim(), draft.password].join("\n");
224
+ if (current !== discoveredConnection.value) {
225
+ draft.deviceId = "";
226
+ draft.portalId = null;
227
+ portals.value = [];
228
+ discoveredConnection.value = "";
229
+ }
230
+ },
231
+ );
232
+
233
+ async function connectReader() {
234
+ discovering.value = true;
235
+ discoveryError.value = "";
236
+ try {
237
+ const response = await discoverReader({
238
+ baseUrl: draft.baseUrl.trim(),
239
+ username: draft.username.trim(),
240
+ password: draft.password,
241
+ });
242
+ const data = response?.data ?? response;
243
+ draft.deviceId = String(data?.deviceId || "");
244
+ portals.value = Array.isArray(data?.portals) ? data.portals : [];
245
+ draft.portalId = portals.value.length === 1 ? portals.value[0].id : null;
246
+ discoveredConnection.value = [draft.baseUrl.trim(), draft.username.trim(), draft.password].join("\n");
247
+ } catch (error: any) {
248
+ draft.deviceId = "";
249
+ draft.portalId = null;
250
+ portals.value = [];
251
+ discoveryError.value = error?.data?.message || error?.message || "Unable to connect to the HID reader.";
252
+ } finally {
253
+ discovering.value = false;
254
+ }
255
+ }
256
+
168
257
  function submit() {
258
+ const portal = portals.value.find((item) => item.id === Number(draft.portalId));
169
259
  const payload: Record<string, any> = {
170
260
  ...draft,
261
+ portalName: portal?.name || draft.portalName,
171
262
  monitorPath: monitorEnabled.value ? "api/notifications" : "",
172
263
  enabled: monitorEnabled.value,
173
264
  status: monitorEnabled.value ? "active" : "inactive",
@@ -119,6 +119,7 @@
119
119
  <span>HID reader name</span><strong>{{ selectedReader?.name || "N/A" }}</strong>
120
120
  <span>Device ID</span><strong>{{ selectedReader?.deviceId || "N/A" }}</strong>
121
121
  <span>Location</span><strong>{{ selectedReader?.location || "N/A" }}</strong>
122
+ <span>HID Portal</span><strong>{{ selectedReader?.portalName || selectedReader?.portalId || "N/A" }}</strong>
122
123
  <span>Monitor Path</span><strong>{{ selectedReader?.monitorPath || "N/A" }}</strong>
123
124
  <span>Status</span><strong>{{ formatReaderStatus(resolveReaderStatus(selectedReader)) }}</strong>
124
125
  <span>Username</span><strong>{{ selectedReader?.username || "N/A" }}</strong>
@@ -12,7 +12,7 @@
12
12
  </v-btn>
13
13
 
14
14
  <v-select
15
- v-if="readers.length > 1"
15
+ v-if="readers.length"
16
16
  v-model="selectedReaderId"
17
17
  :items="readerOptions"
18
18
  item-title="title"
@@ -48,6 +48,18 @@
48
48
  />
49
49
  </div>
50
50
 
51
+ <v-alert
52
+ v-if="selectedReader"
53
+ :type="selectedReader.portalId && selectedReader.portalName ? 'info' : 'warning'"
54
+ variant="tonal"
55
+ density="compact"
56
+ class="mb-4"
57
+ icon="mdi-door-open"
58
+ >
59
+ Access Reader: {{ selectedReader.name || "HID Reader" }} · Access Portal:
60
+ {{ selectedReader.portalName || "Not configured" }}
61
+ </v-alert>
62
+
51
63
  <v-card flat border class="table-card">
52
64
  <div class="table-refresh">
53
65
  <v-btn
@@ -429,10 +441,13 @@ const statusOptions = ["Status", "Mapped", "Unmapped"];
429
441
 
430
442
  const readerOptions = computed(() =>
431
443
  readers.value.map((reader) => ({
432
- title: reader.name || reader.deviceId || reader._id,
444
+ title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
433
445
  value: reader._id,
434
446
  }))
435
447
  );
448
+ const selectedReader = computed(() =>
449
+ readers.value.find((reader) => reader._id === (selectedReaderId.value || form.reader)) || null,
450
+ );
436
451
 
437
452
  const pageRange = computed(() => {
438
453
  if (serverPageRange.value) return serverPageRange.value;
@@ -689,6 +704,10 @@ async function saveUser() {
689
704
  showToast("Access PIN must contain numbers only.", "error");
690
705
  return;
691
706
  }
707
+ if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
708
+ showToast("Reconnect this HID reader and select a portal before enrolling facial recognition.", "error");
709
+ return;
710
+ }
692
711
 
693
712
  saving.value = true;
694
713
  try {
@@ -75,6 +75,7 @@
75
75
  v-model:cards="memberPassCards" :settings="props.settings" :loading="props.settingsLoading"
76
76
  :site-id="props.site" :unit-id="props.unitId" :visitor-type="props.type"
77
77
  :hid-qr-code-enabled="props.hidQrCodeEnabled" :hid-qr-printer-configured="props.hidQrPrinterConfigured"
78
+ :hid-reader-name="props.hidReaderName" :hid-portal-name="props.hidPortalName"
78
79
  :excluded-card-ids="props.selectedNfcCards.map((c) => c._id)" />
79
80
  </v-col>
80
81
 
@@ -192,6 +193,14 @@ const props = defineProps({
192
193
  type: Boolean,
193
194
  default: false,
194
195
  },
196
+ hidReaderName: {
197
+ type: String,
198
+ default: "",
199
+ },
200
+ hidPortalName: {
201
+ type: String,
202
+ default: "",
203
+ },
195
204
  selectedNfcCards: {
196
205
  type: Array as PropType<{ _id: string; cardNo: string }[]>,
197
206
  default: () => [],
@@ -260,6 +260,8 @@
260
260
  :loading="entryPassSettingsPending || siteDataPending" :site-id="prop.site"
261
261
  :unit-id="visitor.unit || null" :visitor-type="prop.type" :hid-qr-code-enabled="hidQrCodePassAllowed"
262
262
  :hid-qr-printer-configured="hasHidQrPrinterConfig"
263
+ :hid-reader-name="hidQrReader?.name || ''"
264
+ :hid-portal-name="hidQrReader?.portalName || ''"
263
265
  @update:available-qr-count="(val) => availableQrCount = val" />
264
266
  </v-col>
265
267
 
@@ -269,6 +271,7 @@
269
271
  :settings="entryPassSettings" :settings-loading="entryPassSettingsPending || siteDataPending"
270
272
  :unit-id="visitor.unit || null" :hid-qr-code-enabled="hidQrCodePassAllowed"
271
273
  :hid-qr-printer-configured="hasHidQrPrinterConfig"
274
+ :hid-reader-name="hidQrReader?.name || ''" :hid-portal-name="hidQrReader?.portalName || ''"
272
275
  :selected-nfc-cards="passType === 'NFC' ? passCards : []" />
273
276
  </v-col>
274
277
 
@@ -533,6 +536,9 @@
533
536
  </v-btn>
534
537
 
535
538
  <div class="text-h6 font-weight-bold">{{ visitor.name }}</div>
539
+ <div v-if="hidQrReader?.portalName" class="text-caption text-medium-emphasis mt-1">
540
+ {{ hidQrReader.name || "HID Reader" }} · {{ hidQrReader.portalName }}
541
+ </div>
536
542
  <div v-if="hidQrPreview.expiresAt" class="text-caption text-medium-emphasis mt-1">
537
543
  Valid until {{ formatHidQrExpiry(hidQrPreview.expiresAt) }}
538
544
  </div>
@@ -668,7 +674,7 @@ const {
668
674
  const { getBySiteId: getEntryPassSettingsBySiteId } =
669
675
  useSiteEntryPassSettings();
670
676
  const { createVisitorPass, signQr } = useAccessManagement();
671
- const { issueVisitorQr } = useHidAmico();
677
+ const { getReaders, issueVisitorQr } = useHidAmico();
672
678
  const { testConnection } = useWebUsb();
673
679
  const {
674
680
  findPersonByNRIC,
@@ -877,6 +883,23 @@ const hidQrValidityMinutes = computed(() => {
877
883
  });
878
884
 
879
885
  const hidQrReaderId = computed(() => String(hidQrCodePassConfig.value?.readerId || ""));
886
+ const hidQrReader = ref<Record<string, any> | null>(null);
887
+
888
+ watch(
889
+ [hidQrReaderId, () => prop.site],
890
+ async ([readerId, site]) => {
891
+ hidQrReader.value = null;
892
+ if (!readerId || !site) return;
893
+ try {
894
+ const response = await getReaders({ site, page: 1, limit: 100 });
895
+ const readers = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
896
+ hidQrReader.value = readers.find((reader: Record<string, any>) => String(reader._id) === readerId) || null;
897
+ } catch {
898
+ hidQrReader.value = null;
899
+ }
900
+ },
901
+ { immediate: true },
902
+ );
880
903
 
881
904
  const hidQrCodePassAllowed = computed(() => {
882
905
  return (
@@ -2100,6 +2123,12 @@ function validateHidQrReaderConfig() {
2100
2123
  return false;
2101
2124
  }
2102
2125
 
2126
+ if (!hidQrReader.value?.portalId || !hidQrReader.value?.portalName) {
2127
+ errorMessage.value =
2128
+ "The selected HID reader has no portal configured. Reconnect the reader and select a portal before generating a QR code.";
2129
+ return false;
2130
+ }
2131
+
2103
2132
  return true;
2104
2133
  }
2105
2134
 
@@ -6,6 +6,8 @@ type HidReaderPayload = {
6
6
  username?: string;
7
7
  password?: string;
8
8
  deviceId?: string;
9
+ portalId?: number;
10
+ portalName?: string;
9
11
  monitorPath?: string;
10
12
  enabled?: boolean;
11
13
  status?: string;
@@ -34,6 +36,9 @@ type HidVisitorQrData = {
34
36
  hidUserId: number;
35
37
  qrValue: string;
36
38
  qrFormat: "0";
39
+ portalId: number;
40
+ portalName: string;
41
+ readerName: string;
37
42
  issuedAt: string;
38
43
  expiresAt: string;
39
44
  };
@@ -189,6 +194,13 @@ export default function useHidAmico() {
189
194
  );
190
195
  }
191
196
 
197
+ function discoverReader(payload: Pick<HidReaderPayload, "baseUrl" | "username" | "password">) {
198
+ return useNuxtApp().$api<Record<string, any>>(`${basePath}/readers/discover`, {
199
+ method: "POST",
200
+ body: payload,
201
+ });
202
+ }
203
+
192
204
  function getUserPinStatus(readerId: string, hidUserId: string | number) {
193
205
  return useNuxtApp().$api<{ data: { pinEnrolled: boolean } }>(
194
206
  `${basePath}/readers/${readerId}/users/${hidUserId}/pin`,
@@ -294,6 +306,7 @@ export default function useHidAmico() {
294
306
 
295
307
  return {
296
308
  getReaders,
309
+ discoverReader,
297
310
  createReader,
298
311
  updateReader,
299
312
  deleteReader,
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.2-staging.107",
5
+ "version": "3.2.2-staging.108",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {