@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,203 +1,229 @@
1
+ <!--
2
+ HID QR CODE CONFIGURATION - the reader's own settings, inside Site Settings.
3
+
4
+ It is mounted in `SiteSettings.vue`'s "HID Face Reader" expansion panel, next
5
+ to `HidServiceSettingsPanel`, so it takes the shape the handoff gives a
6
+ settings module BODY: section labels at 13px/700, explanatory lines at
7
+ 12-13px/600 in `--muted`, the 38x22 toggle, 40px fields and 38px buttons.
8
+
9
+ Two pieces of legacy chrome are gone rather than restyled:
10
+ * the `#082e43` navy title bar. The design puts NO card inside an accordion
11
+ body - the accordion head ("HID Face Reader") is the card, and a second
12
+ titled panel inside it is a header the design does not draw. Its title is
13
+ kept as the body's first section label.
14
+ * the chevron button that sat on that bar. It had no `@click` and no model:
15
+ it looked like a collapse control and did nothing.
16
+
17
+ Every field, toggle, dialog, handler and save call is otherwise unchanged.
18
+ -->
1
19
  <template>
2
20
  <section class="hid-qr-config">
3
- <v-card flat class="config-panel">
4
- <div class="panel-header">
21
+ <div class="config-body">
22
+ <div class="section-label config-body__title">HID Configuration</div>
23
+
24
+ <div class="online-mode-row">
5
25
  <div>
6
- <h2>HID Configuration</h2>
26
+ <h3>Activate Online Mode</h3>
27
+ <p>Enables real-time server communication, synchronizing live credential verification and access data across the network.</p>
7
28
  </div>
8
- <v-btn icon="mdi-chevron-up" variant="text" density="comfortable" />
29
+ <v-switch
30
+ v-model="form.onlineMode"
31
+ class="app-switch"
32
+ hide-details
33
+ :loading="savingSection === 'online-mode'"
34
+ :disabled="Boolean(savingSection) || !form.readerId"
35
+ @update:model-value="saveOnlineMode"
36
+ />
9
37
  </div>
10
38
 
11
- <div class="config-body">
12
- <div class="section-head">
13
- <div>
14
- <h3>HID QR Code Pass</h3>
15
- <p>Allow users to generate and scan digital QR codes at compatible HID readers for touchless access control.</p>
39
+ <v-divider />
40
+
41
+ <div class="form-section identification-section">
42
+ <div class="section-label">Enable/disable reader identification method</div>
43
+ <p class="section-description">
44
+ Toggles multi-factor access methods, allowing users to authenticate via biometric, physical card, or credential inputs.
45
+ </p>
46
+ <div class="methods-grid">
47
+ <div
48
+ v-for="method in identificationMethodOptions"
49
+ :key="method.key"
50
+ class="method-control"
51
+ :class="{ 'method-control--disabled': !form.onlineMode }"
52
+ >
53
+ <span>{{ method.label }}</span>
54
+ <v-switch
55
+ v-model="form.identificationMethods[method.key]"
56
+ class="app-switch"
57
+ hide-details
58
+ density="compact"
59
+ :loading="savingSection === `method-${method.key}`"
60
+ :disabled="Boolean(savingSection) || !form.onlineMode"
61
+ @update:model-value="saveIdentificationMethod(method.key)"
62
+ />
16
63
  </div>
17
- <v-switch
18
- v-model="form.enabled"
19
- color="success"
20
- hide-details
21
- inset
22
- :loading="savingSection === 'enabled'"
23
- @update:model-value="saveSection('enabled')"
24
- />
25
64
  </div>
65
+ </div>
26
66
 
27
- <div class="form-section">
28
- <div class="section-label">HID Reader</div>
29
- <v-select
30
- v-model="form.readerId"
31
- :items="readerOptions"
32
- item-title="title"
33
- item-value="value"
34
- placeholder="Select HID reader"
35
- variant="outlined"
36
- density="compact"
37
- hide-details
38
- class="reader-select"
39
- />
67
+ <v-divider />
68
+
69
+ <div class="form-section">
70
+ <div class="section-label">Printer Setup</div>
71
+ <div class="printer-grid">
72
+ <AppField v-model="form.printer.vendorId" placeholder="Vendor" />
73
+ <AppField v-model="form.printer.productId" placeholder="Product Id" />
74
+ <AppButton variant="ghost" icon="mdi-magnify" @click="scanPrinter">
75
+ Scan Printer
76
+ </AppButton>
77
+ <AppButton
78
+ variant="ghost"
79
+ icon="mdi-printer-check"
80
+ :disabled="!hasPrinterConfig || testingPrinter"
81
+ @click="testPrint"
82
+ >
83
+ Test &amp; Print
84
+ </AppButton>
85
+ <AppButton
86
+ icon="mdi-printer"
87
+ :disabled="savingSection === 'printer'"
88
+ @click="saveSection('printer')"
89
+ >
90
+ Save Settings
91
+ </AppButton>
40
92
  </div>
93
+ </div>
41
94
 
42
- <v-divider />
95
+ <v-divider />
96
+
97
+ <div class="form-section">
98
+ <div class="section-label">HID QR Code Template</div>
99
+ <div class="template-grid">
100
+ <AppField v-model="form.template.header" placeholder="Header" />
101
+ <AppField v-model="form.template.subtext" placeholder="Subtext" />
102
+ <AppButton variant="ghost" icon="mdi-eye" @click="previewDialog = true">
103
+ Preview
104
+ </AppButton>
105
+ <AppButton
106
+ icon="mdi-qrcode-scan"
107
+ :disabled="savingSection === 'template'"
108
+ @click="saveSection('template')"
109
+ >
110
+ Save
111
+ </AppButton>
112
+ </div>
113
+ </div>
43
114
 
44
- <div class="form-section">
45
- <div class="section-label">QR Code Format</div>
46
- <div class="validity-grid">
47
- <v-select
48
- v-model="form.qrFormat"
49
- :items="qrFormatOptions"
50
- item-title="title"
51
- item-value="value"
52
- placeholder="Select format"
53
- variant="outlined"
54
- density="compact"
55
- hide-details
56
- />
57
- <v-btn
58
- class="text-none save-button"
59
- variant="flat"
60
- prepend-icon="mdi-qrcode-edit"
61
- height="40"
62
- :loading="savingSection === 'qr-format'"
63
- :disabled="!form.readerId"
64
- @click="saveSection('qr-format')"
65
- >
66
- Save Format
67
- </v-btn>
68
- </div>
115
+ <v-divider />
116
+
117
+ <div class="form-section">
118
+ <div class="section-label">HID QR Code Validity</div>
119
+ <div class="validity-grid">
120
+ <AppField v-model="validityInput" type="number" placeholder="Validity (minutes)" />
121
+ <AppButton
122
+ icon="mdi-timer-outline"
123
+ :disabled="savingSection === 'validity'"
124
+ @click="saveSection('validity')"
125
+ >
126
+ Save
127
+ </AppButton>
69
128
  </div>
129
+ </div>
70
130
 
