@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.
- package/assets/css/screens.css +471 -0
- package/components/AttendanceMain.vue +4 -3
- package/components/CleaningScheduleMain.vue +29 -46
- package/components/HidIntercomManagement.vue +40 -24
- package/components/HidQrCodeConfiguration.vue +24 -2
- package/components/HidUserEnrollment.vue +68 -31
- package/components/InvitationMain.vue +10 -8
- package/components/MemberMain.vue +9 -6
- package/components/MyAttendanceMain.vue +3 -5
- package/components/RolePermissionMain.vue +15 -10
- package/components/ScheduleTaskMain.vue +23 -26
- package/components/ServiceProviderMain.vue +17 -42
- package/components/StatusChip.vue +16 -2
- package/components/TableMain.vue +0 -133
- package/components/VisitorForm.vue +100 -7
- package/components/VisitorManagement.vue +170 -2
- package/composables/useHidAmico.ts +26 -2
- package/composables/useSipWebPhone.ts +28 -3
- package/package.json +1 -1
- package/plugins/vuetify.ts +1 -0
- package/utils/status.test.ts +36 -0
- package/utils/status.ts +36 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
63
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
157
|
-
|
|
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="
|
|
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
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
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(() =>
|
|
43
|
+
const toneClass = computed(() =>
|
|
44
|
+
props.tone ? `tone-${props.tone}` : statusToneClass(props.status)
|
|
45
|
+
);
|
|
32
46
|
</script>
|
|
33
47
|
|
|
34
48
|
<style scoped>
|
package/components/TableMain.vue
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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>
|