@7365admin1/layer-common 3.2.2-staging.84 → 3.2.2-staging.86

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.
@@ -5,20 +5,16 @@
5
5
  <div class="d-flex flex-wrap align-center">
6
6
  <v-btn
7
7
  v-if="showAddButton && canAddServiceProvider"
8
- class="text-none mr-2"
9
- rounded="pill"
10
- variant="tonal"
11
- size="large"
8
+ class="text-none mr-2 screen-btn-primary"
9
+ variant="flat"
12
10
  @click="(createDialog = true), setServiceProvider({ mode: 'add' })"
13
11
  >
14
12
  Add
15
13
  </v-btn>
16
14
  <v-btn
17
15
  v-if="canInviteServiceProvider"
18
- class="text-none mr-2"
19
- rounded="pill"
20
- variant="tonal"
21
- size="large"
16
+ class="text-none mr-2 screen-btn-primary"
17
+ variant="flat"
22
18
  @click="
23
19
  (createInviteDialog = true),
24
20
  setServiceProvider({ mode: 'invite' })
@@ -28,10 +24,8 @@
28
24
  </v-btn>
29
25
  <v-btn
30
26
  v-if="showBillingButton"
31
- class="text-none mr-2"
32
- rounded="pill"
33
- variant="tonal"
34
- size="large"
27
+ class="text-none mr-2 screen-btn-primary"
28
+ variant="flat"
35
29
  @click="
