@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.
Files changed (283) hide show
  1. package/assets/css/primitives.css +1113 -0
  2. package/assets/css/screens.css +1097 -0
  3. package/assets/css/shell.css +258 -0
  4. package/assets/css/tokens.css +236 -0
  5. package/components/AcceptDialog.vue +17 -18
  6. package/components/AccessCard/AvailableStats.vue +24 -16
  7. package/components/AccessCardAddForm.vue +42 -42
  8. package/components/AccessCardAssignToUnitForm.vue +13 -21
  9. package/components/AccessCardDeleteDialog.vue +41 -39
  10. package/components/AccessCardDetailsDialog.vue +125 -79
  11. package/components/AccessCardPreviewDialog.vue +58 -84
  12. package/components/AccessCardQrTagging.vue +60 -63
  13. package/components/AccessCardReplaceForm.vue +27 -23
  14. package/components/AccessManagement.vue +86 -85
  15. package/components/AddEqupmentForm.vue +18 -22
  16. package/components/AddPassKeyToVisitor.vue +95 -73
  17. package/components/AppButton.vue +62 -0
  18. package/components/AppCard.vue +20 -0
  19. package/components/AppDateField.vue +49 -0
  20. package/components/AppField.vue +76 -0
  21. package/components/AppKpiTile.vue +65 -0
  22. package/components/AppSegmented.vue +38 -0
  23. package/components/AppSelect.vue +247 -0
  24. package/components/AreaChecklistHistoryLogs.vue +51 -44
  25. package/components/AreaChecklistHistoryMain.vue +14 -7
  26. package/components/AreaFormDialog.vue +71 -59
  27. package/components/AreaMain.vue +73 -107
  28. package/components/AttachmentsViewer.vue +84 -22
  29. package/components/AttendanceCheckInOutDialog.vue +85 -88
  30. package/components/AttendanceDetailsDialog.vue +57 -63
  31. package/components/AttendanceMain.vue +4 -3
  32. package/components/AttendanceMapSearchDialog.vue +42 -44
  33. package/components/AttendanceSettingsDialog.vue +79 -63
  34. package/components/Avatar/Main.vue +54 -2
  35. package/components/BillingMain.vue +26 -30
  36. package/components/BtnUploadFile.vue +53 -11
  37. package/components/BuildingForm.vue +34 -27
  38. package/components/BuildingManagement/buildings.vue +39 -39
  39. package/components/BuildingManagement/units.vue +26 -42
  40. package/components/BuildingUnitFormAdd.vue +20 -23
  41. package/components/BuildingUnitFormEdit.vue +8 -4
  42. package/components/BulletinBoardForm.vue +19 -24
  43. package/components/BulletinBoardManagement.vue +49 -57
  44. package/components/BulletinBoardView.vue +9 -6
  45. package/components/BulletinExpirationChip.vue +13 -4
  46. package/components/Button/Close.vue +2 -2
  47. package/components/CameraForm.vue +21 -22
  48. package/components/CameraMain.vue +50 -49
  49. package/components/Card/DeleteConfirmation.vue +24 -22
  50. package/components/Card/MemberInfoSummary.vue +28 -3
  51. package/components/Card/Toggle.vue +15 -3
  52. package/components/CardToolbar.vue +37 -0
  53. package/components/Carousel.vue +56 -36
  54. package/components/Chat/Bubbles.vue +43 -14
  55. package/components/Chat/Information.vue +49 -32
  56. package/components/Chat/ListCard.vue +38 -9
  57. package/components/Chat/Message.vue +41 -15
  58. package/components/Chat/Navigation.vue +27 -15
  59. package/components/Chat/SkeletonLoader.vue +9 -2
  60. package/components/ChecklistItemRow.vue +25 -1
  61. package/components/CleaningScheduleMain.vue +44 -49
  62. package/components/ClientDetailForm.vue +81 -56
  63. package/components/ClientMain.vue +92 -82
  64. package/components/ConfirmDialog.vue +14 -8
  65. package/components/Container/Standard.vue +9 -7
  66. package/components/CreateSubsciptionPlan.vue +148 -89
  67. package/components/DashboardEmptyState.vue +48 -3
  68. package/components/DashboardMain.vue +924 -676
  69. package/components/DashboardPanel.vue +84 -21
  70. package/components/DeliveryCompany.vue +34 -20
  71. package/components/Dialog/DeleteConfirmation.vue +20 -21
  72. package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
  73. package/components/Dialog/ReusablePrompt.vue +30 -29
  74. package/components/Dialog/UpdateMoreAction.vue +62 -59
  75. package/components/DocumentForm.vue +14 -21
  76. package/components/DocumentManagement.vue +162 -108
  77. package/components/DocumentViewer.vue +29 -12
  78. package/components/DrawImage.vue +29 -21
  79. package/components/EntryPass/QrTemplatePreview.vue +27 -16
  80. package/components/EntryPassInformation.vue +40 -10
  81. package/components/EntryPassMain.vue +49 -27
  82. package/components/EquipmentItemMain.vue +84 -97
  83. package/components/EquipmentManagementMain.vue +81 -103
  84. package/components/Facility/BookingSetup.vue +43 -52
  85. package/components/Feedback/Form.vue +26 -12
  86. package/components/FeedbackMain.vue +35 -49
  87. package/components/FileInput.vue +75 -58
  88. package/components/FileInputWithList.vue +59 -12
  89. package/components/FormDialog.vue +60 -21
  90. package/components/HidAccessLogDashboard.vue +512 -490
  91. package/components/HidCameraCaptureDialog.vue +216 -0
  92. package/components/HidEnabledGate.vue +67 -9
  93. package/components/HidIntercomManagement.vue +1322 -653
  94. package/components/HidQrCodeConfiguration.vue +1030 -258
  95. package/components/HidReaderForm.vue +142 -59
  96. package/components/HidReaderManagement.vue +211 -323
  97. package/components/HidServiceSettingsPanel.vue +44 -13
  98. package/components/HidUserEnrollment.vue +1185 -412
  99. package/components/HygieneUpdateMoreAction.vue +44 -44
  100. package/components/IncidentReport/Authorities.vue +30 -18
  101. package/components/IncidentReport/IncidentInformation.vue +10 -10
  102. package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
  103. package/components/IncidentReport/affectedEntities.vue +41 -20
  104. package/components/Input/Date.vue +3 -0
  105. package/components/Input/DatePicker.vue +1 -2
  106. package/components/Input/DateTimePicker.vue +1 -1
  107. package/components/Input/File.vue +55 -10
  108. package/components/Input/FileV2.vue +39 -6
  109. package/components/Input/InputPhoneNumberV2.vue +2 -1
  110. package/components/Input/ListGroupSelection.vue +56 -8
  111. package/components/Input/NRICNumber.vue +1 -0
  112. package/components/Input/NewDate.vue +1 -0
  113. package/components/Input/Number.vue +1 -0
  114. package/components/Input/Password.vue +1 -0
  115. package/components/Input/PhoneNumber.vue +21 -1
  116. package/components/Input/VehicleNumber.vue +1 -0
  117. package/components/InputLabel.vue +2 -2
  118. package/components/InvitationClientForm.vue +21 -19
  119. package/components/InvitationForm.vue +15 -21
  120. package/components/InvitationMain.vue +46 -61
  121. package/components/Layout/Header.vue +263 -36
  122. package/components/Layout/NavigationDrawer.vue +300 -30
  123. package/components/ListItem.vue +33 -3
  124. package/components/LocalPagination.vue +27 -3
  125. package/components/ManageChecklistMain.vue +136 -86
  126. package/components/MemberInformation.vue +32 -16
  127. package/components/MemberMain.vue +113 -93
  128. package/components/MyAttendanceMain.vue +3 -5
  129. package/components/NavigationItem.vue +83 -3
  130. package/components/Nfc/NFCPatrolReportMain.vue +47 -33
  131. package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
  132. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  133. package/components/Nfc/NFCPatrolSettings.vue +14 -4
  134. package/components/Nfc/NFCTagForm.vue +22 -8
  135. package/components/Nfc/NFCTagMain.vue +41 -43
  136. package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
  137. package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
  138. package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
  139. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
  140. package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
  141. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
  142. package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
  143. package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
  144. package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
  145. package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
  146. package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
  147. package/components/NotificationSettings.vue +373 -0
  148. package/components/NumberSettingField.vue +9 -2
  149. package/components/OnlineFormConfigurationForm.vue +725 -237
  150. package/components/OnlineFormFill.vue +213 -106
  151. package/components/OnlineFormRenderPreview.vue +25 -30
  152. package/components/OnlineFormsConfiguration.vue +113 -109
  153. package/components/OrgViewDialog.vue +34 -16
  154. package/components/OvernightParkingAvailability.vue +49 -26
  155. package/components/OvernightParkingManagement.vue +78 -18
  156. package/components/PageHeader.vue +43 -0
  157. package/components/PassInformation.vue +109 -53
  158. package/components/PasswordConfirmation.vue +44 -26
  159. package/components/PeopleForm.vue +41 -20
  160. package/components/PhotoUpload.vue +110 -48
  161. package/components/PlaceholderComponent.vue +35 -19
  162. package/components/PlanActionsMenu.vue +12 -2
  163. package/components/PlanStatusBadge.vue +7 -8
  164. package/components/PlateNumberDisplay.vue +9 -6
  165. package/components/QrTemplate/PrintDialog.vue +66 -43
  166. package/components/RolePermissionFormCreate.vue +104 -79
  167. package/components/RolePermissionFormPreviewUpdate.vue +111 -109
  168. package/components/RolePermissionMain.vue +183 -81
  169. package/components/ScanVisitorQRCode.vue +99 -35
  170. package/components/ScheduleAreaMain.vue +34 -59
  171. package/components/ScheduleTaskForm.vue +61 -42
  172. package/components/ScheduleTaskMain.vue +56 -69
  173. package/components/SearchVehicleNumberUser.vue +39 -37
  174. package/components/ServiceProviderFormCreate.vue +29 -20
  175. package/components/ServiceProviderInvitationPrompt.vue +155 -0
  176. package/components/ServiceProviderMain.vue +601 -432
  177. package/components/Signature.vue +40 -34
  178. package/components/SignaturePad.vue +5 -29
  179. package/components/SiteSettings.vue +41 -11
  180. package/components/SiteSettingsWorkOrder.vue +61 -30
  181. package/components/SlideCardGroup.vue +35 -11
  182. package/components/Snackbar.vue +36 -3
  183. package/components/SpecificAttr.vue +18 -8
  184. package/components/StatusChip.vue +156 -0
  185. package/components/StockCard.vue +17 -12
  186. package/components/SubscriptionPlanTable.vue +207 -140
  187. package/components/SwitchContext.vue +201 -9
  188. package/components/TableHygiene.vue +36 -46
  189. package/components/TableList.vue +10 -10
  190. package/components/TableListSecondary.vue +105 -54
  191. package/components/TableMain.vue +251 -78
  192. package/components/TableV3.vue +61 -70
  193. package/components/TiptapEditor.vue +33 -40
  194. package/components/TiptapEditorQr.vue +5 -2
  195. package/components/Tooltip/Info.vue +69 -24
  196. package/components/UnitMain.vue +40 -60
  197. package/components/UnitPersonCard.vue +42 -13
  198. package/components/VehicleAddSelection.vue +48 -32
  199. package/components/VehicleForm.vue +112 -129
  200. package/components/VehicleManagement.vue +26 -12
  201. package/components/VehicleUpdateMoreAction.vue +59 -46
  202. package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
  203. package/components/VisitorForm.vue +774 -210
  204. package/components/VisitorFormSelection.vue +40 -37
  205. package/components/VisitorManagement.vue +239 -82
  206. package/components/VisitorPassKeyQRScanner.vue +65 -18
  207. package/components/VisitorSearchResultSummary.vue +60 -16
  208. package/components/VisitorSocketPopUp.vue +22 -14
  209. package/components/VisitorsReportPreview.vue +44 -29
  210. package/components/WorkOrder/Create.vue +26 -17
  211. package/components/WorkOrder/ListView.vue +38 -7
  212. package/components/WorkOrder/Main.vue +62 -59
  213. package/composables/useFeedback.ts +3 -3
  214. package/composables/useHidAmico.ts +269 -0
  215. package/composables/useHidNavigation.ts +2 -9
  216. package/composables/useMember.ts +3 -3
  217. package/composables/useNFCPatrolDailyReport.ts +13 -0
  218. package/composables/useNFCPatrolMonthlyReport.ts +22 -56
  219. package/composables/useNFCPatrolReport.ts +13 -0
  220. package/composables/useNotificationPreference.ts +87 -0
  221. package/composables/useOnlineFormPages.ts +2 -0
  222. package/composables/useRole.ts +22 -0
  223. package/composables/useServiceProvider.ts +3 -3
  224. package/composables/useServiceProviderInvitation.ts +145 -0
  225. package/composables/useSipWebPhone.ts +336 -0
  226. package/composables/useSubscriptionPlan.ts +62 -0
  227. package/composables/useThemePreference.ts +63 -0
  228. package/composables/useVisitor.ts +6 -1
  229. package/composables/useWebUsb.ts +127 -37
  230. package/composables/useWorkOrder.ts +17 -3
  231. package/error.vue +30 -6
  232. package/nuxt.config.ts +36 -0
  233. package/package.json +25 -4
  234. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
  235. package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
  236. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  237. package/pages/notification-settings.vue +19 -0
  238. package/pages/payment-method-linked.vue +34 -10
  239. package/pages/require-customer.vue +48 -10
  240. package/pages/require-organization-membership.vue +32 -8
  241. package/pages/unauthorized.vue +29 -4
  242. package/plugins/secure-member.client.ts +21 -56
  243. package/plugins/vuetify.ts +69 -2
  244. package/types/nfc-patrol-report.ts +3 -2
  245. package/types/site.d.ts +71 -0
  246. package/types/subscription.ts +18 -11
  247. package/utils/breadcrumb.test.ts +108 -0
  248. package/utils/breadcrumb.ts +82 -0
  249. package/utils/dashboard.test.ts +118 -0
  250. package/utils/dashboard.ts +53 -0
  251. package/utils/data.test.ts +95 -0
  252. package/utils/data.ts +40 -2
  253. package/utils/hid-discover.test.ts +102 -0
  254. package/utils/list-page.test.ts +60 -0
  255. package/utils/list-page.ts +38 -0
  256. package/utils/materialSymbols.ts +363 -0
  257. package/utils/nav-sections.test.ts +190 -0
  258. package/utils/nav-sections.ts +151 -0
  259. package/utils/nfc-patrol-report.test.ts +130 -0
  260. package/utils/nfc-patrol-report.ts +101 -0
  261. package/utils/role.test.ts +69 -0
  262. package/utils/role.ts +10 -0
  263. package/utils/route-name.test.ts +85 -0
  264. package/utils/route-name.ts +24 -0
  265. package/utils/status.test.ts +110 -0
  266. package/utils/status.ts +135 -0
  267. package/utils/table-card.test.ts +84 -0
  268. package/utils/theme-aa-ledger.ts +148 -0
  269. package/utils/theme.test.ts +485 -7
  270. package/utils/theme.ts +295 -110
  271. package/.changeset/README.md +0 -8
  272. package/.changeset/config.json +0 -11
  273. package/.editorconfig +0 -12
  274. package/.github/workflows/main.yml +0 -17
  275. package/.github/workflows/publish-staging.yml +0 -47
  276. package/.github/workflows/publish.yml +0 -39
  277. package/PUBLISHING.md +0 -269
  278. package/components/HidIdentityMapping.vue +0 -975
  279. package/components/ScheduleTaskAreaFormDialog.vue +0 -144
  280. package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
  281. package/components/ScheduleTastTicketMain.vue +0 -182
  282. package/components/TableWithButton.vue +0 -94
  283. package/test/visitor-socket.test.mjs +0 -36
