@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.
- package/assets/css/primitives.css +1113 -0
- package/assets/css/screens.css +1097 -0
- package/assets/css/shell.css +258 -0
- package/assets/css/tokens.css +236 -0
- package/components/AcceptDialog.vue +17 -18
- package/components/AccessCard/AvailableStats.vue +24 -16
- package/components/AccessCardAddForm.vue +42 -42
- package/components/AccessCardAssignToUnitForm.vue +13 -21
- package/components/AccessCardDeleteDialog.vue +41 -39
- package/components/AccessCardDetailsDialog.vue +125 -79
- package/components/AccessCardPreviewDialog.vue +58 -84
- package/components/AccessCardQrTagging.vue +60 -63
- package/components/AccessCardReplaceForm.vue +27 -23
- package/components/AccessManagement.vue +86 -85
- package/components/AddEqupmentForm.vue +18 -22
- package/components/AddPassKeyToVisitor.vue +95 -73
- package/components/AppButton.vue +62 -0
- package/components/AppCard.vue +20 -0
- package/components/AppDateField.vue +49 -0
- package/components/AppField.vue +76 -0
- package/components/AppKpiTile.vue +65 -0
- package/components/AppSegmented.vue +38 -0
- package/components/AppSelect.vue +247 -0
- package/components/AreaChecklistHistoryLogs.vue +51 -44
- package/components/AreaChecklistHistoryMain.vue +14 -7
- package/components/AreaFormDialog.vue +71 -59
- package/components/AreaMain.vue +73 -107
- package/components/AttachmentsViewer.vue +84 -22
- package/components/AttendanceCheckInOutDialog.vue +85 -88
- package/components/AttendanceDetailsDialog.vue +57 -63
- package/components/AttendanceMain.vue +4 -3
- package/components/AttendanceMapSearchDialog.vue +42 -44
- package/components/AttendanceSettingsDialog.vue +79 -63
- package/components/Avatar/Main.vue +54 -2
- package/components/BillingMain.vue +26 -30
- package/components/BtnUploadFile.vue +53 -11
- package/components/BuildingForm.vue +34 -27
- package/components/BuildingManagement/buildings.vue +39 -39
- package/components/BuildingManagement/units.vue +26 -42
- package/components/BuildingUnitFormAdd.vue +20 -23
- package/components/BuildingUnitFormEdit.vue +8 -4
- package/components/BulletinBoardForm.vue +19 -24
- package/components/BulletinBoardManagement.vue +49 -57
- package/components/BulletinBoardView.vue +9 -6
- package/components/BulletinExpirationChip.vue +13 -4
- package/components/Button/Close.vue +2 -2
- package/components/CameraForm.vue +21 -22
- package/components/CameraMain.vue +50 -49
- package/components/Card/DeleteConfirmation.vue +24 -22
- package/components/Card/MemberInfoSummary.vue +28 -3
- package/components/Card/Toggle.vue +15 -3
- package/components/CardToolbar.vue +37 -0
- package/components/Carousel.vue +56 -36
- package/components/Chat/Bubbles.vue +43 -14
- package/components/Chat/Information.vue +49 -32
- package/components/Chat/ListCard.vue +38 -9
- package/components/Chat/Message.vue +41 -15
- package/components/Chat/Navigation.vue +27 -15
- package/components/Chat/SkeletonLoader.vue +9 -2
- package/components/ChecklistItemRow.vue +25 -1
- package/components/CleaningScheduleMain.vue +44 -49
- package/components/ClientDetailForm.vue +81 -56
- package/components/ClientMain.vue +92 -82
- package/components/ConfirmDialog.vue +14 -8
- package/components/Container/Standard.vue +9 -7
- package/components/CreateSubsciptionPlan.vue +148 -89
- package/components/DashboardEmptyState.vue +48 -3
- package/components/DashboardMain.vue +924 -676
- package/components/DashboardPanel.vue +84 -21
- package/components/DeliveryCompany.vue +34 -20
- package/components/Dialog/DeleteConfirmation.vue +20 -21
- package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
- package/components/Dialog/ReusablePrompt.vue +30 -29
- package/components/Dialog/UpdateMoreAction.vue +62 -59
- package/components/DocumentForm.vue +14 -21
- package/components/DocumentManagement.vue +162 -108
- package/components/DocumentViewer.vue +29 -12
- package/components/DrawImage.vue +29 -21
- package/components/EntryPass/QrTemplatePreview.vue +27 -16
- package/components/EntryPassInformation.vue +40 -10
- package/components/EntryPassMain.vue +49 -27
- package/components/EquipmentItemMain.vue +84 -97
- package/components/EquipmentManagementMain.vue +81 -103
- package/components/Facility/BookingSetup.vue +43 -52
- package/components/Feedback/Form.vue +26 -12
- package/components/FeedbackMain.vue +35 -49
- package/components/FileInput.vue +75 -58
- package/components/FileInputWithList.vue +59 -12
- package/components/FormDialog.vue +60 -21
- package/components/HidAccessLogDashboard.vue +512 -490
- package/components/HidCameraCaptureDialog.vue +216 -0
- package/components/HidEnabledGate.vue +67 -9
- package/components/HidIntercomManagement.vue +1322 -653
- package/components/HidQrCodeConfiguration.vue +1030 -258
- package/components/HidReaderForm.vue +142 -59
- package/components/HidReaderManagement.vue +211 -323
- package/components/HidServiceSettingsPanel.vue +44 -13
- package/components/HidUserEnrollment.vue +1185 -412
- package/components/HygieneUpdateMoreAction.vue +44 -44
- package/components/IncidentReport/Authorities.vue +30 -18
- package/components/IncidentReport/IncidentInformation.vue +10 -10
- package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
- package/components/IncidentReport/affectedEntities.vue +41 -20
- package/components/Input/Date.vue +3 -0
- package/components/Input/DatePicker.vue +1 -2
- package/components/Input/DateTimePicker.vue +1 -1
- package/components/Input/File.vue +55 -10
- package/components/Input/FileV2.vue +39 -6
- package/components/Input/InputPhoneNumberV2.vue +2 -1
- package/components/Input/ListGroupSelection.vue +56 -8
- package/components/Input/NRICNumber.vue +1 -0
- package/components/Input/NewDate.vue +1 -0
- package/components/Input/Number.vue +1 -0
- package/components/Input/Password.vue +1 -0
- package/components/Input/PhoneNumber.vue +21 -1
- package/components/Input/VehicleNumber.vue +1 -0
- package/components/InputLabel.vue +2 -2
- package/components/InvitationClientForm.vue +21 -19
- package/components/InvitationForm.vue +15 -21
- package/components/InvitationMain.vue +46 -61
- package/components/Layout/Header.vue +263 -36
- package/components/Layout/NavigationDrawer.vue +300 -30
- package/components/ListItem.vue +33 -3
- package/components/LocalPagination.vue +27 -3
- package/components/ManageChecklistMain.vue +136 -86
- package/components/MemberInformation.vue +32 -16
- package/components/MemberMain.vue +113 -93
- package/components/MyAttendanceMain.vue +3 -5
- package/components/NavigationItem.vue +83 -3
- package/components/Nfc/NFCPatrolReportMain.vue +47 -33
- package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
- package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
- package/components/Nfc/NFCPatrolSettings.vue +14 -4
- package/components/Nfc/NFCTagForm.vue +22 -8
- package/components/Nfc/NFCTagMain.vue +41 -43
- package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
- package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
- package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
- package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
- package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
- package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
- package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
- package/components/NotificationSettings.vue +373 -0
- package/components/NumberSettingField.vue +9 -2
- package/components/OnlineFormConfigurationForm.vue +725 -237
- package/components/OnlineFormFill.vue +213 -106
- package/components/OnlineFormRenderPreview.vue +25 -30
- package/components/OnlineFormsConfiguration.vue +113 -109
- package/components/OrgViewDialog.vue +34 -16
- package/components/OvernightParkingAvailability.vue +49 -26
- package/components/OvernightParkingManagement.vue +78 -18
- package/components/PageHeader.vue +43 -0
- package/components/PassInformation.vue +109 -53
- package/components/PasswordConfirmation.vue +44 -26
- package/components/PeopleForm.vue +41 -20
- package/components/PhotoUpload.vue +110 -48
- package/components/PlaceholderComponent.vue +35 -19
- package/components/PlanActionsMenu.vue +12 -2
- package/components/PlanStatusBadge.vue +7 -8
- package/components/PlateNumberDisplay.vue +9 -6
- package/components/QrTemplate/PrintDialog.vue +66 -43
- package/components/RolePermissionFormCreate.vue +104 -79
- package/components/RolePermissionFormPreviewUpdate.vue +111 -109
- package/components/RolePermissionMain.vue +183 -81
- package/components/ScanVisitorQRCode.vue +99 -35
- package/components/ScheduleAreaMain.vue +34 -59
- package/components/ScheduleTaskForm.vue +61 -42
- package/components/ScheduleTaskMain.vue +56 -69
- package/components/SearchVehicleNumberUser.vue +39 -37
- package/components/ServiceProviderFormCreate.vue +29 -20
- package/components/ServiceProviderInvitationPrompt.vue +155 -0
- package/components/ServiceProviderMain.vue +601 -432
- package/components/Signature.vue +40 -34
- package/components/SignaturePad.vue +5 -29
- package/components/SiteSettings.vue +41 -11
- package/components/SiteSettingsWorkOrder.vue +61 -30
- package/components/SlideCardGroup.vue +35 -11
- package/components/Snackbar.vue +36 -3
- package/components/SpecificAttr.vue +18 -8
- package/components/StatusChip.vue +156 -0
- package/components/StockCard.vue +17 -12
- package/components/SubscriptionPlanTable.vue +207 -140
- package/components/SwitchContext.vue +201 -9
- package/components/TableHygiene.vue +36 -46
- package/components/TableList.vue +10 -10
- package/components/TableListSecondary.vue +105 -54
- package/components/TableMain.vue +241 -79
- package/components/TableV3.vue +61 -70
- package/components/TiptapEditor.vue +33 -40
- package/components/TiptapEditorQr.vue +5 -2
- package/components/Tooltip/Info.vue +69 -24
- package/components/UnitMain.vue +40 -60
- package/components/UnitPersonCard.vue +42 -13
- package/components/VehicleAddSelection.vue +48 -32
- package/components/VehicleForm.vue +112 -129
- package/components/VehicleManagement.vue +26 -12
- package/components/VehicleUpdateMoreAction.vue +59 -46
- package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
- package/components/VisitorForm.vue +774 -210
- package/components/VisitorFormSelection.vue +40 -37
- package/components/VisitorManagement.vue +239 -82
- package/components/VisitorPassKeyQRScanner.vue +65 -18
- package/components/VisitorSearchResultSummary.vue +60 -16
- package/components/VisitorSocketPopUp.vue +22 -14
- package/components/VisitorsReportPreview.vue +44 -29
- package/components/WorkOrder/Create.vue +26 -17
- package/components/WorkOrder/ListView.vue +38 -7
- package/components/WorkOrder/Main.vue +62 -59
- package/composables/useFeedback.ts +3 -3
- package/composables/useHidAmico.ts +269 -0
- package/composables/useHidNavigation.ts +2 -9
- package/composables/useMember.ts +3 -3
- package/composables/useNFCPatrolDailyReport.ts +13 -0
- package/composables/useNFCPatrolMonthlyReport.ts +22 -56
- package/composables/useNFCPatrolReport.ts +13 -0
- package/composables/useNotificationPreference.ts +87 -0
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useRole.ts +22 -0
- package/composables/useServiceProvider.ts +3 -3
- package/composables/useServiceProviderInvitation.ts +145 -0
- package/composables/useSipWebPhone.ts +336 -0
- package/composables/useSubscriptionPlan.ts +62 -0
- package/composables/useThemePreference.ts +63 -0
- package/composables/useVisitor.ts +6 -1
- package/composables/useWebUsb.ts +127 -37
- package/composables/useWorkOrder.ts +17 -3
- package/error.vue +30 -6
- package/nuxt.config.ts +36 -0
- package/package.json +25 -4
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/pages/notification-settings.vue +19 -0
- package/pages/payment-method-linked.vue +34 -10
- package/pages/require-customer.vue +48 -10
- package/pages/require-organization-membership.vue +32 -8
- package/pages/unauthorized.vue +29 -4
- package/plugins/secure-member.client.ts +21 -56
- package/plugins/vuetify.ts +69 -2
- package/types/nfc-patrol-report.ts +3 -2
- package/types/site.d.ts +71 -0
- package/types/subscription.ts +18 -11
- package/utils/breadcrumb.test.ts +108 -0
- package/utils/breadcrumb.ts +82 -0
- package/utils/dashboard.test.ts +118 -0
- package/utils/dashboard.ts +53 -0
- package/utils/data.test.ts +95 -0
- package/utils/data.ts +40 -2
- package/utils/hid-discover.test.ts +102 -0
- package/utils/list-page.test.ts +60 -0
- package/utils/list-page.ts +38 -0
- package/utils/materialSymbols.ts +363 -0
- package/utils/nav-sections.test.ts +190 -0
- package/utils/nav-sections.ts +151 -0
- package/utils/nfc-patrol-report.test.ts +130 -0
- package/utils/nfc-patrol-report.ts +101 -0
- package/utils/role.test.ts +69 -0
- package/utils/role.ts +10 -0
- package/utils/route-name.test.ts +85 -0
- package/utils/route-name.ts +24 -0
- package/utils/status.test.ts +110 -0
- package/utils/status.ts +135 -0
- package/utils/table-card.test.ts +84 -0
- package/utils/theme-aa-ledger.ts +148 -0
- package/utils/theme.test.ts +485 -7
- package/utils/theme.ts +295 -110
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.editorconfig +0 -12
- package/.github/workflows/main.yml +0 -17
- package/.github/workflows/publish-staging.yml +0 -47
- package/.github/workflows/publish.yml +0 -39
- package/PUBLISHING.md +0 -269
- package/components/HidIdentityMapping.vue +0 -975
- package/components/ScheduleTaskAreaFormDialog.vue +0 -144
- package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
- package/components/ScheduleTastTicketMain.vue +0 -182
- package/components/TableWithButton.vue +0 -94
- package/test/visitor-socket.test.mjs +0 -36
|
@@ -1,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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
</
|
|
109
|
-
|
|
110
|
-
<
|
|
111
|
-
<span>{{
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
235
|
+
<AppField
|
|
148
236
|
v-model="form.name"
|
|
149
237
|
aria-label="Name"
|
|
150
238
|
placeholder="Enter name"
|
|
151
|
-
|
|
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-
|
|
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
|
-
<
|
|
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
|
-
|
|
251
|
+
klass="mb-3"
|
|
178
252
|
/>
|
|
179
253
|
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
class="mb-3"
|
|
258
|
+
hide-details
|
|
259
|
+
class="admin-rule-checkbox"
|
|
189
260
|
/>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
<span>
|
|
225
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
|
246
|
-
|
|
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
|
-
|
|
264
|
-
type:
|
|
265
|
-
default:
|
|
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("
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
603
|
+
isAdministrator: false,
|
|
604
|
+
accessPin: "",
|
|
315
605
|
photoPreview: "",
|
|
316
606
|
});
|
|
317
607
|
|
|
318
|
-
const
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
720
|
+
loadAdministratorRoles(selectedReaderId.value),
|
|
379
721
|
]);
|
|
380
722
|
|
|
381
|
-
const identities =
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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.
|
|
438
|
-
form.
|
|
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.
|
|
464
|
-
form.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
585
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
|
797
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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:
|
|
921
|
-
|
|
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 =
|
|
933
|
-
|
|
934
|
-
item.hidUserId,
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
.
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
.
|
|
1029
|
-
.search-input {
|
|
1787
|
+
.hid-user-filters :deep(.app-field) {
|
|
1030
1788
|
max-width: 240px;
|
|
1031
1789
|
}
|
|
1032
1790
|
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
.
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
overflow: hidden;
|
|
1798
|
+
.small-title {
|
|
1799
|
+
font-size: 14px;
|
|
1800
|
+
font-weight: 700;
|
|
1801
|
+
color: var(--text);
|
|
1042
1802
|
}
|
|
1043
1803
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
.
|
|
1051
|
-
|
|
1052
|
-
white-space: nowrap;
|
|
1812
|
+
.field-label span {
|
|
1813
|
+
color: var(--err);
|
|
1053
1814
|
}
|
|
1054
1815
|
|
|
1055
|
-
|
|
1056
|
-
.
|
|
1057
|
-
|
|
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
|
-
.
|
|
1061
|
-
|
|
1062
|
-
|
|
1825
|
+
.photo-wrap {
|
|
1826
|
+
display: grid;
|
|
1827
|
+
place-items: center;
|
|
1828
|
+
margin-bottom: 14px;
|
|
1063
1829
|
}
|
|
1064
1830
|
|
|
1065
|
-
.
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
.
|
|
1071
|
-
|
|
1072
|
-
|
|
1845
|
+
.photo-preview {
|
|
1846
|
+
width: 100%;
|
|
1847
|
+
height: 100%;
|
|
1073
1848
|
}
|
|
1074
1849
|
|
|
1075
|
-
.
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
.
|
|
1081
|
-
|
|
1082
|
-
|
|
1864
|
+
.hid-face-avatar {
|
|
1865
|
+
border: 1px solid var(--border);
|
|
1866
|
+
background: var(--hover);
|
|
1083
1867
|
}
|
|
1084
1868
|
|
|
1085
|
-
.
|
|
1086
|
-
|
|
1087
|
-
|
|
1869
|
+
.admin-rule-indicator {
|
|
1870
|
+
display: inline-flex;
|
|
1871
|
+
vertical-align: middle;
|
|
1088
1872
|
}
|
|
1089
1873
|
|
|
1090
|
-
.
|
|
1091
|
-
.table-footer {
|
|
1874
|
+
.hid-card-user {
|
|
1092
1875
|
display: flex;
|
|
1093
1876
|
align-items: center;
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
.
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
.
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
.
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
.
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
.
|
|
1129
|
-
|
|
1130
|
-
color: #1b7f3a !important;
|
|
1921
|
+
.hid-card-row + .hid-card-row {
|
|
1922
|
+
border-top: 1px solid var(--border);
|
|
1131
1923
|
}
|
|
1132
1924
|
|
|
1133
|
-
.
|
|
1134
|
-
|
|
1135
|
-
|
|
1925
|
+
.hid-card-row__content {
|
|
1926
|
+
display: flex;
|
|
1927
|
+
flex: 1;
|
|
1928
|
+
flex-direction: column;
|
|
1929
|
+
min-width: 0;
|
|
1136
1930
|
}
|
|
1137
1931
|
|
|
1138
|
-
.
|
|
1139
|
-
|
|
1140
|
-
|
|
1932
|
+
.hid-card-row__content strong {
|
|
1933
|
+
color: var(--text);
|
|
1934
|
+
font-size: 13px;
|
|
1141
1935
|
}
|
|
1142
1936
|
|
|
1143
|
-
.
|
|
1144
|
-
|
|
1145
|
-
|
|
1937
|
+
.hid-card-fields {
|
|
1938
|
+
display: grid;
|
|
1939
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1940
|
+
gap: 12px;
|
|
1146
1941
|
}
|
|
1147
1942
|
|
|
1148
|
-
.
|
|
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
|
-
.
|
|
1156
|
-
|
|
1947
|
+
.hid-card-access-hint {
|
|
1948
|
+
margin-bottom: 14px;
|
|
1157
1949
|
}
|
|
1158
1950
|
|
|
1159
|
-
.
|
|
1160
|
-
display:
|
|
1161
|
-
|
|
1162
|
-
|
|
1951
|
+
.hid-card-actions {
|
|
1952
|
+
display: flex;
|
|
1953
|
+
flex-wrap: wrap;
|
|
1954
|
+
gap: 8px;
|
|
1163
1955
|
}
|
|
1164
1956
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
-
.
|
|
1180
|
-
|
|
1181
|
-
height: 100%;
|
|
1963
|
+
.admin-rule-indicator--enabled {
|
|
1964
|
+
color: var(--ok);
|
|
1182
1965
|
}
|
|
1183
1966
|
|
|
1184
|
-
.
|
|
1185
|
-
|
|
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
|
-
.
|
|
1199
|
-
|
|
1200
|
-
|
|
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:
|
|
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:
|
|
1226
|
-
color:
|
|
2016
|
+
background: var(--accent-strong);
|
|
2017
|
+
color: var(--on-accent-strong);
|
|
1227
2018
|
}
|
|
1228
2019
|
|
|
1229
2020
|
@media (max-width: 960px) {
|
|
1230
|
-
.
|
|
1231
|
-
|
|
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;
|