@7365admin1/layer-common 3.2.7 → 3.2.8-staging.200

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 +241 -79
  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,118 +1,184 @@
1
+ <!--
2
+ HID USER ENROLLMENT - the people a reader will recognise.
3
+
4
+ Was a bespoke screen: an above-card toolbar, a raw `v-table` with six fixed
5
+ column widths, its own refresh strip, footer pager, empty state and three
6
+ private `status-*` chip palettes - about twenty-six literal hexes. Those
7
+ literals are why the screen had no dark theme; `#0a2638` on the dark card
8
+ measures 1.07:1.
9
+
10
+ It is the design's standard table screen, so it is drawn with the shared one:
11
+ `TableMain` supplies the page heading, the card, the in-card toolbar, the
12
+ uppercase header row, the row spec and the pager; the reader picker, search
13
+ and status filter move onto the card's `extension` row. Every column, action,
14
+ dialog and handler is unchanged - the same `users` still feed it and the
15
+ overflow menu still opens the same three dialogs.
16
+
17
+ The handoff draws no HID table, so it states no column proportions - the
18
+ table takes `TableMain`'s automatic layout, as panel 2 does, and the screen's
19
+ own fixed 24/20/12/20/16/8 % are dropped rather than carried over.
20
+ -->
1
21
  <template>
2
22
  <section class="hid-user-enrollment">
3
- <div class="toolbar">
4
- <v-btn
5
- rounded="xl"
6
- class="text-none enroll-button"
7
- variant="flat"
8
- prepend-icon="mdi-account-plus"
9
- @click="openEnroll"
10
- >
11
- Enroll
12
- </v-btn>
13
-
14
- <v-select
15
- v-if="readers.length > 1"
16
- v-model="selectedReaderId"
17
- :items="readerOptions"
18
- item-title="title"
19
- item-value="value"
20
- variant="outlined"
21
- density="compact"
22
- hide-details
23
- class="reader-input"
24
- @update:model-value="loadUsers"
25
- />
26
-
27
- <div class="toolbar-spacer" />
28
-
29
- <v-text-field
30
- v-model="search"
31
- placeholder="Search"
32
- variant="outlined"
33
- density="compact"
34
- hide-details
35
- class="search-input"
36
- prepend-inner-icon="mdi-magnify"
37
- @keyup.enter="loadUsers"
38
- />
39
-
40
- <v-select
41
- v-model="status"
42
- :items="statusOptions"
43
- variant="outlined"
44
- density="compact"
45
- hide-details
46
- class="status-input"
47
- @update:model-value="loadUsers"
48
- />
49
- </div>
50
-
51
- <v-card flat border class="table-card">
52
- <div class="table-refresh">
53
- <v-btn icon="mdi-refresh" variant="text" density="comfortable" :loading="loading" @click="loadUsers" />
54
- <div class="page-count">{{ pageRange }}</div>
55
- </div>
56
-
57
- <div class="table-body">
58
- <v-table>
59
- <thead>
60
- <tr>
61
- <th>Name</th>
62
- <th>Facial Data</th>
63
- <th>UID</th>
64
- <th>Status</th>
65
- <th class="text-right"></th>
66
- </tr>
67
- </thead>
68
- <tbody>
69
- <tr v-for="user in users" :key="user._rowKey || user._id || user.hidUserId">
70
- <td>{{ getName(user) }}</td>
71
- <td>
72
- <v-avatar v-if="getUserImageSrc(user)" size="34" rounded="lg" class="hid-face-avatar">
73
- <v-img :src="getUserImageSrc(user)" cover />
74
- </v-avatar>
75
- <span v-else>{{ getFacialData(user) }}</span>
76
- </td>
77
- <td>{{ formatHidUid(user.hidUserId) || "N/A" }}</td>
78
- <td>
79
- <v-chip
80
- size="small"
81
- variant="flat"
82
- class="status-chip"
83
- :class="getMappingStatusClass(user)"
84
- >
85
- {{ getMappingStatus(user) }}
86
- </v-chip>
87
- </td>
88
- <td class="text-right">
89
- <v-menu>
90
- <template #activator="{ props: menuProps }">
91
- <v-btn v-bind="menuProps" icon="mdi-dots-vertical" variant="text" density="comfortable" />
92
- </template>
93
-
94
- <v-list density="compact" min-width="150">
95
- <v-list-item title="View" @click="openView(user)" />
96
- <v-list-item title="Edit" @click="openEdit(user)" />
97
- <v-list-item title="Delete" class="text-error" @click="openDelete(user)" />
98
- </v-list>
99
- </v-menu>
100
- </td>
101
- </tr>
102
- </tbody>
103
- </v-table>
104
-
105
- <div v-if="!users.length" class="empty-state">
106
- No HID users enrolled yet.
23
+ <!-- Staging's portal banner (1e6224e), kept whole: it names the portal the
24
+ selected reader is bound to and warns when it has none. It sat above
25
+ the old table card, so it stays above the shared one. -->
26
+ <v-alert
27
+ v-if="selectedReader"
28
+ :type="selectedReader.portalId && selectedReader.portalName ? 'info' : 'warning'"
29
+ variant="tonal"
30
+ density="compact"
31
+ class="mb-4"
32
+ icon="mdi-door-open"
33
+ >
34
+ Access Reader: {{ selectedReader.name || "HID Reader" }} · Access Portal:
35
+ {{ selectedReader.portalName || "Not configured" }}
36
+ </v-alert>
37
+
38
+ <TableMain
39
+ :title="tableTitle"
40
+ :headers="tableHeaders"
41
+ :items="users"
42
+ :loading="loading"
43
+ :items-per-page="-1"
44
+ :page="page"
45
+ :pages="pages"
46
+ :page-range="pageRange"
47
+ item-value="_rowKey"
48
+ show-header
49
+ @refresh="loadUsers"
50
+ @update:page="goToPage"
51
+ >
52
+ <template v-if="!props.cardManagement" #actions>
53
+ <AppButton icon="mdi-account-plus" @click="openEnroll">Enroll</AppButton>
54
+ </template>
55
+
56
+ <!-- The design puts filters on their own full-width row under the
57
+ toolbar, which is what `extension` is. -->
58
+ <template #extension>
59
+ <div class="app-filter-row hid-user-filters">
60
+ <AppSelect
61
+ v-if="readers.length"
62
+ v-model="selectedReaderId"
63
+ class="reader-select"
64
+ :items="readerOptions"
65
+ @update:model-value="loadUsers"
66
+ />
67
+ <AppField
68
+ v-model="search"
69
+ search
70
+ placeholder="Search"
71
+ @keyup.enter="loadUsers"
72
+ />
73
+ <AppSelect
74
+ v-if="!props.cardManagement"
75
+ v-model="status"
76
+ :items="statusOptions"
77
+ placeholder="Status"
78
+ clearable
79
+ @update:model-value="loadUsers"
80
+ />
107
81
  </div>
108
- </div>
109
-
110
- <div class="table-footer">
111
- <span>{{ pageRange }}</span>
112
- <v-btn icon="mdi-chevron-left" variant="text" density="comfortable" :disabled="page <= 1" @click="page--; loadUsers()" />
113
- <v-btn icon="mdi-chevron-right" variant="text" density="comfortable" :disabled="page >= pages" @click="page++; loadUsers()" />
114
- </div>
115
- </v-card>
82
+ </template>
83
+
84
+ <template #[`item.name`]="{ item }">
85
+ <span class="app-cell--strong">{{ getName(item) }}</span>
86
+ </template>
87
+
88
+ <!-- The face thumbnail, unchanged. It prints whatever `getUserImageSrc`
89
+ already returned; this screen does not read or export a template. -->
90
+ <template #[`item.facialData`]="{ item }">
91
+ <v-avatar
92
+ v-if="getUserImageSrc(item)"
93
+ size="34"
94
+ rounded="lg"
95
+ class="hid-face-avatar"
96
+ >
97
+ <v-img :src="getUserImageSrc(item)" cover />
98
+ </v-avatar>
99
+ <span v-else class="app-cell--muted">{{ getFacialData(item) }}</span>
100
+ </template>
101
+
102
+ <template #[`item.hidUserId`]="{ item }">
103
+ <span class="app-cell--num">{{ formatHidUid(item.hidUserId) || "N/A" }}</span>
104
+ </template>
105
+
106
+ <!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
107
+ the same tones this screen's private `getMappingStatusClass` gave
108
+ them, so the map is one list rather than a fourth private copy. -->
109
+ <template #[`item.status`]="{ item }">
110
+ <StatusChip :status="getMappingStatus(item)" />
111
+ </template>
112
+
113
+ <template #[`item.isAdministrator`]="{ item }">
114
+ <v-icon
115
+ :icon="item.isAdministrator ? 'mdi-check-circle' : 'mdi-close-circle'"
116
+ :class="[
117
+ 'admin-rule-indicator',
118
+ item.isAdministrator
119
+ ? 'admin-rule-indicator--enabled'
120
+ : 'admin-rule-indicator--disabled',
121
+ ]"
122
+ :aria-label="item.isAdministrator ? 'Administrator' : 'Not administrator'"
123
+ size="22"
124
+ />
125
+ </template>
126
+
127
+ <template #[`item.cards`]="{ item }">
128
+ <div v-if="getUserPhysicalCards(item).length" class="hid-card-cell">
129
+ <span
130
+ v-for="card in getUserPhysicalCards(item)"
131
+ :key="card.id"
132
+ class="hid-card-cell__item"
133
+ >
134
+ {{ formatPhysicalCard(card) }}
135
+ </span>
136
+ </div>
137
+ <span v-else class="app-cell--muted">No card</span>
138
+ </template>
139
+
140
+ <template #[`item.action-table`]="{ item }">
141
+ <AppButton
142
+ v-if="props.cardManagement"
143
+ variant="secondary"
144
+ icon="mdi-card-account-details-outline"
145
+ @click="openCardManager(item)"
146
+ >
147
+ Manage
148
+ </AppButton>
149
+ <v-menu v-else>
150
+ <template #activator="{ props: menuProps }">
151
+ <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
152
+ </template>
153
+
154
+ <v-list density="compact" min-width="150">
155
+ <v-list-item title="View" @click="openView(item)" />
156
+ <v-list-item title="Edit" @click="openEdit(item)" />
157
+ <v-list-item title="Manage HID Cards" @click="openCardManager(item)" />
158
+ <v-list-item
159
+ title="Delete"
160
+ class="text-error"
161
+ @click="openDelete(item)"
162
+ />
163
+ </v-list>
164
+ </v-menu>
165
+ </template>
166
+
167
+ <!-- The screen's own sentence, kept: the shared empty state says "No data
168
+ available", which is less useful on a screen whose whole point is
169
+ that nobody has been enrolled yet. -->
170
+ <template #no-data>
171
+ <div class="table-card__empty">
172
+ <v-icon
173
+ :icon="props.cardManagement ? 'mdi-card-account-details-outline' : 'mdi-account-off-outline'"
174
+ size="32"
175
+ />
176
+ <span>
177
+ {{ props.cardManagement ? "No HID users available for card assignment." : "No HID users enrolled yet." }}
178
+ </span>
179
+ </div>
180
+ </template>
181
+ </TableMain>
116
182
 