@@ -1,20 +1,17 @@
1
1
  <template>
2
- <v-card width="100%" :loading="processing">
3
- <v-toolbar>
4
- <v-row no-gutters class="fill-height px-6 d-flex justify-space-between align-center" align="center">
5
- <span class="font-weight-bold text-h5 text-capitalize">
6
- {{ formMode }} {{ formatVisitorType(type) }}
7
- </span>
8
- <ButtonClose @click="emit('close:all')" />
9
- </v-row>
10
- </v-toolbar>
11
- <v-card-text style="max-height: 100vh; overflow-y: auto" class="pa-5 my-3">
12
- <div class="w-100 d-flex justify-space-between ga-2">
13
- <span class="text-subtitle-1 w-100 font-weight-bold mb-3">{{
14
- formTitle
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="flat" color="red" @click.stop="closeAutofillSearchResults('nric')">
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="flat" color="red" @click.stop="closeAutofillSearchResults('contact')">
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="flat" color="red"
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
- <v-radio-group v-model="visitor.deliveryType" inline color="primary" :rules="[requiredRule]">
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
- </v-card-text>
287
-
288
- <v-row no-gutters class="w-100" v-if="errorMessage">
289
- <p class="text-error w-100 text-center text-subtitle-2">
290
- {{ errorMessage }}
291
- </p>
292
- </v-row>
293
- <v-toolbar density="compact">
294
- <v-row no-gutters>
295
- <v-col cols="6">
296
- <v-btn v-if="
297
- formMode === 'add' &&
298
- (withStep2 || withStep3) &&
299
- contractorStep > 1
300
- " tile block variant="text" class="text-none" size="48" @click="handleGoToPreviousPage" text="Back" />
301
- <v-btn v-else-if="formMode === 'add' || formMode === 'register'" tile block variant="text" class="text-none"
302
- size="48" @click="backToSelection" text="Back to Selection" />
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
- <v-card-actions class="justify-end pa-1">
325
- <v-btn icon="mdi-close" variant="text" size="small" @click="closeNricAutofillOptions" />
326
- </v-card-actions>
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 font-weight-bold mb-2">Contact</h3>
338
- <v-radio-group v-model="selectedNricContact" hide-details color="success" class="nric-option-group">
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 font-weight-bold mb-2 mt-5">Vehicle</h3>
346
- <v-radio-group v-model="selectedNricPlate" hide-details color="success" class="nric-option-group">
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
- </v-card-text>
352
+ </div>
352
353
 
353
- <v-card-actions class="justify-end px-4 pb-4">
354
- <v-btn variant="text" class="text-none" size="small" @click="closeNricAutofillOptions">Cancel</v-btn>
355
- <v-btn color="primary-button" variant="flat" class="text-none px-5" size="small"
356
- @click="confirmNricAutofillOptions">
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 :loading="loading.checkingOut || loading.searchingNonCheckedOut">
365
- <v-card-actions class="justify-end bg-grey-lighten-3 pa-4">
366
- <v-btn icon="mdi-close" variant="text" size="large" @click="dialog.showNonCheckedOutDialog = false" />
367
- </v-card-actions>
368
-
369
- <v-card-text class="pa-6">
370
- <p class="text-subtitle-1 font-weight-medium mb-8">
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="text-subtitle-1 mb-8">
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="border rounded" :headers="nonCheckedOutVisitorHeaders"
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 color="red" size="32">
384
- <span class="text-white text-subtitle-2">{{ getVisitorInitial(item.name) }}</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="font-weight-medium">{{ item.name || "N/A" }}</div>
388
- <div class="text-caption">
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" color="green" size="20" class="mr-1" />
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 size="small" class="text-none" color="red" variant="flat"
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
- <div class="d-flex justify-end mt-8">
439
- <v-btn v-if="!skipNonCheckedOutNric" color="red" variant="flat" size="large" class="text-none px-10"
440
- :loading="loading.searchingNonCheckedOut" @click="handleSkipNricNonCheckedOut">
441
- Skip NRIC: {{ visitor.nric || "N/A" }}
442
- </v-btn>
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 color="background" class="pa-4">
450
- <v-card-actions class="justify-end pa-0">
451
- <v-btn icon="mdi-close" variant="text" size="large" @click="closeUnregisteredPlateDialog" />
452
- </v-card-actions>
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
- <v-card-text class="text-center pt-2">
455
- <v-icon color="orange" size="96">mdi-alert-circle-outline</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
- <div :class="`text-body-${$vuetify.display.xs ? 'mobile' : 'tablet'} mt-8`">
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
- </div>
514
+ </p>
464
515
 
465
- <v-table class="mt-8 border rounded">
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 font-weight-bold text-uppercase">
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
- <v-btn color="red" class="text-capitalize" density="compact" size="large"
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
- </v-card-text>
538
+ </div>
491
539
 
492
- <v-card-actions>
493
- <v-btn block color="red" variant="flat" size="x-large" @click="closeUnregisteredPlateDialog">
494
- Cancel & Add New
540
+ <div class="visitor-form__footer">
541
+ <AppButton variant="ghost" @click="closeUnregisteredPlateDialog">Cancel &amp; 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
- </v-card-actions>
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
- return (siteData.value as any)?.metadata?.hidQrCodePass || {};
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
- async function handleCheckout(visitorId: string) {
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" && !validateHidQrPrinterConfig()) return;
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 printed = await printHidQrCodePass(
1804
- getVisitorIdFromResponse(res),
1805
- false
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: any) {
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
- response?._id ||
1954
- response?.id ||
1955
- response?.data?._id ||
1956
- response?.data?.id ||
1957
- response?.insertedId ||
1958
- response?.data?.insertedId ||
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 printHidQrCodePass(visitorId = "", manageProcessing = true) {
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
- if (manageProcessing) processing.value = true;
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
- buildHidQrCodePayload(visitorId),
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
- errorMessage.value =
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: any) {
2068
- errorMessage.value = error?.message || "Unable to print HID QR Code.";
2368
+ } catch (error: unknown) {
2369
+ hidQrPreviewError.value = errorConverter(error) || "Unable to print HID QR Code.";
2069
2370
  return false;
2070
2371
  } finally {
2071
- if (manageProcessing) processing.value = false;
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 rgba(var(--v-border-color), var(--v-border-opacity));
2153
- border-radius: 4px;
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-card {
2176
- border-radius: 16px !important;
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
- .nric-options-title {
2180
- color: #06253b;
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
- color: #06253b;
2187
- font-size: 1.05rem;
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 rgba(var(--v-border-color), var(--v-border-opacity));
2193
- border-radius: 12px;
2194
- color: #06253b;
2195
- font-size: 0.95rem;
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 rgba(var(--v-border-color), var(--v-border-opacity));
2677
+ border-top: 1px solid var(--border);
2202
2678
  }
2203
2679
 
2204
2680
  .nric-option-radio {
2205
- border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
2206
- color: #06253b;
2207
- font-size: 0.95rem;
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: 0.95rem;
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>