36
30
  useRouter().push({
37
31
  name: 'org-site-service-provider-mgmt-billing',
@@ -59,9 +53,8 @@
59
53
  <v-col cols="12">
60
54
  <v-card
61
55
  width="100%"
62
- variant="outlined"
63
- border="thin"
64
- rounded="lg"
56
+ variant="flat"
57
+ class="table-card"
65
58
  :loading="loading"
66
59
  >
67
60
  <div class="sp-table-header">
@@ -101,7 +94,7 @@
101
94
  v-model="listTab"
102
95
  color="primary"
103
96
  density="comfortable"
104
- class="sp-tabs"
97
+ class="sp-tabs screen-tabs"
105
98
  align-tabs="start"
106
99
  >
107
100
  <v-tab value="active" class="text-none">Service Providers</v-tab>
@@ -147,14 +140,11 @@
147
140
  4.5:1 a 12px label needs. Filled puts the colour in the
148
141
  background and lets Vuetify pick a readable foreground for it.
149
142
  -->
150
- <v-chip
151
- :color="statusChipColor(item.status, item.statusRaw)"
152
- size="small"
153
- :variant="item.source === 'invite' ? 'flat' : 'tonal'"
143
+ <StatusChip
154
144
  class="text-none"
155
- >
156
- {{ item.status }}
157
- </v-chip>
145
+ :status="item.statusRaw || item.status"
146
+ :label="item.status"
147
+ />
158
148
  <div
159
149
  v-if="item.rejectionReason"
160
150
  class="text-caption text-medium-emphasis mt-1 sp-reject-reason"
@@ -520,7 +510,7 @@
520
510
 
521
511
  <v-dialog v-model="createInviteDialog" width="580" persistent>
522
512
  <v-card width="100%" rounded="lg" class="sp-invite-card">
523
- <v-toolbar flat color="grey-lighten-4" height="76">
513
+ <v-toolbar flat color="transparent" class="table-card__toolbar" height="76">
524
514
  <v-row no-gutters class="fill-height px-6" align="center">
525
515
  <span class="font-weight-bold text-h5">
526
516
  Invite Service Provider
@@ -1147,24 +1137,9 @@ function statusDisplay(raw: string, source: TableRow["source"]) {
1147
1137
  return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
1148
1138
  }
1149
1139
 
1150
- const INVITE_STATUS_COLOURS: Record<string, string> = {
1151
- "awaiting-approval": "warning",
1152
- pending: "info",
1153
- complete: "success",
1154
- rejected: "error",
1155
- declined: "default",
1156
- cancelled: "default",
1157
- expired: "default",
1158
- };
1159
-
1160
- function statusChipColor(statusLabel: string, statusRaw?: string) {
1161
- if (statusRaw) return INVITE_STATUS_COLOURS[statusRaw] ?? "primary";
1162
- const s = statusLabel.toLowerCase();
1163
- if (s === "active") return "success";
1164
- if (s === "pending") return "warning";
1165
- if (s === "in-active" || s === "inactive") return "default";
1166
- return "primary";
1167
- }
1140
+ /* The status chips read their tone from the shared mapping in
1141
+ `utils/status.ts` now - which carries every raw invitation status this map
1142
+ carried, with the same tone. */
1168
1143
 
1169
1144
  // async function loadInviteRoles() {
1170
1145
  // loadingInviteRoles.value = true;
@@ -12,7 +12,10 @@
12
12
  <template>
13
13
  <span class="status-chip" :class="[toneClass, { 'status-chip--plain': !dot }]">
14
14
  <span v-if="dot" class="status-chip__dot" />
15
- {{ label ?? status }}
15
+ <!-- The slot is for the chips that already carried an icon next to their
16
+ word - a padlock on "Closed - View Only" - so adopting the design's
17
+ pill does not quietly drop it. -->
18
+ <slot>{{ label ?? status }}</slot>
16
19
  </span>
17
20
  </template>
18
21
 
@@ -26,9 +29,20 @@ const props = defineProps({
26
29
  label: { type: String, default: undefined },
27
30
  /** The design's tag chips (pass IDs, role names) carry no leading dot. */
28
31
  dot: { type: Boolean, default: true },
32
+ /**
33
+ * For the chips that are not a status WORD at all - a countdown, a rating, a
34
+ * count - where the screen has already worked out the meaning and only needs
35
+ * the design's tone. Given, it wins over the word mapping.
36
+ */
37
+ tone: {
38
+ type: String as PropType<"ok" | "warn" | "err" | "info" | "neutral" | "">,
39
+ default: "",
40
+ },
29
41
  });
30
42
 
31
- const toneClass = computed(() => statusToneClass(props.status));
43
+ const toneClass = computed(() =>
44
+ props.tone ? `tone-${props.tone}` : statusToneClass(props.status)
45
+ );
32
46
  </script>
33
47
 
34
48
  <style scoped>
@@ -216,136 +216,3 @@ watch(
216
216
  }
217
217
  );
218
218
  </script>
219
-
220
- <style scoped>
221
- /* ------------------------------------------------------------------ */
222
- /* The card. */
223
- /* ------------------------------------------------------------------ */
224
-
225
- .table-card {
226
- font-family: var(--font-sans);
227
- background: var(--card);
228
- border: 1px solid var(--border);
229
- border-radius: var(--r-card);
230
- box-shadow: var(--shadow);
231
- overflow: hidden;
232
- }
233
-
234
- /**
235
- * The toolbar was `color="grey-lighten-4"` - a literal Vuetify grey, which is
236
- * a near-white bar sitting on a #1c1e24 card in dark mode. It is the card's
237
- * own surface now, with the design's rule under it.
238
- */
239
- .table-card__toolbar {
240
- border-bottom: 1px solid var(--border);
241
- }
242
-
243
- .table-card__range {
244
- font-size: 12px;
245
- font-weight: 600;
246
- color: var(--muted);
247
- font-variant-numeric: tabular-nums;
248
- }
249
-
250
- /* ------------------------------------------------------------------ */
251
- /* The header row and the rows under it. */
252
- /* ------------------------------------------------------------------ */
253
-
254
- .table-card :deep(thead th) {
255
- background: var(--thead) !important;
256
- color: var(--muted) !important;
257
- font-size: var(--fs-table-head) !important;
258
- font-weight: var(--fw-table-head) !important;
259
- letter-spacing: var(--ls-table-head);
260
- text-transform: uppercase;
261
- padding: 10px var(--cellpad-x) !important;
262
- height: auto !important;
263
- border-bottom: 1px solid var(--border) !important;
264
- white-space: nowrap;
265
- }
266
-
267
- .table-card :deep(tbody td) {
268
- font-size: var(--fs-cell);
269
- font-weight: var(--fw-cell);
270
- color: var(--text);
271
- padding: var(--cellpad) var(--cellpad-x) !important;
272
- height: auto !important;
273
- font-variant-numeric: tabular-nums;
274
- }
275
-
276
- .table-card :deep(tbody tr:hover > td) {
277
- background: var(--hover);
278
- }
279
-
280
- /* ------------------------------------------------------------------ */
281
- /* Empty state, in-card search, buttons and in-card tabs. */
282
- /* ------------------------------------------------------------------ */
283
-
284
- .table-card__empty {
285
- display: flex;
286
- flex-direction: column;
287
- align-items: center;
288
- justify-content: center;
289
- gap: 8px;
290
- padding: 52px 16px;
291
- font-size: var(--fs-cell);
292
- color: var(--muted);
293
- }
294
-
295
- .table-card__empty :deep(.v-icon) {
296
- color: var(--muted);
297
- }
298
-
299
- /* The in-card variant: 34px, 10px radius, as drawn. */
300
- .table-card__search :deep(.v-field) {
301
- border-radius: var(--r-inner);
302
- font-size: var(--fs-cell);
303
- }
304
-
305
- .table-card__primary {
306
- height: var(--btn-h);
307
- border-radius: var(--r-inner);
308
- padding: 0 16px;
309
- font-size: var(--fs-btn);
310
- font-weight: var(--fw-btn-strong);
311
- letter-spacing: 0;
312
- background: var(--accent-strong);
313
- color: var(--on-accent-strong);
314
- }
315
-
316
- .table-card__ghost {
317
- height: var(--btn-h);
318
- border-radius: var(--r-inner);
319
- padding: 0 16px;
320
- font-size: var(--fs-btn);
321
- font-weight: var(--fw-btn);
322
- letter-spacing: 0;
323
- color: var(--text2);
324
- border-color: var(--border);
325
- background: var(--card);
326
- }
327
-
328
- /**
329
- * Tabs that a screen puts in this card's extension slot: 13px, the active one
330
- * at 800 in `--accent-text` over a 2px accent underline. Tabs elsewhere in the
331
- * product are their own screens' and are restyled with them.
332
- */
333
- .table-card :deep(.v-tab) {
334
- font-size: var(--fs-nav);
335
- font-weight: var(--fw-cell);
336
- letter-spacing: 0;
337
- text-transform: none;
338
- color: var(--muted);
339
- min-width: 0;
340
- }
341
-
342
- .table-card :deep(.v-tab.v-tab--selected) {
343
- font-weight: var(--fw-table-head);
344
- color: var(--accent-text);
345
- }
346
-
347
- .table-card :deep(.v-tab .v-tab__slider) {
348
- height: 2px;
349
- background: var(--accent);
350
- }
351
- </style>
@@ -515,13 +515,49 @@
515
515
  </div>
516
516
 
517
517
  <div class="hid-qr-image-shell my-5">
518
- <v-img :src="hidQrPreview.imageUrl" width="280" height="280" alt="Visitor access QR code" />
518
+ <v-img v-if="hidQrVisible" :src="hidQrPreview.imageUrl" width="280" height="280" alt="Visitor access QR code" />
519
+ <button v-else type="button" class="hid-qr-reveal" aria-label="Show QR code" @click="hidQrVisible = true">
520
+ <v-icon icon="mdi-eye-outline" size="42" />
521
+ <span>Click to show QR code</span>
522
+ </button>
519
523
  </div>
520
524
 
525
+ <v-btn
526
+ variant="text"
527
+ density="comfortable"
528
+ class="text-none"
529
+ :prepend-icon="hidQrVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
530
+ @click="hidQrVisible = !hidQrVisible"
531
+ >
532
+ {{ hidQrVisible ? "Hide QR code" : "Show QR code" }}
533
+ </v-btn>
534
+
521
535
  <div class="text-h6 font-weight-bold">{{ visitor.name }}</div>
522
536
  <div v-if="hidQrPreview.expiresAt" class="text-caption text-medium-emphasis mt-1">
523
537
  Valid until {{ formatHidQrExpiry(hidQrPreview.expiresAt) }}
524
538
  </div>
539
+ <v-chip
540
+ v-if="hidQrPreview.expiresAt"
541
+ class="mt-2"
542
+ size="small"
543
+ :color="hidQrExpired ? 'error' : 'success'"
544
+ variant="tonal"
545
+ >
546
+ {{ hidQrExpired ? "Expired" : `Expires in ${formatHidQrCountdown(hidQrRemainingSeconds)}` }}
547
+ </v-chip>
548
+
549
+ <v-btn
550
+ v-if="hidQrExpired"
551
+ block
552
+ class="mt-4 text-none"
553
+ color="primary-button"
554
+ variant="flat"
555
+ prepend-icon="mdi-refresh"
556
+ :loading="hidQrRegenerating"
557
+ @click="regenerateHidQrCode"
558
+ >
559
+ Generate QR again
560
+ </v-btn>
525
561
 
526
562
  <v-alert v-if="hidQrPreviewError" type="error" variant="tonal" density="compact" class="mt-4 text-left">
527
563
  {{ hidQrPreviewError }}
@@ -684,6 +720,11 @@ const isNricFieldFocused = ref(false);
684
720
  const hidQrPrinting = ref(false);
685
721
  const hidQrPreviewDialog = ref(false);
686
722
  const hidQrPreviewError = ref("");
723
+ const hidQrVisible = ref(false);
724
+ const hidQrRegenerating = ref(false);
725
+ const hidQrVisitorId = ref("");
726
+ const hidQrNow = ref(Date.now());
727
+ let hidQrTimer: ReturnType<typeof setInterval> | null = null;
687
728
  const hidQrPendingCompletion = ref<"done" | "done:more" | null>(null);
688
729
  const hidQrPreview = reactive({
689
730
  imageUrl: "",
@@ -691,6 +732,24 @@ const hidQrPreview = reactive({
691
732
  expiresAt: "",
692
733
  });
693
734
 
735
+ const hidQrRemainingSeconds = computed(() => {
736
+ const expiry = new Date(hidQrPreview.expiresAt).getTime();
737
+ return Number.isFinite(expiry) ? Math.max(0, Math.ceil((expiry - hidQrNow.value) / 1000)) : 0;
738
+ });
739
+ const hidQrExpired = computed(() => Boolean(hidQrPreview.expiresAt) && hidQrRemainingSeconds.value === 0);
740
+
741
+ watch(hidQrPreviewDialog, (open) => {
742
+ if (hidQrTimer) clearInterval(hidQrTimer);
743
+ hidQrTimer = null;
744
+ if (!open) return;
745
+ hidQrNow.value = Date.now();
746
+ hidQrTimer = setInterval(() => { hidQrNow.value = Date.now(); }, 1000);
747
+ });
748
+
749
+ onBeforeUnmount(() => {
750
+ if (hidQrTimer) clearInterval(hidQrTimer);
751
+ });
752
+
694
753
  const registeredUnitCompanyName = ref("N/A");
695
754
 
696
755
  const dialog = reactive({
@@ -818,10 +877,6 @@ const hidQrValidityMinutes = computed(() => {
818
877
  });
819
878
 
820
879
  const hidQrReaderId = computed(() => String(hidQrCodePassConfig.value?.readerId || ""));
821
- const hidQrFormat = computed<"0" | "1" | "2">(() => {
822
- const value = String(hidQrCodePassConfig.value?.qrFormat || "0");
823
- return value === "1" || value === "2" ? value : "0";
824
- });
825
880
 
826
881
  const hidQrCodePassAllowed = computed(() => {
827
882
  return (
@@ -2088,10 +2143,10 @@ async function prepareHidQrCodePreview(visitorId: string) {
2088
2143
  }
2089
2144
 
2090
2145
  try {
2146
+ hidQrVisitorId.value = visitorId;
2091
2147
  const response = await issueVisitorQr(hidQrReaderId.value, {
2092
2148
  visitorId,
2093
2149
  validityMinutes: hidQrValidityMinutes.value,
2094
- qrFormat: hidQrFormat.value,
2095
2150
  });
2096
2151
  const credential = "data" in response ? response.data : response;
2097
2152
  hidQrPrintPayload.value = credential.qrValue;
@@ -2107,14 +2162,37 @@ async function prepareHidQrCodePreview(visitorId: string) {
2107
2162
  });
2108
2163
  hidQrPreview.issuedAt = credential.issuedAt || "";
2109
2164
  hidQrPreview.expiresAt = credential.expiresAt || "";
2165
+ hidQrVisible.value = false;
2166
+ hidQrNow.value = Date.now();
2110
2167
  hidQrPreviewDialog.value = true;
2111
2168
  return true;
2112
2169
  } catch (error: unknown) {
2113
- errorMessage.value = errorConverter(error) || "Unable to generate HID QR Code.";
2170
+ const message = errorConverter(error) || "Unable to generate HID QR Code.";
2171
+ if (hidQrPreviewDialog.value) hidQrPreviewError.value = message;
2172
+ else errorMessage.value = message;
2114
2173
  return false;
2115
2174
  }
2116
2175
  }
2117
2176
 
2177
+ function formatHidQrCountdown(totalSeconds: number) {
2178
+ const safe = Math.max(0, totalSeconds);
2179
+ const hours = Math.floor(safe / 3600);
2180
+ const minutes = Math.floor((safe % 3600) / 60);
2181
+ const seconds = safe % 60;
2182
+ return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
2183
+ }
2184
+
2185
+ async function regenerateHidQrCode() {
2186
+ if (!hidQrVisitorId.value) return;
2187
+ hidQrRegenerating.value = true;
2188
+ hidQrPreviewError.value = "";
2189
+ try {
2190
+ await prepareHidQrCodePreview(hidQrVisitorId.value);
2191
+ } finally {
2192
+ hidQrRegenerating.value = false;
2193
+ }
2194
+ }
2195
+
2118
2196
  async function printHidQrCodePass() {
2119
2197
  if (hidQrPrinted.value) return true;
2120
2198
 
@@ -2335,4 +2413,19 @@ onMounted(() => {
2335
2413
  border: 1px solid #dce3e8;
2336
2414
  background: #ffffff;
2337
2415
  }
2416
+
2417
+ .hid-qr-reveal {
2418
+ display: flex;
2419
+ width: 280px;
2420
+ height: 280px;
2421
+ align-items: center;
2422
+ justify-content: center;
2423
+ border: 0;
2424
+ background: #f5f7f9;
2425
+ color: #344054;
2426
+ cursor: pointer;
2427
+ flex-direction: column;
2428
+ gap: 10px;
2429
+ font: inherit;
2430
+ }
2338
2431
  </style>
@@ -303,6 +303,18 @@
303
303
  >
304
304
  </div>
305
305
 
306
+ <v-btn
307
+ v-if="hidQrEnabled"
308
+ icon="mdi-eye-outline"
309
+ size="x-small"
310
+ variant="text"
311
+ color="primary"
312
+ title="View HID QR code"
313
+ aria-label="View HID QR code"
314
+ class="align-self-start mt-1"
315
+ @click.stop="openVisitorHidQr(item)"
316
+ />
317
+
306
318
  <v-menu :close-on-content-click="true">
307
319
  <template v-slot:activator="{ props: menuProps }">
308
320
  <div
@@ -1177,12 +1189,52 @@
1177
1189
  </v-card>
1178
1190
  </v-dialog>
1179
1191
 
1192
+ <v-dialog v-model="hidQrDialog" max-width="440" persistent>
1193
+ <v-card>
1194
+ <v-toolbar color="transparent" density="comfortable" class="px-2">
1195
+ <v-toolbar-title class="text-subtitle-1 font-weight-bold">Visitor QR Code</v-toolbar-title>
1196
+ <v-btn icon="mdi-close" variant="text" title="Close" @click="hidQrDialog = false" />
1197
+ </v-toolbar>
1198
+ <v-card-text class="px-8 pb-6 text-center">
1199
+ <v-progress-circular v-if="hidQrLoading" indeterminate color="primary" class="my-10" />
1200
+ <template v-else-if="hidQrImageUrl">
1201
+ <div class="text-h6 font-weight-bold">{{ hidQrVisitor?.name || "Visitor Access Pass" }}</div>
1202
+ <div class="visitor-hid-qr my-5">
1203
+ <v-img :src="hidQrImageUrl" width="280" height="280" alt="Visitor HID QR code" />
1204
+ </div>
1205
+ <div class="text-caption text-medium-emphasis">
1206
+ Valid until {{ formatHidQrExpiry(hidQrExpiresAt) }}
1207
+ </div>
1208
+ <v-chip class="mt-2" size="small" :color="hidQrExpired ? 'error' : 'success'" variant="tonal">
1209
+ {{ hidQrExpired ? "Expired" : `Expires in ${formatHidQrCountdown(hidQrRemainingSeconds)}` }}
1210
+ </v-chip>
1211
+ </template>
1212
+ <v-alert v-if="hidQrError" type="warning" variant="tonal" density="compact" class="mt-4 text-left">
1213
+ {{ hidQrError }}
1214
+ </v-alert>
1215
+ <v-btn
1216
+ v-if="!hidQrLoading && (!hidQrImageUrl || hidQrExpired)"
1217
+ block
1218
+ color="primary-button"
1219
+ variant="flat"
1220
+ class="mt-4 text-none"
1221
+ prepend-icon="mdi-refresh"
1222
+ :loading="hidQrGenerating"
1223
+ @click="generateVisitorHidQr"
1224
+ >
1225
+ {{ hidQrExpired ? "Generate QR again" : "Generate HID QR code" }}
1226
+ </v-btn>
1227
+ </v-card-text>
1228
+ </v-card>
1229
+ </v-dialog>
1230
+
1180
1231
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
1181
1232
  </v-row>
1182
1233
  </template>
1183
1234
 
1184
1235
  <script setup lang="ts">
1185
1236
  import moment from "moment-timezone";
1237
+ import QRCode from "qrcode";
1186
1238
 
1187
1239
  definePageMeta({
1188
1240
  middleware: ["01-auth", "02-org"],
@@ -1244,6 +1296,8 @@ const {
1244
1296
  } = useUtils();
1245
1297
  const { formatLocation } = useSecurityUtils();
1246
1298
  const { getFileUrlAnpr } = useFile();
1299
+ const { getSiteById } = useSite();
1300
+ const { getIdentities, issueVisitorQr } = useHidAmico();
1247
1301
  // const { status: visitorStatus, search } = useRoute().query as { status: string, search: string};
1248
1302
 
1249
1303
  const routeName = route.name;
@@ -1255,6 +1309,106 @@ const pageRange = ref("-- - -- of --");
1255
1309
  const activeTab = ref("registered");
1256
1310
  const activeVisitorFormType = ref<TVisitorType | null>(null);
1257
1311
  const selectedVisitorId = ref<string | null>(""); // selected visitor for viewing/actions
1312
+ const hidQrDialog = ref(false);
1313
+ const hidQrLoading = ref(false);
1314
+ const hidQrGenerating = ref(false);
1315
+ const hidQrError = ref("");
1316
+ const hidQrImageUrl = ref("");
1317
+ const hidQrExpiresAt = ref("");
1318
+ const hidQrNow = ref(Date.now());
1319
+ const hidQrVisitor = ref<Record<string, any> | null>(null);
1320
+ const hidQrConfig = ref({ enabled: false, readerId: "", validityMinutes: 60 });
1321
+ let hidQrTimer: ReturnType<typeof setInterval> | null = null;
1322
+ const hidQrEnabled = computed(() => hidQrConfig.value.enabled && Boolean(hidQrConfig.value.readerId));
1323
+ const hidQrRemainingSeconds = computed(() => {
1324
+ const expiry = new Date(hidQrExpiresAt.value).getTime();
1325
+ return Number.isFinite(expiry) ? Math.max(0, Math.ceil((expiry - hidQrNow.value) / 1000)) : 0;
1326
+ });
1327
+ const hidQrExpired = computed(() => Boolean(hidQrExpiresAt.value) && hidQrRemainingSeconds.value === 0);
1328
+
1329
+ async function loadHidQrConfig() {
1330
+ try {
1331
+ const response: any = await getSiteById(siteId);
1332
+ const site = response?.data ?? response;
1333
+ const config = site?.metadata?.hidQrCodePass ?? {};
1334
+ hidQrConfig.value = {
1335
+ enabled: Boolean(config.enabled && config.onlineMode !== false),
1336
+ readerId: String(config.readerId || ""),
1337
+ validityMinutes: Number(config.validityMinutes) > 0 ? Number(config.validityMinutes) : 60,
1338
+ };
1339
+ } catch {
1340
+ hidQrConfig.value = { enabled: false, readerId: "", validityMinutes: 60 };
1341
+ }
1342
+ }
1343
+
1344
+ function extractIdentityItems(response: any) {
1345
+ return response?.items ?? response?.data?.items ?? [];
1346
+ }
1347
+
1348
+ async function openVisitorHidQr(visitor: Record<string, any>) {
1349
+ hidQrVisitor.value = visitor;
1350
+ hidQrDialog.value = true;
1351
+ hidQrLoading.value = true;
1352
+ hidQrError.value = "";
1353
+ hidQrImageUrl.value = "";
1354
+ hidQrExpiresAt.value = "";
1355
+ try {
1356
+ const response = await getIdentities(hidQrConfig.value.readerId, { type: "visitor", limit: 100 });
1357
+ const identity = extractIdentityItems(response).find(
1358
+ (item: any) => String(item.visitor || item.metadata?.visitor || "") === String(visitor._id),
1359
+ );
1360
+ const qrValue = String(identity?.metadata?.qrValue || identity?.cardNo || "");
1361
+ hidQrExpiresAt.value = String(identity?.metadata?.expiresAt || "");
1362
+ if (!qrValue || !hidQrExpiresAt.value) {
1363
+ hidQrError.value = "This visitor does not have an HID QR code yet.";
1364
+ return;
1365
+ }
1366
+ hidQrImageUrl.value = await QRCode.toDataURL(qrValue, { width: 360, margin: 2, errorCorrectionLevel: "M" });
1367
+ hidQrNow.value = Date.now();
1368
+ } catch (error: unknown) {
1369
+ hidQrError.value = errorConverter(error) || "Unable to load the visitor HID QR code.";
1370
+ } finally {
1371
+ hidQrLoading.value = false;
1372
+ }
1373
+ }
1374
+
1375
+ async function generateVisitorHidQr() {
1376
+ if (!hidQrVisitor.value?._id || !hidQrConfig.value.readerId) return;
1377
+ hidQrGenerating.value = true;
1378
+ hidQrError.value = "";
1379
+ try {
1380
+ const response = await issueVisitorQr(hidQrConfig.value.readerId, {
1381
+ visitorId: String(hidQrVisitor.value._id),
1382
+ validityMinutes: hidQrConfig.value.validityMinutes,
1383
+ });
1384
+ const credential = "data" in response ? response.data : response;
1385
+ hidQrImageUrl.value = await QRCode.toDataURL(credential.qrValue, { width: 360, margin: 2, errorCorrectionLevel: "M" });
1386
+ hidQrExpiresAt.value = credential.expiresAt;
1387
+ hidQrNow.value = Date.now();
1388
+ } catch (error: unknown) {
1389
+ hidQrError.value = errorConverter(error) || "Unable to generate the visitor HID QR code.";
1390
+ } finally {
1391
+ hidQrGenerating.value = false;
1392
+ }
1393
+ }
1394
+
1395
+ function formatHidQrExpiry(value: string) {
1396
+ const date = new Date(value);
1397
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
1398
+ }
1399
+
1400
+ function formatHidQrCountdown(totalSeconds: number) {
1401
+ const safe = Math.max(0, totalSeconds);
1402
+ const hours = Math.floor(safe / 3600);
1403
+ const minutes = Math.floor((safe % 3600) / 60);
1404
+ const seconds = safe % 60;
1405
+ return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
1406
+ }
1407
+
1408
+ watch(hidQrDialog, (open) => {
1409
+ if (hidQrTimer) clearInterval(hidQrTimer);
1410
+ hidQrTimer = open ? setInterval(() => { hidQrNow.value = Date.now(); }, 1000) : null;
1411
+ });
1258
1412
 
1259
1413
  //filter states
1260
1414
  const searchInput = ref("");
@@ -2346,7 +2500,14 @@ const syncVisitorFiltersFromRoute = () => {
2346
2500
  }
2347
2501
  };
2348
2502
 
2349
- onMounted(syncVisitorFiltersFromRoute);
2503
+ onMounted(() => {
2504
+ syncVisitorFiltersFromRoute();
2505
+ void loadHidQrConfig();
2506
+ });
2507
+
2508
+ onBeforeUnmount(() => {
2509
+ if (hidQrTimer) clearInterval(hidQrTimer);
2510
+ });
2350
2511
 
2351
2512
  watch(() => route.query, syncVisitorFiltersFromRoute, { deep: true });
2352
2513
 
@@ -2397,4 +2558,11 @@ watchEffect(async () => {
2397
2558
  });
2398
2559
  </script>
2399
2560
 
2400
- <style scoped></style>
2561
+ <style scoped>
2562
+ .visitor-hid-qr {
2563
+ display: inline-flex;
2564
+ padding: 12px;
2565
+ border: 1px solid #dce3e8;
2566
+ background: #fff;
2567
+ }
2568
+ </style>