117
183
  <v-dialog v-model="formDialog" max-width="420" persistent>
118
184
  <v-card rounded="lg">
@@ -125,109 +191,294 @@
125
191
  <v-menu>
126
192
  <template #activator="{ props: menuProps }">
127
193
  <button v-bind="menuProps" class="photo-button" type="button">
128
- <v-img v-if="form.photoPreview" :src="form.photoPreview" cover class="photo-preview" />
194
+ <v-img
195
+ v-if="form.photoPreview"
196
+ :src="form.photoPreview"
197
+ cover
198
+ class="photo-preview"
199
+ />
129
200
  <span v-if="form.photoPreview" class="photo-edit-icon">
130
201
  <v-icon icon="mdi-pencil" size="16" />
131
202
  </span>
132
203
  <template v-else>
133
- <v-icon icon="mdi-account-plus" size="40" color="#a4acb5" />
204
+ <!-- Was `color="#a4acb5"`, the same grey on both themes.
205
+ `.photo-button` already sets `--muted`, so it inherits. -->
206
+ <v-icon icon="mdi-account-plus" size="40" />
134
207
  <span>Add Photo</span>
135
208
  </template>
136
209
  </button>
137
210
  </template>
138
211
  <v-list density="compact" min-width="190">
139
212
  <v-list-item title="Browse" @click="triggerPhotoInput" />
140
- <v-list-item title="Take Photo using Camera" @click="triggerPhotoInput" />
213
+ <v-list-item
214
+ title="Take Photo using Camera"
215
+ @click="triggerCameraInput"
216
+ />
217
+ <v-list-item
218
+ v-if="form.photoPreview"
219
+ title="Remove Photo"
220
+ class="text-error"
221
+ @click="removePhoto"
222
+ />
141
223
  </v-list>
142
224
  </v-menu>
143
- <input ref="photoInput" type="file" accept="image/*" class="hidden-input" @change="onPhotoChange" />
225
+ <input
226
+ ref="photoInput"
227
+ type="file"
228
+ accept="image/*"
229
+ class="hidden-input"
230
+ @change="onPhotoChange"
231
+ />
144
232
  </div>
145
233
 
146
234
  <div class="field-label">Name <span>*</span></div>
147
- <v-text-field
235
+ <AppField
148
236
  v-model="form.name"
149
237
  aria-label="Name"
150
238
  placeholder="Enter name"
151
- density="compact"
152
- variant="outlined"
153
- hide-details="auto"
154
- class="mb-3"
239
+ klass="mb-3"
155
240
  />
156
241
 
157
242
  <div class="field-label">ID</div>
158
- <v-text-field
159
- v-model="form.hidUserId"
160
- aria-label="ID"
161
- density="compact"
162
- variant="outlined"
163
- hide-details="auto"
164
- readonly
165
- class="mb-3"
166
- />
243
+ <AppField v-model="form.hidUserId" aria-label="ID" readonly klass="mb-3" />
167
244
 
168
245
  <div class="field-label">Registration No. <span>*</span></div>
169
- <v-text-field
246
+ <AppField
170
247
  v-model="form.registration"
171
248
  aria-label="Registration No."
172
249
  placeholder="Enter registration no."
173
- density="compact"
174
- variant="outlined"
175
- hide-details="auto"
176
250
  :readonly="!selectedUser"
177
- class="mb-3"
251
+ klass="mb-3"
178
252
  />
179
253
 
180
- <div class="field-label">Panic Pin (optional)</div>
181
- <v-text-field
182
- v-model="form.cardNo"
183
- aria-label="Panic Pin"
184
- placeholder="Enter panic pin"
254
+ <v-checkbox
255
+ v-model="form.isAdministrator"
256
+ label="Administrator"
185
257
  density="compact"
186
- variant="outlined"
187
- hide-details="auto"
188
- class="mb-3"
258
+ hide-details
259
+ class="admin-rule-checkbox"
189
260
  />