71
- <div class="form-section">
72
- <div class="section-label">Printer Setup</div>
73
- <div class="printer-grid">
74
- <v-text-field
75
- v-model="form.printer.vendorId"
76
- placeholder="Vendor"
77
- variant="outlined"
78
- density="compact"
79
- hide-details
80
- />
81
- <v-text-field
82
- v-model="form.printer.productId"
83
- placeholder="Product Id"
84
- variant="outlined"
85
- density="compact"
86
- hide-details
87
- />
88
- <v-btn
89
- class="text-none utility-button"
90
- variant="outlined"
91
- prepend-icon="mdi-magnify"
92
- height="40"
93
- @click="scanPrinter"
94
- >
95
- Scan Printer
96
- </v-btn>
97
- <v-btn
98
- class="text-none test-button"
99
- variant="outlined"
100
- prepend-icon="mdi-printer-check"
101
- height="40"
102
- :disabled="!hasPrinterConfig"
103
- @click="testPrint"
104
- >
105
- Test & Print
106
- </v-btn>
107
- <v-btn
108
- class="text-none save-button"
109
- variant="flat"
110
- prepend-icon="mdi-printer"
111
- height="40"
112
- :loading="savingSection === 'printer'"
113
- @click="saveSection('printer')"
114
- >
115
- Save Settings
116
- </v-btn>
131
+ <v-divider />
132
+
133
+ <div v-if="isPermissionManager" class="permission-section">
134
+ <div class="permission-location">
135
+ <div class="permission-copy">
136
+ <div class="section-label">Permission Location</div>
137
+ <p class="section-description">
138
+ Permissions are scoped to the location configured on the HID Face Reader.
139
+ </p>
117
140
  </div>
141
+ <AppSelect
142
+ v-model="permissionReaderId"
143
+ class="permission-location__select"
144
+ :items="permissionLocations"
145
+ item-title="label"
146
+ item-value="readerId"
147
+ placeholder="Select a Face Reader location"
148
+ :disabled="permissionLocations.length === 0"
149
+ @update:model-value="loadPermissions"
150
+ />
118
151
  </div>
119
152
 
153
+ <v-alert
154
+ v-if="permissionLocations.length === 0"
155
+ class="mb-4"
156
+ type="warning"
157
+ variant="tonal"
158
+ density="compact"
159
+ >
160
+ Add a Location to a Face Reader before assigning HID permissions.
161
+ </v-alert>
162
+
120
163
  <v-divider />
121
164
 
122
- <div class="form-section">
123
- <div class="section-label">HID QR Code Template</div>
124
- <div class="template-grid">
125
- <v-text-field
126
- v-model="form.template.header"
127
- placeholder="Header"
128
- variant="outlined"
129
- density="compact"
130
- hide-details
131
- />
132
- <v-text-field
133
- v-model="form.template.subtext"
134
- placeholder="Subtext"
135
- variant="outlined"
136
- density="compact"
137
- hide-details
138
- />
139
- <v-btn
140
- class="text-none preview-button"
141
- variant="flat"
142
- prepend-icon="mdi-eye"
143
- height="40"
144
- @click="previewDialog = true"
145
- >
146
- Preview
147
- </v-btn>
148
- <v-btn
149
- class="text-none save-button"
150
- variant="flat"
151
- prepend-icon="mdi-qrcode-scan"
152
- height="40"
153
- :loading="savingSection === 'template'"
154
- @click="saveSection('template')"
165
+ <div class="permission-row">
166
+ <div class="permission-copy">
167
+ <div class="section-label">Access Permission</div>
168
+ <p class="section-description">
169
+ Select who can use the enabled HID identification methods at this site.
170
+ </p>
171
+ </div>
172
+ <div class="permission-actions">
173
+ <AppButton
174
+ v-for="option in accessPermissionOptions"
175
+ :key="option.category"
176
+ variant="ghost"
177
+ class="permission-button"
178
+ :disabled="!form.onlineMode || !permissionReaderId"
179
+ @click="openPermissionDialog(option.category)"
155
180
  >
156
- Save
157
- </v-btn>
181
+ <span class="permission-count">{{ permissionCount(option.category) }}</span>
182
+ <span>{{ option.label }}</span>
183
+ </AppButton>
158
184
  </div>
159
185
  </div>
160
186
 
161
187
  <v-divider />
162
188
 
163
- <div class="form-section">
164
- <div class="section-label">HID QR Code Validity</div>
165
- <div class="validity-grid">
166
- <v-text-field
167
- v-model.number="form.validityMinutes"
168
- placeholder="Validity (minutes)"
169
- variant="outlined"
170
- density="compact"
171
- hide-details
172
- min="1"
173
- type="number"
174
- />
175
- <v-btn
176
- class="text-none save-button"
177
- variant="flat"
178
- prepend-icon="mdi-timer-outline"
179
- height="40"
180
- :loading="savingSection === 'validity'"
181
- @click="saveSection('validity')"
189
+ <div class="permission-row permission-row--intercom">
190
+ <div class="permission-copy">
191
+ <div class="section-label">Access Intercom</div>
192
+ <p class="section-description">
193
+ Choose which authorized people and providers may use HID intercom access.
194
+ </p>
195
+ </div>
196
+ <div class="permission-actions permission-actions--single">
197
+ <AppButton
198
+ variant="ghost"
199
+ class="permission-button"
200
+ :disabled="!form.onlineMode || !permissionReaderId || permissionState.assignments.length === 0"
201
+ @click="openPermissionDialog('intercom')"
182
202
  >
183
- Save
184
- </v-btn>
203
+ <span class="permission-count">{{ permissionState.counts.intercom }}</span>
204
+ <span>Intercom Permissions</span>
205
+ </AppButton>
185
206
  </div>
186
207
  </div>
187
208
  </div>
188
- </v-card>
209
+ </div>
189
210
 
190
211
  <v-dialog v-model="previewDialog" max-width="420">
191
212
  <v-card class="preview-shell">
192
- <v-btn
213
+ <AppButton
214
+ variant="icon"
193
215
  class="preview-close"
194
216
  icon="mdi-close"
195
- variant="text"
196
- density="comfortable"
197
217
  @click="previewDialog = false"
198
218
  />
199
219
  <v-card-text>
200
220
  <div class="preview-title">QR code Preview</div>
221
+ <!--
222
+ THE PRINTED PASS. Deliberately NOT themed: this is a preview of a
223
+ piece of card that comes out of a printer, so it is white with black
224
+ ink on both themes, the way the physical pass is. Everything AROUND
225
+ it sits on the dialog scrim, which is dark in both themes.
226
+ -->
201
227
  <div class="pass-preview">
202
228
  <div class="pass-heading">{{ form.template.header || "Header" }}</div>
203
229
  <div class="pass-subtext">{{ form.template.subtext || "Subtext" }}</div>
@@ -222,37 +248,148 @@
222
248
  </v-card>
223
249
  </v-dialog>
224
250
 
