@7365admin1/layer-common 3.2.7 → 3.2.8-staging.201
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/primitives.css +1113 -0
- package/assets/css/screens.css +1097 -0
- package/assets/css/shell.css +258 -0
- package/assets/css/tokens.css +236 -0
- package/components/AcceptDialog.vue +17 -18
- package/components/AccessCard/AvailableStats.vue +24 -16
- package/components/AccessCardAddForm.vue +42 -42
- package/components/AccessCardAssignToUnitForm.vue +13 -21
- package/components/AccessCardDeleteDialog.vue +41 -39
- package/components/AccessCardDetailsDialog.vue +125 -79
- package/components/AccessCardPreviewDialog.vue +58 -84
- package/components/AccessCardQrTagging.vue +60 -63
- package/components/AccessCardReplaceForm.vue +27 -23
- package/components/AccessManagement.vue +86 -85
- package/components/AddEqupmentForm.vue +18 -22
- package/components/AddPassKeyToVisitor.vue +95 -73
- package/components/AppButton.vue +62 -0
- package/components/AppCard.vue +20 -0
- package/components/AppDateField.vue +49 -0
- package/components/AppField.vue +76 -0
- package/components/AppKpiTile.vue +65 -0
- package/components/AppSegmented.vue +38 -0
- package/components/AppSelect.vue +247 -0
- package/components/AreaChecklistHistoryLogs.vue +51 -44
- package/components/AreaChecklistHistoryMain.vue +14 -7
- package/components/AreaFormDialog.vue +71 -59
- package/components/AreaMain.vue +73 -107
- package/components/AttachmentsViewer.vue +84 -22
- package/components/AttendanceCheckInOutDialog.vue +85 -88
- package/components/AttendanceDetailsDialog.vue +57 -63
- package/components/AttendanceMain.vue +4 -3
- package/components/AttendanceMapSearchDialog.vue +42 -44
- package/components/AttendanceSettingsDialog.vue +79 -63
- package/components/Avatar/Main.vue +54 -2
- package/components/BillingMain.vue +26 -30
- package/components/BtnUploadFile.vue +53 -11
- package/components/BuildingForm.vue +34 -27
- package/components/BuildingManagement/buildings.vue +39 -39
- package/components/BuildingManagement/units.vue +26 -42
- package/components/BuildingUnitFormAdd.vue +20 -23
- package/components/BuildingUnitFormEdit.vue +8 -4
- package/components/BulletinBoardForm.vue +19 -24
- package/components/BulletinBoardManagement.vue +49 -57
- package/components/BulletinBoardView.vue +9 -6
- package/components/BulletinExpirationChip.vue +13 -4
- package/components/Button/Close.vue +2 -2
- package/components/CameraForm.vue +21 -22
- package/components/CameraMain.vue +50 -49
- package/components/Card/DeleteConfirmation.vue +24 -22
- package/components/Card/MemberInfoSummary.vue +28 -3
- package/components/Card/Toggle.vue +15 -3
- package/components/CardToolbar.vue +37 -0
- package/components/Carousel.vue +56 -36
- package/components/Chat/Bubbles.vue +43 -14
- package/components/Chat/Information.vue +49 -32
- package/components/Chat/ListCard.vue +38 -9
- package/components/Chat/Message.vue +41 -15
- package/components/Chat/Navigation.vue +27 -15
- package/components/Chat/SkeletonLoader.vue +9 -2
- package/components/ChecklistItemRow.vue +25 -1
- package/components/CleaningScheduleMain.vue +44 -49
- package/components/ClientDetailForm.vue +81 -56
- package/components/ClientMain.vue +92 -82
- package/components/ConfirmDialog.vue +14 -8
- package/components/Container/Standard.vue +9 -7
- package/components/CreateSubsciptionPlan.vue +148 -89
- package/components/DashboardEmptyState.vue +48 -3
- package/components/DashboardMain.vue +924 -676
- package/components/DashboardPanel.vue +84 -21
- package/components/DeliveryCompany.vue +34 -20
- package/components/Dialog/DeleteConfirmation.vue +20 -21
- package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
- package/components/Dialog/ReusablePrompt.vue +30 -29
- package/components/Dialog/UpdateMoreAction.vue +62 -59
- package/components/DocumentForm.vue +14 -21
- package/components/DocumentManagement.vue +162 -108
- package/components/DocumentViewer.vue +29 -12
- package/components/DrawImage.vue +29 -21
- package/components/EntryPass/QrTemplatePreview.vue +27 -16
- package/components/EntryPassInformation.vue +40 -10
- package/components/EntryPassMain.vue +49 -27
- package/components/EquipmentItemMain.vue +84 -97
- package/components/EquipmentManagementMain.vue +81 -103
- package/components/Facility/BookingSetup.vue +43 -52
- package/components/Feedback/Form.vue +26 -12
- package/components/FeedbackMain.vue +35 -49
- package/components/FileInput.vue +75 -58
- package/components/FileInputWithList.vue +59 -12
- package/components/FormDialog.vue +60 -21
- package/components/HidAccessLogDashboard.vue +512 -490
- package/components/HidCameraCaptureDialog.vue +216 -0
- package/components/HidEnabledGate.vue +67 -9
- package/components/HidIntercomManagement.vue +1322 -653
- package/components/HidQrCodeConfiguration.vue +1030 -258
- package/components/HidReaderForm.vue +142 -59
- package/components/HidReaderManagement.vue +211 -323
- package/components/HidServiceSettingsPanel.vue +44 -13
- package/components/HidUserEnrollment.vue +1185 -412
- package/components/HygieneUpdateMoreAction.vue +44 -44
- package/components/IncidentReport/Authorities.vue +30 -18
- package/components/IncidentReport/IncidentInformation.vue +10 -10
- package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
- package/components/IncidentReport/affectedEntities.vue +41 -20
- package/components/Input/Date.vue +3 -0
- package/components/Input/DatePicker.vue +1 -2
- package/components/Input/DateTimePicker.vue +1 -1
- package/components/Input/File.vue +55 -10
- package/components/Input/FileV2.vue +39 -6
- package/components/Input/InputPhoneNumberV2.vue +2 -1
- package/components/Input/ListGroupSelection.vue +56 -8
- package/components/Input/NRICNumber.vue +1 -0
- package/components/Input/NewDate.vue +1 -0
- package/components/Input/Number.vue +1 -0
- package/components/Input/Password.vue +1 -0
- package/components/Input/PhoneNumber.vue +21 -1
- package/components/Input/VehicleNumber.vue +1 -0
- package/components/InputLabel.vue +2 -2
- package/components/InvitationClientForm.vue +21 -19
- package/components/InvitationForm.vue +15 -21
- package/components/InvitationMain.vue +46 -61
- package/components/Layout/Header.vue +263 -36
- package/components/Layout/NavigationDrawer.vue +300 -30
- package/components/ListItem.vue +33 -3
- package/components/LocalPagination.vue +27 -3
- package/components/ManageChecklistMain.vue +136 -86
- package/components/MemberInformation.vue +32 -16
- package/components/MemberMain.vue +113 -93
- package/components/MyAttendanceMain.vue +3 -5
- package/components/NavigationItem.vue +83 -3
- package/components/Nfc/NFCPatrolReportMain.vue +47 -33
- package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
- package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
- package/components/Nfc/NFCPatrolSettings.vue +14 -4
- package/components/Nfc/NFCTagForm.vue +22 -8
- package/components/Nfc/NFCTagMain.vue +41 -43
- package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
- package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
- package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
- package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
- package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
- package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
- package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
- package/components/NotificationSettings.vue +373 -0
- package/components/NumberSettingField.vue +9 -2
- package/components/OnlineFormConfigurationForm.vue +725 -237
- package/components/OnlineFormFill.vue +213 -106
- package/components/OnlineFormRenderPreview.vue +25 -30
- package/components/OnlineFormsConfiguration.vue +113 -109
- package/components/OrgViewDialog.vue +34 -16
- package/components/OvernightParkingAvailability.vue +49 -26
- package/components/OvernightParkingManagement.vue +78 -18
- package/components/PageHeader.vue +43 -0
- package/components/PassInformation.vue +109 -53
- package/components/PasswordConfirmation.vue +44 -26
- package/components/PeopleForm.vue +41 -20
- package/components/PhotoUpload.vue +110 -48
- package/components/PlaceholderComponent.vue +35 -19
- package/components/PlanActionsMenu.vue +12 -2
- package/components/PlanStatusBadge.vue +7 -8
- package/components/PlateNumberDisplay.vue +9 -6
- package/components/QrTemplate/PrintDialog.vue +66 -43
- package/components/RolePermissionFormCreate.vue +104 -79
- package/components/RolePermissionFormPreviewUpdate.vue +111 -109
- package/components/RolePermissionMain.vue +183 -81
- package/components/ScanVisitorQRCode.vue +99 -35
- package/components/ScheduleAreaMain.vue +34 -59
- package/components/ScheduleTaskForm.vue +61 -42
- package/components/ScheduleTaskMain.vue +56 -69
- package/components/SearchVehicleNumberUser.vue +39 -37
- package/components/ServiceProviderFormCreate.vue +29 -20
- package/components/ServiceProviderInvitationPrompt.vue +155 -0
- package/components/ServiceProviderMain.vue +601 -432
- package/components/Signature.vue +40 -34
- package/components/SignaturePad.vue +5 -29
- package/components/SiteSettings.vue +41 -11
- package/components/SiteSettingsWorkOrder.vue +61 -30
- package/components/SlideCardGroup.vue +35 -11
- package/components/Snackbar.vue +36 -3
- package/components/SpecificAttr.vue +18 -8
- package/components/StatusChip.vue +156 -0
- package/components/StockCard.vue +17 -12
- package/components/SubscriptionPlanTable.vue +207 -140
- package/components/SwitchContext.vue +201 -9
- package/components/TableHygiene.vue +36 -46
- package/components/TableList.vue +10 -10
- package/components/TableListSecondary.vue +105 -54
- package/components/TableMain.vue +251 -78
- package/components/TableV3.vue +61 -70
- package/components/TiptapEditor.vue +33 -40
- package/components/TiptapEditorQr.vue +5 -2
- package/components/Tooltip/Info.vue +69 -24
- package/components/UnitMain.vue +40 -60
- package/components/UnitPersonCard.vue +42 -13
- package/components/VehicleAddSelection.vue +48 -32
- package/components/VehicleForm.vue +112 -129
- package/components/VehicleManagement.vue +26 -12
- package/components/VehicleUpdateMoreAction.vue +59 -46
- package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
- package/components/VisitorForm.vue +774 -210
- package/components/VisitorFormSelection.vue +40 -37
- package/components/VisitorManagement.vue +239 -82
- package/components/VisitorPassKeyQRScanner.vue +65 -18
- package/components/VisitorSearchResultSummary.vue +60 -16
- package/components/VisitorSocketPopUp.vue +22 -14
- package/components/VisitorsReportPreview.vue +44 -29
- package/components/WorkOrder/Create.vue +26 -17
- package/components/WorkOrder/ListView.vue +38 -7
- package/components/WorkOrder/Main.vue +62 -59
- package/composables/useFeedback.ts +3 -3
- package/composables/useHidAmico.ts +269 -0
- package/composables/useHidNavigation.ts +2 -9
- package/composables/useMember.ts +3 -3
- package/composables/useNFCPatrolDailyReport.ts +13 -0
- package/composables/useNFCPatrolMonthlyReport.ts +22 -56
- package/composables/useNFCPatrolReport.ts +13 -0
- package/composables/useNotificationPreference.ts +87 -0
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useRole.ts +22 -0
- package/composables/useServiceProvider.ts +3 -3
- package/composables/useServiceProviderInvitation.ts +145 -0
- package/composables/useSipWebPhone.ts +336 -0
- package/composables/useSubscriptionPlan.ts +62 -0
- package/composables/useThemePreference.ts +63 -0
- package/composables/useVisitor.ts +6 -1
- package/composables/useWebUsb.ts +127 -37
- package/composables/useWorkOrder.ts +17 -3
- package/error.vue +30 -6
- package/nuxt.config.ts +36 -0
- package/package.json +25 -4
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/pages/notification-settings.vue +19 -0
- package/pages/payment-method-linked.vue +34 -10
- package/pages/require-customer.vue +48 -10
- package/pages/require-organization-membership.vue +32 -8
- package/pages/unauthorized.vue +29 -4
- package/plugins/secure-member.client.ts +21 -56
- package/plugins/vuetify.ts +69 -2
- package/types/nfc-patrol-report.ts +3 -2
- package/types/site.d.ts +71 -0
- package/types/subscription.ts +18 -11
- package/utils/breadcrumb.test.ts +108 -0
- package/utils/breadcrumb.ts +82 -0
- package/utils/dashboard.test.ts +118 -0
- package/utils/dashboard.ts +53 -0
- package/utils/data.test.ts +95 -0
- package/utils/data.ts +40 -2
- package/utils/hid-discover.test.ts +102 -0
- package/utils/list-page.test.ts +60 -0
- package/utils/list-page.ts +38 -0
- package/utils/materialSymbols.ts +363 -0
- package/utils/nav-sections.test.ts +190 -0
- package/utils/nav-sections.ts +151 -0
- package/utils/nfc-patrol-report.test.ts +130 -0
- package/utils/nfc-patrol-report.ts +101 -0
- package/utils/role.test.ts +69 -0
- package/utils/role.ts +10 -0
- package/utils/route-name.test.ts +85 -0
- package/utils/route-name.ts +24 -0
- package/utils/status.test.ts +110 -0
- package/utils/status.ts +135 -0
- package/utils/table-card.test.ts +84 -0
- package/utils/theme-aa-ledger.ts +148 -0
- package/utils/theme.test.ts +485 -7
- package/utils/theme.ts +295 -110
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.editorconfig +0 -12
- package/.github/workflows/main.yml +0 -17
- package/.github/workflows/publish-staging.yml +0 -47
- package/.github/workflows/publish.yml +0 -39
- package/PUBLISHING.md +0 -269
- package/components/HidIdentityMapping.vue +0 -975
- package/components/ScheduleTaskAreaFormDialog.vue +0 -144
- package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
- package/components/ScheduleTastTicketMain.vue +0 -182
- package/components/TableWithButton.vue +0 -94
- package/test/visitor-socket.test.mjs +0 -36
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<v-card width="100%" :loading="processing">
|
|
3
|
-
<
|
|
4
|
-
<v-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<span
|
|
14
|
-
|
|
15
|
-
}}</span>
|
|
16
|
-
<span v-if="withStep2 || withStep3" class="text-subtitle-2 font-weight-bold" style="text-wrap: nowrap">Step
|
|
17
|
-
<span class="text-primary-button">{{ contractorStep }}</span>/{{ withStep3 ? 3 : withStep2 ? 2 : 1 }}</span>
|
|
2
|
+
<v-card width="100%" class="screen-modal visitor-form" :loading="processing">
|
|
3
|
+
<div class="visitor-form__head">
|
|
4
|
+
<v-card-title class="text-capitalize">
|
|
5
|
+
{{ formMode }} {{ formatVisitorType(type) }}
|
|
6
|
+
</v-card-title>
|
|
7
|
+
<AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="emit('close:all')" />
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="screen-modal__body visitor-form__body filter-field">
|
|
11
|
+
<div class="visitor-form__section-row">
|
|
12
|
+
<span class="visitor-form__section">{{ formTitle }}</span>
|
|
13
|
+
<span v-if="withStep2 || withStep3" class="visitor-form__step">Step
|
|
14
|
+
<span class="visitor-form__step-now">{{ contractorStep }}</span>/{{ withStep3 ? 3 : withStep2 ? 2 : 1 }}</span>
|
|
18
15
|
</div>
|
|
19
16
|
<v-form ref="formRef" v-model="validForm" :disabled="processing" @click="errorMessage = ''">
|
|
20
17
|
<v-row no-gutters class="pt-4">
|
|
@@ -27,7 +24,8 @@
|
|
|
27
24
|
<v-row>
|
|
28
25
|
<v-col cols="12">
|
|
29
26
|
<InputLabel class="text-capitalize" title="NRIC" :required="requireNRIC" />
|
|
30
|
-
<v-menu v-model="nricSearchMenu" location="bottom" offset-y :close-on-content-click="false"
|
|
27
|
+
<v-menu v-model="nricSearchMenu" location="bottom" offset-y :close-on-content-click="false"
|
|
28
|
+
content-class="visitor-form__menu">
|
|
31
29
|
<template #activator="{ props }">
|
|
32
30
|
<InputNRICNumber v-bind="props" v-model="visitor.nric" density="comfortable"
|
|
33
31
|
@focus="isNricFieldFocused = true" @blur="isNricFieldFocused = false"
|
|
@@ -43,7 +41,8 @@
|
|
|
43
41
|
</v-list-item>
|
|
44
42
|
</div>
|
|
45
43
|
<div class="search-results-close pa-1">
|
|
46
|
-
<v-btn block variant="
|
|
44
|
+
<v-btn block variant="outlined" class="screen-btn-ghost"
|
|
45
|
+
@click.stop="closeAutofillSearchResults('nric')">
|
|
47
46
|
Close
|
|
48
47
|
</v-btn>
|
|
49
48
|
</div>
|
|
@@ -57,7 +56,8 @@
|
|
|
57
56
|
<v-row>
|
|
58
57
|
<v-col cols="12">
|
|
59
58
|
<InputLabel class="text-capitalize" title="Phone Number" required />
|
|
60
|
-
<v-menu v-model="phoneSearchMenu" location="bottom" offset-y :close-on-content-click="false"
|
|
59
|
+
<v-menu v-model="phoneSearchMenu" location="bottom" offset-y :close-on-content-click="false"
|
|
60
|
+
content-class="visitor-form__menu">
|
|
61
61
|
<template #activator="{ props }">
|
|
62
62
|
<InputPhoneNumberV2 v-bind="props" v-model="visitor.contact" :rules="[requiredRule]"
|
|
63
63
|
@focus="isPhoneFieldFocused = true" @blur="isPhoneFieldFocused = false" density="comfortable"
|
|
@@ -72,7 +72,8 @@
|
|
|
72
72
|
</v-list-item>
|
|
73
73
|
</div>
|
|
74
74
|
<div class="search-results-close pa-1">
|
|
75
|
-
<v-btn block variant="
|
|
75
|
+
<v-btn block variant="outlined" class="screen-btn-ghost"
|
|
76
|
+
@click.stop="closeAutofillSearchResults('contact')">
|
|
76
77
|
Close
|
|
77
78
|
</v-btn>
|
|
78
79
|
</div>
|
|
@@ -88,7 +89,8 @@
|
|
|
88
89
|
<v-col cols="12">
|
|
89
90
|
<InputLabel class="text-capitalize" title="Vehicle Number" :required="isVehicleRequired" />
|
|
90
91
|
<!-- <v-text-field v-model.trim.uppercase="visitor.plateNumber" density="comfortable" /> -->
|
|
91
|
-
<v-menu v-model="vehicleSearchMenu" location="bottom" offset-y :close-on-content-click="false"
|
|
92
|
+
<v-menu v-model="vehicleSearchMenu" location="bottom" offset-y :close-on-content-click="false"
|
|
93
|
+
content-class="visitor-form__menu">
|
|
92
94
|
<template #activator="{ props }">
|
|
93
95
|
<InputVehicleNumber v-bind="props" v-model="visitor.plateNumber" density="comfortable"
|
|
94
96
|
@update:model-value="handleUpdateVehicleNumber" @focus="handleFocusPlateNumber"
|
|
@@ -103,7 +105,7 @@
|
|
|
103
105
|
</v-list-item>
|
|
104
106
|
</div>
|
|
105
107
|
<div class="search-results-close pa-1">
|
|
106
|
-
<v-btn block variant="
|
|
108
|
+
<v-btn block variant="outlined" class="screen-btn-ghost"
|
|
107
109
|
@click.stop="closeAutofillSearchResults('vehicleNumber')">
|
|
108
110
|
Close
|
|
109
111
|
</v-btn>
|
|
@@ -150,7 +152,15 @@
|
|
|
150
152
|
<v-col v-if="shouldShowField('deliveryType')" cols="12">
|
|
151
153
|
<v-row>
|
|
152
154
|
<v-col cols="12">
|
|
153
|
-
|
|
155
|
+
<!-- The handoff never draws the delivery type, so this takes the
|
|
156
|
+
form's OWN established pattern rather than inventing one: an
|
|
157
|
+
`InputLabel` like every other field, and `accent` - the
|
|
158
|
+
colour the converted NRIC picker's radios already use - in
|
|
159
|
+
place of `primary`, which is the darker link tone. Without
|
|
160
|
+
the label this was the only required field in the form with
|
|
161
|
+
nothing naming it: just a bare Food/Parcel pair. -->
|
|
162
|
+
<InputLabel class="text-capitalize" title="Delivery Type" required />
|
|
163
|
+
<v-radio-group v-model="visitor.deliveryType" inline color="accent" :rules="[requiredRule]">
|
|
154
164
|
<v-radio label="Food" value="Food"></v-radio>
|
|
155
165
|
<v-radio label="Parcel" value="Parcel"></v-radio>
|
|
156
166
|
</v-radio-group>
|
|
@@ -260,6 +270,8 @@
|
|
|
260
270
|
:loading="entryPassSettingsPending || siteDataPending" :site-id="prop.site"
|
|
261
271
|
:unit-id="visitor.unit || null" :visitor-type="prop.type" :hid-qr-code-enabled="hidQrCodePassAllowed"
|
|
262
272
|
:hid-qr-printer-configured="hasHidQrPrinterConfig"
|
|
273
|
+
:hid-reader-name="hidQrReader?.name || ''"
|
|
274
|
+
:hid-portal-name="hidQrReader?.portalName || ''"
|
|
263
275
|
@update:available-qr-count="(val) => availableQrCount = val" />
|
|
264
276
|
</v-col>
|
|
265
277
|
|
|
@@ -269,6 +281,7 @@
|
|
|
269
281
|
:settings="entryPassSettings" :settings-loading="entryPassSettingsPending || siteDataPending"
|
|
270
282
|
:unit-id="visitor.unit || null" :hid-qr-code-enabled="hidQrCodePassAllowed"
|
|
271
283
|
:hid-qr-printer-configured="hasHidQrPrinterConfig"
|
|
284
|
+
:hid-reader-name="hidQrReader?.name || ''" :hid-portal-name="hidQrReader?.portalName || ''"
|
|
272
285
|
:selected-nfc-cards="passType === 'NFC' ? passCards : []" />
|
|
273
286
|
</v-col>
|
|
274
287
|
|
|
@@ -283,34 +296,23 @@
|
|
|
283
296
|
</v-col>
|
|
284
297
|
</v-row>
|
|
285
298
|
</v-form>
|
|
286
|
-
</
|
|
287
|
-
|
|
288
|
-
<v-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
<v-btn v-else tile block variant="text" class="text-none" size="48" @click="emit('close:all')" text="Close" />
|
|
304
|
-
</v-col>
|
|
305
|
-
<v-col cols="6">
|
|
306
|
-
<v-btn v-if="showNextButton" tile block variant="flat" color="primary-button" class="text-none" size="48"
|
|
307
|
-
:disabled="processing" @click="handleNextPage" :text="'Next'" />
|
|
308
|
-
<v-btn v-else tile block variant="flat" color="black" class="text-none" size="48"
|
|
309
|
-
:disabled="!validForm || processing || membersFieldIncomplete" @click="submit"
|
|
310
|
-
:text="formMode == 'add' ? 'Submit' : 'Update'" />
|
|
311
|
-
</v-col>
|
|
312
|
-
</v-row>
|
|
313
|
-
</v-toolbar>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<p v-if="errorMessage" class="visitor-form__error">
|
|
302
|
+
{{ errorMessage }}
|
|
303
|
+
</p>
|
|
304
|
+
|
|
305
|
+
<div class="visitor-form__footer">
|
|
306
|
+
<AppButton v-if="formMode === 'add' && (withStep2 || withStep3) && contractorStep > 1" variant="ghost"
|
|
307
|
+
@click="handleGoToPreviousPage">Back</AppButton>
|
|
308
|
+
<AppButton v-else-if="formMode === 'add' || formMode === 'register'" variant="ghost" @click="backToSelection">
|
|
309
|
+
Back to Selection</AppButton>
|
|
310
|
+
<AppButton v-else variant="ghost" @click="emit('close:all')">Close</AppButton>
|
|
311
|
+
|
|
312
|
+
<AppButton v-if="showNextButton" :disabled="processing" @click="handleNextPage">Next</AppButton>
|
|
313
|
+
<AppButton v-else :disabled="!validForm || processing || membersFieldIncomplete" @click="submit">
|
|
314
|
+
{{ formMode == 'add' ? 'Submit' : 'Update' }}</AppButton>
|
|
315
|
+
</div>
|
|
314
316
|
|
|
315
317
|
<v-dialog v-model="dialog.vehicleNumberUsersList" v-if="vehicleNumberUserItems.length > 0" persistent
|
|
316
318
|
max-width="600">
|
|
@@ -320,72 +322,73 @@
|
|
|
320
322
|
</v-dialog>
|
|
321
323
|
|
|
322
324
|
<v-dialog v-model="dialog.nricAutofillOptions" max-width="430">
|
|
323
|
-
<v-card v-if="selectedNricSearchResult" class="nric-options-card">
|
|
324
|
-
<
|
|
325
|
-
<v-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
<v-card-text class="pa-4 pt-1">
|
|
329
|
-
<h2 class="nric-options-title font-weight-bold mb-4">Select</h2>
|
|
325
|
+
<v-card v-if="selectedNricSearchResult" class="screen-modal nric-options-card">
|
|
326
|
+
<div class="visitor-form__head">
|
|
327
|
+
<v-card-title>Select</v-card-title>
|
|
328
|
+
<AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="closeNricAutofillOptions" />
|
|
329
|
+
</div>
|
|
330
330
|
|
|
331
|
+
<div class="screen-modal__body nric-options-body">
|
|
331
332
|
<div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
|
|
332
333
|
<span>{{ selectedNricSearchResult.name || "Unknown" }}</span>
|
|
333
334
|
<span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ selectedNricSearchResult.nric || "N/A" }}</span>
|
|
334
335
|
</div>
|
|
335
336
|
|
|
336
337
|
<template v-if="(!visitor.contact || currentAutofillSource === 'contact') && selectedNricContactOptions.length >= 1">
|
|
337
|
-
<h3 class="nric-options-section-title
|
|
338
|
-
<v-radio-group v-model="selectedNricContact" hide-details color="
|
|
338
|
+
<h3 class="nric-options-section-title mb-2">Contact</h3>
|
|
339
|
+
<v-radio-group v-model="selectedNricContact" hide-details color="accent" class="nric-option-group">
|
|
339
340
|
<v-radio v-for="contact in selectedNricContactOptions" :key="contact" :label="contact" :value="contact"
|
|
340
341
|
class="nric-option-radio" />
|
|
341
342
|
</v-radio-group>
|
|
342
343
|
</template>
|
|
343
344
|
|
|
344
345
|
<template v-if="(!visitor.plateNumber || currentAutofillSource === 'vehicleNumber') && selectedNricPlateOptions.length >= 1">
|
|
345
|
-
<h3 class="nric-options-section-title
|
|
346
|
-
<v-radio-group v-model="selectedNricPlate" hide-details color="
|
|
346
|
+
<h3 class="nric-options-section-title mb-2 mt-5">Vehicle</h3>
|
|
347
|
+
<v-radio-group v-model="selectedNricPlate" hide-details color="accent" class="nric-option-group">
|
|
347
348
|
<v-radio v-for="plate in selectedNricPlateOptions" :key="plate" :label="plate" :value="plate"
|
|
348
349
|
class="nric-option-radio" />
|
|
349
350
|
</v-radio-group>
|
|
350
351
|
</template>
|
|
351
|
-
</
|
|
352
|
+
</div>
|
|
352
353
|
|
|
353
|
-
<
|
|
354
|
-
<
|
|
355
|
-
<
|
|
356
|
-
|
|
357
|
-
Select
|
|
358
|
-
</v-btn>
|
|
359
|
-
</v-card-actions>
|
|
354
|
+
<div class="visitor-form__footer">
|
|
355
|
+
<AppButton variant="ghost" @click="closeNricAutofillOptions">Cancel</AppButton>
|
|
356
|
+
<AppButton @click="confirmNricAutofillOptions">Select</AppButton>
|
|
357
|
+
</div>
|
|
360
358
|
</v-card>
|
|
361
359
|
</v-dialog>
|
|
362
360
|
|
|
363
361
|
<v-dialog v-model="dialog.showNonCheckedOutDialog" max-width="1200">
|
|
364
|
-
<v-card
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
<
|
|
370
|
-
<
|
|
362
|
+
<v-card class="screen-modal"
|
|
363
|
+
:loading="loading.checkingOut || loading.searchingNonCheckedOut">
|
|
364
|
+
<!-- No heading: the product gives this dialog none, and inventing copy
|
|
365
|
+
is not this batch's call. The design's modal frame does draw a
|
|
366
|
+
title, so that gap is raised on the PR as a copy decision. -->
|
|
367
|
+
<div class="visitor-form__head visitor-form__head--bare">
|
|
368
|
+
<AppButton variant="icon" icon="mdi-close" aria-label="Close"
|
|
369
|
+
@click="dialog.showNonCheckedOutDialog = false" />
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<div class="screen-modal__body checkout-dialog__body">
|
|
373
|
+
<p class="checkout-dialog__lead">
|
|
371
374
|
The following list needs to check-out before proceeding to add related to the NRIC/Phone Number below:
|
|
372
375
|
</p>
|
|
373
376
|
|
|
374
|
-
<div class="
|
|
377
|
+
<div class="checkout-dialog__subject">
|
|
375
378
|
<div v-if="!skipNonCheckedOutNric">NRIC: {{ visitor.nric || "N/A" }}</div>
|
|
376
379
|
<div>Phone Number: {{ visitor.contact || "N/A" }}</div>
|
|
377
380
|
</div>
|
|
378
381
|
|
|
379
|
-
<v-data-table class="
|
|
382
|
+
<v-data-table class="table-card" :headers="nonCheckedOutVisitorHeaders"
|
|
380
383
|
:items="matchingNonCheckedOutVisitors" item-value="_id" :items-per-page="5" hide-default-footer>
|
|
381
384
|
<template #item.name="{ item }">
|
|
382
385
|
<div class="d-flex align-center ga-3">
|
|
383
|
-
<v-avatar
|
|
384
|
-
<span
|
|
386
|
+
<v-avatar class="checkout-dialog__avatar" size="32">
|
|
387
|
+
<span>{{ getVisitorInitial(item.name) }}</span>
|
|
385
388
|
</v-avatar>
|
|
386
389
|
<div>
|
|
387
|
-
<div class="
|
|
388
|
-
<div class="
|
|
390
|
+
<div class="checkout-dialog__name">{{ item.name || "N/A" }}</div>
|
|
391
|
+
<div class="checkout-dialog__meta">
|
|
389
392
|
<v-icon icon="mdi-card-account-details-outline" size="16" class="mr-1" />
|
|
390
393
|
{{ maskNRIC(item.nric) || "N/A" }}
|
|
391
394
|
</div>
|
|
@@ -422,11 +425,11 @@
|
|
|
422
425
|
|
|
423
426
|
<template #item.checkInOut="{ item }">
|
|
424
427
|
<div class="py-2">
|
|
425
|
-
<div class="mb-2">
|
|
426
|
-
<v-icon icon="mdi-clock-check-outline"
|
|
428
|
+
<div class="checkout-dialog__in mb-2">
|
|
429
|
+
<v-icon icon="mdi-clock-check-outline" size="20" class="mr-1" />
|
|
427
430
|
{{ formatNonCheckedOutDate(item.checkIn) }}
|
|
428
431
|
</div>
|
|
429
|
-
<v-btn
|
|
432
|
+
<v-btn class="screen-btn-primary" variant="flat"
|
|
430
433
|
:loading="loading.checkingOut && selectedCheckoutVisitorId === item._id" :disabled="!item._id"
|
|
431
434
|
@click.stop="item._id && handleCheckout(item._id)">
|
|
432
435
|
Check Out
|
|
@@ -434,35 +437,83 @@
|
|
|
434
437
|
</div>
|
|
435
438
|
</template>
|
|
436
439
|
</v-data-table>
|
|
440
|
+
</div>
|
|
437
441
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
442
|
+
<div class="visitor-form__footer" v-if="!skipNonCheckedOutNric">
|
|
443
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="loading.searchingNonCheckedOut"
|
|
444
|
+
@click="handleSkipNricNonCheckedOut">
|
|
445
|
+
Skip NRIC: {{ visitor.nric || "N/A" }}
|
|
446
|
+
</v-btn>
|
|
447
|
+
</div>
|
|
448
|
+
</v-card>
|
|
449
|
+
</v-dialog>
|
|
450
|
+
|
|
451
|
+
<v-dialog v-model="dialog.returnNfcCard" max-width="450" persistent>
|
|
452
|
+
<v-card>
|
|
453
|
+
<v-toolbar density="compact" color="">
|
|
454
|
+
<v-row no-gutters class="d-flex fill-height justify-space-between align-center px-4">
|
|
455
|
+
<span class="font-weight-bold">Return NFC Card</span>
|
|
456
|
+
<v-btn icon="mdi-close" variant="text" @click="dialog.returnNfcCard = false" />
|
|
457
|
+
</v-row>
|
|
458
|
+
</v-toolbar>
|
|
459
|
+
<v-card-text class="px-4 pb-2">
|
|
460
|
+
<p class="text-body-2 mb-3">
|
|
461
|
+
Please indicate the status of the NFC card before checking out.
|
|
462
|
+
</p>
|
|
463
|
+
<div>
|
|
464
|
+
<InputLabel class="text-capitalize" title="Full Name" />
|
|
465
|
+
<v-text-field :model-value="selectedCheckoutVisitor?.name" density="comfortable" readonly />
|
|
466
|
+
</div>
|
|
467
|
+
<div>
|
|
468
|
+
<InputLabel class="text-capitalize" title="Location" />
|
|
469
|
+
<v-text-field :model-value="formatVisitorLocation(selectedCheckoutVisitor)" density="comfortable"
|
|
470
|
+
readonly />
|
|
471
|
+
</div>
|
|
472
|
+
<div v-for="(card, idx) in nfcCardReturnStatuses" :key="card.cardId" class="mb-4">
|
|
473
|
+
<div class="d-flex align-center ga-2 mb-2">
|
|
474
|
+
<v-icon size="18" icon="mdi-credit-card-outline" color="purple" />
|
|
475
|
+
<v-chip size="small" variant="tonal" color="purple">{{ card.cardNo }}</v-chip>
|
|
476
|
+
</div>
|
|
477
|
+
<v-select v-model="nfcCardReturnStatuses[idx].status" :items="nfcPassStatusOptions" item-title="label"
|
|
478
|
+
item-value="value" density="comfortable" hide-details />
|
|
479
|
+
<v-textarea v-if="card.status === 'Lost' || card.status === 'Damage'"
|
|
480
|
+
v-model="nfcCardReturnStatuses[idx].remarks" label="Remarks (required)" no-resize rows="3" class="mt-2"
|
|
481
|
+
density="compact" />
|
|
443
482
|
</div>
|
|
444
483
|
</v-card-text>
|
|
484
|
+
<v-toolbar class="pa-0" density="compact">
|
|
485
|
+
<v-row no-gutters>
|
|
486
|
+
<v-col cols="6">
|
|
487
|
+
<v-btn variant="text" block @click="dialog.returnNfcCard = false">Close</v-btn>
|
|
488
|
+
</v-col>
|
|
489
|
+
<v-col cols="6">
|
|
490
|
+
<v-btn color="red" variant="flat" height="48" rounded="0" block :loading="loading.checkingOut"
|
|
491
|
+
:disabled="!canConfirmNfcReturnCheckout" @click="handleNfcReturnCheckout">Confirm Checkout</v-btn>
|
|
492
|
+
</v-col>
|
|
493
|
+
</v-row>
|
|
494
|
+
</v-toolbar>
|
|
445
495
|
</v-card>
|
|
446
496
|
</v-dialog>
|
|
447
497
|
|
|
448
498
|
<v-dialog v-if="enableUnregistered" v-model="dialog.showUnregisteredPlateDialog" max-width="900" persistent>
|
|
449
|
-
<v-card
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
499
|
+
<v-card class="screen-modal">
|
|
500
|
+
<!-- No heading in the product; see the note on the check-out dialog. -->
|
|
501
|
+
<div class="visitor-form__head visitor-form__head--bare">
|
|
502
|
+
<AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="closeUnregisteredPlateDialog" />
|
|
503
|
+
</div>
|
|
453
504
|
|
|
454
|
-
<
|
|
455
|
-
<v-icon
|
|
505
|
+
<div class="screen-modal__body unreg-dialog__body">
|
|
506
|
+
<v-icon class="unreg-dialog__alert" size="96">mdi-alert-circle-outline</v-icon>
|
|
456
507
|
|
|
457
|
-
<
|
|
508
|
+
<p class="unreg-dialog__lead">
|
|
458
509
|
Vehicle Number {{ unregisteredPlateSearchText }} is similar to below
|
|
459
510
|
<br />
|
|
460
511
|
Unregistered visitors last 24 hours. Please select to
|
|
461
512
|
<br />
|
|
462
513
|
Update
|
|
463
|
-
</
|
|
514
|
+
</p>
|
|
464
515
|
|
|
465
|
-
<v-table class="
|
|
516
|
+
<v-table class="table-card unreg-dialog__table">
|
|
466
517
|
<thead>
|
|
467
518
|
<tr>
|
|
468
519
|
<th class="text-left">Plate Number</th>
|
|
@@ -472,34 +523,109 @@
|
|
|
472
523
|
</thead>
|
|
473
524
|
<tbody>
|
|
474
525
|
<tr v-for="match in unregisteredPlateMatches" :key="match._id || match.plateNumber">
|
|
475
|
-
<td class="text-left
|
|
526
|
+
<td class="text-left text-uppercase unreg-dialog__plate">
|
|
476
527
|
{{ match.plateNumber || "N/A" }}
|
|
477
528
|
</td>
|
|
478
529
|
<td class="text-left">
|
|
479
530
|
{{ formatUnregisteredCheckIn(match.checkIn) }}
|
|
480
531
|
</td>
|
|
481
532
|
<td class="text-center">
|
|
482
|
-
<
|
|
483
|
-
@click="selectUnregisteredPlateMatch(match)">
|
|
484
|
-
Update
|
|
485
|
-
</v-btn>
|
|
533
|
+
<AppButton @click="selectUnregisteredPlateMatch(match)">Update</AppButton>
|
|
486
534
|
</td>
|
|
487
535
|
</tr>
|
|
488
536
|
</tbody>
|
|
489
537
|
</v-table>
|
|
490
|
-
</
|
|
538
|
+
</div>
|
|
491
539
|
|
|
492
|
-
<
|
|
493
|
-
<
|
|
494
|
-
|
|
540
|
+
<div class="visitor-form__footer">
|
|
541
|
+
<AppButton variant="ghost" @click="closeUnregisteredPlateDialog">Cancel & Add New</AppButton>
|
|
542
|
+
</div>
|
|
543
|
+
</v-card>
|
|
544
|
+
</v-dialog>
|
|
545
|
+
|
|
546
|
+
<v-dialog v-model="hidQrPreviewDialog" max-width="440" persistent>
|
|
547
|
+
<v-card class="screen-modal hid-qr-preview-card">
|
|
548
|
+
<div class="visitor-form__head">
|
|
549
|
+
<v-card-title>Visitor QR Code</v-card-title>
|
|
550
|
+
<AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="closeHidQrPreview" />
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
<div class="screen-modal__body hid-qr__body">
|
|
554
|
+
<div class="hid-qr__header">
|
|
555
|
+
{{ hidQrTemplate.header || "Visitor Access Pass" }}
|
|
556
|
+
</div>
|
|
557
|
+
<div class="hid-qr__subtext">
|
|
558
|
+
{{ hidQrTemplate.subtext || "Scan at the HID reader for entry" }}
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
<div class="hid-qr-image-shell my-5">
|
|
562
|
+
<v-img v-if="hidQrVisible" :src="hidQrPreview.imageUrl" width="280" height="280" alt="Visitor access QR code" />
|
|
563
|
+
<button v-else type="button" class="hid-qr-reveal" aria-label="Show QR code" @click="hidQrVisible = true">
|
|
564
|
+
<v-icon icon="mdi-eye-outline" size="42" />
|
|
565
|
+
<span>Click to show QR code</span>
|
|
566
|
+
</button>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<AppButton
|
|
570
|
+
variant="ghost"
|
|
571
|
+
:icon="hidQrVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
|
572
|
+
@click="hidQrVisible = !hidQrVisible"
|
|
573
|
+
>
|
|
574
|
+
{{ hidQrVisible ? "Hide QR code" : "Show QR code" }}
|
|
575
|
+
</AppButton>
|
|
576
|
+
|
|
577
|
+
<div class="hid-qr__name">{{ visitor.name }}</div>
|
|
578
|
+
<div v-if="hidQrReader?.portalName" class="hid-qr__meta">
|
|
579
|
+
{{ hidQrReader.name || "HID Reader" }} · {{ hidQrReader.portalName }}
|
|
580
|
+
</div>
|
|
581
|
+
<div v-if="hidQrPreview.expiresAt" class="hid-qr__meta">
|
|
582
|
+
Valid until {{ formatHidQrExpiry(hidQrPreview.expiresAt) }}
|
|
583
|
+
</div>
|
|
584
|
+
<StatusChip
|
|
585
|
+
v-if="hidQrPreview.expiresAt"
|
|
586
|
+
class="mt-2"
|
|
587
|
+
:tone="hidQrExpired ? 'err' : 'ok'"
|
|
588
|
+
:label="hidQrExpired ? 'Expired' : `Expires in ${formatHidQrCountdown(hidQrRemainingSeconds)}`"
|
|
589
|
+
/>
|
|
590
|
+
|
|
591
|
+
<v-btn
|
|
592
|
+
v-if="hidQrExpired"
|
|
593
|
+
block
|
|
594
|
+
class="mt-4 screen-btn-primary"
|
|
595
|
+
variant="flat"
|
|
596
|
+
prepend-icon="mdi-refresh"
|
|
597
|
+
:loading="hidQrRegenerating"
|
|
598
|
+
@click="regenerateHidQrCode"
|
|
599
|
+
>
|
|
600
|
+
Generate QR again
|
|
495
601
|
</v-btn>
|
|
496
|
-
|
|
602
|
+
|
|
603
|
+
<v-alert v-if="hidQrPreviewError" type="error" variant="tonal" density="compact" class="mt-4 text-left">
|
|
604
|
+
{{ hidQrPreviewError }}
|
|
605
|
+
</v-alert>
|
|
606
|
+
</div>
|
|
607
|
+
|
|
608
|
+
<div class="visitor-form__footer">
|
|
609
|
+
<AppButton variant="ghost" icon="mdi-download" @click="downloadHidQrCode">
|
|
610
|
+
Download
|
|
611
|
+
</AppButton>
|
|
612
|
+
<AppButton
|
|
613
|
+
v-if="hasHidQrPrinterConfig"
|
|
614
|
+
icon="mdi-printer"
|
|
615
|
+
:disabled="hidQrPrinting"
|
|
616
|
+
@click="printHidQrCodePass"
|
|
617
|
+
>
|
|
618
|
+
Print
|
|
619
|
+
</AppButton>
|
|
620
|
+
<AppButton v-else @click="closeHidQrPreview">Close</AppButton>
|
|
621
|
+
</div>
|
|
497
622
|
</v-card>
|
|
498
623
|
</v-dialog>
|
|
499
624
|
</v-card>
|
|
500
625
|
</template>
|
|
501
626
|
|
|
502
627
|
<script setup lang="ts">
|
|
628
|
+
import QRCode from "qrcode";
|
|
503
629
|
import { propsFactory } from "vuetify/lib/util/propsFactory.mjs";
|
|
504
630
|
import useBuildingUnit from "../composables/useBuildingUnit";
|
|
505
631
|
import useWebUsb from "../composables/useWebUsb";
|
|
@@ -511,6 +637,7 @@ import useVisitor from "../composables/useVisitor";
|
|
|
511
637
|
import useSiteEntryPassSettings from "../composables/useSiteEntryPassSettings";
|
|
512
638
|
import useAccessManagement from "../composables/useAccessManagement";
|
|
513
639
|
import usePeople from "../composables/usePeople";
|
|
640
|
+
import useHidAmico from "../composables/useHidAmico";
|
|
514
641
|
|
|
515
642
|
const prop = defineProps({
|
|
516
643
|
type: {
|
|
@@ -561,7 +688,8 @@ const {
|
|
|
561
688
|
} = useVisitor();
|
|
562
689
|
const { getBySiteId: getEntryPassSettingsBySiteId } =
|
|
563
690
|
useSiteEntryPassSettings();
|
|
564
|
-
const { createVisitorPass, signQr } = useAccessManagement();
|
|
691
|
+
const { createVisitorPass, signQr, visitorCheckout } = useAccessManagement();
|
|
692
|
+
const { getReaders, issueVisitorQr } = useHidAmico();
|
|
565
693
|
const { testConnection } = useWebUsb();
|
|
566
694
|
const {
|
|
567
695
|
findPersonByNRIC,
|
|
@@ -610,6 +738,38 @@ const hidQrPrinted = ref(false);
|
|
|
610
738
|
const isPhoneFieldFocused = ref(false);
|
|
611
739
|
const isPlateNumberFieldFocused = ref(false);
|
|
612
740
|
const isNricFieldFocused = ref(false);
|
|
741
|
+
const hidQrPrinting = ref(false);
|
|
742
|
+
const hidQrPreviewDialog = ref(false);
|
|
743
|
+
const hidQrPreviewError = ref("");
|
|
744
|
+
const hidQrVisible = ref(false);
|
|
745
|
+
const hidQrRegenerating = ref(false);
|
|
746
|
+
const hidQrVisitorId = ref("");
|
|
747
|
+
const hidQrNow = ref(Date.now());
|
|
748
|
+
let hidQrTimer: ReturnType<typeof setInterval> | null = null;
|
|
749
|
+
const hidQrPendingCompletion = ref<"done" | "done:more" | null>(null);
|
|
750
|
+
const hidQrPreview = reactive({
|
|
751
|
+
imageUrl: "",
|
|
752
|
+
issuedAt: "",
|
|
753
|
+
expiresAt: "",
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const hidQrRemainingSeconds = computed(() => {
|
|
757
|
+
const expiry = new Date(hidQrPreview.expiresAt).getTime();
|
|
758
|
+
return Number.isFinite(expiry) ? Math.max(0, Math.ceil((expiry - hidQrNow.value) / 1000)) : 0;
|
|
759
|
+
});
|
|
760
|
+
const hidQrExpired = computed(() => Boolean(hidQrPreview.expiresAt) && hidQrRemainingSeconds.value === 0);
|
|
761
|
+
|
|
762
|
+
watch(hidQrPreviewDialog, (open) => {
|
|
763
|
+
if (hidQrTimer) clearInterval(hidQrTimer);
|
|
764
|
+
hidQrTimer = null;
|
|
765
|
+
if (!open) return;
|
|
766
|
+
hidQrNow.value = Date.now();
|
|
767
|
+
hidQrTimer = setInterval(() => { hidQrNow.value = Date.now(); }, 1000);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
onBeforeUnmount(() => {
|
|
771
|
+
if (hidQrTimer) clearInterval(hidQrTimer);
|
|
772
|
+
});
|
|
613
773
|
|
|
614
774
|
const registeredUnitCompanyName = ref("N/A");
|
|
615
775
|
|
|
@@ -618,6 +778,7 @@ const dialog = reactive({
|
|
|
618
778
|
vehicleNumberUsersList: false,
|
|
619
779
|
showNonCheckedOutDialog: false,
|
|
620
780
|
showUnregisteredPlateDialog: false,
|
|
781
|
+
returnNfcCard: false,
|
|
621
782
|
});
|
|
622
783
|
|
|
623
784
|
const loading = reactive({
|
|
@@ -651,6 +812,30 @@ const deliveryCompanyList = ref<string[]>([]);
|
|
|
651
812
|
const matchingNonCheckedOutVisitors = ref<TVisitor[]>([]);
|
|
652
813
|
const selectedCheckoutVisitorId = ref<string | null>(null);
|
|
653
814
|
const skipNonCheckedOutNric = ref(false);
|
|
815
|
+
|
|
816
|
+
const nfcCardReturnStatuses = ref<
|
|
817
|
+
{ cardId: string; cardNo: string; status: string; remarks: string }[]
|
|
818
|
+
>([]);
|
|
819
|
+
const nfcPassStatusOptions = [
|
|
820
|
+
{ label: "Returned", value: "Returned" },
|
|
821
|
+
{ label: "Not Returned", value: "In Use" },
|
|
822
|
+
{ label: "Damaged", value: "Damage" },
|
|
823
|
+
{ label: "Lost", value: "Lost" },
|
|
824
|
+
];
|
|
825
|
+
const selectedCheckoutVisitor = computed(() =>
|
|
826
|
+
(matchingNonCheckedOutVisitors.value as any[]).find(
|
|
827
|
+
(v) => v?._id === selectedCheckoutVisitorId.value
|
|
828
|
+
)
|
|
829
|
+
);
|
|
830
|
+
const canConfirmNfcReturnCheckout = computed(() => {
|
|
831
|
+
if (nfcCardReturnStatuses.value.length === 0) return false;
|
|
832
|
+
return nfcCardReturnStatuses.value.every(({ status, remarks }) => {
|
|
833
|
+
if (!status || status === "In Use") return false;
|
|
834
|
+
if ((status === "Lost" || status === "Damage") && !remarks.trim())
|
|
835
|
+
return false;
|
|
836
|
+
return true;
|
|
837
|
+
});
|
|
838
|
+
});
|
|
654
839
|
const nonCheckedOutVisitorHeaders = [
|
|
655
840
|
{ title: "Name/NRIC", key: "name", align: "start", sortable: false },
|
|
656
841
|
{ title: "Type / Company", key: "typeCompany", align: "start", sortable: false },
|
|
@@ -710,7 +895,8 @@ const requireNRIC = computed(() => {
|
|
|
710
895
|
});
|
|
711
896
|
|
|
712
897
|
const hidQrCodePassConfig = computed(() => {
|
|
713
|
-
|
|
898
|
+
const site = siteData.value as TSite | null;
|
|
899
|
+
return site?.metadata?.hidQrCodePass;
|
|
714
900
|
});
|
|
715
901
|
|
|
716
902
|
const hidQrCodePassEnabled = computed(() => {
|
|
@@ -736,6 +922,15 @@ const hidQrValidityMinutes = computed(() => {
|
|
|
736
922
|
return Number.isFinite(value) && value > 0 ? value : 60;
|
|
737
923
|
});
|
|
738
924
|
|
|
925
|
+
const hidQrReaderId = computed(() => String(hidQrCodePassConfig.value?.readerId || ""));
|
|
926
|
+
type HidQrReaderSummary = {
|
|
927
|
+
_id: string;
|
|
928
|
+
name?: string;
|
|
929
|
+
portalId?: number;
|
|
930
|
+
portalName?: string;
|
|
931
|
+
};
|
|
932
|
+
const hidQrReader = ref<HidQrReaderSummary | null>(null);
|
|
933
|
+
|
|
739
934
|
const hidQrCodePassAllowed = computed(() => {
|
|
740
935
|
return (
|
|
741
936
|
hidQrCodePassEnabled.value &&
|
|
@@ -1223,19 +1418,43 @@ async function handleSkipNricNonCheckedOut() {
|
|
|
1223
1418
|
dialog.showNonCheckedOutDialog = false;
|
|
1224
1419
|
}
|
|
1225
1420
|
|
|
1226
|
-
|
|
1421
|
+
function handleCheckout(visitorId: string) {
|
|
1227
1422
|
if (!visitorId) {
|
|
1228
1423
|
errorMessage.value = "Invalid visitor ID. Cannot proceed with checkout.";
|
|
1229
1424
|
return;
|
|
1230
1425
|
}
|
|
1231
1426
|
|
|
1427
|
+
selectedCheckoutVisitorId.value = visitorId;
|
|
1428
|
+
|
|
1429
|
+
const visitor = (matchingNonCheckedOutVisitors.value as any[]).find(
|
|
1430
|
+
(v) => v?._id === visitorId
|
|
1431
|
+
);
|
|
1432
|
+
const nfcCards = ((visitor?.cards as any[]) || []).filter(
|
|
1433
|
+
(card) => card?.type === "NFC"
|
|
1434
|
+
);
|
|
1435
|
+
|
|
1436
|
+
if (nfcCards.length > 0) {
|
|
1437
|
+
nfcCardReturnStatuses.value = nfcCards.map((card: any) => ({
|
|
1438
|
+
cardId: card._id,
|
|
1439
|
+
cardNo: card.cardNo,
|
|
1440
|
+
status: "In Use",
|
|
1441
|
+
remarks: "",
|
|
1442
|
+
}));
|
|
1443
|
+
dialog.returnNfcCard = true;
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
proceedNonCheckedOutCheckout(visitorId);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
async function proceedNonCheckedOutCheckout(visitorId: string) {
|
|
1232
1451
|
try {
|
|
1233
1452
|
loading.checkingOut = true;
|
|
1234
|
-
selectedCheckoutVisitorId.value = visitorId;
|
|
1235
1453
|
const res = await updateVisitor(visitorId as string, {
|
|
1236
1454
|
checkOut: new Date().toISOString(),
|
|
1237
1455
|
});
|
|
1238
1456
|
if (res) {
|
|
1457
|
+
dialog.returnNfcCard = false;
|
|
1239
1458
|
await checkNonCheckedOutVisitorsBeforeSubmit();
|
|
1240
1459
|
}
|
|
1241
1460
|
} catch (error: any) {
|
|
@@ -1249,6 +1468,33 @@ async function handleCheckout(visitorId: string) {
|
|
|
1249
1468
|
}
|
|
1250
1469
|
}
|
|
1251
1470
|
|
|
1471
|
+
async function handleNfcReturnCheckout() {
|
|
1472
|
+
const visitorId = selectedCheckoutVisitorId.value;
|
|
1473
|
+
if (!visitorId) return;
|
|
1474
|
+
|
|
1475
|
+
try {
|
|
1476
|
+
loading.checkingOut = true;
|
|
1477
|
+
const res = await visitorCheckout({
|
|
1478
|
+
cards: nfcCardReturnStatuses.value.map(({ cardId, status, remarks }) => ({
|
|
1479
|
+
cardId,
|
|
1480
|
+
userId: visitorId,
|
|
1481
|
+
status,
|
|
1482
|
+
remarks: remarks ?? "",
|
|
1483
|
+
})),
|
|
1484
|
+
});
|
|
1485
|
+
if (res) {
|
|
1486
|
+
await proceedNonCheckedOutCheckout(visitorId);
|
|
1487
|
+
}
|
|
1488
|
+
} catch (error: any) {
|
|
1489
|
+
const checkoutErrorMessage = error?.response?._data?.message;
|
|
1490
|
+
console.log("[ERROR]", error);
|
|
1491
|
+
errorMessage.value =
|
|
1492
|
+
checkoutErrorMessage || "An error occurred while returning the NFC card.";
|
|
1493
|
+
} finally {
|
|
1494
|
+
loading.checkingOut = false;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1252
1498
|
const debounceFetchCompany = debounce(
|
|
1253
1499
|
async () => fetchCompanyListRefresh(),
|
|
1254
1500
|
200
|
|
@@ -1295,6 +1541,33 @@ const {
|
|
|
1295
1541
|
getSiteById(prop.site)
|
|
1296
1542
|
);
|
|
1297
1543
|
|
|
1544
|
+
// Has to sit BELOW `siteData`, not beside `hidQrReaderId` up at the HID block.
|
|
1545
|
+
// `hidQrReaderId` reads `hidQrCodePassConfig`, which reads `siteData`, and Vue
|
|
1546
|
+
// runs a watcher's source getter the moment `watch()` is called - so declared
|
|
1547
|
+
// any earlier this throws `Cannot access 'siteData' before initialization` and
|
|
1548
|
+
// the whole form fails to mount.
|
|
1549
|
+
watch(
|
|
1550
|
+
[hidQrReaderId, () => prop.site],
|
|
1551
|
+
async ([readerId, site]) => {
|
|
1552
|
+
hidQrReader.value = null;
|
|
1553
|
+
if (!readerId || !site) return;
|
|
1554
|
+
try {
|
|
1555
|
+
const response = await getReaders({ site, page: 1, limit: 100 });
|
|
1556
|
+
const readers: unknown = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
|
|
1557
|
+
hidQrReader.value = Array.isArray(readers)
|
|
1558
|
+
? readers.find((reader: unknown): reader is HidQrReaderSummary => (
|
|
1559
|
+
typeof reader === "object"
|
|
1560
|
+
&& reader !== null
|
|
1561
|
+
&& String((reader as Record<string, unknown>)._id || "") === readerId
|
|
1562
|
+
)) || null
|
|
1563
|
+
: null;
|
|
1564
|
+
} catch {
|
|
1565
|
+
hidQrReader.value = null;
|
|
1566
|
+
}
|
|
1567
|
+
},
|
|
1568
|
+
{ immediate: true },
|
|
1569
|
+
);
|
|
1570
|
+
|
|
1298
1571
|
const {
|
|
1299
1572
|
data: blocksListData,
|
|
1300
1573
|
refresh: refreshBlockListData,
|
|
@@ -1710,7 +1983,7 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
|
|
|
1710
1983
|
return;
|
|
1711
1984
|
}
|
|
1712
1985
|
|
|
1713
|
-
if (passType.value === "HID_QR" && !
|
|
1986
|
+
if (passType.value === "HID_QR" && !validateHidQrReaderConfig()) return;
|
|
1714
1987
|
if (
|
|
1715
1988
|
prop.mode === "add" &&
|
|
1716
1989
|
!options.skipNonCheckedOutCheck &&
|
|
@@ -1800,11 +2073,10 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
|
|
|
1800
2073
|
|
|
1801
2074
|
if (res) {
|
|
1802
2075
|
if (passType.value === "HID_QR") {
|
|
1803
|
-
const
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
if (!printed) return;
|
|
2076
|
+
const previewReady = await prepareHidQrCodePreview(getVisitorIdFromResponse(res));
|
|
2077
|
+
if (!previewReady) return;
|
|
2078
|
+
hidQrPendingCompletion.value = createMore.value ? "done:more" : "done";
|
|
2079
|
+
return;
|
|
1808
2080
|
}
|
|
1809
2081
|
|
|
1810
2082
|
if (
|
|
@@ -1837,12 +2109,9 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
|
|
|
1837
2109
|
});
|
|
1838
2110
|
|
|
1839
2111
|
if (passType.value === "QR") {
|
|
1840
|
-
console.log("[QR Print] passRes:", passRes);
|
|
1841
2112
|
const accessCards: any[] =
|
|
1842
2113
|
(passRes as any)?.data?.accessCards ?? [];
|
|
1843
|
-
console.log("[QR Print] accessCards:", accessCards);
|
|
1844
2114
|
const printer = entryPassSettings.value?.data?.settings?.printer;
|
|
1845
|
-
console.log("[QR Print] printer config:", printer);
|
|
1846
2115
|
if (!printer?.vendorId || !printer?.productId) {
|
|
1847
2116
|
console.warn("[QR Print] Aborted: no printer configured.");
|
|
1848
2117
|
} else if (!accessCards.length) {
|
|
@@ -1863,31 +2132,14 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
|
|
|
1863
2132
|
const address = [blockLabel, levelLabel, unitLabel]
|
|
1864
2133
|
.filter(Boolean)
|
|
1865
2134
|
.join("/");
|
|
1866
|
-
console.log(
|
|
1867
|
-
"[QR Print] Printing",
|
|
1868
|
-
accessCards.length,
|
|
1869
|
-
"card(s). vendorId:",
|
|
1870
|
-
vendorId,
|
|
1871
|
-
"productId:",
|
|
1872
|
-
productId,
|
|
1873
|
-
"address:",
|
|
1874
|
-
address
|
|
1875
|
-
);
|
|
1876
2135
|
|
|
1877
2136
|
await nextTick();
|
|
1878
2137
|
for (const qrCode of accessCards) {
|
|
1879
|
-
console.log("[QR Print] Signing QR for card:", qrCode._id);
|
|
1880
2138
|
const signed = await signQr({
|
|
1881
2139
|
cardId: qrCode._id,
|
|
1882
2140
|
purpose: "vms",
|
|
1883
2141
|
});
|
|
1884
2142
|
const qrData = signed?.data;
|
|
1885
|
-
console.log(
|
|
1886
|
-
"[QR Print] signQr result:",
|
|
1887
|
-
signed,
|
|
1888
|
-
"| qrData:",
|
|
1889
|
-
qrData
|
|
1890
|
-
);
|
|
1891
2143
|
if (!qrData) {
|
|
1892
2144
|
console.warn(
|
|
1893
2145
|
"[QR Print] Skipping card",
|
|
@@ -1947,17 +2199,23 @@ function parseUsbId(value: unknown) {
|
|
|
1947
2199
|
: parseInt(text, 10);
|
|
1948
2200
|
}
|
|
1949
2201
|
|
|
1950
|
-
function getVisitorIdFromResponse(response:
|
|
2202
|
+
function getVisitorIdFromResponse(response: unknown) {
|
|
1951
2203
|
if (typeof response === "string") return response;
|
|
2204
|
+
if (!response || typeof response !== "object") return "";
|
|
2205
|
+
|
|
2206
|
+
const result = response as Record<string, unknown>;
|
|
2207
|
+
const nested = result.data && typeof result.data === "object"
|
|
2208
|
+
? result.data as Record<string, unknown>
|
|
2209
|
+
: {};
|
|
1952
2210
|
return (
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2211
|
+
result._id ||
|
|
2212
|
+
result.id ||
|
|
2213
|
+
nested._id ||
|
|
2214
|
+
nested.id ||
|
|
2215
|
+
result.insertedId ||
|
|
2216
|
+
nested.insertedId ||
|
|
1959
2217
|
""
|
|
1960
|
-
);
|
|
2218
|
+
).toString();
|
|
1961
2219
|
}
|
|
1962
2220
|
|
|
1963
2221
|
function getHidQrPrinterUsbIds() {
|
|
@@ -1966,7 +2224,25 @@ function getHidQrPrinterUsbIds() {
|
|
|
1966
2224
|
return { vendorId, productId };
|
|
1967
2225
|
}
|
|
1968
2226
|
|
|
2227
|
+
function validateHidQrReaderConfig() {
|
|
2228
|
+
if (!hidQrReaderId.value) {
|
|
2229
|
+
errorMessage.value =
|
|
2230
|
+
"No HID reader is configured for QR Code Pass. Please select a reader in Settings.";
|
|
2231
|
+
return false;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
if (!hidQrReader.value?.portalId || !hidQrReader.value?.portalName) {
|
|
2235
|
+
errorMessage.value =
|
|
2236
|
+
"The selected HID reader has no portal configured. Reconnect the reader and select a portal before generating a QR code.";
|
|
2237
|
+
return false;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
return true;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
1969
2243
|
function validateHidQrPrinterConfig() {
|
|
2244
|
+
if (!validateHidQrReaderConfig()) return false;
|
|
2245
|
+
|
|
1970
2246
|
if (!hasHidQrPrinterConfig.value) {
|
|
1971
2247
|
errorMessage.value =
|
|
1972
2248
|
"No printer is configured on HID settings. Please configure the HID QR Code printer before continuing.";
|
|
@@ -1984,42 +2260,6 @@ function validateHidQrPrinterConfig() {
|
|
|
1984
2260
|
return true;
|
|
1985
2261
|
}
|
|
1986
2262
|
|
|
1987
|
-
function buildHidQrCodePayload(visitorId = "") {
|
|
1988
|
-
if (hidQrPrintPayload.value) return hidQrPrintPayload.value;
|
|
1989
|
-
|
|
1990
|
-
const issuedAt = new Date();
|
|
1991
|
-
const expiresAt = new Date(
|
|
1992
|
-
issuedAt.getTime() + hidQrValidityMinutes.value * 60 * 1000
|
|
1993
|
-
);
|
|
1994
|
-
const nonce =
|
|
1995
|
-
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
1996
|
-
? crypto.randomUUID()
|
|
1997
|
-
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1998
|
-
|
|
1999
|
-
hidQrPrintPayload.value = JSON.stringify({
|
|
2000
|
-
purpose: "hid-amico-visitor-access",
|
|
2001
|
-
visitorId,
|
|
2002
|
-
site: prop.site,
|
|
2003
|
-
org: prop.org,
|
|
2004
|
-
visitorType: prop.type,
|
|
2005
|
-
name: visitor.name || "",
|
|
2006
|
-
company: visitor.company || "",
|
|
2007
|
-
contact: visitor.contact || "",
|
|
2008
|
-
nric: visitor.nric || "",
|
|
2009
|
-
plateNumber: visitor.plateNumber || "",
|
|
2010
|
-
block: visitor.block || "",
|
|
2011
|
-
level: visitor.level || "",
|
|
2012
|
-
unit: visitor.unit || "",
|
|
2013
|
-
unitName: visitor.unitName || "",
|
|
2014
|
-
address: buildVisitorAddress(),
|
|
2015
|
-
issuedAt: issuedAt.toISOString(),
|
|
2016
|
-
expiresAt: expiresAt.toISOString(),
|
|
2017
|
-
nonce,
|
|
2018
|
-
});
|
|
2019
|
-
|
|
2020
|
-
return hidQrPrintPayload.value;
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
2263
|
function buildVisitorAddress() {
|
|
2024
2264
|
const blockLabel =
|
|
2025
2265
|
blocksArray.value.find((b: any) => b.value === visitor.block)?.title || "";
|
|
@@ -2032,20 +2272,81 @@ function buildVisitorAddress() {
|
|
|
2032
2272
|
return [blockLabel, levelLabel, unitLabel].filter(Boolean).join("/");
|
|
2033
2273
|
}
|
|
2034
2274
|
|
|
2035
|
-
async function
|
|
2275
|
+
async function prepareHidQrCodePreview(visitorId: string) {
|
|
2276
|
+
hidQrPreviewError.value = "";
|
|
2277
|
+
if (!visitorId) {
|
|
2278
|
+
errorMessage.value = "Unable to identify the visitor for HID QR Code enrollment.";
|
|
2279
|
+
return false;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
try {
|
|
2283
|
+
hidQrVisitorId.value = visitorId;
|
|
2284
|
+
const response = await issueVisitorQr(hidQrReaderId.value, {
|
|
2285
|
+
visitorId,
|
|
2286
|
+
validityMinutes: hidQrValidityMinutes.value,
|
|
2287
|
+
});
|
|
2288
|
+
const credential = "data" in response ? response.data : response;
|
|
2289
|
+
hidQrPrintPayload.value = credential.qrValue;
|
|
2290
|
+
if (!hidQrPrintPayload.value) {
|
|
2291
|
+
errorMessage.value = "HID QR Code enrollment did not return a QR value.";
|
|
2292
|
+
return false;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
hidQrPreview.imageUrl = await QRCode.toDataURL(hidQrPrintPayload.value, {
|
|
2296
|
+
width: 360,
|
|
2297
|
+
margin: 2,
|
|
2298
|
+
errorCorrectionLevel: "M",
|
|
2299
|
+
});
|
|
2300
|
+
hidQrPreview.issuedAt = credential.issuedAt || "";
|
|
2301
|
+
hidQrPreview.expiresAt = credential.expiresAt || "";
|
|
2302
|
+
hidQrVisible.value = false;
|
|
2303
|
+
hidQrNow.value = Date.now();
|
|
2304
|
+
hidQrPreviewDialog.value = true;
|
|
2305
|
+
return true;
|
|
2306
|
+
} catch (error: unknown) {
|
|
2307
|
+
const message = errorConverter(error) || "Unable to generate HID QR Code.";
|
|
2308
|
+
if (hidQrPreviewDialog.value) hidQrPreviewError.value = message;
|
|
2309
|
+
else errorMessage.value = message;
|
|
2310
|
+
return false;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function formatHidQrCountdown(totalSeconds: number) {
|
|
2315
|
+
const safe = Math.max(0, totalSeconds);
|
|
2316
|
+
const hours = Math.floor(safe / 3600);
|
|
2317
|
+
const minutes = Math.floor((safe % 3600) / 60);
|
|
2318
|
+
const seconds = safe % 60;
|
|
2319
|
+
return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
async function regenerateHidQrCode() {
|
|
2323
|
+
if (!hidQrVisitorId.value) return;
|
|
2324
|
+
hidQrRegenerating.value = true;
|
|
2325
|
+
hidQrPreviewError.value = "";
|
|
2326
|
+
try {
|
|
2327
|
+
await prepareHidQrCodePreview(hidQrVisitorId.value);
|
|
2328
|
+
} finally {
|
|
2329
|
+
hidQrRegenerating.value = false;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
async function printHidQrCodePass() {
|
|
2036
2334
|
if (hidQrPrinted.value) return true;
|
|
2037
2335
|
|
|
2038
2336
|
if (!validateHidQrPrinterConfig()) return false;
|
|
2039
2337
|
|
|
2040
2338
|
const { vendorId, productId } = getHidQrPrinterUsbIds();
|
|
2041
2339
|
|
|
2042
|
-
|
|
2340
|
+
hidQrPrinting.value = true;
|
|
2341
|
+
hidQrPreviewError.value = "";
|
|
2043
2342
|
try {
|
|
2343
|
+
if (!hidQrPrintPayload.value) return false;
|
|
2344
|
+
|
|
2044
2345
|
const printResult = await testConnection(
|
|
2045
2346
|
vendorId,
|
|
2046
2347
|
productId,
|
|
2047
2348
|
true,
|
|
2048
|
-
|
|
2349
|
+
hidQrPrintPayload.value,
|
|
2049
2350
|
null,
|
|
2050
2351
|
null,
|
|
2051
2352
|
visitor.company || visitor.name || "",
|
|
@@ -2055,7 +2356,7 @@ async function printHidQrCodePass(visitorId = "", manageProcessing = true) {
|
|
|
2055
2356
|
);
|
|
2056
2357
|
|
|
2057
2358
|
if (!printResult?.success || printResult?.printTest?.success === false) {
|
|
2058
|
-
|
|
2359
|
+
hidQrPreviewError.value =
|
|
2059
2360
|
printResult?.printTest?.error ||
|
|
2060
2361
|
printResult?.error ||
|
|
2061
2362
|
"Unable to print HID QR Code. Please check the printer connection.";
|
|
@@ -2064,14 +2365,40 @@ async function printHidQrCodePass(visitorId = "", manageProcessing = true) {
|
|
|
2064
2365
|
|
|
2065
2366
|
hidQrPrinted.value = true;
|
|
2066
2367
|
return true;
|
|
2067
|
-
} catch (error:
|
|
2068
|
-
|
|
2368
|
+
} catch (error: unknown) {
|
|
2369
|
+
hidQrPreviewError.value = errorConverter(error) || "Unable to print HID QR Code.";
|
|
2069
2370
|
return false;
|
|
2070
2371
|
} finally {
|
|
2071
|
-
|
|
2372
|
+
hidQrPrinting.value = false;
|
|
2072
2373
|
}
|
|
2073
2374
|
}
|
|
2074
2375
|
|
|
2376
|
+
function formatHidQrExpiry(value: string) {
|
|
2377
|
+
const date = new Date(value);
|
|
2378
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function downloadHidQrCode() {
|
|
2382
|
+
if (!hidQrPreview.imageUrl) return;
|
|
2383
|
+
const safeName = String(visitor.name || "visitor")
|
|
2384
|
+
.trim()
|
|
2385
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
2386
|
+
.replace(/^-|-$/g, "")
|
|
2387
|
+
.toLowerCase();
|
|
2388
|
+
const link = document.createElement("a");
|
|
2389
|
+
link.href = hidQrPreview.imageUrl;
|
|
2390
|
+
link.download = `hid-visitor-qr-${safeName || "visitor"}.png`;
|
|
2391
|
+
link.click();
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function closeHidQrPreview() {
|
|
2395
|
+
hidQrPreviewDialog.value = false;
|
|
2396
|
+
const completion = hidQrPendingCompletion.value;
|
|
2397
|
+
hidQrPendingCompletion.value = null;
|
|
2398
|
+
if (completion === "done:more") emit("done:more");
|
|
2399
|
+
else if (completion === "done") emit("done");
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2075
2402
|
watch(
|
|
2076
2403
|
() => visitor.plateNumber,
|
|
2077
2404
|
(newVal) => {
|
|
@@ -2148,11 +2475,81 @@ onMounted(() => {
|
|
|
2148
2475
|
pointer-events: none;
|
|
2149
2476
|
}
|
|
2150
2477
|
|
|
2478
|
+
.visitor-form__head {
|
|
2479
|
+
display: flex;
|
|
2480
|
+
align-items: center;
|
|
2481
|
+
justify-content: space-between;
|
|
2482
|
+
padding-right: 12px;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/* The card must not scroll as a whole - that takes the Back/Submit footer off
|
|
2486
|
+
screen with it. The BODY scrolls; `.screen-modal` caps the card's height. */
|
|
2487
|
+
.visitor-form__body {
|
|
2488
|
+
padding: 4px 20px 0;
|
|
2489
|
+
max-height: calc(100vh - 160px);
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
.visitor-form__section-row {
|
|
2493
|
+
display: flex;
|
|
2494
|
+
align-items: baseline;
|
|
2495
|
+
justify-content: space-between;
|
|
2496
|
+
gap: 8px;
|
|
2497
|
+
margin-bottom: 4px;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
.visitor-form__section {
|
|
2501
|
+
font-size: var(--fs-cell);
|
|
2502
|
+
font-weight: 800;
|
|
2503
|
+
color: var(--text);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
/* The handoff draws no step counter, so this takes the established type: the
|
|
2507
|
+
cell size, the accent for the step you are on, `--text2` for the rest. */
|
|
2508
|
+
.visitor-form__step {
|
|
2509
|
+
font-size: var(--fs-cell);
|
|
2510
|
+
font-weight: 700;
|
|
2511
|
+
color: var(--text2);
|
|
2512
|
+
white-space: nowrap;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/* `--accent-text`, not `--accent-strong`: the strong one is a FILL for a white
|
|
2516
|
+
label. Used as ink on `--card` it reads 3.64:1 in dark. `--accent-text` is
|
|
2517
|
+
the accent's text form and `utils/theme.test.ts` asserts it >= 4.5:1 there. */
|
|
2518
|
+
.visitor-form__step-now {
|
|
2519
|
+
color: var(--accent-text);
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
.visitor-form__error {
|
|
2523
|
+
margin: 0;
|
|
2524
|
+
padding: 0 20px;
|
|
2525
|
+
font-size: var(--fs-cell);
|
|
2526
|
+
font-weight: 500;
|
|
2527
|
+
color: var(--err);
|
|
2528
|
+
text-align: center;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/* The design's dialog footer: ghost secondary, then one primary, right-aligned.
|
|
2532
|
+
Deliberately NOT the shared `.screen-modal__footer` - that class is on the
|
|
2533
|
+
batch-D branch (#1150) and not on this base, and adding a second copy of it
|
|
2534
|
+
to `screens.css` here would collide with that PR. */
|
|
2535
|
+
.visitor-form__footer {
|
|
2536
|
+
display: flex;
|
|
2537
|
+
justify-content: flex-end;
|
|
2538
|
+
gap: 8px;
|
|
2539
|
+
padding: 16px 20px 20px;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
/* `!important` only because Vuetify's own is: a `v-list-item` with no `rounded`
|
|
2543
|
+
prop emits `rounded-0`, and that utility is `border-radius: 0 !important`.
|
|
2544
|
+
The original `border-radius: 4px` here lost the same fight and never drew -
|
|
2545
|
+
measured 0px on the glass before this change. Same pattern `.screen-modal`
|
|
2546
|
+
already uses for `--r-modal`. */
|
|
2151
2547
|
.nric-search-item {
|
|
2152
|
-
border: 1px solid
|
|
2153
|
-
border-radius:
|
|
2548
|
+
border: 1px solid var(--border);
|
|
2549
|
+
border-radius: var(--r-inner) !important;
|
|
2154
2550
|
margin: 4px;
|
|
2155
2551
|
min-height: 72px;
|
|
2552
|
+
font-size: var(--fs-cell);
|
|
2156
2553
|
}
|
|
2157
2554
|
|
|
2158
2555
|
.phone-search-results {
|
|
@@ -2160,6 +2557,9 @@ onMounted(() => {
|
|
|
2160
2557
|
flex-direction: column;
|
|
2161
2558
|
max-height: 400px;
|
|
2162
2559
|
overflow: hidden;
|
|
2560
|
+
background: var(--card);
|
|
2561
|
+
border: 1px solid var(--border);
|
|
2562
|
+
box-shadow: var(--shadow-menu);
|
|
2163
2563
|
}
|
|
2164
2564
|
|
|
2165
2565
|
.search-results-scroll {
|
|
@@ -2172,44 +2572,208 @@ onMounted(() => {
|
|
|
2172
2572
|
flex: 0 0 auto;
|
|
2173
2573
|
}
|
|
2174
2574
|
|
|
2175
|
-
.nric-options-
|
|
2176
|
-
|
|
2575
|
+
.nric-options-body {
|
|
2576
|
+
padding: 0 20px;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
.visitor-form__head--bare {
|
|
2580
|
+
justify-content: flex-end;
|
|
2581
|
+
padding-top: 12px;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
.checkout-dialog__body {
|
|
2585
|
+
padding: 0 20px;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
.checkout-dialog__lead {
|
|
2589
|
+
margin-bottom: 20px;
|
|
2590
|
+
font-size: var(--fs-cell);
|
|
2591
|
+
font-weight: 700;
|
|
2592
|
+
color: var(--text);
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
.checkout-dialog__subject {
|
|
2596
|
+
margin-bottom: 20px;
|
|
2597
|
+
font-size: var(--fs-cell);
|
|
2598
|
+
color: var(--text2);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
/* The initial badge was a flat `red` avatar with a white letter - 3.99:1 in
|
|
2602
|
+
both themes, and red is the design's ERROR tone, which a person's initial is
|
|
2603
|
+
not. It takes the accent tag: `--accent-soft` fill, `--accent-text` ink. */
|
|
2604
|
+
.checkout-dialog__avatar {
|
|
2605
|
+
background: var(--accent-soft);
|
|
2606
|
+
color: var(--accent-text);
|
|
2607
|
+
font-size: var(--fs-chip);
|
|
2608
|
+
font-weight: var(--fw-chip);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
.checkout-dialog__name {
|
|
2612
|
+
font-size: var(--fs-cell);
|
|
2613
|
+
font-weight: var(--fw-cell);
|
|
2614
|
+
color: var(--text);
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
.checkout-dialog__meta {
|
|
2618
|
+
font-size: var(--fs-breadcrumb);
|
|
2619
|
+
color: var(--text2);
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
/* Checked-in IS a success state, so this one keeps its green - as the token,
|
|
2623
|
+
not Vuetify's `green`, which does not follow the theme. */
|
|
2624
|
+
.checkout-dialog__in {
|
|
2625
|
+
font-size: var(--fs-cell);
|
|
2626
|
+
color: var(--text);
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
.checkout-dialog__in :deep(.v-icon) {
|
|
2630
|
+
color: var(--ok);
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
.unreg-dialog__body {
|
|
2634
|
+
padding: 0 20px;
|
|
2635
|
+
text-align: center;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
/* A warning IS the design's warning tone; Vuetify's `orange` is not it and
|
|
2639
|
+
does not follow the theme. */
|
|
2640
|
+
.unreg-dialog__alert {
|
|
2641
|
+
color: var(--warn);
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
.unreg-dialog__lead {
|
|
2645
|
+
margin-top: 20px;
|
|
2646
|
+
font-size: var(--fs-cell);
|
|
2647
|
+
color: var(--text);
|
|
2177
2648
|
}
|
|
2178
2649
|
|
|
2179
|
-
.
|
|
2180
|
-
|
|
2181
|
-
font-size: 1.45rem;
|
|
2182
|
-
line-height: 1.2;
|
|
2650
|
+
.unreg-dialog__table {
|
|
2651
|
+
margin-top: 20px;
|
|
2183
2652
|
}
|
|
2184
2653
|
|
|
2654
|
+
.unreg-dialog__plate {
|
|
2655
|
+
font-weight: 800;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
/* `#06253b` was a literal navy on every line of this dialog - unreadable on a
|
|
2659
|
+
dark card. All of it is `--text` / `--text2` now. */
|
|
2185
2660
|
.nric-options-section-title {
|
|
2186
|
-
|
|
2187
|
-
font-
|
|
2661
|
+
font-size: var(--fs-cell);
|
|
2662
|
+
font-weight: 800;
|
|
2188
2663
|
line-height: 1.25;
|
|
2664
|
+
color: var(--text);
|
|
2189
2665
|
}
|
|
2190
2666
|
|
|
2191
2667
|
.nric-selected-person {
|
|
2192
|
-
border: 1px solid
|
|
2193
|
-
border-radius:
|
|
2194
|
-
color:
|
|
2195
|
-
font-size:
|
|
2668
|
+
border: 1px solid var(--border);
|
|
2669
|
+
border-radius: var(--r-inner-lg);
|
|
2670
|
+
color: var(--text);
|
|
2671
|
+
font-size: var(--fs-cell);
|
|
2196
2672
|
min-height: 52px;
|
|
2197
2673
|
padding: 10px 14px;
|
|
2198
2674
|
}
|
|
2199
2675
|
|
|
2200
2676
|
.nric-option-group {
|
|
2201
|
-
border-top: 1px solid
|
|
2677
|
+
border-top: 1px solid var(--border);
|
|
2202
2678
|
}
|
|
2203
2679
|
|
|
2204
2680
|
.nric-option-radio {
|
|
2205
|
-
border-bottom: 1px solid
|
|
2206
|
-
color:
|
|
2207
|
-
font-size:
|
|
2681
|
+
border-bottom: 1px solid var(--border);
|
|
2682
|
+
color: var(--text);
|
|
2683
|
+
font-size: var(--fs-cell);
|
|
2208
2684
|
min-height: 46px;
|
|
2209
2685
|
}
|
|
2210
2686
|
|
|
2211
2687
|
.nric-option-radio :deep(.v-label) {
|
|
2212
|
-
font-size:
|
|
2688
|
+
font-size: var(--fs-cell);
|
|
2689
|
+
color: var(--text);
|
|
2213
2690
|
opacity: 1;
|
|
2214
2691
|
}
|
|
2692
|
+
|
|
2693
|
+
.hid-qr__body {
|
|
2694
|
+
padding: 0 20px;
|
|
2695
|
+
text-align: center;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
.hid-qr__header {
|
|
2699
|
+
font-size: var(--fs-card-title);
|
|
2700
|
+
font-weight: var(--fw-card-title);
|
|
2701
|
+
color: var(--text);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
.hid-qr__subtext {
|
|
2705
|
+
margin-top: 4px;
|
|
2706
|
+
font-size: var(--fs-cell);
|
|
2707
|
+
color: var(--text2);
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
.hid-qr__name {
|
|
2711
|
+
margin-top: 12px;
|
|
2712
|
+
font-size: var(--fs-card-title);
|
|
2713
|
+
font-weight: var(--fw-card-title);
|
|
2714
|
+
color: var(--text);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
.hid-qr__meta {
|
|
2718
|
+
margin-top: 4px;
|
|
2719
|
+
font-size: var(--fs-breadcrumb);
|
|
2720
|
+
color: var(--text2);
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
/* The QR itself STAYS on a white plate with dark ink, in both themes, on
|
|
2724
|
+
purpose: a phone camera and an HID reader need the light-on-dark contrast the
|
|
2725
|
+
code is printed with, and inverting it in dark mode would stop it scanning.
|
|
2726
|
+
The frame around it follows the theme; the plate does not. */
|
|
2727
|
+
.hid-qr-image-shell {
|
|
2728
|
+
display: inline-flex;
|
|
2729
|
+
margin: 20px 0;
|
|
2730
|
+
padding: 12px;
|
|
2731
|
+
border: 1px solid var(--border);
|
|
2732
|
+
border-radius: var(--r-inner);
|
|
2733
|
+
background: #ffffff;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
.hid-qr-reveal {
|
|
2737
|
+
display: flex;
|
|
2738
|
+
width: 280px;
|
|
2739
|
+
height: 280px;
|
|
2740
|
+
align-items: center;
|
|
2741
|
+
justify-content: center;
|
|
2742
|
+
border: 0;
|
|
2743
|
+
border-radius: var(--r-inner-sm);
|
|
2744
|
+
/* The white plate above is a deliberate exception FOR THE PRINTED CODE. This
|
|
2745
|
+
is the tap-to-reveal button that sits in its place beforehand, so it is
|
|
2746
|
+
ordinary chrome and follows the theme: a literal `#f5f7f9`/`#344054` drew a
|
|
2747
|
+
light slab with dark ink inside the dark modal. `--bg` reads as a recess
|
|
2748
|
+
against the modal's `--card` in both themes. Measured `--text2` on `--bg`:
|
|
2749
|
+
8.88:1 light, 11.21:1 dark. */
|
|
2750
|
+
background: var(--bg);
|
|
2751
|
+
color: var(--text2);
|
|
2752
|
+
cursor: pointer;
|
|
2753
|
+
flex-direction: column;
|
|
2754
|
+
gap: 10px;
|
|
2755
|
+
font: inherit;
|
|
2756
|
+
font-size: var(--fs-cell);
|
|
2757
|
+
}
|
|
2758
|
+
</style>
|
|
2759
|
+
|
|
2760
|
+
<!--
|
|
2761
|
+
UNSCOPED on purpose, and only for the menu surface.
|
|
2762
|
+
|
|
2763
|
+
A `v-menu`'s panel is TELEPORTED to the overlay container, so it never
|
|
2764
|
+
carries this component's scope attribute - a scoped rule cannot reach it.
|
|
2765
|
+
And Vuetify owns the radius from outside at a specificity a scoped class
|
|
2766
|
+
cannot beat: `.v-menu > .v-overlay__content > .v-list { border-radius:
|
|
2767
|
+
inherit }` is 0,3,0 against a scoped class's 0,2,0, so the list inherits the
|
|
2768
|
+
overlay's 4px however the list itself is styled. Measured: 4px on the glass
|
|
2769
|
+
with the scoped rule in place.
|
|
2770
|
+
|
|
2771
|
+
So the radius is set where Vuetify sets it - on the overlay content, reached
|
|
2772
|
+
by the menu's own `content-class`. The class name is component-prefixed, so
|
|
2773
|
+
nothing else in the layer can pick it up. No `!important`.
|
|
2774
|
+
-->
|
|
2775
|
+
<style>
|
|
2776
|
+
.v-menu > .v-overlay__content.visitor-form__menu {
|
|
2777
|
+
border-radius: var(--r-menu);
|
|
2778
|
+
}
|
|
2215
2779
|
</style>
|