190
-
191
- <div class="field-label">Panic Password (optional)</div>
192
- <v-text-field
193
- v-model="form.pinPassword"
194
- aria-label="Panic Password"
195
- placeholder="Enter password"
261
+ <p class="field-hint admin-rule-hint">
262
+ Allow this HID user to access the reader as an administrator.
263
+ </p>
264
+
265
+ <div class="field-label">Access PIN (optional)</div>
266
+ <!--
267
+ The handoff draws no password field anywhere (the sign-in screens
268
+ are the owner's own), so the reveal toggle is this product's, not
269
+ the design's. It goes in `AppField`'s `trailing` slot so the eye
270
+ sits INSIDE the 40px box as it did inside the Vuetify field, rather
271
+ than becoming a second control beside it.
272
+ -->
273
+ <AppField
274
+ v-model="form.accessPin"
275
+ aria-label="Access PIN"
276
+ placeholder="Enter numeric access PIN"
277
+ inputmode="numeric"
278
+ autocomplete="new-password"
279
+ :type="showPin ? 'text' : 'password'"
280
+ >
281
+ <template #trailing>
282
+ <button
283
+ type="button"
284
+ class="app-field__trailing"
285
+ :aria-label="showPin ? 'Hide access PIN' : 'Show access PIN'"
286
+ @click="showPin = !showPin"
287
+ >
288
+ <v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
289
+ </button>
290
+ </template>
291
+ </AppField>
292
+ <!-- `persistent-hint` was Vuetify's; the design's settings module
293
+ draws an explanatory line as 12-13px/600 in `--muted`. -->
294
+ <p class="field-hint">
295
+ {{ selectedUser && pinEnrolled ? "Leave blank to keep the current PIN." : "PIN works as an alternative to face or QR." }}
296
+ </p>
297
+ <v-checkbox
298
+ v-if="selectedUser && pinEnrolled"
299
+ v-model="removePinRequested"
300
+ label="Remove current access PIN"
196
301
  density="compact"
197
- variant="outlined"
198
- hide-details="auto"
199
- :type="showPassword ? 'text' : 'password'"
200
- :append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
201
- @click:append-inner="showPassword = !showPassword"
302
+ hide-details
202
303
  />
203
304
  </v-card-text>
204
305
 
306
+ <!-- The split footer bar, as panel 2 keeps it: `v-btn` because it needs
307
+ `:loading` and a full-bleed shape the handoff does not draw. Only
308
+ the colours change - they were fixed hexes on both themes. -->
205
309
  <v-card-actions class="pa-0">
206
- <v-btn class="text-none action-cancel" variant="flat" height="44" @click="formDialog = false">Cancel</v-btn>
207
- <v-btn class="text-none action-submit" variant="flat" height="44" :loading="saving" @click="saveUser">
310
+ <v-btn
311
+ class="text-none action-cancel"
312
+ variant="flat"
313
+ height="44"
314
+ @click="formDialog = false"
315
+ >Cancel</v-btn
316
+ >
317
+ <v-btn
318
+ class="text-none action-submit"
319
+ variant="flat"
320
+ height="44"
321
+ :loading="saving"
322
+ @click="saveUser"
323
+ >
208
324
  Save
209
325
  </v-btn>
210
326
  </v-card-actions>
211
327
  </v-card>
212
328
  </v-dialog>
213
329
 
330
+ <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
331
+
214
332
  <v-dialog v-model="viewDialog" max-width="430">
215
333
  <v-card rounded="lg">
216
334
  <v-card-title class="small-title">User Information</v-card-title>
217
335
  <v-card-text>
218
336
  <div class="detail-grid">
219
337
  <span>Name</span><strong>{{ getName(selectedUser) }}</strong>
220
- <span>User ID</span><strong>{{ selectedUser ? formatHidUid(selectedUser.hidUserId) : "N/A" }}</strong>
221
- <span>Registration No.</span><strong>{{ selectedUser?.registration || "N/A" }}</strong>
222
- <span>Last access time</span><strong>{{ formatDate(selectedUser?.metadata?.lastAccessAt) }}</strong>
223
- <span>Status</span><strong>{{ selectedUser ? getMappingStatus(selectedUser) : "N/A" }}</strong>
224
- <span>Panic Pin</span><strong>{{ selectedUser?.cardNo || "N/A" }}</strong>
225
- <span>Private Password</span><strong>{{ selectedUser?.metadata?.pinPassword ? "********" : "N/A" }}</strong>
338
+ <span>User ID</span
339
+ ><strong>{{
340
+ selectedUser ? formatHidUid(selectedUser.hidUserId) : "N/A"
341
+ }}</strong>
342
+ <span>Registration No.</span
343
+ ><strong>{{ selectedUser?.registration || "N/A" }}</strong>
344
+ <span>Last access time</span
345
+ ><strong>{{
346
+ formatDate(selectedUser?.metadata?.lastAccessAt)
347
+ }}</strong>
348
+ <span>Status</span
349
+ ><strong>{{
350
+ selectedUser ? getMappingStatus(selectedUser) : "N/A"
351
+ }}</strong>
352
+ <span>Administrator</span>
353
+ <strong class="admin-rule-detail">
354
+ <v-icon
355
+ :icon="selectedUser?.isAdministrator ? 'mdi-check-circle' : 'mdi-close-circle'"
356
+ :class="selectedUser?.isAdministrator
357
+ ? 'admin-rule-indicator--enabled'
358
+ : 'admin-rule-indicator--disabled'"
359
+ size="18"
360
+ />
361
+ {{ selectedUser?.isAdministrator ? "Yes" : "No" }}
362
+ </strong>
363
+ <span>Access PIN</span
364
+ ><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
365
+ </div>
366
+ </v-card-text>
367
+ <v-card-actions>
368
+ <v-spacer />
369
+ <AppButton variant="ghost" @click="viewDialog = false">Close</AppButton>
370
+ </v-card-actions>
371
+ </v-card>
372
+ </v-dialog>
373
+
374
+ <v-dialog v-model="cardDialog" max-width="560" persistent>
375
+ <v-card rounded="lg">
376
+ <v-card-title class="small-title">Physical HID Cards</v-card-title>
377
+ <v-card-text>
378
+ <p class="hid-card-user">
379
+ {{ getName(cardUser) }}
380
+ <span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
381
+ </p>
382
+
383
+ <v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
384
+ <div v-else-if="physicalCards.length" class="hid-card-list mb-4">
385
+ <div v-for="card in physicalCards" :key="card.id" class="hid-card-row">
386
+ <v-icon icon="mdi-card-account-details-outline" size="22" />
387
+ <div class="hid-card-row__content">
388
+ <strong>{{ formatPhysicalCard(card) }}</strong>
389
+ <span>{{ getPhysicalCardSubtitle(card) }}</span>
390
+ </div>
391
+ <AppButton
392
+ variant="row"
393
+ icon="mdi-delete-outline"
394
+ :disabled="deletingCardId === String(card.id)"
395
+ @click="removePhysicalCard(card)"
396
+ />
397
+ </div>
398
+ </div>
399
+ <v-alert v-else type="info" variant="tonal" density="compact" class="mb-4">
400
+ No physical HID card is assigned to this user.
401
+ </v-alert>
402
+
403
+ <div class="field-label">Card data type</div>
404
+ <AppSelect
405
+ v-model="cardForm.cardType"
406
+ :items="physicalCardTypeOptions"
407
+ class="mb-3"
408
+ />
409
+ <p class="field-hint hid-card-type-hint">
410
+ PACS uses Facility Code + Card Number. CSN uses the raw serial number read from the physical card.
411
+ </p>
412
+ <p class="field-hint hid-card-access-hint">
413
+ The card uses this HID user's existing portal and access rules; assigning a card does not grant new access.
414
+ </p>
415
+
416
+ <div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
417
+ <div>
418
+ <div class="field-label">Facility Code</div>
419
+ <AppField
420
+ v-model="cardForm.facilityCode"
421
+ inputmode="numeric"
422
+ placeholder="e.g. 123"
423
+ />
424
+ </div>
425
+ <div>
426
+ <div class="field-label">Card Number</div>
427
+ <AppField
428
+ v-model="cardForm.cardNumber"
429
+ inputmode="numeric"
430
+ placeholder="e.g. 45678"
431
+ />
432
+ </div>
433
+ </div>
434
+ <template v-else>
435
+ <div class="field-label">CSN / Raw Card Value</div>
436
+ <AppField
437
+ v-model="cardForm.cardValue"
438
+ inputmode="numeric"
439
+ placeholder="Enter the decimal card value"
440
+ />
441
+ </template>
442
+
443
+ <v-alert
444
+ v-if="enrollingCard"
445
+ type="info"
446
+ variant="tonal"
447
+ density="compact"
448
+ class="mt-4"
449
+ >
450
+ Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
451
+ </v-alert>
452
+
453
+ <div class="hid-card-actions mt-4">
454
+ <AppButton
455
+ variant="secondary"
456
+ :disabled="savingCard || enrollingCard"
457
+ @click="assignPhysicalCard"
458
+ >
459
+ Assign Manually
460
+ </AppButton>
461
+ <AppButton
462
+ icon="mdi-contactless-payment"
463
+ :disabled="savingCard || enrollingCard"
464
+ @click="enrollPhysicalCard"
465
+ >
466
+ Read at Reader
467
+ </AppButton>
468
+ <AppButton
469
+ v-if="enrollingCard"
470
+ variant="ghost"
471
+ @click="cancelPhysicalCardEnrollment"
472
+ >
473
+ Cancel Reading
474
+ </AppButton>
226
475
  </div>
227
476
  </v-card-text>
228
477
  <v-card-actions>
229
478
  <v-spacer />
230
- <v-btn class="text-none" variant="text" @click="viewDialog = false">Close</v-btn>
479
+ <AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
480
+ Close
481
+ </AppButton>
231
482
  </v-card-actions>
232
483
  </v-card>
233
484
  </v-dialog>
@@ -238,12 +489,26 @@
238
489
  <v-card-text class="text-body-2">
239
490
  Are you sure you want to permanently delete this HID user?
240
491
  <div class="text-caption text-medium-emphasis mt-2">
241
- This action will remove the user from the HID reader and mark the identity as deleted.
492
+ This action will remove the user from the HID reader and mark the
493
+ identity as deleted.
242
494
  </div>
243
495
  </v-card-text>
244
496
  <v-card-actions class="pa-0">
245
- <v-btn class="text-none action-cancel" variant="flat" height="44" @click="deleteDialog = false">Cancel</v-btn>
246
- <v-btn class="text-none action-submit" variant="flat" height="44" :loading="deleting" @click="deleteUser">Delete</v-btn>
497
+ <v-btn
498
+ class="text-none action-cancel"
499
+ variant="flat"
500
+ height="44"
501
+ @click="deleteDialog = false"
502
+ >Cancel</v-btn
503
+ >
504
+ <v-btn
505
+ class="text-none action-submit"
506
+ variant="flat"
507
+ height="44"
508
+ :loading="deleting"
509
+ @click="deleteUser"
510
+ >Delete</v-btn
511
+ >
247
512
  </v-card-actions>
248
513
  </v-card>
249
514
  </v-dialog>
@@ -260,9 +525,9 @@ const props = defineProps({
260
525
  type: String,
261
526
  required: true,
262
527
  },
263
- identityType: {
264
- type: String,
265
- default: "resident",
528
+ cardManagement: {
529
+ type: Boolean,
530
+ default: false,
266
531
  },
267
532
  });
268
533
 
@@ -270,6 +535,16 @@ const {
270
535
  getReaders,
271
536
  getIdentities,
272
537
  getUserImage,
538
+ setUserImage,
539
+ deleteUserImage,
540
+ getUserCards,
541
+ assignUserCard,
542
+ enrollUserCard,
543
+ cancelUserCardEnrollment,
544
+ deleteUserCard,
545
+ getUserPinStatus,
546
+ setUserPin,
547
+ deleteUserPin,
273
548
  runObjectOperation,
274
549
  createIdentity,
275
550
  updateIdentity,
@@ -279,9 +554,10 @@ const {
279
554
  const readers = ref<Record<string, any>[]>([]);
280
555
  const users = ref<Record<string, any>[]>([]);
281
556
  const userImages = ref<Record<string, string>>({});
557
+ const administratorUserIds = ref<Set<number>>(new Set());
282
558
  const selectedReaderId = ref("");
283
559
  const search = ref("");
284
- const status = ref("Status");
560
+ const status = ref("");
285
561
  const page = ref(1);
286
562
  const limit = ref(10);
287
563
  const pages = ref(1);
@@ -293,9 +569,23 @@ const deleting = ref(false);
293
569
  const formDialog = ref(false);
294
570
  const viewDialog = ref(false);
295
571
  const deleteDialog = ref(false);
572
+ const cardDialog = ref(false);
296
573
  const selectedUser = ref<Record<string, any> | null>(null);
297
- const showPassword = ref(false);
574
+ const cardUser = ref<Record<string, any> | null>(null);
575
+ const physicalCards = ref<Record<string, any>[]>([]);
576
+ const physicalCardsByUserId = ref<Record<string, Record<string, any>[]>>({});
577
+ const loadingCards = ref(false);
578
+ const savingCard = ref(false);
579
+ const enrollingCard = ref(false);
580
+ const cardEnrollmentCancelled = ref(false);
581
+ const deletingCardId = ref("");
582
+ const showPin = ref(false);
583
+ const pinEnrolled = ref(false);
298
584
  const photoInput = ref<HTMLInputElement | null>(null);
585
+ const cameraDialog = ref(false);
586
+ const selectedPhotoFile = ref<File | null>(null);
587
+ const removePhotoRequested = ref(false);
588
+ const removePinRequested = ref(false);
299
589
  const snackbar = reactive({
300
590
  show: false,
301
591
  color: "success",
@@ -310,18 +600,69 @@ const form = reactive({
310
600
  name: "",
311
601
  hidUserId: "",
312
602
  registration: "",
313
- cardNo: "",
314
- pinPassword: "",
603
+ isAdministrator: false,
604
+ accessPin: "",
315
605
  photoPreview: "",
316
606
  });
317
607
 
318
- const statusOptions = ["Status", "Mapped", "Unmapped"];
608
+ const cardForm = reactive({
609
+ cardType: "pacs" as "pacs" | "csn",
610
+ facilityCode: "",
611
+ cardNumber: "",
612
+ cardValue: "",
613
+ });
614
+
615
+ const physicalCardTypeOptions = [
616
+ { title: "PACS (Facility Code + Card Number)", value: "pacs" },
617
+ { title: "CSN (Raw Card Serial Number)", value: "csn" },
618
+ ];
619
+
620
+ const statusOptions = ["Mapped", "Unmapped"];
621
+
622
+ /**
623
+ * `sortable: false` keeps the previous behaviour - the raw `v-table` never
624
+ * sorted - and no `weight` is set, so the table uses `TableMain`'s automatic
625
+ * layout. The handoff draws no HID table, so the screen's own fixed
626
+ * 24/20/12/20/16/8 % were a guess with no design number behind them.
627
+ */
628
+ const userHeaders = [
629
+ { title: "Name", value: "name", sortable: false },
630
+ { title: "Facial Data", value: "facialData", sortable: false },
631
+ { title: "UID", value: "hidUserId", sortable: false },
632
+ { title: "Status", value: "status", sortable: false },
633
+ { title: "Admin", value: "isAdministrator", sortable: false },
634
+ { title: "", value: "action-table", sortable: false },
635
+ ];
636
+
637
+ const cardHeaders = [
638
+ { title: "Name", value: "name", sortable: false },
639
+ { title: "UID", value: "hidUserId", sortable: false },
640
+ { title: "Physical HID Cards", value: "cards", sortable: false },
641
+ { title: "Admin", value: "isAdministrator", sortable: false },
642
+ { title: "", value: "action-table", sortable: false },
643
+ ];
644
+
645
+ const tableTitle = computed(() => props.cardManagement ? "HID Cards" : "HID Users");
646
+ const tableHeaders = computed(() => props.cardManagement ? cardHeaders : userHeaders);
647
+
648
+ /**
649
+ * The list is paged on the SERVER, so the shared pager only moves the cursor -
650
+ * the rows still come from `loadUsers`. This is the same pair of statements
651
+ * the old footer's two chevrons ran.
652
+ */
653
+ function goToPage(next: number) {
654
+ page.value = next;
655
+ loadUsers();
656
+ }
319
657
 
320
658
  const readerOptions = computed(() =>
321
659
  readers.value.map((reader) => ({
322
- title: reader.name || reader.deviceId || reader._id,
660
+ title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
323
661
  value: reader._id,
324
- })),
662
+ }))
663
+ );
664
+ const selectedReader = computed(() =>
665
+ readers.value.find((reader) => reader._id === (selectedReaderId.value || form.reader)) || null,
325
666
  );
326
667
 
327
668
  const pageRange = computed(() => {
@@ -335,7 +676,7 @@ const pageRange = computed(() => {
335
676
  watch(
336
677
  () => props.site,
337
678
  () => init(),
338
- { immediate: true },
679
+ { immediate: true }
339
680
  );
340
681
 
341
682
  async function init() {
@@ -346,7 +687,8 @@ async function init() {
346
687
  async function loadReaders() {
347
688
  if (!props.site) return;
348
689
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
349
- readers.value = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
690
+ readers.value =
691
+ response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
350
692
  if (!selectedReaderId.value && readers.value.length) {
351
693
  selectedReaderId.value = readers.value[0]._id;
352
694
  }
@@ -355,16 +697,16 @@ async function loadReaders() {
355
697
  async function loadUsers() {
356
698
  if (!selectedReaderId.value) {
357
699
  users.value = [];
700
+ administratorUserIds.value = new Set();
701
+ physicalCardsByUserId.value = {};
358
702
  return;
359
703
  }
360
704
 
361
705
  loading.value = true;
362
706
  try {
363
707
  const identityQuery: Record<string, any> = {
364
- page: page.value,
708
+ page: 1,
365
709
  limit: 500,
366
- type: props.identityType,
367
- search: search.value,
368
710
  };
369
711
 
370
712
  const [identityResponse, readerResponse, roleResponse] = await Promise.all([
@@ -375,17 +717,24 @@ async function loadUsers() {
375
717
  limit: 500,
376
718
  offset: 0,
377
719
  }),
378
- isAdministratorIdentity() ? loadAdministratorRoles(selectedReaderId.value) : Promise.resolve(null),
720
+ loadAdministratorRoles(selectedReaderId.value),
379
721
  ]);
380
722
 
381
- const identities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
382
- const adminRoleIds = getAdministratorRoleUserIds(roleResponse);
383
- const readerUsers = normalizeHidUsers(readerResponse).filter((user) => {
384
- if (!isAdministratorIdentity()) return true;
385
- const hidUserId = toHidNumericId(user.hidUserId);
386
- return Boolean(hidUserId && adminRoleIds.has(hidUserId));
387
- });
388
- const mergedUsers = mergeReaderUsersWithIdentities(readerUsers, identities);
723
+ const identities =
724
+ identityResponse?.items ??
725
+ identityResponse?.data?.items ??
726
+ identityResponse?.data?.identities ??
727
+ [];
728
+ administratorUserIds.value = getAdministratorRoleUserIds(roleResponse);
729
+ const readerUsers = normalizeHidUsers(readerResponse).filter(
730
+ (user) => Number(user.userTypeId) !== 1,
731
+ );
732
+ const mergedUsers = mergeReaderUsersWithIdentities(readerUsers, identities).map(
733
+ (user) => ({
734
+ ...user,
735
+ isAdministrator: hasAdministratorRule(user),
736
+ }),
737
+ );
389
738
  const filteredUsers = filterUsers(mergedUsers);
390
739
  total.value = filteredUsers.length;
391
740
  pages.value = Math.max(1, Math.ceil(total.value / limit.value));
@@ -393,11 +742,17 @@ async function loadUsers() {
393
742
  const start = (page.value - 1) * limit.value;
394
743
  users.value = filteredUsers.slice(start, start + limit.value);
395
744
  serverPageRange.value = "";
396
- await loadUserImages(users.value);
745
+ if (props.cardManagement) {
746
+ await loadVisibleUserCards(users.value);
747
+ } else {
748
+ await loadUserImages(users.value);
749
+ }
397
750
  } catch (error) {
398
751
  console.error("Unable to load HID reader users:", error);
399
752
  showToast("Unable to load HID users from reader.", "error");
400
753
  users.value = [];
754
+ administratorUserIds.value = new Set();
755
+ physicalCardsByUserId.value = {};
401
756
  total.value = 0;
402
757
  pages.value = 1;
403
758
  serverPageRange.value = "";
@@ -409,21 +764,23 @@ async function loadUsers() {
409
764
  async function loadUserImages(items: Record<string, any>[]) {
410
765
  if (!selectedReaderId.value) return;
411
766
 
412
- await Promise.all(items.map(async (user) => {
413
- const hidUserId = toHidNumericId(user.hidUserId);
414
- const key = String(hidUserId || "");
415
- if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
416
-
417
- try {
418
- const response = await getUserImage(selectedReaderId.value, hidUserId);
419
- const image = response?.data || response;
420
- userImages.value[key] = image?.base64
421
- ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
422
- : "";
423
- } catch {
424
- userImages.value[key] = "";
425
- }
426
- }));
767
+ await Promise.all(
768
+ items.map(async (user) => {
769
+ const hidUserId = toHidNumericId(user.hidUserId);
770
+ const key = String(hidUserId || "");
771
+ if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
772
+
773
+ try {
774
+ const response = await getUserImage(selectedReaderId.value, hidUserId);
775
+ const image = response?.data || response;
776
+ userImages.value[key] = image?.base64
777
+ ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
778
+ : "";
779
+ } catch {
780
+ userImages.value[key] = "";
781
+ }
782
+ })
783
+ );
427
784
  }
428
785
 
429
786
  function resetForm() {
@@ -434,9 +791,14 @@ function resetForm() {
434
791
  form.name = "";
435
792
  form.hidUserId = "";
436
793
  form.registration = "";
437
- form.cardNo = "";
438
- form.pinPassword = "";
794
+ form.isAdministrator = false;
795
+ form.accessPin = "";
796
+ pinEnrolled.value = false;
797
+ showPin.value = false;
798
+ removePinRequested.value = false;
439
799
  form.photoPreview = "";
800
+ selectedPhotoFile.value = null;
801
+ removePhotoRequested.value = false;
440
802
  }
441
803
 
442
804
  async function openEnroll() {
@@ -460,11 +822,25 @@ async function openEdit(user: Record<string, any>) {
460
822
  form.name = getName(user);
461
823
  form.hidUserId = formatHidUid(user.hidUserId);
462
824
  form.registration = user.registration ?? "";
463
- form.cardNo = user.cardNo ?? "";
464
- form.pinPassword = user.metadata?.pinPassword ?? "";
825
+ form.isAdministrator = hasAdministratorRule(user);
826
+ form.accessPin = "";
827
+ pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
828
+ removePinRequested.value = false;
465
829
  form.photoPreview = getUserImageSrc(user);
830
+ selectedPhotoFile.value = null;
831
+ removePhotoRequested.value = false;
466
832
  formDialog.value = true;
467
833
 
834
+ try {
835
+ const status = await getUserPinStatus(
836
+ form.reader,
837
+ toHidNumericId(user.hidUserId),
838
+ );
839
+ pinEnrolled.value = Boolean(status?.data?.pinEnrolled);
840
+ } catch {
841
+ // Preserve the last known status if the reader is temporarily offline.
842
+ }
843
+
468
844
  if (!form.photoPreview) {
469
845
  await loadUserImages([user]);
470
846
  if (selectedUser.value?._rowKey === user._rowKey) {
@@ -487,9 +863,53 @@ function triggerPhotoInput() {
487
863
  photoInput.value?.click();
488
864
  }
489
865
 
866
+ function triggerCameraInput() {
867
+ cameraDialog.value = true;
868
+ }
869
+
870
+ function onCameraCapture(dataUrl: string) {
871
+ const [header, encodedData] = dataUrl.split(",", 2);
872
+ if (!header || !encodedData) {
873
+ showToast("Unable to capture the facial image.", "error");
874
+ return;
875
+ }
876
+
877
+ const mimeType = header.match(/^data:([^;]+);base64$/)?.[1] || "image/jpeg";
878
+ const decodedData = atob(encodedData);
879
+ const bytes = new Uint8Array(decodedData.length);
880
+ for (let index = 0; index < decodedData.length; index += 1) {
881
+ bytes[index] = decodedData.charCodeAt(index);
882
+ }
883
+
884
+ selectedPhotoFile.value = new File([bytes], `hid-facial-${Date.now()}.jpg`, {
885
+ type: mimeType,
886
+ });
887
+ form.photoPreview = dataUrl;
888
+ removePhotoRequested.value = false;
889
+ }
890
+
891
+ function removePhoto() {
892
+ form.photoPreview = "";
893
+ selectedPhotoFile.value = null;
894
+ removePhotoRequested.value = true;
895
+ }
896
+
490
897
  function onPhotoChange(event: Event) {
491
- const file = (event.target as HTMLInputElement).files?.[0];
898
+ const input = event.target as HTMLInputElement;
899
+ const file = input.files?.[0];
900
+ input.value = "";
492
901
  if (!file) return;
902
+ if (!["image/jpeg", "image/png"].includes(file.type)) {
903
+ showToast("Facial enrollment only accepts JPG or PNG images.", "error");
904
+ return;
905
+ }
906
+ if (file.size > 2 * 1024 * 1024) {
907
+ showToast("Facial enrollment images must be smaller than 2 MB.", "error");
908
+ return;
909
+ }
910
+
911
+ selectedPhotoFile.value = file;
912
+ removePhotoRequested.value = false;
493
913
  const reader = new FileReader();
494
914
  reader.onload = () => {
495
915
  form.photoPreview = String(reader.result || "");
@@ -498,11 +918,20 @@ function onPhotoChange(event: Event) {
498
918
  }
499
919
 
500
920
  async function saveUser() {
501
- const readerId = selectedReaderId.value || form.reader || readers.value[0]?._id;
921
+ const readerId =
922
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
502
923
  if (!readerId || !form.name || !form.hidUserId || !form.registration) {
503
924
  showToast("Please complete the required HID user fields.", "error");
504
925
  return;
505
926
  }
927
+ if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
928
+ showToast("Access PIN must contain numbers only.", "error");
929
+ return;
930
+ }
931
+ if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
932
+ showToast("Reconnect this HID reader and select a portal before enrolling facial recognition.", "error");
933
+ return;
934
+ }
506
935
 
507
936
  saving.value = true;
508
937
  try {
@@ -519,8 +948,13 @@ async function saveUser() {
519
948
  const payload = {
520
949
  hidUserId: String(hidUser.id),
521
950
  registration: form.registration,
522
- cardNo: form.cardNo,
523
- type: props.identityType as "resident" | "staff" | "contractor" | "visitor" | "admin" | "unknown",
951
+ type: (selectedUser.value?.type || "resident") as
952
+ | "resident"
953
+ | "staff"
954
+ | "contractor"
955
+ | "visitor"
956
+ | "admin"
957
+ | "unknown",
524
958
  status: "active" as const,
525
959
  metadata: {
526
960
  name: form.name,
@@ -530,13 +964,19 @@ async function saveUser() {
530
964
  unitLabel: buildUnitLabel(),
531
965
  facialData: form.photoPreview ? form.hidUserId : "",
532
966
  photo: form.photoPreview,
533
- pinPassword: form.pinPassword,
967
+ imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
968
+ facialScores: selectedUser.value?.metadata?.facialScores || {},
969
+ pinEnrolled: pinEnrolled.value,
970
+ hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
534
971
  },
535
972
  };
536
973
 
537
974
  if (selectedUser.value) {
538
975
  await updateHidUserOnReader(readerId, hidUser);
539
- await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
976
+ await syncHidUserRole(readerId, hidUser.id, form.isAdministrator);
977
+ const facialResult = await syncFacialImage(readerId, hidUser.id);
978
+ applyFacialResult(payload.metadata, facialResult);
979
+ payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
540
980
  if (selectedUser.value._id) {
541
981
  await updateIdentity(selectedUser.value._id, payload);
542
982
  } else {
@@ -544,14 +984,21 @@ async function saveUser() {
544
984
  }
545
985
  } else {
546
986
  await createHidUserOnReader(readerId, hidUser);
547
- await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
987
+ await syncHidUserRole(readerId, hidUser.id, form.isAdministrator);
988
+ const facialResult = await syncFacialImage(readerId, hidUser.id);
989
+ applyFacialResult(payload.metadata, facialResult);
990
+ payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
548
991
  await saveIdentityOnReader(readerId, payload);
549
992
  selectedReaderId.value = readerId;
550
993
  }
551
994
 
552
995
  formDialog.value = false;
553
996
  await loadUsers();
554
- showToast(selectedUser.value?._id ? "HID user updated on reader." : "HID user enrolled on reader.");
997
+ showToast(
998
+ selectedUser.value?._id
999
+ ? "HID user updated on reader."
1000
+ : "HID user enrolled on reader."
1001
+ );
555
1002
  } catch (error: any) {
556
1003
  console.error("Unable to save HID user:", error);
557
1004
  showToast(getHidErrorMessage(error), "error");
@@ -560,12 +1007,86 @@ async function saveUser() {
560
1007
  }
561
1008
  }
562
1009
 
1010
+ async function syncAccessPin(readerId: string, hidUserId: number) {
1011
+ if (removePinRequested.value) {
1012
+ await deleteUserPin(readerId, hidUserId);
1013
+ pinEnrolled.value = false;
1014
+ return false;
1015
+ }
1016
+ if (form.accessPin) {
1017
+ await setUserPin(readerId, hidUserId, form.accessPin);
1018
+ form.accessPin = "";
1019
+ pinEnrolled.value = true;
1020
+ return true;
1021
+ }
1022
+ return pinEnrolled.value;
1023
+ }
1024
+
1025
+ type FacialSyncResult = {
1026
+ action: "none" | "enrolled" | "removed";
1027
+ timestamp?: number;
1028
+ scores?: Record<string, unknown>;
1029
+ };
1030
+
1031
+ async function syncFacialImage(
1032
+ readerId: string,
1033
+ hidUserId: number
1034
+ ): Promise<FacialSyncResult> {
1035
+ if (selectedPhotoFile.value) {
1036
+ const timestamp = Math.floor(Date.now() / 1000);
1037
+ const response = await setUserImage(
1038
+ readerId,
1039
+ hidUserId,
1040
+ selectedPhotoFile.value,
1041
+ {
1042
+ timestamp,
1043
+ match: true,
1044
+ }
1045
+ );
1046
+ const result = response?.data;
1047
+ userImages.value[String(hidUserId)] = form.photoPreview;
1048
+ return { action: "enrolled", timestamp, scores: result?.scores };
1049
+ }
1050
+
1051
+ if (removePhotoRequested.value) {
1052
+ await deleteUserImage(readerId, hidUserId);
1053
+ delete userImages.value[String(hidUserId)];
1054
+ return { action: "removed" };
1055
+ }
1056
+
1057
+ return { action: "none" };
1058
+ }
1059
+
1060
+ function applyFacialResult(
1061
+ metadata: Record<string, unknown>,
1062
+ result: FacialSyncResult
1063
+ ) {
1064
+ if (result.action === "enrolled") {
1065
+ metadata.facialData = form.hidUserId;
1066
+ metadata.imageTimestamp = String(result.timestamp || "");
1067
+ metadata.facialScores = result.scores || {};
1068
+ } else if (result.action === "removed") {
1069
+ metadata.facialData = "";
1070
+ metadata.photo = "";
1071
+ metadata.imageTimestamp = "";
1072
+ metadata.facialScores = {};
1073
+ }
1074
+ }
1075
+
563
1076
  async function deleteUser() {
564
1077
  if (!selectedUser.value) return;
565
1078
 
566
1079
  deleting.value = true;
567
1080
  try {
568
- await syncHidUserRole(selectedReaderId.value, toHidNumericId(selectedUser.value.hidUserId), false);
1081
+ await syncHidUserRole(
1082
+ selectedReaderId.value,
1083
+ toHidNumericId(selectedUser.value.hidUserId),
1084
+ false
1085
+ );
1086
+ await deleteUserPin(
1087
+ selectedReaderId.value,
1088
+ toHidNumericId(selectedUser.value.hidUserId),
1089
+ );
569
1090
  await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
570
1091
  if (selectedUser.value._id) {
571
1092
  await deleteIdentity(selectedUser.value._id);
@@ -581,8 +1102,14 @@ async function deleteUser() {
581
1102
  }
582
1103
  }
583
1104
 
584
- async function saveIdentityOnReader(readerId: string, payload: Record<string, any>) {
585
- const existingIdentity = await findExistingIdentityOnReader(readerId, payload);
1105
+ async function saveIdentityOnReader(
1106
+ readerId: string,
1107
+ payload: Record<string, any>
1108
+ ) {
1109
+ const existingIdentity = await findExistingIdentityOnReader(
1110
+ readerId,
1111
+ payload
1112
+ );
586
1113
  if (existingIdentity?._id) {
587
1114
  await updateIdentity(existingIdentity._id, payload);
588
1115
  return;
@@ -594,31 +1121,47 @@ async function saveIdentityOnReader(readerId: string, payload: Record<string, an
594
1121
  const message = getHidErrorMessage(error);
595
1122
  if (!message.toLowerCase().includes("already exists")) throw error;
596
1123
 
597
- const duplicatedIdentity = await findExistingIdentityOnReader(readerId, payload);
1124
+ const duplicatedIdentity = await findExistingIdentityOnReader(
1125
+ readerId,
1126
+ payload
1127
+ );
598
1128
  if (!duplicatedIdentity?._id) throw error;
599
1129
  await updateIdentity(duplicatedIdentity._id, payload);
600
1130
  }
601
1131
  }
602
1132
 
603
- async function findExistingIdentityOnReader(readerId: string, payload: Record<string, any>) {
1133
+ async function findExistingIdentityOnReader(
1134
+ readerId: string,
1135
+ payload: Record<string, any>
1136
+ ) {
604
1137
  const response = await getIdentities(readerId, {
605
1138
  page: 1,
606
1139
  limit: 500,
607
1140
  search: payload.hidUserId || payload.registration || payload.cardNo || "",
608
1141
  });
609
- const identities = response?.items ?? response?.data?.items ?? response?.data?.identities ?? [];
1142
+ const identities =
1143
+ response?.items ??
1144
+ response?.data?.items ??
1145
+ response?.data?.identities ??
1146
+ [];
610
1147
  const hidUserId = String(payload.hidUserId || "");
611
1148
  const registration = String(payload.registration || "");
612
1149
  const cardNo = String(payload.cardNo || "");
613
1150
 
614
- return identities.find((identity: Record<string, any>) =>
615
- String(identity.hidUserId || "") === hidUserId ||
616
- Boolean(registration && String(identity.registration || "") === registration) ||
617
- Boolean(cardNo && String(identity.cardNo || "") === cardNo),
1151
+ return identities.find(
1152
+ (identity: Record<string, any>) =>
1153
+ String(identity.hidUserId || "") === hidUserId ||
1154
+ Boolean(
1155
+ registration && String(identity.registration || "") === registration
1156
+ ) ||
1157
+ Boolean(cardNo && String(identity.cardNo || "") === cardNo)
618
1158
  );
619
1159
  }
620
1160
 
621
- async function createHidUserOnReader(readerId: string, user: Record<string, any>) {
1161
+ async function createHidUserOnReader(
1162
+ readerId: string,
1163
+ user: Record<string, any>
1164
+ ) {
622
1165
  const existing = await getHidUserFromReader(readerId, user.id);
623
1166
  if (existing) {
624
1167
  await updateHidUserOnReader(readerId, user);
@@ -648,7 +1191,10 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
648
1191
  return normalizeHidUsers(response)[0];
649
1192
  }
650
1193
 
651
- async function updateHidUserOnReader(readerId: string, user: Record<string, any>) {
1194
+ async function updateHidUserOnReader(
1195
+ readerId: string,
1196
+ user: Record<string, any>
1197
+ ) {
652
1198
  const { id, ...values } = user;
653
1199
  await runObjectOperation(readerId, {
654
1200
  operation: "modify",
@@ -662,7 +1208,10 @@ async function updateHidUserOnReader(readerId: string, user: Record<string, any>
662
1208
  });
663
1209
  }
664
1210
 
665
- async function deleteHidUserFromReader(readerId: string, user: Record<string, any>) {
1211
+ async function deleteHidUserFromReader(
1212
+ readerId: string,
1213
+ user: Record<string, any>
1214
+ ) {
666
1215
  const id = toHidNumericId(user.hidUserId);
667
1216
  if (!readerId || !id) return;
668
1217
 
@@ -677,7 +1226,11 @@ async function deleteHidUserFromReader(readerId: string, user: Record<string, an
677
1226
  });
678
1227
  }
679
1228
 
680
- async function syncHidUserRole(readerId: string, hidUserId: unknown, isAdministrator: boolean) {
1229
+ async function syncHidUserRole(
1230
+ readerId: string,
1231
+ hidUserId: unknown,
1232
+ isAdministrator: boolean
1233
+ ) {
681
1234
  const userId = toHidNumericId(hidUserId);
682
1235
  if (!readerId || !userId) return;
683
1236
 
@@ -771,14 +1324,17 @@ function getAdministratorRoleUserIds(response: Record<string, any> | null) {
771
1324
  return new Set(
772
1325
  (Array.isArray(roles) ? roles : [])
773
1326
  .map((role) => toHidNumericId(role.user_id))
774
- .filter((userId): userId is number => Boolean(userId)),
1327
+ .filter((userId): userId is number => Boolean(userId))
775
1328
  );
776
1329
  }
777
1330
 
778
1331
  function toHidUserObject(identity: Record<string, any>) {
779
1332
  return {
780
1333
  id: toHidNumericId(identity.hidUserId),
781
- name: identity.metadata?.name || identity.name || `User ${identity.hidUserId || ""}`.trim(),
1334
+ name:
1335
+ identity.metadata?.name ||
1336
+ identity.name ||
1337
+ `User ${identity.hidUserId || ""}`.trim(),
782
1338
  registration: identity.registration || "",
783
1339
  };
784
1340
  }
@@ -793,8 +1349,182 @@ function toHidNumericId(value: unknown) {
793
1349
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
794
1350
  }
795
1351
 
796
- function isAdministratorIdentity() {
797
- return props.identityType === "admin";
1352
+ function hasAdministratorRule(user?: Record<string, any> | null) {
1353
+ const hidUserId = toHidNumericId(user?.hidUserId);
1354
+ return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
1355
+ }
1356
+
1357
+ async function loadVisibleUserCards(items: Record<string, any>[]) {
1358
+ if (!selectedReaderId.value) return;
1359
+
1360
+ const entries = await Promise.all(
1361
+ items.map(async (user) => {
1362
+ const hidUserId = toHidNumericId(user.hidUserId);
1363
+ if (!hidUserId) return ["", []] as const;
1364
+
1365
+ try {
1366
+ const response = await getUserCards(selectedReaderId.value, hidUserId);
1367
+ return [String(hidUserId), response?.data?.cards || []] as const;
1368
+ } catch {
1369
+ return [String(hidUserId), []] as const;
1370
+ }
1371
+ }),
1372
+ );
1373
+
1374
+ physicalCardsByUserId.value = Object.fromEntries(
1375
+ entries.filter(([hidUserId]) => Boolean(hidUserId)),
1376
+ );
1377
+ }
1378
+
1379
+ function getUserPhysicalCards(user: Record<string, any>) {
1380
+ const hidUserId = toHidNumericId(user.hidUserId);
1381
+ return hidUserId ? physicalCardsByUserId.value[String(hidUserId)] || [] : [];
1382
+ }
1383
+
1384
+ async function openCardManager(user: Record<string, any>) {
1385
+ cardUser.value = user;
1386
+ cardForm.cardType = "pacs";
1387
+ cardForm.facilityCode = "";
1388
+ cardForm.cardNumber = "";
1389
+ cardForm.cardValue = "";
1390
+ physicalCards.value = [];
1391
+ cardDialog.value = true;
1392
+ await loadPhysicalCards();
1393
+ }
1394
+
1395
+ function getCardReaderAndUser() {
1396
+ const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
1397
+ const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
1398
+ return { readerId, hidUserId };
1399
+ }
1400
+
1401
+ async function loadPhysicalCards() {
1402
+ const { readerId, hidUserId } = getCardReaderAndUser();
1403
+ if (!readerId || !hidUserId) return;
1404
+
1405
+ loadingCards.value = true;
1406
+ try {
1407
+ const response = await getUserCards(readerId, hidUserId);
1408
+ physicalCards.value = response?.data?.cards || [];
1409
+ } catch (error: unknown) {
1410
+ physicalCards.value = [];
1411
+ showToast(getHidErrorMessage(error), "error");
1412
+ } finally {
1413
+ loadingCards.value = false;
1414
+ }
1415
+ }
1416
+
1417
+ function parsePhysicalCardInteger(value: string, label: string) {
1418
+ if (!/^\d+$/.test(value.trim())) {
1419
+ showToast(`${label} must contain numbers only.`, "error");
1420
+ return null;
1421
+ }
1422
+ const number = Number(value);
1423
+ if (!Number.isSafeInteger(number) || number < 0) {
1424
+ showToast(`${label} is outside the supported range.`, "error");
1425
+ return null;
1426
+ }
1427
+ return number;
1428
+ }
1429
+
1430
+ async function assignPhysicalCard() {
1431
+ const { readerId, hidUserId } = getCardReaderAndUser();
1432
+ if (!readerId || !hidUserId) return;
1433
+
1434
+ let payload:
1435
+ | { cardType: "pacs"; facilityCode: number; cardNumber: number }
1436
+ | { cardType: "csn"; cardValue: string };
1437
+ if (cardForm.cardType === "pacs") {
1438
+ const facilityCode = parsePhysicalCardInteger(cardForm.facilityCode, "Facility Code");
1439
+ const cardNumber = parsePhysicalCardInteger(cardForm.cardNumber, "Card Number");
1440
+ if (facilityCode === null || cardNumber === null) return;
1441
+ payload = { cardType: "pacs", facilityCode, cardNumber };
1442
+ } else {
1443
+ const cardValue = parsePhysicalCardInteger(cardForm.cardValue, "CSN");
1444
+ if (cardValue === null || cardValue <= 0) {
1445
+ if (cardValue === 0) showToast("CSN must be greater than zero.", "error");
1446
+ return;
1447
+ }
1448
+ payload = { cardType: "csn", cardValue: String(cardValue) };
1449
+ }
1450
+
1451
+ savingCard.value = true;
1452
+ try {
1453
+ await assignUserCard(readerId, hidUserId, payload);
1454
+ cardForm.facilityCode = "";
1455
+ cardForm.cardNumber = "";
1456
+ cardForm.cardValue = "";
1457
+ await loadPhysicalCards();
1458
+ await loadUsers();
1459
+ showToast("Physical HID card assigned and verified on the reader.");
1460
+ } catch (error: unknown) {
1461
+ showToast(getHidErrorMessage(error), "error");
1462
+ } finally {
1463
+ savingCard.value = false;
1464
+ }
1465
+ }
1466
+
1467
+ async function enrollPhysicalCard() {
1468
+ const { readerId, hidUserId } = getCardReaderAndUser();
1469
+ if (!readerId || !hidUserId) return;
1470
+
1471
+ enrollingCard.value = true;
1472
+ cardEnrollmentCancelled.value = false;
1473
+ try {
1474
+ await enrollUserCard(readerId, hidUserId, cardForm.cardType, 30);
1475
+ await loadPhysicalCards();
1476
+ await loadUsers();
1477
+ showToast("Physical HID card read and enrolled on the reader.");
1478
+ } catch (error: unknown) {
1479
+ if (!cardEnrollmentCancelled.value) {
1480
+ showToast(getHidErrorMessage(error), "error");
1481
+ }
1482
+ } finally {
1483
+ enrollingCard.value = false;
1484
+ }
1485
+ }
1486
+
1487
+ async function cancelPhysicalCardEnrollment() {
1488
+ const { readerId } = getCardReaderAndUser();
1489
+ if (!readerId) return;
1490
+ cardEnrollmentCancelled.value = true;
1491
+ try {
1492
+ await cancelUserCardEnrollment(readerId);
1493
+ showToast("Physical HID card reading cancelled.");
1494
+ } catch (error: unknown) {
1495
+ showToast(getHidErrorMessage(error), "error");
1496
+ }
1497
+ }
1498
+
1499
+ async function removePhysicalCard(card: Record<string, any>) {
1500
+ if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`)) return;
1501
+ const { readerId, hidUserId } = getCardReaderAndUser();
1502
+ if (!readerId || !hidUserId || !card?.id) return;
1503
+
1504
+ deletingCardId.value = String(card.id);
1505
+ try {
1506
+ await deleteUserCard(readerId, hidUserId, card.id);
1507
+ await loadPhysicalCards();
1508
+ await loadUsers();
1509
+ showToast("Physical HID card removed from the reader.");
1510
+ } catch (error: unknown) {
1511
+ showToast(getHidErrorMessage(error), "error");
1512
+ } finally {
1513
+ deletingCardId.value = "";
1514
+ }
1515
+ }
1516
+
1517
+ function formatPhysicalCard(card: Record<string, any>) {
1518
+ if (card?.cardType === "pacs") {
1519
+ return `PACS ${card.facilityCode}.${card.cardNumber}`;
1520
+ }
1521
+ if (card?.cardType === "csn") return `CSN ${card.value}`;
1522
+ return `Card ${card?.value || "N/A"}`;
1523
+ }
1524
+
1525
+ function getPhysicalCardSubtitle(card: Record<string, any>) {
1526
+ const source = card?.source === "reader" ? "Read at reader" : card?.source === "manual" ? "Manual" : "On device";
1527
+ return `${source} - Raw value ${card?.value || "N/A"}`;
798
1528
  }
799
1529
 
800
1530
  function buildUnitLabel() {
@@ -807,23 +1537,40 @@ function getName(user?: Record<string, any> | null) {
807
1537
  }
808
1538
 
809
1539
  function getUnit(user?: Record<string, any> | null) {
810
- return user?.metadata?.unitLabel || [user?.metadata?.block, user?.metadata?.level, user?.metadata?.unit].filter(Boolean).join("/") || "N/A";
1540
+ return (
1541
+ user?.metadata?.unitLabel ||
1542
+ [user?.metadata?.block, user?.metadata?.level, user?.metadata?.unit]
1543
+ .filter(Boolean)
1544
+ .join("/") ||
1545
+ "N/A"
1546
+ );
811
1547
  }
812
1548
 
813
1549
  function getFacialData(user: Record<string, any>) {
814
- return user?.metadata?.facialData || user?.metadata?.imageTimestamp || (user?.metadata?.photo ? user.hidUserId : "N/A");
1550
+ return (
1551
+ user?.metadata?.facialData ||
1552
+ user?.metadata?.imageTimestamp ||
1553
+ (user?.metadata?.photo ? user.hidUserId : "N/A")
1554
+ );
815
1555
  }
816
1556
 
817
1557
  function getUserImageSrc(user: Record<string, any>) {
818
1558
  const hidUserId = toHidNumericId(user?.hidUserId);
819
- return user?.metadata?.photo || (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
1559
+ return (
1560
+ user?.metadata?.photo ||
1561
+ (hidUserId ? userImages.value[String(hidUserId)] : "") ||
1562
+ ""
1563
+ );
820
1564
  }
821
1565
 
822
1566
  async function getNextUid() {
823
1567
  const readerIds = await getExistingHidReaderIds();
824
1568
  const maxNumber = users.value.reduce((max, user) => {
825
1569
  const match = String(user.hidUserId ?? "").match(/^UID(\d+)$/i);
826
- return Math.max(max, match ? Number(match[1]) : toHidNumericId(user.hidUserId) || 0);
1570
+ return Math.max(
1571
+ max,
1572
+ match ? Number(match[1]) : toHidNumericId(user.hidUserId) || 0
1573
+ );
827
1574
  }, 0);
828
1575
  const nextNumber = Math.max(maxNumber, ...readerIds, 0) + 1;
829
1576
  return `UID${String(nextNumber).padStart(6, "0")}`;
@@ -837,10 +1584,15 @@ async function getNextRegistration() {
837
1584
  const numericRegistrations = registrations
838
1585
  .map((registration) => {
839
1586
  const match = registration.match(/^(\d+)$/);
840
- return match ? { value: Number(match[1]), width: match[1].length } : undefined;
1587
+ return match
1588
+ ? { value: Number(match[1]), width: match[1].length }
1589
+ : undefined;
841
1590
  })
842
1591
  .filter((item): item is { value: number; width: number } => Boolean(item));
843
- const maxRegistration = numericRegistrations.reduce((max, item) => Math.max(max, item.value), 0);
1592
+ const maxRegistration = numericRegistrations.reduce(
1593
+ (max, item) => Math.max(max, item.value),
1594
+ 0
1595
+ );
844
1596
  const width = Math.max(6, ...numericRegistrations.map((item) => item.width));
845
1597
 
846
1598
  return String(maxRegistration + 1).padStart(width, "0");
@@ -882,11 +1634,14 @@ function normalizeHidUsers(response: Record<string, any>) {
882
1634
  hidUserId: String(user.id || ""),
883
1635
  rawHidUserId: user.id,
884
1636
  registration: user.registration,
1637
+ userTypeId: user.user_type_id,
885
1638
  cardNo: user.card_value || user.cardNo || "",
886
1639
  name: user.name,
887
1640
  metadata: {
888
1641
  name: user.name,
889
- imageTimestamp: user.image_timestamp ? String(user.image_timestamp) : "",
1642
+ imageTimestamp: user.image_timestamp
1643
+ ? String(user.image_timestamp)
1644
+ : "",
890
1645
  lastAccessAt: fromUnixSeconds(user.last_access),
891
1646
  hidReaderSource: true,
892
1647
  },
@@ -894,7 +1649,10 @@ function normalizeHidUsers(response: Record<string, any>) {
894
1649
  : [];
895
1650
  }
896
1651
 
897
- function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], identities: Record<string, any>[]) {
1652
+ function mergeReaderUsersWithIdentities(
1653
+ readerUsers: Record<string, any>[],
1654
+ identities: Record<string, any>[]
1655
+ ) {
898
1656
  const identityById = new Map<string, Record<string, any>>();
899
1657
  identities.forEach((identity) => {
900
1658
  const id = toHidNumericId(identity.hidUserId);
@@ -902,7 +1660,9 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
902
1660
  });
903
1661
 
904
1662
  const merged = readerUsers.map((readerUser) => {
905
- const identity = identityById.get(String(toHidNumericId(readerUser.hidUserId)));
1663
+ const identity = identityById.get(
1664
+ String(toHidNumericId(readerUser.hidUserId))
1665
+ );
906
1666
  if (!identity) return readerUser;
907
1667
 
908
1668
  return {
@@ -917,8 +1677,11 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
917
1677
  metadata: {
918
1678
  ...readerUser.metadata,
919
1679
  ...identity.metadata,
920
- imageTimestamp: identity.metadata?.imageTimestamp || readerUser.metadata?.imageTimestamp,
921
- lastAccessAt: identity.metadata?.lastAccessAt || readerUser.metadata?.lastAccessAt,
1680
+ imageTimestamp:
1681
+ identity.metadata?.imageTimestamp ||
1682
+ readerUser.metadata?.imageTimestamp,
1683
+ lastAccessAt:
1684
+ identity.metadata?.lastAccessAt || readerUser.metadata?.lastAccessAt,
922
1685
  },
923
1686
  };
924
1687
  });
@@ -929,13 +1692,15 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
929
1692
  function filterUsers(items: Record<string, any>[]) {
930
1693
  const searchText = search.value.trim().toLowerCase();
931
1694
  return items.filter((item) => {
932
- const matchesSearch = !searchText || [
933
- getName(item),
934
- item.hidUserId,
935
- item.registration,
936
- item.cardNo,
937
- ].some((value) => String(value || "").toLowerCase().includes(searchText));
938
- const matchesStatus = status.value === "Status" || getMappingStatus(item) === status.value;
1695
+ const matchesSearch =
1696
+ !searchText ||
1697
+ [getName(item), item.hidUserId, item.registration, item.cardNo].some(
1698
+ (value) =>
1699
+ String(value || "")
1700
+ .toLowerCase()
1701
+ .includes(searchText)
1702
+ );
1703
+ const matchesStatus = !status.value || getMappingStatus(item) === status.value;
939
1704
  return matchesSearch && matchesStatus;
940
1705
  });
941
1706
  }
@@ -949,10 +1714,6 @@ function getMappingStatus(user: Record<string, any>) {
949
1714
  return hasMappedRecord(user) ? "Mapped" : "Unmapped";
950
1715
  }
951
1716
 
952
- function getMappingStatusClass(user: Record<string, any>) {
953
- return hasMappedRecord(user) ? "status-success" : "status-warning";
954
- }
955
-
956
1717
  function hasMappedRecord(user: Record<string, any>) {
957
1718
  return Boolean(user?.person || user?.user || user?.member || user?.visitor);
958
1719
  }
@@ -995,209 +1756,230 @@ function getHidErrorMessage(error: any) {
995
1756
  data?.statusMessage ||
996
1757
  error?.statusMessage ||
997
1758
  error?.message ||
998
- "Unable to save HID user.",
1759
+ "Unable to save HID user."
999
1760
  );
1000
1761
  }
1001
1762
  </script>
1002
1763
 
1003
1764
  <style scoped>
1765
+ /*
1766
+ WHAT IS LEFT AFTER THE CARD AND TABLE MOVED TO `TableMain`.
1767
+
1768
+ The toolbar, the card, the six fixed column widths, the refresh strip, the
1769
+ footer pager, the empty state and the three `status-*` chip palettes are all
1770
+ gone - they were a private copy of the shared table drawn in literal hexes.
1771
+ What remains is the three dialogs, and every colour in them is now a token,
1772
+ so the dark theme reaches them for the first time.
1773
+ */
1004
1774
  .hid-user-enrollment {
1005
1775
  padding: 24px 0;
1006
1776
  width: 100%;
1007
1777
  min-width: 0;
1008
1778
  }
1009
1779
 
1010
- .toolbar {
1011
- display: flex;
1012
- flex-wrap: wrap;
1013
- gap: 12px;
1014
- align-items: center;
1015
- margin-bottom: 16px;
1016
- }
1017
-
1018
- .toolbar-spacer {
1019
- flex: 1;
1020
- }
1021
-
1022
- .enroll-button {
1023
- min-width: 170px;
1024
- background: #e4e4e4;
1025
- color: #0a2638;
1780
+ .hid-user-filters {
1781
+ /* The filter row sits INSIDE the card's extension, so it needs the card's
1782
+ own side padding rather than the row's default bottom margin. */
1783
+ margin: 0;
1784
+ padding: 10px 14px;
1026
1785
  }
1027
1786
 
1028
- .reader-input,
1029
- .search-input {
1787
+ .hid-user-filters :deep(.app-field) {
1030
1788
  max-width: 240px;
1031
1789
  }
1032
1790
 
1033
- .status-input {
1034
- max-width: 160px;
1791
+ /* The reader option now reads "Reader - Portal", so it needs more room than
1792
+ the other filters. `AppSelect`'s root class IS `app-field`, so this has to
1793
+ be more specific than the rule above rather than a `.app-select` one. */
1794
+ .hid-user-filters :deep(.app-field.reader-select) {
1795
+ max-width: 320px;
1035
1796
  }
1036
1797
 
1037
- .table-card {
1038
- display: flex;
1039
- flex-direction: column;
1040
- min-height: 430px;
1041
- overflow: hidden;
1798
+ .small-title {
1799
+ font-size: 14px;
1800
+ font-weight: 700;
1801
+ color: var(--text);
1042
1802
  }
1043
1803
 
1044
- .table-card :deep(table) {
1045
- table-layout: fixed;
1046
- width: 100%;
1047
- min-width: 820px;
1804
+ /* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
1805
+ .field-label {
1806
+ color: var(--text2);
1807
+ font-size: 12px;
1808
+ font-weight: 600;
1809
+ margin-bottom: 6px;
1048
1810
  }
1049
1811
 
1050
- .table-card :deep(th),
1051
- .table-card :deep(td) {
1052
- white-space: nowrap;
1812
+ .field-label span {
1813
+ color: var(--err);
1053
1814
  }
1054
1815
 
1055
- .table-card :deep(th:nth-child(1)),
1056
- .table-card :deep(td:nth-child(1)) {
1057
- width: 24%;
1816
+ /* Vuetify's `persistent-hint` line, redrawn as the design's explanatory line. */
1817
+ .field-hint {
1818
+ margin: 6px 0 0;
1819
+ color: var(--muted);
1820
+ font-size: 12px;
1821
+ font-weight: 600;
1822
+ line-height: 1.45;
1058
1823
  }
1059
1824
 
1060
- .table-card :deep(th:nth-child(2)),
1061
- .table-card :deep(td:nth-child(2)) {
1062
- width: 20%;
1825
+ .photo-wrap {
1826
+ display: grid;
1827
+ place-items: center;
1828
+ margin-bottom: 14px;
1063
1829
  }
1064
1830
 
1065
- .table-card :deep(th:nth-child(3)),
1066
- .table-card :deep(td:nth-child(3)) {
1067
- width: 12%;
1831
+ .photo-button {
1832
+ width: 120px;
1833
+ height: 120px;
1834
+ border: 1px solid var(--border);
1835
+ border-radius: var(--r-inner);
1836
+ background: var(--card);
1837
+ display: grid;
1838
+ place-items: center;
1839
+ color: var(--muted);
1840
+ font-size: 12px;
1841
+ overflow: hidden;
1842
+ position: relative;
1068
1843
  }
1069
1844
 
1070
- .table-card :deep(th:nth-child(4)),
1071
- .table-card :deep(td:nth-child(4)) {
1072
- width: 20%;
1845
+ .photo-preview {
1846
+ width: 100%;
1847
+ height: 100%;
1073
1848
  }
1074
1849
 
1075
- .table-card :deep(th:nth-child(5)),
1076
- .table-card :deep(td:nth-child(5)) {
1077
- width: 16%;
1850
+ .photo-edit-icon {
1851
+ position: absolute;
1852
+ right: 8px;
1853
+ bottom: 8px;
1854
+ width: 28px;
1855
+ height: 28px;
1856
+ border-radius: 50%;
1857
+ background: var(--card);
1858
+ color: var(--text);
1859
+ display: grid;
1860
+ place-items: center;
1861
+ box-shadow: var(--shadow-menu);
1078
1862
  }
1079
1863
 
1080
- .table-card :deep(th:nth-child(6)),
1081
- .table-card :deep(td:nth-child(6)) {
1082
- width: 8%;
1864
+ .hid-face-avatar {
1865
+ border: 1px solid var(--border);
1866
+ background: var(--hover);
1083
1867
  }
1084
1868
 
1085
- .table-body {
1086
- flex: 1;
1087
- overflow-x: auto;
1869
+ .admin-rule-indicator {
1870
+ display: inline-flex;
1871
+ vertical-align: middle;
1088
1872
  }
1089
1873
 
1090
- .table-refresh,
1091
- .table-footer {
1874
+ .hid-card-user {
1092
1875
  display: flex;
1093
1876
  align-items: center;
1094
- gap: 8px;
1095
- padding: 8px 10px;
1096
- border-bottom: 1px solid #eeeeee;
1877
+ justify-content: space-between;
1878
+ gap: 12px;
1879
+ margin: 0 0 14px;
1880
+ color: var(--text);
1881
+ font-weight: 700;
1097
1882
  }
1098
1883
 
1099
- .table-footer {
1100
- justify-content: flex-end;
1101
- border-top: 1px solid #eeeeee;
1102
- border-bottom: 0;
1103
- background: #f6f6f6;
1104
- min-height: 44px;
1105
- padding: 4px 10px;
1884
+ .hid-card-user span,
1885
+ .hid-card-row__content span {
1886
+ color: var(--muted);
1887
+ font-size: 12px;
1888
+ font-weight: 600;
1106
1889
  }
1107
1890
 
1108
- .page-count {
1109
- margin-left: auto;
1110
- color: #475467;
1891
+ .hid-card-cell {
1892
+ display: flex;
1893
+ flex-wrap: wrap;
1894
+ gap: 6px;
1895
+ }
1896
+
1897
+ .hid-card-cell__item {
1898
+ padding: 4px 9px;
1899
+ border-radius: 999px;
1900
+ background: color-mix(in srgb, var(--primary) 10%, transparent);
1901
+ color: var(--primary);
1111
1902
  font-size: 12px;
1903
+ font-weight: 700;
1112
1904
  }
1113
1905
 
1114
- .empty-state {
1115
- min-height: 280px;
1116
- display: grid;
1117
- place-items: center;
1118
- color: #687382;
1119
- font-size: 13px;
1906
+ .hid-card-list {
1907
+ overflow: hidden;
1908
+ border: 1px solid var(--border);
1909
+ border-radius: var(--r-inner);
1120
1910
  }
1121
1911
 
1122
- .status-chip {
1123
- min-width: 86px;
1124
- justify-content: center;
1125
- font-weight: 600;
1912
+ .hid-card-row {
1913
+ display: flex;
1914
+ align-items: center;
1915
+ gap: 12px;
1916
+ min-height: 58px;
1917
+ padding: 8px 10px 8px 14px;
1918
+ color: var(--text2);
1126
1919
  }
1127
1920
 
1128
- .status-success {
1129
- background: #dff7e7 !important;
1130
- color: #1b7f3a !important;
1921
+ .hid-card-row + .hid-card-row {
1922
+ border-top: 1px solid var(--border);
1131
1923
  }
1132
1924
 
1133
- .status-warning {
1134
- background: #fff0cc !important;
1135
- color: #9a6700 !important;
1925
+ .hid-card-row__content {
1926
+ display: flex;
1927
+ flex: 1;
1928
+ flex-direction: column;
1929
+ min-width: 0;
1136
1930
  }
1137
1931
 
1138
- .status-error {
1139
- background: #fde2e1 !important;
1140
- color: #b42318 !important;
1932
+ .hid-card-row__content strong {
1933
+ color: var(--text);
1934
+ font-size: 13px;
1141
1935
  }
1142
1936
 
1143
- .small-title {
1144
- font-size: 14px;
1145
- font-weight: 700;
1937
+ .hid-card-fields {
1938
+ display: grid;
1939
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1940
+ gap: 12px;
1146
1941
  }
1147
1942
 
1148
- .field-label {
1149
- color: #4f5a66;
1150
- font-size: 12px;
1151
- font-weight: 600;
1943
+ .hid-card-type-hint {
1152
1944
  margin-bottom: 6px;
1153
1945
  }
1154
1946
 
1155
- .field-label span {
1156
- color: #e53935;
1947
+ .hid-card-access-hint {
1948
+ margin-bottom: 14px;
1157
1949
  }
1158
1950
 
1159
- .photo-wrap {
1160
- display: grid;
1161
- place-items: center;
1162
- margin-bottom: 14px;
1951
+ .hid-card-actions {
1952
+ display: flex;
1953
+ flex-wrap: wrap;
1954
+ gap: 8px;
1163
1955
  }
1164
1956
 
1165
- .photo-button {
1166
- width: 120px;
1167
- height: 120px;
1168
- border: 1px solid #d8dde4;
1169
- border-radius: 8px;
1170
- background: #ffffff;
1171
- display: grid;
1172
- place-items: center;
1173
- color: #98a2b3;
1174
- font-size: 12px;
1175
- overflow: hidden;
1176
- position: relative;
1957
+ @media (max-width: 520px) {
1958
+ .hid-card-fields {
1959
+ grid-template-columns: 1fr;
1960
+ }
1177
1961
  }
1178
1962
 
1179
- .photo-preview {
1180
- width: 100%;
1181
- height: 100%;
1963
+ .admin-rule-indicator--enabled {
1964
+ color: var(--ok);
1182
1965
  }
1183
1966
 
1184
- .photo-edit-icon {
1185
- position: absolute;
1186
- right: 8px;
1187
- bottom: 8px;
1188
- width: 28px;
1189
- height: 28px;
1190
- border-radius: 50%;
1191
- background: #ffffff;
1192
- color: #00324a;
1193
- display: grid;
1194
- place-items: center;
1195
- box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
1967
+ .admin-rule-indicator--disabled {
1968
+ color: var(--err);
1196
1969
  }
1197
1970
 
1198
- .hid-face-avatar {
1199
- border: 1px solid #d8dde4;
1200
- background: #f2f4f7;
1971
+ .admin-rule-checkbox {
1972
+ margin-top: -4px;
1973
+ }
1974
+
1975
+ .admin-rule-hint {
1976
+ margin: -2px 0 14px;
1977
+ }
1978
+
1979
+ .admin-rule-detail {
1980
+ display: inline-flex;
1981
+ align-items: center;
1982
+ gap: 6px;
1201
1983
  }
1202
1984
 
1203
1985
  .hidden-input {
@@ -1209,43 +1991,38 @@ function getHidErrorMessage(error: any) {
1209
1991
  grid-template-columns: 1fr 1fr;
1210
1992
  gap: 8px 16px;
1211
1993
  font-size: 13px;
1994
+ color: var(--text);
1212
1995
  }
1213
1996
 
1214
1997
  .detail-grid span {
1215
- color: #667085;
1998
+ color: var(--muted);
1216
1999
  }
1217
2000
 
2001
+ /* The split footer bar. The handoff does not draw one, so the SHAPE is the
2002
+ screen's own; only the colours change - `#062d42` was a fixed navy that sat
2003
+ on the dark card unchanged. Same treatment as panel 2. */
1218
2004
  .action-cancel,
1219
2005
  .action-submit {
1220
2006
  flex: 1;
1221
2007
  border-radius: 0;
1222
2008
  }
1223
2009
 
2010
+ .action-cancel {
2011
+ background: var(--card);
2012
+ color: var(--text2);
2013
+ }
2014
+
1224
2015
  .action-submit {
1225
- background: #062d42;
1226
- color: #ffffff;
2016
+ background: var(--accent-strong);
2017
+ color: var(--on-accent-strong);
1227
2018
  }
1228
2019
 
1229
2020
  @media (max-width: 960px) {
1230
- .toolbar {
1231
- align-items: stretch;
1232
- }
1233
-
1234
- .toolbar-spacer {
1235
- display: none;
1236
- }
1237
-
1238
- .enroll-button,
1239
- .reader-input,
1240
- .search-input,
1241
- .status-input {
2021
+ .hid-user-filters :deep(.app-field),
2022
+ .hid-user-filters :deep(.app-select) {
1242
2023
  max-width: none;
1243
2024
  width: 100%;
1244
2025
  }
1245
-
1246
- .table-card {
1247
- min-height: 360px;
1248
- }
1249
2026
  }
1250
2027
 
1251
2028
  @media (max-width: 600px) {
@@ -1262,10 +2039,6 @@ function getHidErrorMessage(error: any) {
1262
2039
  height: 104px;
1263
2040
  }
1264
2041
 
1265
- .table-footer {
1266
- justify-content: center;
1267
- }
1268
-
1269
2042
  :global(.v-dialog > .v-overlay__content) {
1270
2043
  max-width: calc(100vw - 24px) !important;
1271
2044
  margin: 12px !important;