251
+ <v-dialog v-model="permissionDialog" max-width="680" persistent>
252
+ <v-card class="permission-dialog">
253
+ <v-card-title class="permission-dialog__header">
254
+ <div>
255
+ <div class="permission-dialog__title">{{ permissionDialogTitle }}</div>
256
+ <div class="permission-dialog__subtitle">{{ permissionDialogDescription }}</div>
257
+ </div>
258
+ <AppButton
259
+ variant="icon"
260
+ icon="mdi-close"
261
+ aria-label="Close permission dialog"
262
+ @click="closePermissionDialog"
263
+ />
264
+ </v-card-title>
265
+
266
+ <v-card-text class="permission-dialog__body">
267
+ <AppField
268
+ v-model="permissionSearch"
269
+ search
270
+ placeholder="Search by name or details"
271
+ />
272
+
273
+ <div class="permission-list-summary" aria-live="polite">
274
+ <span>{{ visiblePermissionCandidates.length }} entries</span>
275
+ <span class="permission-list-summary__selected">
276
+ {{ selectedPermissionIds.size }} selected
277
+ </span>
278
+ </div>
279
+
280
+ <div class="permission-list" :class="{ 'permission-list--loading': loadingPermissionCandidates }">
281
+ <div v-if="loadingPermissionCandidates" class="permission-empty">
282
+ <v-progress-circular indeterminate class="permission-spinner" size="28" />
283
+ <span>Loading permissions...</span>
284
+ </div>
285
+
286
+ <template v-else-if="visiblePermissionCandidates.length">
287
+ <label
288
+ v-for="candidate in visiblePermissionCandidates"
289
+ :key="`${candidate.category}:${candidate.subjectId}`"
290
+ class="permission-list-item"
291
+ :class="{ 'permission-list-item--selected': selectedPermissionIds.has(candidate.subjectId) }"
292
+ >
293
+ <span class="permission-list-item__content">
294
+ <strong>{{ candidate.name }}</strong>
295
+ <small>{{ candidate.subtitle || permissionCategoryLabel(candidate.category) }}</small>
296
+ </span>
297
+ <span class="permission-list-item__group">{{ permissionCategoryLabel(candidate.category) }}</span>
298
+ <v-checkbox-btn
299
+ class="permission-list-item__checkbox"
300
+ :model-value="selectedPermissionIds.has(candidate.subjectId)"
301
+ :aria-label="`Select ${candidate.name}`"
302
+ @update:model-value="togglePermissionCandidate(candidate.subjectId, Boolean($event))"
303
+ />
304
+ </label>
305
+ </template>
306
+
307
+ <div v-else class="permission-empty">
308
+ <v-icon icon="mdi-account-search-outline" size="30" />
309
+ <span>{{ permissionEmptyMessage }}</span>
310
+ </div>
311
+ </div>
312
+ </v-card-text>
313
+
314
+ <v-card-actions class="permission-dialog__actions">
315
+ <AppButton variant="ghost" @click="closePermissionDialog">Cancel</AppButton>
316
+ <AppButton
317
+ :disabled="loadingPermissionCandidates || savingPermissions"
318
+ @click="savePermissions"
319
+ >
320
+ Save Permissions
321
+ </AppButton>
322
+ </v-card-actions>
323
+ </v-card>
324
+ </v-dialog>
325
+
225
326
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
226
327
  </section>
227
328
  </template>
228
329
 
330
+
229
331
  <script setup lang="ts">
