@7365admin1/layer-common 3.2.1-staging.75 → 3.2.2-staging.100
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/.changeset/notification-preferences.md +17 -0
- package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
- package/CHANGELOG.md +6 -0
- package/assets/css/primitives.css +680 -0
- package/assets/css/screens.css +541 -0
- package/assets/css/shell.css +258 -0
- package/assets/css/tokens.css +220 -0
- package/components/AccessCardQrTagging.vue +1 -1
- package/components/AccessManagement.vue +53 -72
- package/components/AppButton.vue +52 -0
- package/components/AppCard.vue +20 -0
- package/components/AppDateField.vue +35 -0
- package/components/AppField.vue +46 -0
- package/components/AppKpiTile.vue +65 -0
- package/components/AppSegmented.vue +38 -0
- package/components/AppSelect.vue +203 -0
- package/components/AreaChecklistHistoryMain.vue +6 -2
- package/components/AreaMain.vue +9 -17
- package/components/AttendanceMain.vue +4 -3
- package/components/Avatar/Main.vue +54 -2
- package/components/BillingMain.vue +26 -30
- package/components/BuildingManagement/buildings.vue +27 -38
- package/components/BuildingManagement/units.vue +26 -42
- package/components/BulletinBoardManagement.vue +23 -10
- package/components/CardToolbar.vue +37 -0
- package/components/CleaningScheduleMain.vue +29 -46
- package/components/ClientMain.vue +90 -78
- package/components/DashboardEmptyState.vue +11 -1
- package/components/DashboardMain.vue +285 -207
- package/components/DocumentManagement.vue +129 -93
- package/components/EntryPassMain.vue +49 -27
- package/components/EquipmentItemMain.vue +4 -6
- package/components/EquipmentManagementMain.vue +9 -7
- package/components/FeedbackMain.vue +35 -49
- package/components/FormDialog.vue +60 -21
- package/components/HidIntercomManagement.vue +40 -24
- package/components/HidQrCodeConfiguration.vue +24 -2
- package/components/HidUserEnrollment.vue +68 -31
- package/components/InvitationMain.vue +46 -61
- package/components/Layout/Header.vue +289 -37
- package/components/Layout/NavigationDrawer.vue +299 -30
- package/components/ManageChecklistMain.vue +17 -17
- package/components/MemberMain.vue +79 -60
- package/components/MyAttendanceMain.vue +3 -5
- package/components/Nfc/NFCPatrolRouteMain.vue +64 -96
- package/components/NotificationSettings.vue +361 -0
- package/components/OnlineFormsConfiguration.vue +1 -1
- package/components/PageHeader.vue +43 -0
- package/components/PassInformation.vue +104 -45
- package/components/RolePermissionMain.vue +184 -91
- package/components/ScanVisitorQRCode.vue +35 -7
- package/components/ScheduleAreaMain.vue +15 -49
- package/components/ScheduleTaskMain.vue +23 -26
- package/components/ScheduleTastTicketMain.vue +18 -43
- package/components/ServiceProviderMain.vue +113 -168
- package/components/SiteSettings.vue +21 -8
- package/components/SpecificAttr.vue +10 -2
- package/components/StatusChip.vue +93 -0
- package/components/SwitchContext.vue +33 -1
- package/components/TableHygiene.vue +29 -46
- package/components/TableList.vue +1 -1
- package/components/TableListSecondary.vue +1 -1
- package/components/TableMain.vue +161 -77
- package/components/TableV3.vue +61 -66
- package/components/UnitMain.vue +9 -17
- package/components/VehicleManagement.vue +20 -9
- package/components/VisitorForm.vue +100 -7
- package/components/VisitorManagement.vue +219 -68
- package/components/WorkOrder/Main.vue +42 -51
- package/composables/useHidAmico.ts +26 -2
- package/composables/useNotificationPreference.ts +87 -0
- package/composables/useOrg.ts +1 -1
- package/composables/useRole.ts +22 -0
- package/composables/useSipWebPhone.ts +28 -3
- package/composables/useThemePreference.ts +63 -0
- package/composables/useVisitor.ts +6 -1
- package/nuxt.config.ts +31 -0
- package/package.json +3 -2
- package/pages/notification-settings.vue +19 -0
- package/plugins/vuetify.ts +32 -1
- package/types/org.d.ts +2 -0
- package/utils/nav-sections.test.ts +190 -0
- package/utils/nav-sections.ts +151 -0
- package/utils/status.test.ts +77 -0
- package/utils/status.ts +90 -0
- package/utils/theme.test.ts +325 -7
- package/utils/theme.ts +268 -137
|
@@ -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>
|
|
@@ -1,50 +1,28 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-row no-gutters>
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@row-click="handleRowClick"
|
|
16
|
-
>
|
|
17
|
-
<template #actions>
|
|
18
|
-
<v-row no-gutters>
|
|
19
|
-
<v-btn
|
|
20
|
-
v-if="canAddVisitor"
|
|
21
|
-
class="text-none"
|
|
22
|
-
rounded="pill"
|
|
23
|
-
variant="tonal"
|
|
24
|
-
size="large"
|
|
25
|
-
@click="handleAddNew"
|
|
26
|
-
text="Add Visitor"
|
|
27
|
-
/>
|
|
28
|
-
<v-btn
|
|
3
|
+
<!--
|
|
4
|
+
THE DESIGN'S PAGE TITLE ROW (Phase 9).
|
|
5
|
+
|
|
6
|
+
Drawn here rather than through `TableMain`'s `title` prop because the
|
|
7
|
+
design puts the screen's actions on the title line, and `TableMain`
|
|
8
|
+
renders its `actions` slot as a separate row under the heading. Same
|
|
9
|
+
three buttons, same handlers, same permission gates - moved, not changed.
|
|
10
|
+
-->
|
|
11
|
+
<v-col cols="12">
|
|
12
|
+
<PageHeader title="Visitor Mgmt">
|
|
13
|
+
<template #actions>
|
|
14
|
+
<AppButton
|
|
29
15
|
v-if="canScanVisitorQRCode"
|
|
30
|
-
|
|
31
|
-
class="text-none ml-2"
|
|
32
|
-
rounded="pill"
|
|
33
|
-
variant="tonal"
|
|
34
|
-
size="large"
|
|
16
|
+
variant="ghost"
|
|
35
17
|
@click="dialog.scanVisitorQRCode = true"
|
|
36
|
-
|
|
18
|
+
>
|
|
19
|
+
Scan QR Code
|
|
20
|
+
</AppButton>
|
|
37
21
|
<v-menu>
|
|
38
22
|
<template #activator="{ props }">
|
|
39
|
-
<v-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class="text-none ml-2"
|
|
43
|
-
prepend-icon="mdi-download"
|
|
44
|
-
rounded="pill"
|
|
45
|
-
variant="tonal"
|
|
46
|
-
size="large"
|
|
47
|
-
/>
|
|
23
|
+
<AppButton v-bind="props" variant="ghost" icon="mdi-download">
|
|
24
|
+
Download Report
|
|
25
|
+
</AppButton>
|
|
48
26
|
</template>
|
|
49
27
|
<v-list>
|
|
50
28
|
<v-list-item
|
|
@@ -59,8 +37,27 @@
|
|
|
59
37
|
/>
|
|
60
38
|
</v-list>
|
|
61
39
|
</v-menu>
|
|
62
|
-
|
|
63
|
-
|
|
40
|
+
<AppButton v-if="canAddVisitor" icon="mdi-plus" @click="handleAddNew">
|
|
41
|
+
Add Visitor
|
|
42
|
+
</AppButton>
|
|
43
|
+
</template>
|
|
44
|
+
</PageHeader>
|
|
45
|
+
</v-col>
|
|
46
|
+
|
|
47
|
+
<TableMain
|
|
48
|
+
:headers="headers"
|
|
49
|
+
:items="items"
|
|
50
|
+
:loading="getVisitorPending"
|
|
51
|
+
:page="page"
|
|
52
|
+
:pages="pages"
|
|
53
|
+
:extension-height="110"
|
|
54
|
+
:offset="300"
|
|
55
|
+
:pageRange="pageRange"
|
|
56
|
+
@refresh="getVisitorRefresh"
|
|
57
|
+
@update:page="handleUpdatePage"
|
|
58
|
+
show-header
|
|
59
|
+
@row-click="handleRowClick"
|
|
60
|
+
>
|
|
64
61
|
<template #extension>
|
|
65
62
|
<v-row no-gutters class="w-100 d-flex flex-column">
|
|
66
63
|
<v-tabs
|
|
@@ -109,32 +106,18 @@
|
|
|
109
106
|
hide-details
|
|
110
107
|
/>
|
|
111
108
|
<InputDatePicker v-model="dateTo" density="compact" hide-details />
|
|
112
|
-
<
|
|
109
|
+
<AppSelect
|
|
113
110
|
v-if="activeTab == 'registered'"
|
|
114
111
|
v-model="filterTypes"
|
|
115
112
|
label="Filter by types"
|
|
116
113
|
item-title="label"
|
|
117
114
|
item-value="value"
|
|
118
115
|
:items="visitorSelection"
|
|
119
|
-
|
|
116
|
+
class="visitor-type-filter"
|
|
117
|
+
compact
|
|
120
118
|
clearable
|
|
121
119
|
multiple
|
|
122
|
-
|
|
123
|
-
hide-details
|
|
124
|
-
>
|
|
125
|
-
<template v-slot:selection="{ item, index }">
|
|
126
|
-
<div class="d-flex align-center text-caption text-nowrap">
|
|
127
|
-
<v-chip
|
|
128
|
-
v-if="index === 0"
|
|
129
|
-
color="error"
|
|
130
|
-
:text="filterTypeSelectionLabel()"
|
|
131
|
-
size="x-small"
|
|
132
|
-
variant="tonal"
|
|
133
|
-
class="ml-2"
|
|
134
|
-
/>
|
|
135
|
-
</div>
|
|
136
|
-
</template>
|
|
137
|
-
</v-select>
|
|
120
|
+
/>
|
|
138
121
|
</v-card>
|
|
139
122
|
</v-row>
|
|
140
123
|
</template>
|
|
@@ -303,6 +286,18 @@
|
|
|
303
286
|
>
|
|
304
287
|
</div>
|
|
305
288
|
|
|
289
|
+
<v-btn
|
|
290
|
+
v-if="hidQrEnabled"
|
|
291
|
+
icon="mdi-eye-outline"
|
|
292
|
+
size="x-small"
|
|
293
|
+
variant="text"
|
|
294
|
+
color="primary"
|
|
295
|
+
title="View HID QR code"
|
|
296
|
+
aria-label="View HID QR code"
|
|
297
|
+
class="align-self-start mt-1"
|
|
298
|
+
@click.stop="openVisitorHidQr(item)"
|
|
299
|
+
/>
|
|
300
|
+
|
|
306
301
|
<v-menu :close-on-content-click="true">
|
|
307
302
|
<template v-slot:activator="{ props: menuProps }">
|
|
308
303
|
<div
|
|
@@ -1177,12 +1172,52 @@
|
|
|
1177
1172
|
</v-card>
|
|
1178
1173
|
</v-dialog>
|
|
1179
1174
|
|
|
1175
|
+
<v-dialog v-model="hidQrDialog" max-width="440" persistent>
|
|
1176
|
+
<v-card>
|
|
1177
|
+
<v-toolbar color="transparent" density="comfortable" class="px-2">
|
|
1178
|
+
<v-toolbar-title class="text-subtitle-1 font-weight-bold">Visitor QR Code</v-toolbar-title>
|
|
1179
|
+
<v-btn icon="mdi-close" variant="text" title="Close" @click="hidQrDialog = false" />
|
|
1180
|
+
</v-toolbar>
|
|
1181
|
+
<v-card-text class="px-8 pb-6 text-center">
|
|
1182
|
+
<v-progress-circular v-if="hidQrLoading" indeterminate color="primary" class="my-10" />
|
|
1183
|
+
<template v-else-if="hidQrImageUrl">
|
|
1184
|
+
<div class="text-h6 font-weight-bold">{{ hidQrVisitor?.name || "Visitor Access Pass" }}</div>
|
|
1185
|
+
<div class="visitor-hid-qr my-5">
|
|
1186
|
+
<v-img :src="hidQrImageUrl" width="280" height="280" alt="Visitor HID QR code" />
|
|
1187
|
+
</div>
|
|
1188
|
+
<div class="text-caption text-medium-emphasis">
|
|
1189
|
+
Valid until {{ formatHidQrExpiry(hidQrExpiresAt) }}
|
|
1190
|
+
</div>
|
|
1191
|
+
<v-chip class="mt-2" size="small" :color="hidQrExpired ? 'error' : 'success'" variant="tonal">
|
|
1192
|
+
{{ hidQrExpired ? "Expired" : `Expires in ${formatHidQrCountdown(hidQrRemainingSeconds)}` }}
|
|
1193
|
+
</v-chip>
|
|
1194
|
+
</template>
|
|
1195
|
+
<v-alert v-if="hidQrError" type="warning" variant="tonal" density="compact" class="mt-4 text-left">
|
|
1196
|
+
{{ hidQrError }}
|
|
1197
|
+
</v-alert>
|
|
1198
|
+
<v-btn
|
|
1199
|
+
v-if="!hidQrLoading && (!hidQrImageUrl || hidQrExpired)"
|
|
1200
|
+
block
|
|
1201
|
+
color="primary-button"
|
|
1202
|
+
variant="flat"
|
|
1203
|
+
class="mt-4 text-none"
|
|
1204
|
+
prepend-icon="mdi-refresh"
|
|
1205
|
+
:loading="hidQrGenerating"
|
|
1206
|
+
@click="generateVisitorHidQr"
|
|
1207
|
+
>
|
|
1208
|
+
{{ hidQrExpired ? "Generate QR again" : "Generate HID QR code" }}
|
|
1209
|
+
</v-btn>
|
|
1210
|
+
</v-card-text>
|
|
1211
|
+
</v-card>
|
|
1212
|
+
</v-dialog>
|
|
1213
|
+
|
|
1180
1214
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
1181
1215
|
</v-row>
|
|
1182
1216
|
</template>
|
|
1183
1217
|
|
|
1184
1218
|
<script setup lang="ts">
|
|
1185
1219
|
import moment from "moment-timezone";
|
|
1220
|
+
import QRCode from "qrcode";
|
|
1186
1221
|
|
|
1187
1222
|
definePageMeta({
|
|
1188
1223
|
middleware: ["01-auth", "02-org"],
|
|
@@ -1244,6 +1279,8 @@ const {
|
|
|
1244
1279
|
} = useUtils();
|
|
1245
1280
|
const { formatLocation } = useSecurityUtils();
|
|
1246
1281
|
const { getFileUrlAnpr } = useFile();
|
|
1282
|
+
const { getSiteById } = useSite();
|
|
1283
|
+
const { getIdentities, issueVisitorQr } = useHidAmico();
|
|
1247
1284
|
// const { status: visitorStatus, search } = useRoute().query as { status: string, search: string};
|
|
1248
1285
|
|
|
1249
1286
|
const routeName = route.name;
|
|
@@ -1255,6 +1292,106 @@ const pageRange = ref("-- - -- of --");
|
|
|
1255
1292
|
const activeTab = ref("registered");
|
|
1256
1293
|
const activeVisitorFormType = ref<TVisitorType | null>(null);
|
|
1257
1294
|
const selectedVisitorId = ref<string | null>(""); // selected visitor for viewing/actions
|
|
1295
|
+
const hidQrDialog = ref(false);
|
|
1296
|
+
const hidQrLoading = ref(false);
|
|
1297
|
+
const hidQrGenerating = ref(false);
|
|
1298
|
+
const hidQrError = ref("");
|
|
1299
|
+
const hidQrImageUrl = ref("");
|
|
1300
|
+
const hidQrExpiresAt = ref("");
|
|
1301
|
+
const hidQrNow = ref(Date.now());
|
|
1302
|
+
const hidQrVisitor = ref<Record<string, any> | null>(null);
|
|
1303
|
+
const hidQrConfig = ref({ enabled: false, readerId: "", validityMinutes: 60 });
|
|
1304
|
+
let hidQrTimer: ReturnType<typeof setInterval> | null = null;
|
|
1305
|
+
const hidQrEnabled = computed(() => hidQrConfig.value.enabled && Boolean(hidQrConfig.value.readerId));
|
|
1306
|
+
const hidQrRemainingSeconds = computed(() => {
|
|
1307
|
+
const expiry = new Date(hidQrExpiresAt.value).getTime();
|
|
1308
|
+
return Number.isFinite(expiry) ? Math.max(0, Math.ceil((expiry - hidQrNow.value) / 1000)) : 0;
|
|
1309
|
+
});
|
|
1310
|
+
const hidQrExpired = computed(() => Boolean(hidQrExpiresAt.value) && hidQrRemainingSeconds.value === 0);
|
|
1311
|
+
|
|
1312
|
+
async function loadHidQrConfig() {
|
|
1313
|
+
try {
|
|
1314
|
+
const response: any = await getSiteById(siteId);
|
|
1315
|
+
const site = response?.data ?? response;
|
|
1316
|
+
const config = site?.metadata?.hidQrCodePass ?? {};
|
|
1317
|
+
hidQrConfig.value = {
|
|
1318
|
+
enabled: Boolean(config.enabled && config.onlineMode !== false),
|
|
1319
|
+
readerId: String(config.readerId || ""),
|
|
1320
|
+
validityMinutes: Number(config.validityMinutes) > 0 ? Number(config.validityMinutes) : 60,
|
|
1321
|
+
};
|
|
1322
|
+
} catch {
|
|
1323
|
+
hidQrConfig.value = { enabled: false, readerId: "", validityMinutes: 60 };
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function extractIdentityItems(response: any) {
|
|
1328
|
+
return response?.items ?? response?.data?.items ?? [];
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async function openVisitorHidQr(visitor: Record<string, any>) {
|
|
1332
|
+
hidQrVisitor.value = visitor;
|
|
1333
|
+
hidQrDialog.value = true;
|
|
1334
|
+
hidQrLoading.value = true;
|
|
1335
|
+
hidQrError.value = "";
|
|
1336
|
+
hidQrImageUrl.value = "";
|
|
1337
|
+
hidQrExpiresAt.value = "";
|
|
1338
|
+
try {
|
|
1339
|
+
const response = await getIdentities(hidQrConfig.value.readerId, { type: "visitor", limit: 100 });
|
|
1340
|
+
const identity = extractIdentityItems(response).find(
|
|
1341
|
+
(item: any) => String(item.visitor || item.metadata?.visitor || "") === String(visitor._id),
|
|
1342
|
+
);
|
|
1343
|
+
const qrValue = String(identity?.metadata?.qrValue || identity?.cardNo || "");
|
|
1344
|
+
hidQrExpiresAt.value = String(identity?.metadata?.expiresAt || "");
|
|
1345
|
+
if (!qrValue || !hidQrExpiresAt.value) {
|
|
1346
|
+
hidQrError.value = "This visitor does not have an HID QR code yet.";
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
hidQrImageUrl.value = await QRCode.toDataURL(qrValue, { width: 360, margin: 2, errorCorrectionLevel: "M" });
|
|
1350
|
+
hidQrNow.value = Date.now();
|
|
1351
|
+
} catch (error: unknown) {
|
|
1352
|
+
hidQrError.value = errorConverter(error) || "Unable to load the visitor HID QR code.";
|
|
1353
|
+
} finally {
|
|
1354
|
+
hidQrLoading.value = false;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
async function generateVisitorHidQr() {
|
|
1359
|
+
if (!hidQrVisitor.value?._id || !hidQrConfig.value.readerId) return;
|
|
1360
|
+
hidQrGenerating.value = true;
|
|
1361
|
+
hidQrError.value = "";
|
|
1362
|
+
try {
|
|
1363
|
+
const response = await issueVisitorQr(hidQrConfig.value.readerId, {
|
|
1364
|
+
visitorId: String(hidQrVisitor.value._id),
|
|
1365
|
+
validityMinutes: hidQrConfig.value.validityMinutes,
|
|
1366
|
+
});
|
|
1367
|
+
const credential = "data" in response ? response.data : response;
|
|
1368
|
+
hidQrImageUrl.value = await QRCode.toDataURL(credential.qrValue, { width: 360, margin: 2, errorCorrectionLevel: "M" });
|
|
1369
|
+
hidQrExpiresAt.value = credential.expiresAt;
|
|
1370
|
+
hidQrNow.value = Date.now();
|
|
1371
|
+
} catch (error: unknown) {
|
|
1372
|
+
hidQrError.value = errorConverter(error) || "Unable to generate the visitor HID QR code.";
|
|
1373
|
+
} finally {
|
|
1374
|
+
hidQrGenerating.value = false;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function formatHidQrExpiry(value: string) {
|
|
1379
|
+
const date = new Date(value);
|
|
1380
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function formatHidQrCountdown(totalSeconds: number) {
|
|
1384
|
+
const safe = Math.max(0, totalSeconds);
|
|
1385
|
+
const hours = Math.floor(safe / 3600);
|
|
1386
|
+
const minutes = Math.floor((safe % 3600) / 60);
|
|
1387
|
+
const seconds = safe % 60;
|
|
1388
|
+
return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
watch(hidQrDialog, (open) => {
|
|
1392
|
+
if (hidQrTimer) clearInterval(hidQrTimer);
|
|
1393
|
+
hidQrTimer = open ? setInterval(() => { hidQrNow.value = Date.now(); }, 1000) : null;
|
|
1394
|
+
});
|
|
1258
1395
|
|
|
1259
1396
|
//filter states
|
|
1260
1397
|
const searchInput = ref("");
|
|
@@ -1505,11 +1642,6 @@ const formattedFields = {
|
|
|
1505
1642
|
attachments: "Attachments",
|
|
1506
1643
|
} as const;
|
|
1507
1644
|
|
|
1508
|
-
function filterTypeSelectionLabel() {
|
|
1509
|
-
const length = filterTypes.value.length;
|
|
1510
|
-
return `${length} selected ${length === 1 ? "type" : "types"}` as string;
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
1645
|
const passStatusOptions = [
|
|
1514
1646
|
{ label: "Returned", value: "Returned" },
|
|
1515
1647
|
{ label: "Not Returned", value: "In Use" },
|
|
@@ -2346,7 +2478,14 @@ const syncVisitorFiltersFromRoute = () => {
|
|
|
2346
2478
|
}
|
|
2347
2479
|
};
|
|
2348
2480
|
|
|
2349
|
-
onMounted(
|
|
2481
|
+
onMounted(() => {
|
|
2482
|
+
syncVisitorFiltersFromRoute();
|
|
2483
|
+
void loadHidQrConfig();
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
onBeforeUnmount(() => {
|
|
2487
|
+
if (hidQrTimer) clearInterval(hidQrTimer);
|
|
2488
|
+
});
|
|
2350
2489
|
|
|
2351
2490
|
watch(() => route.query, syncVisitorFiltersFromRoute, { deep: true });
|
|
2352
2491
|
|
|
@@ -2397,4 +2536,16 @@ watchEffect(async () => {
|
|
|
2397
2536
|
});
|
|
2398
2537
|
</script>
|
|
2399
2538
|
|
|
2400
|
-
<style scoped
|
|
2539
|
+
<style scoped>
|
|
2540
|
+
/* The width the type filter carried as `max-width="200"` on the v-select. */
|
|
2541
|
+
.visitor-type-filter {
|
|
2542
|
+
max-width: 200px;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
.visitor-hid-qr {
|
|
2546
|
+
display: inline-flex;
|
|
2547
|
+
padding: 12px;
|
|
2548
|
+
border: 1px solid #dce3e8;
|
|
2549
|
+
background: #fff;
|
|
2550
|
+
}
|
|
2551
|
+
</style>
|
|
@@ -1,58 +1,47 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
>
|
|
2
|
+
<div>
|
|
3
|
+
<!--
|
|
4
|
+
The design's page scaffold. The screen had no heading, its Create button
|
|
5
|
+
sat top-left and the search floated top-right outside the card, with
|
|
6
|
+
refresh and the pager stacked on their own bar above the table. Same
|
|
7
|
+
button, same dialog, same permission, same debounced search, same pager.
|
|
8
|
+
-->
|
|
9
|
+
<PageHeader title="Work Orders">
|
|
10
|
+
<template v-if="canCreateWorkOrder" #actions>
|
|
11
|
+
<AppButton @click="(showCreateDialog = true), (isEditMode = false)">
|
|
13
12
|
Create Work Order
|
|
14
|
-
</
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
variant="outlined"
|
|
20
|
-
density="comfortable"
|
|
21
|
-
clearable
|
|
22
|
-
hide-details
|
|
23
|
-
class="ml-2"
|
|
24
|
-
style="max-width: 250px"
|
|
25
|
-
/>
|
|
26
|
-
</v-row>
|
|
27
|
-
</v-col>
|
|
13
|
+
</AppButton>
|
|
14
|
+
</template>
|
|
15
|
+
</PageHeader>
|
|
16
|
+
|
|
17
|
+
<v-row no-gutters>
|
|
28
18
|
<v-col cols="12">
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<v-btn fab icon density="comfortable" @click="updatePage(1)">
|
|
39
|
-
<v-icon>mdi-refresh</v-icon>
|
|
40
|
-
</v-btn>
|
|
19
|
+
<AppCard class="table-card">
|
|
20
|
+
<CardToolbar :count="pageRange">
|
|
21
|
+
<template #left>
|
|
22
|
+
<AppButton
|
|
23
|
+
variant="icon"
|
|
24
|
+
icon="mdi-refresh"
|
|
25
|
+
aria-label="Refresh"
|
|
26
|
+
@click="updatePage(1)"
|
|
27
|
+
/>
|
|
41
28
|
</template>
|
|
42
29
|
|
|
43
|
-
<template #
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
30
|
+
<template #right>
|
|
31
|
+
<AppField
|
|
32
|
+
v-model="searchText"
|
|
33
|
+
search
|
|
34
|
+
compact
|
|
35
|
+
placeholder="Search work order..."
|
|
36
|
+
klass="app-toolbar__search"
|
|
37
|
+
/>
|
|
38
|
+
<local-pagination
|
|
39
|
+
v-model="page"
|
|
40
|
+
:length="pages"
|
|
41
|
+
@update:value="updatePage"
|
|
42
|
+
/>
|
|
54
43
|
</template>
|
|
55
|
-
</
|
|
44
|
+
</CardToolbar>
|
|
56
45
|
|
|
57
46
|
<v-data-table
|
|
58
47
|
:headers="headers"
|
|
@@ -62,6 +51,7 @@
|
|
|
62
51
|
fixed-header
|
|
63
52
|
hide-default-footer
|
|
64
53
|
:hide-default-header="false"
|
|
54
|
+
:loading="loading"
|
|
65
55
|
@click:row="tableRowClickHandler"
|
|
66
56
|
style="max-height: calc(100vh - (200px))"
|
|
67
57
|
>
|
|
@@ -69,9 +59,10 @@
|
|
|
69
59
|
{{ formatDateDDMMYYYYLocal(item.createdAt) }}
|
|
70
60
|
</template>
|
|
71
61
|
</v-data-table>
|
|
72
|
-
</
|
|
62
|
+
</AppCard>
|
|
73
63
|
</v-col>
|
|
74
|
-
|
|
64
|
+
</v-row>
|
|
65
|
+
</div>
|
|
75
66
|
|
|
76
67
|
<WorkOrderCreate
|
|
77
68
|
v-model="showCreateDialog"
|