230
- type HidQrConfig = {
231
- enabled: boolean;
232
- readerId: string;
233
- qrFormat: "0" | "1" | "2";
234
- printer: {
235
- vendorId: string;
236
- productId: string;
237
- };
238
- template: {
239
- header: string;
240
- subtext: string;
332
+ type HidIdentificationMethod = keyof NonNullable<THidQrCodePassConfig["identificationMethods"]>;
333
+
334
+ type HidQrConfig = Omit<
335
+ THidQrCodePassConfig,
336
+ "identificationMethods" | "onlineMode" | "updatedAt"
337
+ > & {
338
+ onlineMode: boolean;
339
+ identificationMethods: Record<HidIdentificationMethod, boolean>;
340
+ };
341
+
342
+ type HidReaderOption = {
343
+ _id: string;
344
+ name?: string;
345
+ deviceId?: string;
346
+ location?: string;
347
+ };
348
+
349
+ type HidReaderListResponse = {
350
+ items?: HidReaderOption[];
351
+ readers?: HidReaderOption[];
352
+ data?: {
353
+ items?: HidReaderOption[];
354
+ readers?: HidReaderOption[];
241
355
  };
242
- validityMinutes: number | null;
243
356
  };
244
357
 
245
358
  const props = defineProps<{
246
359
  site: string;
360
+ org: string;
247
361
  }>();
248
362
 
363
+ const runtimeConfig = useRuntimeConfig();
364
+ const isPermissionManager = computed(() => runtimeConfig.public.APP === "security_agency");
365
+
249
366
  const { getSiteById, updateSitebyId } = useSiteSettings();
250
- const { getReaders, setReaderConfiguration } = useHidAmico();
367
+ const {
368
+ getReaders,
369
+ getReaderConfiguration,
370
+ setReaderConfiguration,
371
+ getSitePermissions,
372
+ getPermissionCandidates,
373
+ updateSitePermissions,
374
+ } = useHidAmico();
375
+ const {
376
+ requestDevice: requestPrinterDevice,
377
+ testConnection: testPrinterConnection,
378
+ } = useWebUsb();
251
379
 
252
380
  const defaultConfig: HidQrConfig = {
253
381
  enabled: false,
382
+ onlineMode: false,
254
383
  readerId: "",
255
384
  qrFormat: "0",
385
+ identificationMethods: {
386
+ facial: true,
387
+ card: true,
388
+ qrCode: false,
389
+ idPassword: true,
390
+ pin: false,
391
+ bluetooth: false,
392
+ },
256
393
  printer: {
257
394
  vendorId: "",
258
395
  productId: "",
@@ -264,36 +401,123 @@ const defaultConfig: HidQrConfig = {
264
401
  validityMinutes: null,
265
402
  };
266
403
 
267
- const siteData = ref<Record<string, any> | null>(null);
268
- const readers = ref<Record<string, any>[]>([]);
404
+ const siteData = ref<TSite | null>(null);
405
+ const readers = ref<HidReaderOption[]>([]);
269
406
  const form = reactive<HidQrConfig>(structuredClone(defaultConfig));
270
407
  const previewDialog = ref(false);
271
408
  const savingSection = ref("");
409
+ const testingPrinter = ref(false);
272
410
  const message = ref("");
273
411
  const messageColor = ref("success");
274
412
  const messageSnackbar = ref(false);
413
+ const permissionState = reactive<THidSitePermissions>({
414
+ orgId: props.org,
415
+ site: props.site,
416
+ readerId: "",
417
+ location: "",
418
+ assignments: [],
419
+ counts: {
420
+ resident: 0,
421
+ propertyManagement: 0,
422
+ serviceProvider: 0,
423
+ intercom: 0,
424
+ },
425
+ });
426
+ const permissionDialog = ref(false);
427
+ const permissionReaderId = ref("");
428
+ const permissionDialogCategory = ref<THidPermissionCategory | "intercom">("resident");
429
+ const permissionCandidates = ref<THidPermissionCandidate[]>([]);
430
+ const selectedPermissionIds = ref(new Set<string>());
431
+ const permissionSearch = ref("");
432
+ const loadingPermissionCandidates = ref(false);
433
+ const savingPermissions = ref(false);
275
434
 
276
435
  const hasPrinterConfig = computed(() =>
277
436
  Boolean(form.printer.vendorId && form.printer.productId),
278
437
  );
279
- const readerOptions = computed(() => readers.value.map((reader) => ({
280
- title: reader.name || reader.deviceId || reader._id,
281
- value: reader._id,
282
- })));
283
- const qrFormatOptions = [
284
- { title: "Alphanumeric", value: "0" },
285
- { title: "Numeric only", value: "1" },
286
- { title: "Hexadecimal numeric", value: "2" },
438
+ const identificationMethodOptions: Array<{
439
+ key: HidIdentificationMethod;
440
+ label: string;
441
+ }> = [
442
+ { key: "facial", label: "Facial" },
443
+ { key: "card", label: "Card" },
444
+ { key: "qrCode", label: "QR Code" },
445
+ { key: "idPassword", label: "ID Password" },
446
+ { key: "pin", label: "PIN" },
447
+ ];
448
+ const accessPermissionOptions: Array<{
449
+ category: THidPermissionCategory;
450
+ label: string;
451
+ }> = [
452
+ { category: "resident", label: "Resident" },
453
+ { category: "property_management", label: "Property Management" },
454
+ { category: "service_provider", label: "Service Provider" },
287
455
  ];
288
456
 
457
+ const permissionLocations = computed(() => {
458
+ const seen = new Set<string>();
459
+ return readers.value.reduce<Array<{ readerId: string; location: string; label: string }>>((items, reader) => {
460
+ const location = reader.location?.trim();
461
+ const key = location?.toLocaleLowerCase();
462
+ if (!location || !key || seen.has(key)) return items;
463
+ seen.add(key);
464
+ items.push({
465
+ readerId: reader._id,
466
+ location,
467
+ label: location,
468
+ });
469
+ return items;
470
+ }, []);
471
+ });
472
+ const selectedPermissionLocation = computed(() => (
473
+ permissionLocations.value.find((item) => item.readerId === permissionReaderId.value)?.location
474
+ || "the selected location"
475
+ ));
476
+
289
477
  const siteName = computed(() => siteData.value?.name || "Site");
290
478
  const siteLabel = computed(() => siteData.value?.code || siteData.value?.block || props.site);
291
479
  const validityText = computed(() =>
292
480
  form.validityMinutes ? `${form.validityMinutes} minutes` : "Not set",
293
481
  );
294
482
 
483
+ /**
484
+ * `AppField` is a native `<input>` and its model is a string, where
485
+ * `form.validityMinutes` is a `number | null` on the config type. This keeps
486
+ * the field's value exactly the number `v-model.number` used to put there -
487
+ * empty stays `null`, not `NaN` and not `""`.
488
+ */
489
+ const validityInput = computed({
490
+ get: () => (form.validityMinutes === null || form.validityMinutes === undefined ? "" : String(form.validityMinutes)),
491
+ set: (value: string) => {
492
+ form.validityMinutes = value === "" ? null : Number(value);
493
+ },
494
+ });
495
+ const visiblePermissionCandidates = computed(() => {
496
+ const search = permissionSearch.value.trim().toLowerCase();
497
+ if (!search) return permissionCandidates.value;
498
+ return permissionCandidates.value.filter((candidate) => (
499
+ candidate.name.toLowerCase().includes(search)
500
+ || candidate.subtitle?.toLowerCase().includes(search)
501
+ ));
502
+ });
503
+ const permissionDialogTitle = computed(() => (
504
+ permissionDialogCategory.value === "intercom"
505
+ ? "Intercom Permissions"
506
+ : `${permissionCategoryLabel(permissionDialogCategory.value)} Access Permission`
507
+ ));
508
+ const permissionDialogDescription = computed(() => (
509
+ permissionDialogCategory.value === "intercom"
510
+ ? `Select from people and providers who already have HID access permission at ${selectedPermissionLocation.value}.`
511
+ : `Select the ${permissionCategoryLabel(permissionDialogCategory.value).toLowerCase()} entries allowed to use HID access at ${selectedPermissionLocation.value}.`
512
+ ));
513
+ const permissionEmptyMessage = computed(() => (
514
+ permissionDialogCategory.value === "intercom" && permissionState.assignments.length === 0
515
+ ? "Add an access permission before assigning intercom access."
516
+ : "No matching entries found."
517
+ ));
518
+
295
519
  watch(
296
- () => props.site,
520
+ () => [props.org, props.site],
297
521
  () => loadConfig(),
298
522
  { immediate: true },
299
523
  );
@@ -305,13 +529,24 @@ async function loadConfig() {
305
529
  getSiteById(props.site),
306
530
  getReaders({ site: props.site, page: 1, limit: 100 }),
307
531
  ]);
308
- siteData.value = site;
309
- readers.value = readerResponse?.items ?? readerResponse?.data?.items ?? readerResponse?.data?.readers ?? [];
532
+ siteData.value = site as TSite;
533
+ const readerList = readerResponse as HidReaderListResponse;
534
+ readers.value = readerList.items ?? readerList.data?.items ?? readerList.data?.readers ?? [];
310
535
  const config = siteData.value?.metadata?.hidQrCodePass || {};
536
+ const configuredMethods = config.identificationMethods;
311
537
  Object.assign(form, {
312
538
  enabled: Boolean(config.enabled),
539
+ onlineMode: Boolean(config.onlineMode),
313
540
  readerId: String(config.readerId || readers.value[0]?._id || ""),
314
- qrFormat: ["0", "1", "2"].includes(String(config.qrFormat)) ? String(config.qrFormat) as "0" | "1" | "2" : "0",
541
+ qrFormat: "0",
542
+ identificationMethods: {
543
+ facial: configuredMethods?.facial ?? true,
544
+ card: configuredMethods?.card ?? true,
545
+ qrCode: configuredMethods?.qrCode ?? Boolean(config.enabled),
546
+ idPassword: configuredMethods?.idPassword ?? true,
547
+ pin: configuredMethods?.pin ?? false,
548
+ bluetooth: false,
549
+ },
315
550
  printer: {
316
551
  vendorId: String(config.printer?.vendorId || ""),
317
552
  productId: String(config.printer?.productId || ""),
@@ -322,15 +557,192 @@ async function loadConfig() {
322
557
  },
323
558
  validityMinutes: config.validityMinutes ? Number(config.validityMinutes) : null,
324
559
  });
560
+
561
+ permissionReaderId.value = permissionLocations.value.find((item) => item.readerId === form.readerId)?.readerId
562
+ || permissionLocations.value[0]?.readerId
563
+ || "";
564
+
565
+ await Promise.all([
566
+ loadReaderConfiguration(),
567
+ isPermissionManager.value ? loadPermissions() : Promise.resolve(),
568
+ ]);
569
+ }
570
+
571
+ async function loadPermissions() {
572
+ if (!props.org || !props.site || !permissionReaderId.value) {
573
+ Object.assign(permissionState, {
574
+ orgId: props.org,
575
+ site: props.site,
576
+ readerId: "",
577
+ location: "",
578
+ assignments: [],
579
+ counts: { resident: 0, propertyManagement: 0, serviceProvider: 0, intercom: 0 },
580
+ });
581
+ return;
582
+ }
583
+ try {
584
+ const response = await getSitePermissions(props.site, props.org, permissionReaderId.value);
585
+ Object.assign(permissionState, response.data);
586
+ } catch (error: unknown) {
587
+ console.error("Unable to load HID permissions:", errorConverter(error));
588
+ }
589
+ }
590
+
591
+ function permissionCategoryLabel(category: THidPermissionCategory) {
592
+ return accessPermissionOptions.find((option) => option.category === category)?.label || "Permission";
593
+ }
594
+
595
+ function permissionCount(category: THidPermissionCategory) {
596
+ if (category === "property_management") return permissionState.counts.propertyManagement;
597
+ if (category === "service_provider") return permissionState.counts.serviceProvider;
598
+ return permissionState.counts.resident;
599
+ }
600
+
601
+ async function openPermissionDialog(category: THidPermissionCategory | "intercom") {
602
+ if (!props.org || !permissionReaderId.value) return;
603
+ permissionDialogCategory.value = category;
604
+ permissionSearch.value = "";
605
+ permissionCandidates.value = [];
606
+ selectedPermissionIds.value = new Set<string>();
607
+ permissionDialog.value = true;
608
+ loadingPermissionCandidates.value = true;
609
+
610
+ try {
611
+ const response = await getPermissionCandidates(props.site, {
612
+ orgId: props.org,
613
+ readerId: permissionReaderId.value,
614
+ category,
615
+ page: 1,
616
+ limit: 500,
617
+ });
618
+ permissionCandidates.value = response.items || [];
619
+ selectedPermissionIds.value = new Set(
620
+ permissionCandidates.value
621
+ .filter((candidate) => category === "intercom" ? candidate.intercom : candidate.selected)
622
+ .map((candidate) => candidate.subjectId),
623
+ );
624
+ } catch (error: unknown) {
625
+ showMessage(errorConverter(error), "error");
626
+ } finally {
627
+ loadingPermissionCandidates.value = false;
628
+ }
629
+ }
630
+
631
+ function closePermissionDialog() {
632
+ if (savingPermissions.value) return;
633
+ permissionDialog.value = false;
634
+ }
635
+
636
+ function togglePermissionCandidate(subjectId: string, selected: boolean) {
637
+ const next = new Set(selectedPermissionIds.value);
638
+ if (selected) next.add(subjectId);
639
+ else next.delete(subjectId);
640
+ selectedPermissionIds.value = next;
641
+ }
642
+
643
+ async function savePermissions() {
644
+ const category = permissionDialogCategory.value;
645
+ let assignments: Array<Pick<THidPermissionAssignment, "subjectId" | "category" | "intercom">>;
646
+
647
+ if (category === "intercom") {
648
+ assignments = permissionState.assignments.map((assignment) => ({
649
+ subjectId: assignment.subjectId,
650
+ category: assignment.category,
651
+ intercom: selectedPermissionIds.value.has(assignment.subjectId),
652
+ }));
653
+ } else {
654
+ const retained = permissionState.assignments
655
+ .filter((assignment) => assignment.category !== category)
656
+ .map((assignment) => ({
657
+ subjectId: assignment.subjectId,
658
+ category: assignment.category,
659
+ intercom: assignment.intercom,
660
+ }));
661
+ const selected = permissionCandidates.value
662
+ .filter((candidate) => selectedPermissionIds.value.has(candidate.subjectId))
663
+ .map((candidate) => ({
664
+ subjectId: candidate.subjectId,
665
+ category: candidate.category,
666
+ intercom: candidate.intercom,
667
+ }));
668
+ assignments = [...retained, ...selected];
669
+ }
670
+
671
+ savingPermissions.value = true;
672
+ try {
673
+ const response = await updateSitePermissions(
674
+ props.site,
675
+ props.org,
676
+ permissionReaderId.value,
677
+ assignments,
678
+ );
679
+ Object.assign(permissionState, response.data);
680
+ permissionDialog.value = false;
681
+ showMessage("HID permissions updated.", "success");
682
+ } catch (error: unknown) {
683
+ showMessage(errorConverter(error), "error");
684
+ } finally {
685
+ savingPermissions.value = false;
686
+ }
687
+ }
688
+
689
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
690
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
691
+ }
692
+
693
+ function getConfigurationData(response: unknown) {
694
+ if (!isUnknownRecord(response)) return {};
695
+ return isUnknownRecord(response.data) ? response.data : response;
696
+ }
697
+
698
+ function isEnabledConfiguration(value: unknown) {
699
+ return value === true || value === 1 || value === "1";
700
+ }
701
+
702
+ async function loadReaderConfiguration() {
703
+ if (!form.readerId) return;
704
+
705
+ try {
706
+ const response = await getReaderConfiguration(form.readerId, {
707
+ general: ["online"],
708
+ identifier: [
709
+ "card_identification_enabled",
710
+ "pin_identification_enabled",
711
+ "multi_factor_authentication",
712
+ ],
713
+ });
714
+ const configuration = getConfigurationData(response);
715
+ const general = isUnknownRecord(configuration.general) ? configuration.general : {};
716
+ const identifier = isUnknownRecord(configuration.identifier) ? configuration.identifier : {};
717
+
718
+ if (general.online !== undefined) {
719
+ form.onlineMode = isEnabledConfiguration(general.online);
720
+ }
721
+ if (identifier.pin_identification_enabled !== undefined) {
722
+ const pinEnabled = isEnabledConfiguration(identifier.pin_identification_enabled);
723
+ form.identificationMethods.pin = pinEnabled;
724
+ form.identificationMethods.idPassword = !pinEnabled;
725
+ }
726
+ if (identifier.card_identification_enabled !== undefined) {
727
+ form.identificationMethods.card = isEnabledConfiguration(
728
+ identifier.card_identification_enabled,
729
+ );
730
+ }
731
+ } catch (error: unknown) {
732
+ console.warn("Unable to load HID reader configuration:", errorConverter(error));
733
+ }
325
734
  }
326
735
 
327
736
  function buildPayload() {
328
737
  const metadata = {
329
738
  ...(siteData.value?.metadata || {}),
330
739
  hidQrCodePass: {
331
- enabled: form.enabled,
740
+ enabled: form.identificationMethods.qrCode,
741
+ onlineMode: form.onlineMode,
332
742
  readerId: form.readerId,
333
- qrFormat: form.qrFormat,
743
+ // Visitor QR passes always use Amico alphanumeric qrcodes.
744
+ qrFormat: "0",
745
+ identificationMethods: { ...form.identificationMethods },
334
746
  printer: {
335
747
  vendorId: form.printer.vendorId.trim(),
336
748
  productId: form.printer.productId.trim(),
@@ -352,17 +764,65 @@ async function saveSection(section: string) {
352
764
 
353
765
  savingSection.value = section;
354
766
  try {
355
- if (section === "qr-format") {
767
+ await updateSitebyId(props.site, buildPayload());
768
+ await loadConfig();
769
+ showMessage("HID configuration saved.", "success");
770
+ } catch (error: unknown) {
771
+ showMessage(errorConverter(error), "error");
772
+ } finally {
773
+ savingSection.value = "";
774
+ }
775
+ }
776
+
777
+ async function saveOnlineMode() {
778
+ if (!props.site || !form.readerId) return;
779
+
780
+ savingSection.value = "online-mode";
781
+ try {
782
+ await setReaderConfiguration(form.readerId, {
783
+ general: { online: form.onlineMode ? "1" : "0" },
784
+ });
785
+ await updateSitebyId(props.site, buildPayload());
786
+ showMessage("Online mode updated.", "success");
787
+ } catch (error: unknown) {
788
+ await loadConfig();
789
+ showMessage(errorConverter(error), "error");
790
+ } finally {
791
+ savingSection.value = "";
792
+ }
793
+ }
794
+
795
+ async function saveIdentificationMethod(method: HidIdentificationMethod) {
796
+ if (!props.site) return;
797
+
798
+ form.enabled = form.identificationMethods.qrCode;
799
+
800
+ savingSection.value = `method-${method}`;
801
+ try {
802
+ if (method === "card") {
356
803
  await setReaderConfiguration(form.readerId, {
357
- face_id: {
358
- qrcode_legacy_mode_enabled: form.qrFormat,
804
+ identifier: {
805
+ card_identification_enabled: form.identificationMethods.card ? "1" : "0",
806
+ },
807
+ });
808
+ } else if (method === "pin" || method === "idPassword") {
809
+ if (method === "pin" && form.identificationMethods.pin) {
810
+ form.identificationMethods.idPassword = false;
811
+ } else if (method === "idPassword" && form.identificationMethods.idPassword) {
812
+ form.identificationMethods.pin = false;
813
+ }
814
+ await setReaderConfiguration(form.readerId, {
815
+ identifier: {
816
+ pin_identification_enabled: form.identificationMethods.pin ? "1" : "0",
817
+ // Mode 1: PIN is an alternative credential, never a second factor.
818
+ multi_factor_authentication: "0",
359
819
  },
360
820
  });
361
821
  }
362
822
  await updateSitebyId(props.site, buildPayload());
823
+ showMessage(`${identificationMethodOptions.find((item) => item.key === method)?.label || "Identification method"} updated.`, "success");
824
+ } catch (error: unknown) {
363
825
  await loadConfig();
364
- showMessage("HID QR code settings saved.", "success");
365
- } catch (error: any) {
366
826
  showMessage(errorConverter(error), "error");
367
827
  } finally {
368
828
  savingSection.value = "";
@@ -376,19 +836,36 @@ async function scanPrinter() {
376
836
  }
377
837
 
378
838
  try {
379
- const device = await (navigator as any).usb.requestDevice({ filters: [] });
839
+ const device = await requestPrinterDevice();
380
840
  form.printer.vendorId = `0x${Number(device.vendorId).toString(16).padStart(4, "0")}`;
381
841
  form.printer.productId = `0x${Number(device.productId).toString(16).padStart(4, "0")}`;
382
842
  showMessage("Printer selected. Save settings to keep it.", "success");
383
- } catch (error: any) {
384
- if (error?.name !== "NotFoundError") {
385
- showMessage(error?.message || "Unable to scan printer.", "error");
843
+ } catch (error: unknown) {
844
+ const message = errorConverter(error);
845
+ if (message !== "No device selected") {
846
+ showMessage(message || "Unable to scan printer.", "error");
386
847
  }
387
848
  }
388
849
  }
389
850
 
390
- function testPrint() {
391
- showMessage("Printer settings are ready. Connect print command when HID QR payload generation is available.", "success");
851
+ async function testPrint() {
852
+ const vendorId = Number(form.printer.vendorId);
853
+ const productId = Number(form.printer.productId);
854
+ if (!vendorId || !productId) {
855
+ showMessage("Please scan a valid USB printer first.", "error");
856
+ return;
857
+ }
858
+
859
+ testingPrinter.value = true;
860
+ try {
861
+ const result = await testPrinterConnection(vendorId, productId);
862
+ showMessage(
863
+ result.success ? result.message : result.error || result.message,
864
+ result.success ? "success" : "error",
865
+ );
866
+ } finally {
867
+ testingPrinter.value = false;
868
+ }
392
869
  }
393
870
 
394
871
  function showMessage(text: string, color: string) {
@@ -398,71 +875,121 @@ function showMessage(text: string, color: string) {
398
875
  }
399
876
  </script>
400
877
 
878
+
401
879
  <style scoped>
880
+ /*
881
+ EVERY COLOUR IS A TOKEN NOW.
882
+
883
+ This file carried 38 distinct literal hexes - a navy `#082e43` bar, greens
884
+ `#2eaf55` / `#35b84b` / `#34b84a` / `#2da643`, greys `#233545` / `#677280` /
885
+ `#334454` / `#8b9297` / `#dce2e7` / `#e6eaee` and a dozen more. A literal is
886
+ the same colour on both themes, which is why this screen had no dark theme at
887
+ all. The LAYOUT (the grids, the breakpoints, the 42px method boxes) is the
888
+ screen's own and is kept; only the palette moved.
889
+
890
+ The one deliberate exception is `.pass-preview` - see the template comment.
891
+ */
402
892
  .hid-qr-config {
403
893
  width: 100%;
404
894
  min-width: 0;
405
895
  padding: 8px 0 28px;
406
896
  }
407
897
 
408
- .config-panel {
409
- border: 1px solid #e6eaee;
410
- border-radius: 6px;
411
- overflow: hidden;
412
- background: #ffffff;
413
- }
414
-
415
- .panel-header {
416
- display: flex;
417
- align-items: center;
418
- justify-content: space-between;
419
- min-height: 48px;
420
- padding: 0 18px;
421
- background: #082e43;
422
- color: #ffffff;
423
- }
424
-
425
- .panel-header h2 {
426
- margin: 0;
427
- font-size: 13px;
428
- font-weight: 700;
898
+ .config-body {
899
+ padding: 4px 0 0;
429
900
  }
430
901
 
431
- .config-body {
432
- padding: 30px 38px;
902
+ .config-body__title {
903
+ margin-bottom: 14px;
433
904
  }
434
905
 
435
- .section-head {
906
+ .online-mode-row {
436
907
  display: flex;
437
- align-items: flex-start;
908
+ align-items: center;
438
909
  justify-content: space-between;
439
910
  gap: 24px;
440
- margin-bottom: 24px;
911
+ padding: 0 0 20px;
441
912
  }
442
913
 
443
- .section-head h3,
914
+ /* The prototype's settings body: a 13px/700 label over a 12-13px/600 muted
915
+ line. */
916
+ .online-mode-row h3,
444
917
  .section-label {
445
918
  margin: 0 0 6px;
446
- color: #233545;
919
+ color: var(--text);
920
+ font-family: var(--font-sans);
447
921
  font-size: 13px;
448
922
  font-weight: 700;
449
923
  }
450
924
 
451
- .section-head p {
925
+ .online-mode-row p,
926
+ .section-description {
452
927
  margin: 0;
453
- color: #677280;
928
+ color: var(--muted);
929
+ font-family: var(--font-sans);
454
930
  font-size: 12px;
931
+ font-weight: 600;
932
+ line-height: 1.5;
455
933
  }
456
934
 
457
935
  .form-section {
458
936
  padding: 16px 0;
459
937
  }
460
938
 
939
+ .identification-section {
940
+ padding-bottom: 20px;
941
+ }
942
+
943
+ .methods-grid {
944
+ display: grid;
945
+ grid-template-columns: repeat(6, minmax(120px, 1fr));
946
+ gap: 18px;
947
+ margin-top: 16px;
948
+ }
949
+
950
+ .method-control {
951
+ display: flex;
952
+ align-items: center;
953
+ justify-content: space-between;
954
+ min-width: 0;
955
+ min-height: 42px;
956
+ padding: 0 10px 0 14px;
957
+ border: 1px solid var(--border);
958
+ border-radius: var(--r-inner);
959
+ color: var(--text2);
960
+ font-family: var(--font-sans);
961
+ font-size: 12px;
962
+ font-weight: 700;
963
+ }
964
+
965
+ .method-control span {
966
+ overflow: hidden;
967
+ text-overflow: ellipsis;
968
+ white-space: nowrap;
969
+ }
970
+
971
+ .method-control--disabled {
972
+ background-color: var(--hover);
973
+ color: var(--muted);
974
+ }
975
+
976
+ .method-control :deep(.v-switch) {
977
+ flex: 0 0 auto;
978
+ }
979
+
980
+ .method-control :deep(.v-selection-control) {
981
+ min-height: 32px;
982
+ }
983
+
984
+ /* The three `.hid-config-switch` rules are gone: `.app-switch` in
985
+ primitives.css is the handoff's toggle, and it is shared with the other two
986
+ HID panels that have one. */
987
+
461
988
  .printer-grid {
462
989
  display: grid;
463
990
  grid-template-columns: minmax(140px, 1fr) minmax(140px, 1fr) minmax(140px, 1fr) minmax(140px, 1fr) minmax(140px, 1fr);
464
991
  gap: 14px;
465
- align-items: center;
992
+ align-items: end;
466
993
  }
467
994
 
468
995
  .template-grid {
@@ -471,37 +998,249 @@ function showMessage(text: string, color: string) {
471
998
  gap: 14px;
472
999
  }
473
1000
 
474
- .template-grid .preview-button,
475
- .template-grid .save-button {
476
- width: 100%;
477
- }
478
-
479
1001
  .validity-grid {
480
1002
  display: grid;
481
1003
  grid-template-columns: minmax(180px, 260px) minmax(140px, 260px);
482
1004
  gap: 14px;
1005
+ align-items: end;
1006
+ }
1007
+
1008
+ .permission-section {
1009
+ padding-top: 2px;
1010
+ }
1011
+
1012
+ .permission-location {
1013
+ display: grid;
1014
+ grid-template-columns: minmax(260px, 1fr) minmax(320px, 1.35fr);
1015
+ gap: 36px;
483
1016
  align-items: center;
1017
+ padding: 18px 0;
484
1018
  }
485
1019
 
486
- .save-button {
487
- background: #082e43;
488
- color: #ffffff;
1020
+ .permission-location__select {
1021
+ width: 100%;
489
1022
  }
490
1023
 
491
- .preview-button {
492
- background: #35b84b;
493
- color: #ffffff;
1024
+ .permission-row {
1025
+ display: grid;
1026
+ grid-template-columns: minmax(260px, 1fr) minmax(520px, 1.35fr);
1027
+ gap: 36px;
1028
+ align-items: center;
1029
+ padding: 18px 0;
1030
+ }
1031
+
1032
+ .permission-row--intercom {
1033
+ padding-bottom: 4px;
1034
+ }
1035
+
1036
+ .permission-copy {
1037
+ min-width: 0;
494
1038
  }
495
1039
 
496
- .test-button {
497
- border-color: #34b84a;
498
- color: #2da643;
1040
+ .permission-actions {
1041
+ display: grid;
1042
+ grid-template-columns: repeat(3, minmax(150px, 1fr));
1043
+ gap: 12px;
499
1044
  }
500
1045
 
501
- .utility-button {
502
- color: #1f3448;
1046
+ .permission-actions--single {
1047
+ grid-template-columns: minmax(180px, 240px);
1048
+ justify-content: end;
503
1049
  }
504
1050
 
1051
+ /* A ghost button carrying a count badge. `AppButton --ghost` supplies the
1052
+ height, radius, border and type; only the alignment is local. */
1053
+ .permission-button {
1054
+ justify-content: flex-start;
1055
+ min-height: 42px;
1056
+ }
1057
+
1058
+ .permission-count {
1059
+ display: inline-grid;
1060
+ place-items: center;
1061
+ width: 20px;
1062
+ height: 20px;
1063
+ margin-right: 8px;
1064
+ border-radius: 50%;
1065
+ background: var(--accent-soft);
1066
+ color: var(--accent-text);
1067
+ font-size: 11px;
1068
+ font-weight: 700;
1069
+ }
1070
+
1071
+ .permission-button:not(:disabled):hover {
1072
+ border-color: var(--accent);
1073
+ color: var(--accent-text);
1074
+ }
1075
+
1076
+ .permission-dialog {
1077
+ overflow: hidden;
1078
+ border-radius: var(--r-modal);
1079
+ background: var(--card);
1080
+ }
1081
+
1082
+ .permission-dialog__header {
1083
+ display: flex;
1084
+ align-items: flex-start;
1085
+ justify-content: space-between;
1086
+ gap: 16px;
1087
+ padding: 16px 18px 14px;
1088
+ border-bottom: 1px solid var(--border);
1089
+ }
1090
+
1091
+ .permission-dialog__title {
1092
+ color: var(--text);
1093
+ font-family: var(--font-sans);
1094
+ font-size: 18px;
1095
+ font-weight: 800;
1096
+ }
1097
+
1098
+ .permission-dialog__subtitle {
1099
+ margin-top: 4px;
1100
+ color: var(--muted);
1101
+ font-size: 12px;
1102
+ font-weight: 600;
1103
+ white-space: normal;
1104
+ }
1105
+
1106
+ .permission-dialog__body {
1107
+ display: grid;
1108
+ gap: 10px;
1109
+ padding: 14px 18px 16px;
1110
+ }
1111
+
1112
+ .permission-list-summary {
1113
+ display: flex;
1114
+ align-items: center;
1115
+ justify-content: space-between;
1116
+ min-height: 22px;
1117
+ color: var(--muted);
1118
+ font-size: 11px;
1119
+ font-weight: 600;
1120
+ }
1121
+
1122
+ .permission-list-summary__selected {
1123
+ color: var(--ok);
1124
+ font-weight: 700;
1125
+ }
1126
+
1127
+ .permission-list {
1128
+ min-height: 180px;
1129
+ max-height: min(360px, 48vh);
1130
+ overflow-y: auto;
1131
+ border: 1px solid var(--border);
1132
+ border-radius: var(--r-inner);
1133
+ background: var(--card);
1134
+ }
1135
+
1136
+ .permission-list--loading {
1137
+ display: grid;
1138
+ place-items: center;
1139
+ }
1140
+
1141
+ .permission-list-item {
1142
+ display: grid;
1143
+ grid-template-columns: minmax(0, 1fr) max-content 32px;
1144
+ gap: 12px;
1145
+ align-items: center;
1146
+ min-height: 56px;
1147
+ padding: 7px 12px;
1148
+ border-bottom: 1px solid var(--border);
1149
+ cursor: pointer;
1150
+ transition: background-color var(--motion) ease;
1151
+ }
1152
+
1153
+ .permission-list-item:last-child {
1154
+ border-bottom: 0;
1155
+ }
1156
+
1157
+ .permission-list-item:hover {
1158
+ background: var(--hover);
1159
+ }
1160
+
1161
+ .permission-list-item--selected,
1162
+ .permission-list-item--selected:hover {
1163
+ background: var(--accent-soft);
1164
+ }
1165
+
1166
+ .permission-list-item__content {
1167
+ display: grid;
1168
+ gap: 2px;
1169
+ min-width: 0;
1170
+ }
1171
+
1172
+ .permission-list-item__content strong,
1173
+ .permission-list-item__content small {
1174
+ overflow: hidden;
1175
+ text-overflow: ellipsis;
1176
+ white-space: nowrap;
1177
+ }
1178
+
1179
+ .permission-list-item__content strong {
1180
+ color: var(--text);
1181
+ font-size: 13px;
1182
+ font-weight: 700;
1183
+ line-height: 1.35;
1184
+ }
1185
+
1186
+ .permission-list-item__content small,
1187
+ .permission-list-item__group {
1188
+ color: var(--muted);
1189
+ font-size: 11px;
1190
+ font-weight: 600;
1191
+ }
1192
+
1193
+ .permission-list-item__group {
1194
+ max-width: 170px;
1195
+ overflow: hidden;
1196
+ padding: 4px 9px;
1197
+ border-radius: var(--r-tag);
1198
+ background: var(--hover);
1199
+ text-overflow: ellipsis;
1200
+ white-space: nowrap;
1201
+ }
1202
+
1203
+ .permission-list-item__checkbox {
1204
+ justify-self: end;
1205
+ }
1206
+
1207
+ .permission-list-item__checkbox :deep(.v-selection-control) {
1208
+ min-height: 32px;
1209
+ }
1210
+
1211
+ .permission-empty {
1212
+ display: flex;
1213
+ flex-direction: column;
1214
+ align-items: center;
1215
+ justify-content: center;
1216
+ gap: 10px;
1217
+ min-height: 180px;
1218
+ padding: 24px;
1219
+ color: var(--muted);
1220
+ font-size: 13px;
1221
+ text-align: center;
1222
+ }
1223
+
1224
+ .permission-spinner {
1225
+ color: var(--accent);
1226
+ }
1227
+
1228
+ .permission-dialog__actions {
1229
+ justify-content: flex-end;
1230
+ gap: 8px;
1231
+ padding: 12px 18px;
1232
+ border-top: 1px solid var(--border);
1233
+ }
1234
+
1235
+ /*
1236
+ THE PRINTED PASS - the one thing here that is NOT themed.
1237
+
1238
+ It previews a physical card that comes out of a badge printer, so it stays
1239
+ white with dark ink on both themes; theming it would show the operator a
1240
+ preview of something the printer cannot produce. Everything around it sits on
1241
+ the dialog scrim, which is dark on both themes - which is why the title above
1242
+ it is white.
1243
+ */
505
1244
  .preview-shell {
506
1245
  position: relative;
507
1246
  overflow: visible;
@@ -514,13 +1253,15 @@ function showMessage(text: string, color: string) {
514
1253
  top: 38px;
515
1254
  right: 18px;
516
1255
  z-index: 2;
1256
+ color: #ffffff;
517
1257
  }
518
1258
 
519
1259
  .preview-title {
520
1260
  margin: 0 0 12px;
521
1261
  color: #ffffff;
1262
+ font-family: var(--font-sans);
522
1263
  font-size: 18px;
523
- font-weight: 700;
1264
+ font-weight: 800;
524
1265
  }
525
1266
 
526
1267
  .pass-preview {
@@ -592,27 +1333,58 @@ function showMessage(text: string, color: string) {
592
1333
  }
593
1334
 
594
1335
  @media (max-width: 1100px) {
1336
+ .methods-grid {
1337
+ grid-template-columns: repeat(3, minmax(140px, 1fr));
1338
+ }
1339
+
595
1340
  .printer-grid,
596
1341
  .template-grid,
597
- .validity-grid {
1342
+ .validity-grid,
1343
+ .permission-location,
1344
+ .permission-row {
598
1345
  grid-template-columns: 1fr 1fr;
599
1346
  }
600
- }
601
1347
 
602
- @media (max-width: 700px) {
603
- .config-body {
604
- padding: 22px 16px;
1348
+ .permission-actions {
1349
+ grid-template-columns: 1fr;
605
1350
  }
1351
+ }
606
1352
 
607
- .section-head,
1353
+ @media (max-width: 700px) {
1354
+ .online-mode-row,
608
1355
  .printer-grid,
609
1356
  .template-grid,
610
- .validity-grid {
1357
+ .validity-grid,
1358
+ .methods-grid,
1359
+ .permission-location,
1360
+ .permission-row,
1361
+ .permission-actions {
611
1362
  grid-template-columns: 1fr;
612
1363
  }
613
1364
 
614
- .section-head {
1365
+ .online-mode-row {
615
1366
  display: grid;
616
1367
  }
1368
+
1369
+ .permission-actions--single {
1370
+ justify-content: stretch;
1371
+ }
1372
+
1373
+ .permission-list-item {
1374
+ grid-template-columns: minmax(0, 1fr) 32px;
1375
+ gap: 8px;
1376
+ }
1377
+
1378
+ .permission-list-item__group {
1379
+ grid-column: 1;
1380
+ grid-row: 2;
1381
+ justify-self: start;
1382
+ max-width: 100%;
1383
+ }
1384
+
1385
+ .permission-list-item__checkbox {
1386
+ grid-column: 2;
1387
+ grid-row: 1 / span 2;
1388
+ }
617
1389
  }
618
1390
  </style>