@7365admin1/layer-common 4.82.1-staging.476 → 4.83.0
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/CHANGELOG.md +10 -4
- package/assets/css/primitives.css +0 -57
- package/assets/css/screens.css +4 -8
- package/components/AccessCardDetailsDialog.vue +4 -57
- package/components/AccessCardPreviewDialog.vue +50 -291
- package/components/AppSelect.vue +4 -143
- package/components/AttendanceDetailsDialog.vue +1 -48
- package/components/AttendanceMain.vue +10 -104
- package/components/AttendanceSettingsDialog.vue +1 -53
- package/components/BuildingUnitFormEdit.vue +0 -6
- package/components/BulletinBoardView.vue +1 -1
- package/components/Dialog/UpdateMoreAction.vue +1 -11
- package/components/DocumentForm.vue +112 -407
- package/components/DocumentManagement.vue +12 -213
- package/components/Facility/BookingSetup.vue +0 -63
- package/components/HidAccessLogDashboard.vue +17 -74
- package/components/HidIntercomManagement.vue +14 -183
- package/components/HidQrCodeConfiguration.vue +195 -17
- package/components/HidReaderManagement.vue +0 -398
- package/components/HidReaderUserRoster.vue +3 -44
- package/components/HidUserEnrollment.vue +250 -1573
- package/components/InventoryMain.vue +2 -11
- package/components/InvitationClientForm.vue +1 -19
- package/components/MemberMain.vue +1 -2
- package/components/MyAttendanceMain.vue +2 -60
- package/components/NotificationBell.vue +2 -14
- package/components/RolePermissionMain.vue +2 -4
- package/components/TableMain.vue +7 -22
- package/components/VisitorForm.vue +0 -12
- package/components/VisitorManagement.vue +5 -267
- package/composables/useAttendance.ts +0 -53
- package/composables/useAttendancePermission.ts +0 -25
- package/composables/useCleaningPermission.ts +0 -5
- package/composables/useDocument.ts +0 -7
- package/composables/useFacility.ts +0 -11
- package/composables/useHidAmico.ts +0 -165
- package/composables/useHidNavigation.ts +1 -25
- package/composables/useLandscapingPermission.ts +0 -5
- package/composables/useMember.ts +0 -12
- package/composables/usePeople.ts +0 -19
- package/composables/usePestControlPermission.ts +0 -5
- package/composables/usePoolMaintenancePermission.ts +0 -5
- package/composables/useSecurityPermission.ts +0 -5
- package/composables/useServiceProvider.ts +2 -40
- package/constants/permissions.ts +0 -1
- package/nuxt.config.ts +0 -18
- package/package.json +2 -4
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/plugins/API.ts +0 -16
- package/types/attendance.d.ts +1 -13
- package/types/document.d.ts +0 -14
- package/types/facility.d.ts +0 -4
- package/types/member.d.ts +0 -4
- package/types/people.d.ts +1 -28
- package/types/service-provider.d.ts +0 -5
- package/utils/notification-target.ts +4 -43
- package/utils/role.ts +2 -15
- package/components/AttendanceProxyDialog.vue +0 -391
- package/components/BuildingUnitManagement.vue +0 -298
- package/components/HidAccessPermissions.vue +0 -424
- package/components/HidProfileQrCode.vue +0 -332
- package/composables/useHidReaderSelection.ts +0 -50
- package/composables/useSiteActions.ts +0 -29
- package/composables/useSiteCategory.ts +0 -45
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
- package/server/plugins/no-powered-by.ts +0 -7
- package/utils/access-suspended.ts +0 -18
- package/utils/attendance-proxy.ts +0 -110
- package/utils/hid-enrolment-subject.ts +0 -144
- package/utils/hid-permission-assignments.ts +0 -190
- package/utils/hid-reader-selection.ts +0 -52
- package/utils/occupancy-role.ts +0 -109
- package/utils/sanitize-html.ts +0 -16
- package/utils/security-headers.ts +0 -52
- package/utils/service-type.ts +0 -30
- package/utils/site-actions.ts +0 -18
|
@@ -25,11 +25,7 @@
|
|
|
25
25
|
the old table card, so it stays above the shared one. -->
|
|
26
26
|
<v-alert
|
|
27
27
|
v-if="selectedReader"
|
|
28
|
-
:type="
|
|
29
|
-
selectedReader.portalId && selectedReader.portalName
|
|
30
|
-
? 'info'
|
|
31
|
-
: 'warning'
|
|
32
|
-
"
|
|
28
|
+
:type="selectedReader.portalId && selectedReader.portalName ? 'info' : 'warning'"
|
|
33
29
|
variant="tonal"
|
|
34
30
|
density="compact"
|
|
35
31
|
class="mb-4"
|
|
@@ -54,9 +50,7 @@
|
|
|
54
50
|
@update:page="goToPage"
|
|
55
51
|
>
|
|
56
52
|
<template v-if="!props.cardManagement" #actions>
|
|
57
|
-
<AppButton icon="mdi-account-plus" @click="openEnroll"
|
|
58
|
-
>Enroll</AppButton
|
|
59
|
-
>
|
|
53
|
+
<AppButton icon="mdi-account-plus" @click="openEnroll">Enroll</AppButton>
|
|
60
54
|
</template>
|
|
61
55
|
|
|
62
56
|
<!-- The design puts filters on their own full-width row under the
|
|
@@ -73,8 +67,8 @@
|
|
|
73
67
|
<AppField
|
|
74
68
|
v-model="search"
|
|
75
69
|
search
|
|
76
|
-
placeholder="Search
|
|
77
|
-
@keyup.enter="
|
|
70
|
+
placeholder="Search"
|
|
71
|
+
@keyup.enter="reloadUsersFromFirstPage"
|
|
78
72
|
/>
|
|
79
73
|
<AppSelect
|
|
80
74
|
v-if="!props.cardManagement"
|
|
@@ -99,15 +93,7 @@
|
|
|
99
93
|
</template>
|
|
100
94
|
|
|
101
95
|
<template #[`item.hidUserId`]="{ item }">
|
|
102
|
-
<span class="app-cell--num">{{
|
|
103
|
-
formatHidUid(item.hidUserId) || "N/A"
|
|
104
|
-
}}</span>
|
|
105
|
-
</template>
|
|
106
|
-
|
|
107
|
-
<!-- The reader allocates this alongside the UID. A row the reader holds
|
|
108
|
-
that we never enrolled has none, so it is not always present. -->
|
|
109
|
-
<template #[`item.registration`]="{ item }">
|
|
110
|
-
<span class="app-cell--num">{{ item.registration || "N/A" }}</span>
|
|
96
|
+
<span class="app-cell--num">{{ formatHidUid(item.hidUserId) || "N/A" }}</span>
|
|
111
97
|
</template>
|
|
112
98
|
|
|
113
99
|
<!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
|
|
@@ -127,10 +113,7 @@
|
|
|
127
113
|
{{ formatPhysicalCard(card) }}
|
|
128
114
|
</span>
|
|
129
115
|
</div>
|
|
130
|
-
<span
|
|
131
|
-
v-else-if="getUserCardLoadError(item)"
|
|
132
|
-
class="hid-card-load-error"
|
|
133
|
-
>
|
|
116
|
+
<span v-else-if="getUserCardLoadError(item)" class="hid-card-load-error">
|
|
134
117
|
Unable to load
|
|
135
118
|
<v-tooltip activator="parent" location="top">
|
|
136
119
|
{{ getUserCardLoadError(item) }}
|
|
@@ -150,20 +133,13 @@
|
|
|
150
133
|
</AppButton>
|
|
151
134
|
<v-menu v-else>
|
|
152
135
|
<template #activator="{ props: menuProps }">
|
|
153
|
-
<AppButton
|
|
154
|
-
v-bind="menuProps"
|
|
155
|
-
variant="row"
|
|
156
|
-
icon="mdi-dots-vertical"
|
|
157
|
-
/>
|
|
136
|
+
<AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
|
|
158
137
|
</template>
|
|
159
138
|
|
|
160
139
|
<v-list density="compact" min-width="150">
|
|
161
140
|
<v-list-item title="View" @click="openView(item)" />
|
|
162
141
|
<v-list-item title="Edit" @click="openEdit(item)" />
|
|
163
|
-
<v-list-item
|
|
164
|
-
title="Manage HID Cards"
|
|
165
|
-
@click="openCardManager(item)"
|
|
166
|
-
/>
|
|
142
|
+
<v-list-item title="Manage HID Cards" @click="openCardManager(item)" />
|
|
167
143
|
<v-list-item
|
|
168
144
|
title="Delete"
|
|
169
145
|
class="text-error"
|
|
@@ -179,31 +155,23 @@
|
|
|
179
155
|
<template #no-data>
|
|
180
156
|
<div class="table-card__empty">
|
|
181
157
|
<v-icon
|
|
182
|
-
:icon="
|
|
183
|
-
props.cardManagement
|
|
184
|
-
? 'mdi-card-account-details-outline'
|
|
185
|
-
: 'mdi-account-off-outline'
|
|
186
|
-
"
|
|
158
|
+
:icon="props.cardManagement ? 'mdi-card-account-details-outline' : 'mdi-account-off-outline'"
|
|
187
159
|
size="32"
|
|
188
160
|
/>
|
|
189
161
|
<span>
|
|
190
|
-
{{
|
|
191
|
-
props.cardManagement
|
|
192
|
-
? "No HID users available for card assignment."
|
|
193
|
-
: "No HID users enrolled yet."
|
|
194
|
-
}}
|
|
162
|
+
{{ props.cardManagement ? "No HID users available for card assignment." : "No HID users enrolled yet." }}
|
|
195
163
|
</span>
|
|
196
164
|
</div>
|
|
197
165
|
</template>
|
|
198
166
|
</TableMain>
|
|
199
167
|
|
|
200
168
|
<v-dialog v-model="formDialog" max-width="420" persistent>
|
|
201
|
-
<v-card
|
|
202
|
-
<v-card-title>
|
|
169
|
+
<v-card rounded="lg">
|
|
170
|
+
<v-card-title class="small-title">
|
|
203
171
|
{{ selectedUser ? "Edit User Information" : "Enroll" }}
|
|
204
172
|
</v-card-title>
|
|
205
173
|
|
|
206
|
-
<v-card-text
|
|
174
|
+
<v-card-text>
|
|
207
175
|
<div class="photo-wrap">
|
|
208
176
|
<v-menu>
|
|
209
177
|
<template #activator="{ props: menuProps }">
|
|
@@ -245,15 +213,6 @@
|
|
|
245
213
|
/>
|
|
246
214
|
</v-list>
|
|
247
215
|
</v-menu>
|
|
248
|
-
<!-- Sits under the button because it describes what the button
|
|
249
|
-
is reporting: the reader holds a face, and this is its
|
|
250
|
-
reference. Nothing to show while enrolling - the value does not
|
|
251
|
-
exist until the reader has accepted a photo. -->
|
|
252
|
-
<p v-if="editFacialData" class="photo-facial">
|
|
253
|
-
<span class="photo-facial__label">Facial Data</span>
|
|
254
|
-
<span class="photo-facial__value">{{ editFacialData }}</span>
|
|
255
|
-
</p>
|
|
256
|
-
|
|
257
216
|
<input
|
|
258
217
|
ref="photoInput"
|
|
259
218
|
type="file"
|
|
@@ -263,197 +222,45 @@
|
|
|
263
222
|
/>
|
|
264
223
|
</div>
|
|
265
224
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
<div class="field-label">Enrolling <span>*</span></div>
|
|
225
|
+
<div class="field-label">Name <span>*</span></div>
|
|
226
|
+
<AppField
|
|
227
|
+
v-model="form.name"
|
|
228
|
+
aria-label="Name"
|
|
229
|
+
placeholder="Enter name"
|
|
230
|
+
klass="mb-3"
|
|
231
|
+
/>
|
|
232
|
+
|
|
233
|
+
<div class="field-label">Link To <span>*</span></div>
|
|
276
234
|
<AppSelect
|
|
277
|
-
v-if="!selectedUser"
|
|
278
235
|
v-model="form.subjectCategory"
|
|
279
236
|
:items="subjectCategoryOptions"
|
|
237
|
+
placeholder="Select person type"
|
|
280
238
|
klass="mb-3"
|
|
281
239
|
@update:model-value="onSubjectCategoryChanged"
|
|
282
240
|
/>
|
|
283
|
-
<p v-else class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
|
|
284
|
-
|
|
285
|
-
<!-- Block / Level / Unit / Resident. Enrolment only.
|
|
286
|
-
Enrollment is resident-only and the resident must already exist
|
|
287
|
-
in iService365 - you pick a person, you do not type one. Name is
|
|
288
|
-
therefore a mirror of that record, never an input, so the reader
|
|
289
|
-
can never end up holding a name the app does not know.
|
|
290
|
-
Every option is derived from the residents themselves, so a
|
|
291
|
-
block, level or unit only appears when somebody lives there.
|
|
292
|
-
The list loads on first touch of Block, not on dialog open.
|
|
293
|
-
Enrolment only, with no exception. Editing does not move
|
|
294
|
-
anybody: the record already carries its block, level and unit,
|
|
295
|
-
`openEdit` loads them into the form and `saveUser` writes them
|
|
296
|
-
back untouched, so the location survives an edit without being
|
|
297
|
-
asked for. A row that never had one keeps not having one; that
|
|
298
|
-
is not a reason to make somebody fill in a location before they
|
|
299
|
-
can change a PIN. -->
|
|
300
|
-
<template v-if="!selectedUser && isResidentSubject">
|
|
301
|
-
<div class="field-label">Block <span>*</span></div>
|
|
302
|
-
<!-- The listener sits on a wrapper, NOT on AppSelect: AppSelect
|
|
303
|
-
spreads `$attrs` over its v-menu activator AFTER the activator's
|
|
304
|
-
own bindings, so an `@click` here would replace the handler that
|
|
305
|
-
opens the dropdown. `focusin` covers keyboard users, and both
|
|
306
|
-
are idempotent - `ensureOccupancy()` fetches once.
|
|
307
|
-
AppSelect has no `loading` prop either; the placeholder carries
|
|
308
|
-
the loading and empty states instead. -->
|
|
309
|
-
<div @click="onBlockOpened" @focusin="onBlockOpened">
|
|
310
|
-
<AppSelect
|
|
311
|
-
v-model="form.block"
|
|
312
|
-
:items="blockOptions"
|
|
313
|
-
:placeholder="blockPlaceholder"
|
|
314
|
-
klass="mb-3"
|
|
315
|
-
@update:model-value="onBlockChanged"
|
|
316
|
-
/>
|
|
317
|
-
</div>
|
|
318
|
-
|
|
319
|
-
<div class="field-label">Level <span>*</span></div>
|
|
320
|
-
<AppSelect
|
|
321
|
-
v-model="form.level"
|
|
322
|
-
:items="levelOptions"
|
|
323
|
-
:disabled="!form.block"
|
|
324
|
-
placeholder="Select level"
|
|
325
|
-
klass="mb-3"
|
|
326
|
-
@update:model-value="onLevelChanged"
|
|
327
|
-
/>
|
|
328
|
-
|
|
329
|
-
<div class="field-label">Unit <span>*</span></div>
|
|
330
|
-
<AppSelect
|
|
331
|
-
v-model="form.unit"
|
|
332
|
-
:items="unitOptions"
|
|
333
|
-
:disabled="!form.level"
|
|
334
|
-
placeholder="Select unit"
|
|
335
|
-
klass="mb-3"
|
|
336
|
-
@update:model-value="onUnitChanged"
|
|
337
|
-
/>
|
|
338
|
-
</template>
|
|
339
|
-
|
|
340
|
-
<!-- Drawn only where it can offer something: while enrolling, or
|
|
341
|
-
while editing a record whose stored unit gives it a list. A row
|
|
342
|
-
with no unit has no candidates to show, and the dialog is then
|
|
343
|
-
purely the reader's own credentials - photo, PIN, password -
|
|
344
|
-
which is exactly the edit that must not be blocked. -->
|
|
345
|
-
<template v-if="isResidentSubject && (!selectedUser || form.unit)">
|
|
346
|
-
<div class="field-label">Resident <span>*</span></div>
|
|
347
|
-
<AppSelect
|
|
348
|
-
v-model="form.subjectId"
|
|
349
|
-
:items="subjectOptions"
|
|
350
|
-
:disabled="!form.unit"
|
|
351
|
-
:placeholder="unitResidentPlaceholder"
|
|
352
|
-
klass="mb-3"
|
|
353
|
-
@update:model-value="onResidentChanged"
|
|
354
|
-
/>
|
|
355
|
-
</template>
|
|
356
|
-
|
|
357
|
-
<!-- STAFF. One picker, because a member has no unit to narrow by.
|
|
358
|
-
The candidates are the permission subjects the Access Permissions
|
|
359
|
-
screen already reads - active members of this site or its
|
|
360
|
-
organisation, each carrying its role as a subtitle. Filtering is
|
|
361
|
-
AppSelect's own, over the loaded page, so typing costs no request.
|
|
362
|
-
A member with no app account is not in the list at all: reader
|
|
363
|
-
access is bound by user id, so there is nothing to bind. The note
|
|
364
|
-
below says how many were left out rather than letting a missing
|
|
365
|
-
colleague read as a bug. -->
|
|
366
|
-
<template v-if="!selectedUser && !isResidentSubject">
|
|
367
|
-
<div class="field-label">Member <span>*</span></div>
|
|
368
|
-
<AppSelect
|
|
369
|
-
v-model="form.subjectId"
|
|
370
|
-
:items="memberOptions"
|
|
371
|
-
searchable
|
|
372
|
-
search-placeholder="Search staff by name or role"
|
|
373
|
-
:placeholder="memberPlaceholder"
|
|
374
|
-
:has-more="hasMoreMembers"
|
|
375
|
-
:loading-more="loadingMoreMembers"
|
|
376
|
-
klass="mb-3"
|
|
377
|
-
@update:model-value="onMemberChanged"
|
|
378
|
-
@load-more="loadMoreMembers"
|
|
379
|
-
/>
|
|
380
|
-
<!-- Ten at a time, because that page size shares the Members
|
|
381
|
-
menu's cache entry. The rest arrive as the list is scrolled, so
|
|
382
|
-
this only has to SAY where the list has got to - and warn that
|
|
383
|
-
the search box filters what is loaded, not the whole estate. -->
|
|
384
|
-
<p v-if="memberRangeNote" class="field-hint mb-3">
|
|
385
|
-
{{ memberRangeNote }}
|
|
386
|
-
</p>
|
|
387
|
-
<p v-if="hiddenMemberNote" class="field-hint mb-3">
|
|
388
|
-
{{ hiddenMemberNote }}
|
|
389
|
-
</p>
|
|
390
|
-
</template>
|
|
391
241
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
v-else-if="residentConflict"
|
|
399
|
-
class="field-hint field-hint--error mb-3"
|
|
400
|
-
>
|
|
401
|
-
{{ residentConflictMessage }}
|
|
402
|
-
</p>
|
|
403
|
-
<p
|
|
404
|
-
v-else-if="isResidentSubject && unitResidentNote"
|
|
405
|
-
class="field-hint mb-3"
|
|
406
|
-
>
|
|
407
|
-
{{ unitResidentNote }}
|
|
408
|
-
</p>
|
|
409
|
-
|
|
410
|
-
<!-- Read-only: it restates the chosen resident, it does not collect
|
|
411
|
-
anything. `selectedUser` is the edit case, where the name is
|
|
412
|
-
whatever the reader already holds. -->
|
|
413
|
-
<!-- A mirror of the chosen member's role, on the same rule as Name:
|
|
414
|
-
restated from the record, never collected. -->
|
|
415
|
-
<template v-if="!selectedUser && !isResidentSubject">
|
|
416
|
-
<div class="field-label">Role</div>
|
|
417
|
-
<AppField
|
|
418
|
-
v-model="form.subjectRole"
|
|
419
|
-
aria-label="Role"
|
|
420
|
-
placeholder="Select a member above"
|
|
421
|
-
readonly
|
|
422
|
-
klass="mb-3"
|
|
423
|
-
/>
|
|
424
|
-
</template>
|
|
425
|
-
|
|
426
|
-
<div class="field-label">Name</div>
|
|
427
|
-
<AppField
|
|
428
|
-
v-model="form.name"
|
|
429
|
-
aria-label="Name"
|
|
430
|
-
:placeholder="
|
|
431
|
-
isResidentSubject
|
|
432
|
-
? 'Select a resident above'
|
|
433
|
-
: 'Select a member above'
|
|
434
|
-
"
|
|
435
|
-
readonly
|
|
242
|
+
<div class="field-label">Resident / Staff / Provider <span>*</span></div>
|
|
243
|
+
<AppSelect
|
|
244
|
+
v-model="form.subjectId"
|
|
245
|
+
:items="subjectOptions"
|
|
246
|
+
:loading="loadingSubjects"
|
|
247
|
+
placeholder="Select an existing site record"
|
|
436
248
|
klass="mb-3"
|
|
437
249
|
/>
|
|
438
250
|
|
|
439
251
|
<div class="field-label">ID</div>
|
|
440
|
-
<AppField
|
|
441
|
-
v-model="form.hidUserId"
|
|
442
|
-
aria-label="ID"
|
|
443
|
-
readonly
|
|
444
|
-
klass="mb-3"
|
|
445
|
-
/>
|
|
252
|
+
<AppField v-model="form.hidUserId" aria-label="ID" readonly klass="mb-3" />
|
|
446
253
|
|
|
447
254
|
<div class="field-label">Registration No. <span>*</span></div>
|
|
448
255
|
<AppField
|
|
449
256
|
v-model="form.registration"
|
|
450
257
|
aria-label="Registration No."
|
|
451
258
|
placeholder="Enter registration no."
|
|
452
|
-
readonly
|
|
259
|
+
:readonly="!selectedUser"
|
|
453
260
|
klass="mb-3"
|
|
454
261
|
/>
|
|
455
262
|
|
|
456
|
-
<div class="field-label">PIN (optional)</div>
|
|
263
|
+
<div class="field-label">Access PIN (optional)</div>
|
|
457
264
|
<!--
|
|
458
265
|
The handoff draws no password field anywhere (the sign-in screens
|
|
459
266
|
are the owner's own), so the reveal toggle is this product's, not
|
|
@@ -463,8 +270,8 @@
|
|
|
463
270
|
-->
|
|
464
271
|
<AppField
|
|
465
272
|
v-model="form.accessPin"
|
|
466
|
-
aria-label="PIN"
|
|
467
|
-
placeholder="Enter
|
|
273
|
+
aria-label="Access PIN"
|
|
274
|
+
placeholder="Enter numeric access PIN"
|
|
468
275
|
inputmode="numeric"
|
|
469
276
|
autocomplete="new-password"
|
|
470
277
|
:type="showPin ? 'text' : 'password'"
|
|
@@ -473,7 +280,7 @@
|
|
|
473
280
|
<button
|
|
474
281
|
type="button"
|
|
475
282
|
class="app-field__trailing"
|
|
476
|
-
:aria-label="showPin ? 'Hide PIN' : 'Show PIN'"
|
|
283
|
+
:aria-label="showPin ? 'Hide access PIN' : 'Show access PIN'"
|
|
477
284
|
@click="showPin = !showPin"
|
|
478
285
|
>
|
|
479
286
|
<v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
|
|
@@ -483,82 +290,33 @@
|
|
|
483
290
|
<!-- `persistent-hint` was Vuetify's; the design's settings module
|
|
484
291
|
draws an explanatory line as 12-13px/600 in `--muted`. -->
|
|
485
292
|
<p class="field-hint">
|
|
486
|
-
{{
|
|
487
|
-
selectedUser && pinEnrolled
|
|
488
|
-
? "Leave blank to keep the current PIN."
|
|
489
|
-
: "PIN works as an alternative to face or QR."
|
|
490
|
-
}}
|
|
293
|
+
{{ selectedUser && pinEnrolled ? "Leave blank to keep the current PIN." : "PIN works as an alternative to face or QR." }}
|
|
491
294
|
</p>
|
|
492
295
|
<v-checkbox
|
|
493
296
|
v-if="selectedUser && pinEnrolled"
|
|
494
297
|
v-model="removePinRequested"
|
|
495
|
-
label="Remove current PIN"
|
|
496
|
-
density="compact"
|
|
497
|
-
hide-details
|
|
498
|
-
/>
|
|
499
|
-
|
|
500
|
-
<!-- Password: the reader stores only a hash plus its salt, so there
|
|
501
|
-
is nothing to read back and the field is always empty on open.
|
|
502
|
-
Same reveal toggle as the PIN above. -->
|
|
503
|
-
<div class="field-label mt-3">Password (optional)</div>
|
|
504
|
-
<AppField
|
|
505
|
-
v-model="form.password"
|
|
506
|
-
aria-label="Password"
|
|
507
|
-
:placeholder="
|
|
508
|
-
selectedUser && passwordSet
|
|
509
|
-
? 'Enter a new password'
|
|
510
|
-
: 'Enter password'
|
|
511
|
-
"
|
|
512
|
-
autocomplete="new-password"
|
|
513
|
-
:type="showPassword ? 'text' : 'password'"
|
|
514
|
-
>
|
|
515
|
-
<template #trailing>
|
|
516
|
-
<button
|
|
517
|
-
type="button"
|
|
518
|
-
class="app-field__trailing"
|
|
519
|
-
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
|
520
|
-
@click="showPassword = !showPassword"
|
|
521
|
-
>
|
|
522
|
-
<v-icon
|
|
523
|
-
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
|
524
|
-
size="18"
|
|
525
|
-
/>
|
|
526
|
-
</button>
|
|
527
|
-
</template>
|
|
528
|
-
</AppField>
|
|
529
|
-
<p class="field-hint">
|
|
530
|
-
{{
|
|
531
|
-
selectedUser && passwordSet
|
|
532
|
-
? "A password is set. Leave blank to keep it."
|
|
533
|
-
: "Used with the user ID on the reader keypad."
|
|
534
|
-
}}
|
|
535
|
-
</p>
|
|
536
|
-
<v-checkbox
|
|
537
|
-
v-if="selectedUser && passwordSet"
|
|
538
|
-
v-model="removePasswordRequested"
|
|
539
|
-
label="Remove current password"
|
|
298
|
+
label="Remove current access PIN"
|
|
540
299
|
density="compact"
|
|
541
300
|
hide-details
|
|
542
301
|
/>
|
|
543
302
|
</v-card-text>
|
|
544
303
|
|
|
545
|
-
<!-- The
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
<v-card-actions class="screen-modal__footer">
|
|
304
|
+
<!-- The split footer bar, as panel 2 keeps it: `v-btn` because it needs
|
|
305
|
+
`:loading` and a full-bleed shape the handoff does not draw. Only
|
|
306
|
+
the colours change - they were fixed hexes on both themes. -->
|
|
307
|
+
<v-card-actions class="pa-0">
|
|
550
308
|
<v-btn
|
|
551
|
-
class="text-none
|
|
309
|
+
class="text-none action-cancel"
|
|
552
310
|
variant="flat"
|
|
311
|
+
height="44"
|
|
553
312
|
@click="formDialog = false"
|
|
313
|
+
>Cancel</v-btn
|
|
554
314
|
>
|
|
555
|
-
Cancel
|
|
556
|
-
</v-btn>
|
|
557
315
|
<v-btn
|
|
558
|
-
class="text-none
|
|
316
|
+
class="text-none action-submit"
|
|
559
317
|
variant="flat"
|
|
318
|
+
height="44"
|
|
560
319
|
:loading="saving"
|
|
561
|
-
:disabled="Boolean(residentConflict) || checkingResident"
|
|
562
320
|
@click="saveUser"
|
|
563
321
|
>
|
|
564
322
|
Save
|
|
@@ -570,9 +328,9 @@
|
|
|
570
328
|
<HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
|
|
571
329
|
|
|
572
330
|
<v-dialog v-model="viewDialog" max-width="430">
|
|
573
|
-
<v-card
|
|
574
|
-
<v-card-title>User Information</v-card-title>
|
|
575
|
-
<v-card-text
|
|
331
|
+
<v-card rounded="lg">
|
|
332
|
+
<v-card-title class="small-title">User Information</v-card-title>
|
|
333
|
+
<v-card-text>
|
|
576
334
|
<div class="detail-grid">
|
|
577
335
|
<span>Name</span><strong>{{ getName(selectedUser) }}</strong>
|
|
578
336
|
<span>User ID</span
|
|
@@ -589,28 +347,21 @@
|
|
|
589
347
|
><strong>{{
|
|
590
348
|
selectedUser ? getMappingStatus(selectedUser) : "N/A"
|
|
591
349
|
}}</strong>
|
|
592
|
-
<span>PIN</span
|
|
593
|
-
><strong>{{
|
|
594
|
-
selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A"
|
|
595
|
-
}}</strong>
|
|
596
|
-
<span>Password</span
|
|
597
|
-
><strong>{{
|
|
598
|
-
selectedUser?.metadata?.passwordSet ? "Configured" : "N/A"
|
|
599
|
-
}}</strong>
|
|
350
|
+
<span>Access PIN</span
|
|
351
|
+
><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
|
|
600
352
|
</div>
|
|
601
353
|
</v-card-text>
|
|
602
|
-
<v-card-actions
|
|
603
|
-
<
|
|
604
|
-
|
|
605
|
-
>
|
|
354
|
+
<v-card-actions>
|
|
355
|
+
<v-spacer />
|
|
356
|
+
<AppButton variant="ghost" @click="viewDialog = false">Close</AppButton>
|
|
606
357
|
</v-card-actions>
|
|
607
358
|
</v-card>
|
|
608
359
|
</v-dialog>
|
|
609
360
|
|
|
610
361
|
<v-dialog v-model="cardDialog" max-width="560" persistent>
|
|
611
|
-
<v-card
|
|
612
|
-
<v-card-title>Physical HID Cards</v-card-title>
|
|
613
|
-
<v-card-text
|
|
362
|
+
<v-card rounded="lg">
|
|
363
|
+
<v-card-title class="small-title">Physical HID Cards</v-card-title>
|
|
364
|
+
<v-card-text>
|
|
614
365
|
<p class="hid-card-user">
|
|
615
366
|
{{ getName(cardUser) }}
|
|
616
367
|
<span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
|
|
@@ -618,11 +369,7 @@
|
|
|
618
369
|
|
|
619
370
|
<v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
|
|
620
371
|
<div v-else-if="physicalCards.length" class="hid-card-list mb-4">
|
|
621
|
-
<div
|
|
622
|
-
v-for="card in physicalCards"
|
|
623
|
-
:key="card.id"
|
|
624
|
-
class="hid-card-row"
|
|
625
|
-
>
|
|
372
|
+
<div v-for="card in physicalCards" :key="card.id" class="hid-card-row">
|
|
626
373
|
<v-icon icon="mdi-card-account-details-outline" size="22" />
|
|
627
374
|
<div class="hid-card-row__content">
|
|
628
375
|
<strong>{{ formatPhysicalCard(card) }}</strong>
|
|
@@ -636,13 +383,7 @@
|
|
|
636
383
|
/>
|
|
637
384
|
</div>
|
|
638
385
|
</div>
|
|
639
|
-
<v-alert
|
|
640
|
-
v-else
|
|
641
|
-
type="info"
|
|
642
|
-
variant="tonal"
|
|
643
|
-
density="compact"
|
|
644
|
-
class="mb-4"
|
|
645
|
-
>
|
|
386
|
+
<v-alert v-else type="info" variant="tonal" density="compact" class="mb-4">
|
|
646
387
|
No physical HID card is assigned to this user.
|
|
647
388
|
</v-alert>
|
|
648
389
|
|
|
@@ -656,8 +397,7 @@
|
|
|
656
397
|
{{ cardProfileLabel }}
|
|
657
398
|
</v-alert>
|
|
658
399
|
<p class="field-hint hid-card-type-hint">
|
|
659
|
-
PACS uses Facility Code + Card Number. CSN uses the raw serial
|
|
660
|
-
number read from the physical card.
|
|
400
|
+
PACS uses Facility Code + Card Number. CSN uses the raw serial number read from the physical card.
|
|
661
401
|
</p>
|
|
662
402
|
<template v-if="hybridCardProfile">
|
|
663
403
|
<div class="field-label mt-3">Presented Card Type</div>
|
|
@@ -668,13 +408,11 @@
|
|
|
668
408
|
class="mb-3"
|
|
669
409
|
/>
|
|
670
410
|
<p class="field-hint hid-card-type-hint">
|
|
671
|
-
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
|
|
672
|
-
card in this reader mode.
|
|
411
|
+
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other card in this reader mode.
|
|
673
412
|
</p>
|
|
674
413
|
</template>
|
|
675
414
|
<p class="field-hint hid-card-access-hint">
|
|
676
|
-
The card uses this HID user's existing portal and access rules;
|
|
677
|
-
assigning a card does not grant new access.
|
|
415
|
+
The card uses this HID user's existing portal and access rules; assigning a card does not grant new access.
|
|
678
416
|
</p>
|
|
679
417
|
|
|
680
418
|
<div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
|
|
@@ -711,15 +449,9 @@
|
|
|
711
449
|
density="compact"
|
|
712
450
|
class="mt-4"
|
|
713
451
|
>
|
|
714
|
-
Present the physical card to the selected Amico reader. The request
|
|
715
|
-
will time out after 30 seconds.
|
|
452
|
+
Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
|
|
716
453
|
</v-alert>
|
|
717
454
|
|
|
718
|
-
<!-- Reading the card at the reader is the primary way in: it is the
|
|
719
|
-
only one that does not require knowing the facility code and card
|
|
720
|
-
number up front, which are printed on nothing the operator holds.
|
|
721
|
-
Assigning by hand stays for the cards whose values are already
|
|
722
|
-
known. -->
|
|
723
455
|
<div class="hid-card-actions mt-4">
|
|
724
456
|
<AppButton
|
|
725
457
|
variant="secondary"
|
|
@@ -738,21 +470,15 @@
|
|
|
738
470
|
<AppButton
|
|
739
471
|
v-if="enrollingCard"
|
|
740
472
|
variant="ghost"
|
|
741
|
-
:disabled="cancellingCard"
|
|
742
473
|
@click="cancelPhysicalCardEnrollment"
|
|
743
474
|
>
|
|
744
|
-
|
|
475
|
+
Cancel Reading
|
|
745
476
|
</AppButton>
|
|
746
477
|
</div>
|
|
747
478
|
</v-card-text>
|
|
748
|
-
<v-card-actions
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
<AppButton
|
|
752
|
-
variant="ghost"
|
|
753
|
-
:disabled="enrollingCard"
|
|
754
|
-
@click="cardDialog = false"
|
|
755
|
-
>
|
|
479
|
+
<v-card-actions>
|
|
480
|
+
<v-spacer />
|
|
481
|
+
<AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
|
|
756
482
|
Close
|
|
757
483
|
</AppButton>
|
|
758
484
|
</v-card-actions>
|
|
@@ -760,31 +486,31 @@
|
|
|
760
486
|
</v-dialog>
|
|
761
487
|
|
|
762
488
|
<v-dialog v-model="deleteDialog" max-width="360" persistent>
|
|
763
|
-
<v-card
|
|
764
|
-
<v-card-title>Delete</v-card-title>
|
|
765
|
-
<v-card-text class="
|
|
489
|
+
<v-card rounded="lg">
|
|
490
|
+
<v-card-title class="small-title">Delete</v-card-title>
|
|
491
|
+
<v-card-text class="text-body-2">
|
|
766
492
|
Are you sure you want to permanently delete this HID user?
|
|
767
493
|
<div class="text-caption text-medium-emphasis mt-2">
|
|
768
494
|
This action will remove the user from the HID reader and mark the
|
|
769
495
|
identity as deleted.
|
|
770
496
|
</div>
|
|
771
497
|
</v-card-text>
|
|
772
|
-
<v-card-actions class="
|
|
498
|
+
<v-card-actions class="pa-0">
|
|
773
499
|
<v-btn
|
|
774
|
-
class="text-none
|
|
500
|
+
class="text-none action-cancel"
|
|
775
501
|
variant="flat"
|
|
502
|
+
height="44"
|
|
776
503
|
@click="deleteDialog = false"
|
|
504
|
+
>Cancel</v-btn
|
|
777
505
|
>
|
|
778
|
-
Cancel
|
|
779
|
-
</v-btn>
|
|
780
506
|
<v-btn
|
|
781
|
-
class="text-none
|
|
507
|
+
class="text-none action-submit"
|
|
782
508
|
variant="flat"
|
|
509
|
+
height="44"
|
|
783
510
|
:loading="deleting"
|
|
784
511
|
@click="deleteUser"
|
|
512
|
+
>Delete</v-btn
|
|
785
513
|
>
|
|
786
|
-
Delete
|
|
787
|
-
</v-btn>
|
|
788
514
|
</v-card-actions>
|
|
789
515
|
</v-card>
|
|
790
516
|
</v-dialog>
|
|
@@ -796,30 +522,11 @@
|
|
|
796
522
|
</template>
|
|
797
523
|
|
|
798
524
|
<script setup lang="ts">
|
|
799
|
-
// Shared with the Access Permissions screen: `paginate` never echoes the page it
|
|
800
|
-
// was asked for, and `items` sits at the root on some of these endpoints and
|
|
801
|
-
// under `data` on others. This reads both, so the mistake is made once.
|
|
802
|
-
import {
|
|
803
|
-
HID_ENROLMENT_SUBJECTS,
|
|
804
|
-
hiddenSubjectNote,
|
|
805
|
-
memberCandidateOf,
|
|
806
|
-
memberHasAccount,
|
|
807
|
-
readMemberPage,
|
|
808
|
-
subjectCategoryLabel as labelForSubjectCategory,
|
|
809
|
-
} from "../utils/hid-enrolment-subject";
|
|
810
|
-
|
|
811
525
|
const props = defineProps({
|
|
812
526
|
site: {
|
|
813
527
|
type: String,
|
|
814
528
|
required: true,
|
|
815
529
|
},
|
|
816
|
-
// Which organisation the access this enrolment grants belongs to. Saving now
|
|
817
|
-
// assigns the person to the reader, and the assignment is stored per
|
|
818
|
-
// organisation - see `schemaCreateHidAmicoIdentity`.
|
|
819
|
-
org: {
|
|
820
|
-
type: String,
|
|
821
|
-
default: "",
|
|
822
|
-
},
|
|
823
530
|
cardManagement: {
|
|
824
531
|
type: Boolean,
|
|
825
532
|
default: false,
|
|
@@ -840,16 +547,12 @@ const {
|
|
|
840
547
|
getUserPinStatus,
|
|
841
548
|
setUserPin,
|
|
842
549
|
deleteUserPin,
|
|
843
|
-
getUserPasswordStatus,
|
|
844
|
-
setUserPassword,
|
|
845
|
-
deleteUserPassword,
|
|
846
550
|
runObjectOperation,
|
|
847
551
|
createIdentity,
|
|
848
552
|
updateIdentity,
|
|
849
553
|
deleteIdentity,
|
|
554
|
+
getPermissionCandidates,
|
|
850
555
|
} = useHidAmico();
|
|
851
|
-
const { getAll: getAllMembers } = useMember();
|
|
852
|
-
const runtimeConfig = useRuntimeConfig();
|
|
853
556
|
|
|
854
557
|
type HidMetadata = Record<string, unknown> & {
|
|
855
558
|
name?: string;
|
|
@@ -863,7 +566,6 @@ type HidMetadata = Record<string, unknown> & {
|
|
|
863
566
|
lastAccessAt?: string;
|
|
864
567
|
facialScores?: Record<string, unknown>;
|
|
865
568
|
pinEnrolled?: boolean;
|
|
866
|
-
passwordSet?: boolean;
|
|
867
569
|
hidPhysicalCards?: HidCard[];
|
|
868
570
|
};
|
|
869
571
|
|
|
@@ -914,7 +616,6 @@ type HidIdentityPayload = Record<string, unknown> & {
|
|
|
914
616
|
const readers = ref<HidReader[]>([]);
|
|
915
617
|
const users = ref<HidUser[]>([]);
|
|
916
618
|
const administratorUserIds = ref<Set<number>>(new Set());
|
|
917
|
-
const { selectHidReader, resolveForSite } = useHidReaderSelection();
|
|
918
619
|
const selectedReaderId = ref("");
|
|
919
620
|
const search = ref("");
|
|
920
621
|
const status = ref("");
|
|
@@ -938,14 +639,7 @@ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
|
|
|
938
639
|
const loadingCards = ref(false);
|
|
939
640
|
const savingCard = ref(false);
|
|
940
641
|
const enrollingCard = ref(false);
|
|
941
|
-
/*
|
|
942
|
-
* Cancelling makes the in-flight enrol REJECT, and that rejection is expected
|
|
943
|
-
* rather than a failure. Without this flag the cancel raises an error toast on
|
|
944
|
-
* top of the one confirming it, which reads as the cancel having gone wrong.
|
|
945
|
-
*/
|
|
946
642
|
const cardEnrollmentCancelled = ref(false);
|
|
947
|
-
/** The cancel's own round trip to the reader — a second or two, not the enrol's. */
|
|
948
|
-
const cancellingCard = ref(false);
|
|
949
643
|
const deletingCardId = ref("");
|
|
950
644
|
const showPin = ref(false);
|
|
951
645
|
const pinEnrolled = ref(false);
|
|
@@ -963,79 +657,17 @@ const facialEnrolled = ref(false);
|
|
|
963
657
|
function hasFacialEnrollment(user?: HidUser | null) {
|
|
964
658
|
return Boolean(
|
|
965
659
|
user?.metadata?.facialEnrolled ||
|
|
966
|
-
|
|
967
|
-
|
|
660
|
+
user?.metadata?.facialData ||
|
|
661
|
+
user?.metadata?.imageTimestamp
|
|
968
662
|
);
|
|
969
663
|
}
|
|
970
|
-
|
|
971
|
-
/**
|
|
972
|
-
* The reader's own reference for the enrolled face, shown while editing.
|
|
973
|
-
*
|
|
974
|
-
* We hold neither the image nor the biometric template - the reader does - so
|
|
975
|
-
* this value is the only handle on the enrollment there is. `getFacialData` is
|
|
976
|
-
* the roster's Facial Data column, reused here so the dialog and the table
|
|
977
|
-
* cannot disagree; it answers "N/A" when there is nothing, which is the case
|
|
978
|
-
* this must not draw a row for.
|
|
979
|
-
*/
|
|
980
|
-
const editFacialData = computed(() => {
|
|
981
|
-
if (!selectedUser.value) return "";
|
|
982
|
-
const value = String(getFacialData(selectedUser.value) ?? "").trim();
|
|
983
|
-
return value && value !== "N/A" ? value : "";
|
|
984
|
-
});
|
|
985
664
|
const removePinRequested = ref(false);
|
|
986
|
-
const passwordSet = ref(false);
|
|
987
|
-
const showPassword = ref(false);
|
|
988
|
-
const removePasswordRequested = ref(false);
|
|
989
665
|
const snackbar = reactive({
|
|
990
666
|
show: false,
|
|
991
667
|
color: "success",
|
|
992
668
|
message: "",
|
|
993
669
|
});
|
|
994
670
|
|
|
995
|
-
type OccupiedUnit = { _id: string; name: string; people: number };
|
|
996
|
-
type OccupiedLevel = { _id: string; name: string; units?: OccupiedUnit[] };
|
|
997
|
-
type OccupiedBlock = {
|
|
998
|
-
_id: string;
|
|
999
|
-
name: string;
|
|
1000
|
-
block: number | null;
|
|
1001
|
-
levels?: OccupiedLevel[];
|
|
1002
|
-
};
|
|
1003
|
-
type UnitPerson = {
|
|
1004
|
-
_id: string;
|
|
1005
|
-
name?: string;
|
|
1006
|
-
unitName?: string;
|
|
1007
|
-
/**
|
|
1008
|
-
* The resident's app account, and the reason this field is read here.
|
|
1009
|
-
*
|
|
1010
|
-
* A HID identity is refused unless its subject is a valid permission subject
|
|
1011
|
-
* at the site, and for a resident that means a `site.people` row WITH a
|
|
1012
|
-
* linked user - `assertPermissionSubject` in `iservice365-core` filters on
|
|
1013
|
-
* exactly this field. The permission model has no way to represent anyone
|
|
1014
|
-
* else: `resolvePermissionUserBindings` binds reader access by user id and
|
|
1015
|
-
* drops every row without one, so a resident enrolled without an account
|
|
1016
|
-
* would end up on the reader holding no access rules at all.
|
|
1017
|
-
*
|
|
1018
|
-
* The picker this replaced was fed by that same server-side function, so it
|
|
1019
|
-
* could never offer an unenrollable resident. The unit cascade asks People
|
|
1020
|
-
* Management instead, which has no such filter - hence this one.
|
|
1021
|
-
*/
|
|
1022
|
-
user?: string | null;
|
|
1023
|
-
};
|
|
1024
|
-
|
|
1025
|
-
/**
|
|
1026
|
-
* THE SUBJECT TYPE THIS SCREEN WAS LAST USED FOR.
|
|
1027
|
-
*
|
|
1028
|
-
* Module scope, not component scope, and deliberately: the dialog is rebuilt on
|
|
1029
|
-
* every open and the component itself is unmounted when the operator moves
|
|
1030
|
-
* between HID screens, so a local ref would forget the choice immediately.
|
|
1031
|
-
* Enrolling ten guards in a row should not mean picking "Member" ten times.
|
|
1032
|
-
*
|
|
1033
|
-
* Not persisted. Surviving a hard refresh is a smaller benefit than surviving
|
|
1034
|
-
* the dialog, and `useHidReaderSelection` already set the precedent for where
|
|
1035
|
-
* that line sits.
|
|
1036
|
-
*/
|
|
1037
|
-
const REMEMBERED_SUBJECT_CATEGORY = ref<THidPermissionCategory>("resident");
|
|
1038
|
-
|
|
1039
671
|
const form = reactive({
|
|
1040
672
|
reader: "",
|
|
1041
673
|
block: "",
|
|
@@ -1046,385 +678,24 @@ const form = reactive({
|
|
|
1046
678
|
registration: "",
|
|
1047
679
|
isAdministrator: false,
|
|
1048
680
|
accessPin: "",
|
|
1049
|
-
password: "",
|
|
1050
681
|
photoPreview: "",
|
|
1051
|
-
subjectCategory:
|
|
682
|
+
subjectCategory: "resident" as THidPermissionCategory,
|
|
1052
683
|
subjectId: "",
|
|
1053
|
-
/** A mirror of the chosen member's role. Never sent; display only. */
|
|
1054
|
-
subjectRole: "",
|
|
1055
684
|
});
|
|
1056
685
|
|
|
1057
686
|
const route = useRoute();
|
|
1058
687
|
const orgId = computed(() => String(route.params.org || ""));
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
// halves different questions is how a unit ends up offered but empty.
|
|
1071
|
-
const OCCUPANT_TYPES = "resident,tenant";
|
|
1072
|
-
|
|
1073
|
-
const occupiedBlocks = ref<OccupiedBlock[]>([]);
|
|
1074
|
-
const occupancyLoaded = ref(false);
|
|
1075
|
-
const loadingOccupancy = ref(false);
|
|
1076
|
-
const unitPeople = ref<UnitPerson[]>([]);
|
|
1077
|
-
const loadingUnitPeople = ref(false);
|
|
1078
|
-
|
|
1079
|
-
// "Is this resident already enrolled on THIS reader?" - the reader keeps its own
|
|
1080
|
-
// user table, so the same person at a different gate is normal and does not
|
|
1081
|
-
// count. Held here rather than derived, because it is the answer to a request.
|
|
1082
|
-
//
|
|
1083
|
-
// The server refuses the duplicate as well (`createIdentity` in
|
|
1084
|
-
// `iservice365-core`), but by the time it answers, `saveUser` has already
|
|
1085
|
-
// created a user on the physical device and has to roll it back. Asking here
|
|
1086
|
-
// keeps that off the reader entirely.
|
|
1087
|
-
const residentConflict = ref<HidUser | null>(null);
|
|
1088
|
-
const checkingResident = ref(false);
|
|
1089
|
-
|
|
1090
|
-
// One spelling of a block, used by the picker and by the label stamped onto the
|
|
1091
|
-
// record. Two copies of this expression is how the dropdown and the Unit column
|
|
1092
|
-
// drift apart.
|
|
1093
|
-
function formatBlockTitle(block?: OccupiedBlock | null) {
|
|
1094
|
-
if (!block) return "";
|
|
1095
|
-
return block.name
|
|
1096
|
-
? `Block ${block.block} (${block.name})`
|
|
1097
|
-
: `Block ${block.block ?? ""}`.trim();
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
const blockOptions = computed(() =>
|
|
1101
|
-
occupiedBlocks.value.map((block) => ({
|
|
1102
|
-
title: formatBlockTitle(block),
|
|
1103
|
-
value: String(block._id ?? ""),
|
|
1104
|
-
}))
|
|
1105
|
-
);
|
|
1106
|
-
|
|
1107
|
-
const levelOptions = computed(() => {
|
|
1108
|
-
const block = occupiedBlocks.value.find(
|
|
1109
|
-
(item) => String(item._id ?? "") === form.block
|
|
1110
|
-
);
|
|
1111
|
-
return (block?.levels ?? []).map((level) => ({
|
|
1112
|
-
title: String(level.name ?? ""),
|
|
1113
|
-
value: String(level._id ?? ""),
|
|
1114
|
-
}));
|
|
1115
|
-
});
|
|
1116
|
-
|
|
1117
|
-
const unitOptions = computed(() => {
|
|
1118
|
-
const block = occupiedBlocks.value.find(
|
|
1119
|
-
(item) => String(item._id ?? "") === form.block
|
|
1120
|
-
);
|
|
1121
|
-
const level = (block?.levels ?? []).find(
|
|
1122
|
-
(item) => String(item._id ?? "") === form.level
|
|
1123
|
-
);
|
|
1124
|
-
return (level?.units ?? []).map((unit) => ({
|
|
1125
|
-
title: String(unit.name ?? ""),
|
|
1126
|
-
value: String(unit._id ?? ""),
|
|
1127
|
-
}));
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
// Only the residents the server will actually accept - see `UnitPerson.user`.
|
|
1131
|
-
// Offering the rest produced "The linked HID identity subject is not active at
|
|
1132
|
-
// this site." at the very end of enrollment, after a user had already been
|
|
1133
|
-
// written to the reader.
|
|
1134
|
-
const enrollableUnitPeople = computed(() =>
|
|
1135
|
-
unitPeople.value.filter((person) => Boolean(person.user))
|
|
1136
|
-
);
|
|
1137
|
-
|
|
1138
|
-
const unenrollableUnitPeopleCount = computed(
|
|
1139
|
-
() => unitPeople.value.length - enrollableUnitPeople.value.length
|
|
1140
|
-
);
|
|
1141
|
-
|
|
1142
|
-
// `_id` here is `site.people._id`, which is exactly the `person` that
|
|
1143
|
-
// `createIdentity` maps a HID user to.
|
|
1144
|
-
const subjectOptions = computed(() =>
|
|
1145
|
-
enrollableUnitPeople.value.map((person) => ({
|
|
1146
|
-
title: String(person.name ?? "Resident"),
|
|
1147
|
-
value: String(person._id ?? ""),
|
|
1148
|
-
}))
|
|
1149
|
-
);
|
|
1150
|
-
|
|
1151
|
-
// Says why somebody the operator can see in People Management is missing here.
|
|
1152
|
-
// Without it the unit just looks empty and the screen looks broken.
|
|
1153
|
-
const unitResidentNote = computed(() => {
|
|
1154
|
-
if (
|
|
1155
|
-
!form.unit ||
|
|
1156
|
-
loadingUnitPeople.value ||
|
|
1157
|
-
!unenrollableUnitPeopleCount.value
|
|
1158
|
-
)
|
|
1159
|
-
return "";
|
|
1160
|
-
const count = unenrollableUnitPeopleCount.value;
|
|
1161
|
-
const subject = count === 1 ? "1 resident" : `${count} residents`;
|
|
1162
|
-
const verb = count === 1 ? "is" : "are";
|
|
1163
|
-
return `${subject} in this unit ${verb} not listed: HID enrollment needs a resident app account, and ${
|
|
1164
|
-
count === 1 ? "that person has" : "they have"
|
|
1165
|
-
} none yet.`;
|
|
1166
|
-
});
|
|
1167
|
-
|
|
1168
|
-
/* ── THE SUBJECT TYPE ──────────────────────────────────────────────────── */
|
|
1169
|
-
|
|
1170
|
-
const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
|
|
1171
|
-
|
|
1172
|
-
const isResidentSubject = computed(() => form.subjectCategory === "resident");
|
|
1173
|
-
|
|
1174
|
-
/* What an EDIT shows in place of the control, since the type cannot change.
|
|
1175
|
-
Looked up in the util, not in the switch's own options: records carry
|
|
1176
|
-
`service_provider` too, and the options list would have called those
|
|
1177
|
-
"Resident". */
|
|
1178
|
-
const subjectCategoryLabel = computed(() =>
|
|
1179
|
-
labelForSubjectCategory(form.subjectCategory)
|
|
1180
|
-
);
|
|
1181
|
-
|
|
1182
|
-
/* ── STAFF CANDIDATES ─────────────────────────────────────────────────────
|
|
1183
|
-
*
|
|
1184
|
-
* Read from `GET /api/members` — the SAME endpoint, with the same filters, that
|
|
1185
|
-
* the Members menu reads. That is deliberate and was the fix for a real bug: the
|
|
1186
|
-
* first version used the HID permission-candidate endpoint, which applies NO
|
|
1187
|
-
* `type` filter, so it offered every active members row at the site including
|
|
1188
|
-
* service providers and residents. A picker that disagrees with the Members menu
|
|
1189
|
-
* is a picker the operator cannot trust.
|
|
1190
|
-
*
|
|
1191
|
-
* `type` is the running app, the same way `HidQrCodeConfiguration` reads it, so
|
|
1192
|
-
* the property app lists its own staff and the security app lists its own. The
|
|
1193
|
-
* members endpoint expands legacy spellings of a type itself (`appTypeQuery`).
|
|
1194
|
-
*
|
|
1195
|
-
* Service providers get their own option next; they are not silently folded in
|
|
1196
|
-
* here.
|
|
1197
|
-
*/
|
|
1198
|
-
/*
|
|
1199
|
-
* TEN, to share the Members menu's cache key.
|
|
1200
|
-
*
|
|
1201
|
-
* `member.repo.ts` caches each page under a key that INCLUDES `limit`, so a
|
|
1202
|
-
* different limit is a different cache entry with its own 15-minute life. This
|
|
1203
|
-
* picker asked for 50 and got its own rarely-touched slot, which went stale and
|
|
1204
|
-
* showed six members where the Members menu showed nine - the same query, the
|
|
1205
|
-
* same instant, two answers. Asking for the menu's own page size means the two
|
|
1206
|
-
* read the same entry and cannot disagree.
|
|
1207
|
-
*/
|
|
1208
|
-
const MEMBER_PAGE_LIMIT = 10;
|
|
1209
|
-
|
|
1210
|
-
type MemberCandidate = { subjectId: string; name: string; subtitle: string };
|
|
1211
|
-
|
|
1212
|
-
const memberCandidates = ref<MemberCandidate[]>([]);
|
|
1213
|
-
const loadingMembers = ref(false);
|
|
1214
|
-
const loadingMoreMembers = ref(false);
|
|
1215
|
-
const membersLoaded = ref(false);
|
|
1216
|
-
/** The last page successfully loaded; 0 before the first. */
|
|
1217
|
-
const memberPage = ref(0);
|
|
1218
|
-
/** Rows fetched so far, INCLUDING those hidden for having no account. */
|
|
1219
|
-
const memberRowsLoaded = ref(0);
|
|
1220
|
-
/** What the server says matches the query, across every page. */
|
|
1221
|
-
const memberTotalRows = ref(0);
|
|
1222
|
-
/* Active staff dropped for having no app account. Counted here rather than
|
|
1223
|
-
* asked of the server, because this endpoint returns the `user` field and so
|
|
1224
|
-
* the answer is already in hand - see `hiddenMemberNote`. */
|
|
1225
|
-
const hiddenMemberCount = ref(0);
|
|
1226
|
-
|
|
1227
|
-
const hasMoreMembers = computed(
|
|
1228
|
-
() => memberRowsLoaded.value < memberTotalRows.value
|
|
1229
|
-
);
|
|
1230
|
-
|
|
1231
|
-
/*
|
|
1232
|
-
* Bumped on every request, so a response that is no longer the current one is
|
|
1233
|
-
* dropped. Scroll-loading makes this matter: a page still in flight when the
|
|
1234
|
-
* dialog reopens, or when the type is switched away and back, would otherwise
|
|
1235
|
-
* append onto a list that has since been replaced. Same guard, and the same
|
|
1236
|
-
* reason, as `cardRequestSeq` in `EntryPassInformation.vue`.
|
|
1237
|
-
*/
|
|
1238
|
-
let memberRequestSeq = 0;
|
|
1239
|
-
|
|
1240
|
-
const memberOptions = computed(() =>
|
|
1241
|
-
memberCandidates.value.map((candidate) => ({
|
|
1242
|
-
// Role in the title as well as the mirror below: two people can share a name,
|
|
1243
|
-
// and the popover is where they have to be told apart.
|
|
1244
|
-
title: candidate.subtitle
|
|
1245
|
-
? `${candidate.name} — ${candidate.subtitle}`
|
|
1246
|
-
: candidate.name,
|
|
1247
|
-
value: candidate.subjectId,
|
|
1248
|
-
}))
|
|
1249
|
-
);
|
|
1250
|
-
|
|
1251
|
-
const memberPlaceholder = computed(() => {
|
|
1252
|
-
if (loadingMembers.value) return "Loading staff...";
|
|
1253
|
-
if (!membersLoaded.value) return "Select a member";
|
|
1254
|
-
if (memberOptions.value.length) return "Select a member";
|
|
1255
|
-
return hiddenMemberCount.value
|
|
1256
|
-
? "No staff here can be enrolled yet"
|
|
1257
|
-
: "No staff at this site";
|
|
1258
|
-
});
|
|
1259
|
-
|
|
1260
|
-
/* The same courtesy the resident cascade pays: say why somebody the operator
|
|
1261
|
-
* can see in the Members menu is not in this list. */
|
|
1262
|
-
const hiddenMemberNote = computed(() => {
|
|
1263
|
-
// Only once everything is loaded. Half a list gives half a count, and
|
|
1264
|
-
// "3 members at this site" would then be a number about this PAGE wearing the
|
|
1265
|
-
// word "site" - the range note below carries the partial truth instead.
|
|
1266
|
-
if (loadingMembers.value || hasMoreMembers.value) return "";
|
|
1267
|
-
return hiddenSubjectNote(hiddenMemberCount.value, "member");
|
|
1268
|
-
});
|
|
1269
|
-
|
|
1270
|
-
/* What is loaded against what exists - and the warning that the picker's own
|
|
1271
|
-
search only ever sees what has been fetched. */
|
|
1272
|
-
const memberRangeNote = computed(() =>
|
|
1273
|
-
hasMoreMembers.value
|
|
1274
|
-
? `Showing ${memberRowsLoaded.value} of ${memberTotalRows.value}. Search looks only at these.`
|
|
1275
|
-
: ""
|
|
1276
|
-
);
|
|
1277
|
-
|
|
1278
|
-
/** Back to "nothing loaded", so the next open starts from page one. */
|
|
1279
|
-
function resetMemberCandidates() {
|
|
1280
|
-
// Anything still in flight belongs to the list being thrown away.
|
|
1281
|
-
memberRequestSeq += 1;
|
|
1282
|
-
memberCandidates.value = [];
|
|
1283
|
-
hiddenMemberCount.value = 0;
|
|
1284
|
-
memberPage.value = 0;
|
|
1285
|
-
memberRowsLoaded.value = 0;
|
|
1286
|
-
memberTotalRows.value = 0;
|
|
1287
|
-
membersLoaded.value = false;
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
/**
|
|
1291
|
-
* One page of this site's active staff.
|
|
1292
|
-
*
|
|
1293
|
-
* Paged rather than fetched whole: a page of ten is what the Members menu asks
|
|
1294
|
-
* for, and sharing that page size shares its cache entry. `append` is what
|
|
1295
|
-
* separates the first load from Load more - the first replaces, the rest add.
|
|
1296
|
-
*
|
|
1297
|
-
* NEVER THROWS. The list is left as it is and the placeholder says so, because
|
|
1298
|
-
* choosing "Member" must not break the dialog.
|
|
1299
|
-
*/
|
|
1300
|
-
async function fetchMemberPage(page: number, append: boolean) {
|
|
1301
|
-
const memberType = String(runtimeConfig.public.APP ?? "").trim();
|
|
1302
|
-
// "App" is the nuxt config's own placeholder default. Without a real app there
|
|
1303
|
-
// is no member type to ask for, and a typeless query returns nothing anyway.
|
|
1304
|
-
if (!props.site || !props.org || !memberType || memberType === "App") {
|
|
1305
|
-
resetMemberCandidates();
|
|
1306
|
-
membersLoaded.value = true;
|
|
1307
|
-
return;
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
const seq = (memberRequestSeq += 1);
|
|
1311
|
-
const busy = append ? loadingMoreMembers : loadingMembers;
|
|
1312
|
-
busy.value = true;
|
|
1313
|
-
try {
|
|
1314
|
-
const response = await getAllMembers({
|
|
1315
|
-
org: props.org,
|
|
1316
|
-
siteId: props.site,
|
|
1317
|
-
type: memberType,
|
|
1318
|
-
status: "active",
|
|
1319
|
-
page,
|
|
1320
|
-
limit: MEMBER_PAGE_LIMIT,
|
|
1321
|
-
});
|
|
1322
|
-
if (seq !== memberRequestSeq) return;
|
|
1323
|
-
const parsed = readMemberPage(response);
|
|
1324
|
-
const enrollable = parsed.items.filter(memberHasAccount);
|
|
1325
|
-
const candidates = enrollable.map(memberCandidateOf);
|
|
1326
|
-
|
|
1327
|
-
memberCandidates.value = append
|
|
1328
|
-
? [...memberCandidates.value, ...candidates]
|
|
1329
|
-
: candidates;
|
|
1330
|
-
const hiddenOnPage = parsed.items.length - enrollable.length;
|
|
1331
|
-
hiddenMemberCount.value = append
|
|
1332
|
-
? hiddenMemberCount.value + hiddenOnPage
|
|
1333
|
-
: hiddenOnPage;
|
|
1334
|
-
memberRowsLoaded.value = append
|
|
1335
|
-
? memberRowsLoaded.value + parsed.items.length
|
|
1336
|
-
: parsed.items.length;
|
|
1337
|
-
memberTotalRows.value = parsed.total;
|
|
1338
|
-
memberPage.value = page;
|
|
1339
|
-
membersLoaded.value = true;
|
|
1340
|
-
} catch (error: unknown) {
|
|
1341
|
-
if (seq !== memberRequestSeq) return;
|
|
1342
|
-
console.error("Unable to load staff for HID enrollment:", error);
|
|
1343
|
-
// An "append" that failed keeps what is already on screen; a first load has
|
|
1344
|
-
// nothing to keep. Either way the dialog stays usable.
|
|
1345
|
-
if (!append) resetMemberCandidates();
|
|
1346
|
-
membersLoaded.value = true;
|
|
1347
|
-
} finally {
|
|
1348
|
-
// Always its OWN flag, superseded or not: a request that leaves its spinner
|
|
1349
|
-
// set strands the list behind a wait that will never end.
|
|
1350
|
-
busy.value = false;
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
/** The first page, replacing whatever was there. */
|
|
1355
|
-
function loadMemberCandidates() {
|
|
1356
|
-
return fetchMemberPage(1, false);
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
/** The next page, added to the list. */
|
|
1360
|
-
function loadMoreMembers() {
|
|
1361
|
-
if (loadingMembers.value || loadingMoreMembers.value || !hasMoreMembers.value)
|
|
1362
|
-
return;
|
|
1363
|
-
return fetchMemberPage(memberPage.value + 1, true);
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
/**
|
|
1367
|
-
* Switching type clears the chosen subject, and nothing else.
|
|
1368
|
-
*
|
|
1369
|
-
* The id spaces do not overlap - a `site.people._id` submitted as a `member`
|
|
1370
|
-
* would be refused by the server, and worse, a stale one could name a real
|
|
1371
|
-
* member - so it is cleared rather than carried. Name and Role mirror the
|
|
1372
|
-
* choice, so they clear with it.
|
|
1373
|
-
*/
|
|
1374
|
-
async function onSubjectCategoryChanged() {
|
|
1375
|
-
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory;
|
|
1376
|
-
form.subjectId = "";
|
|
1377
|
-
form.subjectRole = "";
|
|
1378
|
-
form.name = "";
|
|
1379
|
-
residentConflict.value = null;
|
|
1380
|
-
|
|
1381
|
-
if (!isResidentSubject.value && !membersLoaded.value) {
|
|
1382
|
-
await loadMemberCandidates();
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
/* The staff equivalent of `onResidentChanged`. */
|
|
1387
|
-
async function onMemberChanged() {
|
|
1388
|
-
const candidate = memberCandidates.value.find(
|
|
1389
|
-
(item) => String(item.subjectId) === String(form.subjectId)
|
|
1390
|
-
);
|
|
1391
|
-
form.name = String(candidate?.name ?? "");
|
|
1392
|
-
form.subjectRole = String(candidate?.subtitle ?? "");
|
|
1393
|
-
await checkSubjectEnrollment();
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
const blockPlaceholder = computed(() => {
|
|
1397
|
-
if (loadingOccupancy.value) return "Loading...";
|
|
1398
|
-
if (occupancyLoaded.value && !blockOptions.value.length) {
|
|
1399
|
-
return "No occupied units at this site";
|
|
1400
|
-
}
|
|
1401
|
-
return "Select block";
|
|
1402
|
-
});
|
|
1403
|
-
|
|
1404
|
-
const unitResidentPlaceholder = computed(() => {
|
|
1405
|
-
if (!form.unit) return "Select a unit first";
|
|
1406
|
-
if (loadingUnitPeople.value) return "Loading residents...";
|
|
1407
|
-
if (enrollableUnitPeople.value.length) return "Select a resident";
|
|
1408
|
-
// The two empty cases are different problems with different fixes, so they
|
|
1409
|
-
// do not share a sentence: nobody lives here, versus nobody here can be
|
|
1410
|
-
// enrolled yet. `unitResidentNote` carries the detail for the second.
|
|
1411
|
-
return unitPeople.value.length
|
|
1412
|
-
? "No resident here can be enrolled yet"
|
|
1413
|
-
: "No residents in this unit";
|
|
1414
|
-
});
|
|
1415
|
-
|
|
1416
|
-
// Names the HID user that is in the way, so the operator can go and edit it
|
|
1417
|
-
// rather than being told only that something is wrong.
|
|
1418
|
-
const residentConflictMessage = computed(() => {
|
|
1419
|
-
const conflict = residentConflict.value;
|
|
1420
|
-
if (!conflict) return "";
|
|
1421
|
-
const label = [getName(conflict), formatHidUid(conflict.hidUserId)]
|
|
1422
|
-
.filter(Boolean)
|
|
1423
|
-
.join(" · ");
|
|
1424
|
-
return label
|
|
1425
|
-
? `Already enrolled on this reader as ${label}. Edit that HID user instead of enrolling again.`
|
|
1426
|
-
: "Already enrolled on this reader. Edit that HID user instead of enrolling again.";
|
|
1427
|
-
});
|
|
688
|
+
const permissionCandidates = ref<THidPermissionCandidate[]>([]);
|
|
689
|
+
const loadingSubjects = ref(false);
|
|
690
|
+
const subjectCategoryOptions = [
|
|
691
|
+
{ title: "Resident", value: "resident" },
|
|
692
|
+
{ title: "Property management", value: "property_management" },
|
|
693
|
+
{ title: "Service provider", value: "service_provider" },
|
|
694
|
+
];
|
|
695
|
+
const subjectOptions = computed(() => permissionCandidates.value.map((candidate) => ({
|
|
696
|
+
title: candidate.subtitle ? `${candidate.name} · ${candidate.subtitle}` : candidate.name,
|
|
697
|
+
value: candidate.subjectId,
|
|
698
|
+
})));
|
|
1428
699
|
|
|
1429
700
|
const cardForm = reactive({
|
|
1430
701
|
cardType: "pacs" as "pacs" | "csn",
|
|
@@ -1434,37 +705,23 @@ const cardForm = reactive({
|
|
|
1434
705
|
});
|
|
1435
706
|
|
|
1436
707
|
const cardReader = computed(() => {
|
|
1437
|
-
const readerId = String(
|
|
1438
|
-
|
|
1439
|
-
);
|
|
1440
|
-
return (
|
|
1441
|
-
readers.value.find((reader) => String(reader._id) === readerId) || null
|
|
1442
|
-
);
|
|
708
|
+
const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
|
|
709
|
+
return readers.value.find((reader) => String(reader._id) === readerId) || null;
|
|
1443
710
|
});
|
|
1444
|
-
const cardProfileConfigured = computed(() =>
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
);
|
|
1449
|
-
const hybridCardProfile = computed(
|
|
1450
|
-
() => cardReader.value?.cardReadingMode === "csn_for_mifare"
|
|
1451
|
-
);
|
|
711
|
+
const cardProfileConfigured = computed(() => ["pacs", "csn", "csn_for_mifare"].includes(
|
|
712
|
+
String(cardReader.value?.cardReadingMode || ""),
|
|
713
|
+
));
|
|
714
|
+
const hybridCardProfile = computed(() => cardReader.value?.cardReadingMode === "csn_for_mifare");
|
|
1452
715
|
const hybridCardTypeOptions = [
|
|
1453
716
|
{ title: "MIFARE card (CSN)", value: "csn" },
|
|
1454
717
|
{ title: "Other card (PACS)", value: "pacs" },
|
|
1455
718
|
];
|
|
1456
719
|
const cardProfileLabel = computed(() => {
|
|
1457
720
|
const mode = String(cardReader.value?.cardReadingMode || "unconfigured");
|
|
1458
|
-
if (mode === "pacs")
|
|
1459
|
-
|
|
1460
|
-
if (mode === "csn")
|
|
1461
|
-
return `CSN · ${(
|
|
1462
|
-
cardReader.value?.csnByteOrder || "msb"
|
|
1463
|
-
).toUpperCase()} first`;
|
|
721
|
+
if (mode === "pacs") return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
|
|
722
|
+
if (mode === "csn") return `CSN · ${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first`;
|
|
1464
723
|
if (mode === "csn_for_mifare") {
|
|
1465
|
-
return `PACS for non-MIFARE cards · CSN for MIFARE (${(
|
|
1466
|
-
cardReader.value?.csnByteOrder || "msb"
|
|
1467
|
-
).toUpperCase()} first)`;
|
|
724
|
+
return `PACS for non-MIFARE cards · CSN for MIFARE (${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first)`;
|
|
1468
725
|
}
|
|
1469
726
|
return "Configure the card data mode on the HID Reader screen before assigning a card.";
|
|
1470
727
|
});
|
|
@@ -1481,8 +738,6 @@ const userHeaders = [
|
|
|
1481
738
|
{ title: "Name", value: "name", sortable: false },
|
|
1482
739
|
{ title: "Facial Data", value: "facialData", sortable: false },
|
|
1483
740
|
{ title: "UID", value: "hidUserId", sortable: false },
|
|
1484
|
-
// Reads in the same order the dialog asks for them: UID then registration.
|
|
1485
|
-
{ title: "Registration No.", value: "registration", sortable: false },
|
|
1486
741
|
{ title: "Status", value: "status", sortable: false },
|
|
1487
742
|
{ title: "", value: "action-table", sortable: false },
|
|
1488
743
|
];
|
|
@@ -1494,12 +749,8 @@ const cardHeaders = [
|
|
|
1494
749
|
{ title: "", value: "action-table", sortable: false },
|
|
1495
750
|
];
|
|
1496
751
|
|
|
1497
|
-
const tableTitle = computed(() =>
|
|
1498
|
-
|
|
1499
|
-
);
|
|
1500
|
-
const tableHeaders = computed(() =>
|
|
1501
|
-
props.cardManagement ? cardHeaders : userHeaders
|
|
1502
|
-
);
|
|
752
|
+
const tableTitle = computed(() => props.cardManagement ? "HID Cards" : "HID Users");
|
|
753
|
+
const tableHeaders = computed(() => props.cardManagement ? cardHeaders : userHeaders);
|
|
1503
754
|
|
|
1504
755
|
/**
|
|
1505
756
|
* The list is paged on the SERVER, so the shared pager only moves the cursor -
|
|
@@ -1516,45 +767,14 @@ function reloadUsersFromFirstPage() {
|
|
|
1516
767
|
loadUsers();
|
|
1517
768
|
}
|
|
1518
769
|
|
|
1519
|
-
/**
|
|
1520
|
-
* Typing is the search.
|
|
1521
|
-
*
|
|
1522
|
-
* The field used to reload on `@keyup.enter` and on nothing else, so a screen
|
|
1523
|
-
* that looks like every other search box did nothing at all unless you guessed
|
|
1524
|
-
* that Enter was required - no request left the browser.
|
|
1525
|
-
*
|
|
1526
|
-
* Debounced, because each reload is a login/load/logout round trip to a reader
|
|
1527
|
-
* over a VPN, not a database query. Enter still works and skips the wait.
|
|
1528
|
-
*/
|
|
1529
|
-
const SEARCH_DEBOUNCE_MS = 400;
|
|
1530
|
-
let searchDebounce: ReturnType<typeof setTimeout> | undefined;
|
|
1531
|
-
|
|
1532
|
-
function onSearchInput() {
|
|
1533
|
-
clearTimeout(searchDebounce);
|
|
1534
|
-
searchDebounce = setTimeout(reloadUsersFromFirstPage, SEARCH_DEBOUNCE_MS);
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
function onSearchSubmit() {
|
|
1538
|
-
clearTimeout(searchDebounce);
|
|
1539
|
-
reloadUsersFromFirstPage();
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
watch(search, onSearchInput);
|
|
1543
|
-
onBeforeUnmount(() => clearTimeout(searchDebounce));
|
|
1544
|
-
|
|
1545
770
|
const readerOptions = computed(() =>
|
|
1546
771
|
readers.value.map((reader) => ({
|
|
1547
|
-
title: `${reader.name || reader.deviceId || reader._id} — ${
|
|
1548
|
-
reader.portalName || "Portal not configured"
|
|
1549
|
-
}`,
|
|
772
|
+
title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
|
|
1550
773
|
value: reader._id,
|
|
1551
774
|
}))
|
|
1552
775
|
);
|
|
1553
|
-
const selectedReader = computed(
|
|
1554
|
-
() =>
|
|
1555
|
-
readers.value.find(
|
|
1556
|
-
(reader) => reader._id === (selectedReaderId.value || form.reader)
|
|
1557
|
-
) || null
|
|
776
|
+
const selectedReader = computed(() =>
|
|
777
|
+
readers.value.find((reader) => reader._id === (selectedReaderId.value || form.reader)) || null,
|
|
1558
778
|
);
|
|
1559
779
|
|
|
1560
780
|
const pageRange = computed(() => {
|
|
@@ -1581,28 +801,15 @@ async function loadReaders() {
|
|
|
1581
801
|
const response = await getReaders({ site: props.site, page: 1, limit: 100 });
|
|
1582
802
|
const responseRecord = toRecord(response);
|
|
1583
803
|
const responseData = toRecord(responseRecord.data);
|
|
1584
|
-
const items =
|
|
1585
|
-
responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
|
|
804
|
+
const items = responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
|
|
1586
805
|
readers.value = (Array.isArray(items) ? items : [])
|
|
1587
806
|
.map(toHidReader)
|
|
1588
807
|
.filter((reader): reader is HidReader => reader !== null);
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
an id the site does not own. */
|
|
1593
|
-
selectedReaderId.value = resolveForSite(props.site, readers.value);
|
|
808
|
+
if (!selectedReaderId.value && readers.value.length) {
|
|
809
|
+
selectedReaderId.value = readers.value[0]._id;
|
|
810
|
+
}
|
|
1594
811
|
}
|
|
1595
812
|
|
|
1596
|
-
/**
|
|
1597
|
-
* Discards a load whose answer arrives after a newer one started.
|
|
1598
|
-
*
|
|
1599
|
-
* Each reload is a slow round trip to a reader over a VPN, and typing now fires
|
|
1600
|
-
* them, so "abc" can have three in flight at once. Without this the reply to
|
|
1601
|
-
* "a" can land last and repopulate the table with the wrong rows. Same monotonic
|
|
1602
|
-
* counter `HidAccessLogDashboard` uses.
|
|
1603
|
-
*/
|
|
1604
|
-
const loadUsersSequence = ref(0);
|
|
1605
|
-
|
|
1606
813
|
async function loadUsers() {
|
|
1607
814
|
if (!selectedReaderId.value) {
|
|
1608
815
|
users.value = [];
|
|
@@ -1612,83 +819,32 @@ async function loadUsers() {
|
|
|
1612
819
|
return;
|
|
1613
820
|
}
|
|
1614
821
|
|
|
1615
|
-
const sequence = loadUsersSequence.value + 1;
|
|
1616
|
-
loadUsersSequence.value = sequence;
|
|
1617
822
|
loading.value = true;
|
|
1618
823
|
try {
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
// with each one. This screen enrolls and edits real people; visitors are
|
|
1626
|
-
// owned by the visitor module.
|
|
1627
|
-
let clientFiltersVisitors = false;
|
|
1628
|
-
let response: unknown;
|
|
1629
|
-
try {
|
|
1630
|
-
response = await getReaderUsers(selectedReaderId.value, {
|
|
1631
|
-
page: page.value,
|
|
1632
|
-
limit: limit.value,
|
|
1633
|
-
search: search.value.trim(),
|
|
1634
|
-
status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
|
|
1635
|
-
userType: "user",
|
|
1636
|
-
});
|
|
1637
|
-
} catch (error: unknown) {
|
|
1638
|
-
if (!isUnsupportedUserTypeFilter(error)) throw error;
|
|
1639
|
-
|
|
1640
|
-
// A deployed API can lag behind this shared layer. Keep the roster
|
|
1641
|
-
// correct on one that has not added `userType` yet, the same way
|
|
1642
|
-
// HidReaderUserRoster does, rather than failing the whole screen.
|
|
1643
|
-
clientFiltersVisitors = true;
|
|
1644
|
-
response = await getReaderUsers(selectedReaderId.value, {
|
|
1645
|
-
page: 1,
|
|
1646
|
-
limit: 100,
|
|
1647
|
-
search: search.value.trim(),
|
|
1648
|
-
status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
|
|
1649
|
-
});
|
|
1650
|
-
}
|
|
824
|
+
const response = await getReaderUsers(selectedReaderId.value, {
|
|
825
|
+
page: page.value,
|
|
826
|
+
limit: limit.value,
|
|
827
|
+
search: search.value.trim(),
|
|
828
|
+
status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
|
|
829
|
+
});
|
|
1651
830
|
const responseRecord = toRecord(response);
|
|
1652
831
|
const responseData = toRecord(responseRecord.data);
|
|
1653
832
|
const responseItems = responseRecord.items ?? responseData.items ?? [];
|
|
1654
|
-
const
|
|
1655
|
-
toHidUser
|
|
1656
|
-
);
|
|
1657
|
-
const matchingItems = clientFiltersVisitors
|
|
1658
|
-
? allItems.filter((user) => !isVisitorUser(user))
|
|
1659
|
-
: allItems;
|
|
1660
|
-
const pageStart = (page.value - 1) * limit.value;
|
|
1661
|
-
const pageItems = clientFiltersVisitors
|
|
1662
|
-
? matchingItems.slice(pageStart, pageStart + limit.value)
|
|
1663
|
-
: matchingItems;
|
|
1664
|
-
if (sequence !== loadUsersSequence.value) return;
|
|
833
|
+
const pageItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
|
|
1665
834
|
administratorUserIds.value = new Set(
|
|
1666
835
|
pageItems
|
|
1667
836
|
.filter((user) => user.isAdministrator === true)
|
|
1668
837
|
.map((user) => toHidNumericId(user.hidUserId))
|
|
1669
|
-
.filter((userId: number | undefined): userId is number =>
|
|
1670
|
-
Boolean(userId)
|
|
1671
|
-
)
|
|
838
|
+
.filter((userId: number | undefined): userId is number => Boolean(userId)),
|
|
1672
839
|
);
|
|
1673
840
|
users.value = pageItems;
|
|
1674
|
-
total.value =
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
responseRecord.total ?? responseData.total ?? users.value.length
|
|
1678
|
-
);
|
|
1679
|
-
pages.value = clientFiltersVisitors
|
|
1680
|
-
? Math.max(1, Math.ceil(total.value / limit.value))
|
|
1681
|
-
: Number(responseRecord.pages ?? responseData.pages ?? 1);
|
|
1682
|
-
// The server's `pageRange` counts the unfiltered page, so it cannot be
|
|
1683
|
-
// trusted once this screen has done the filtering itself.
|
|
1684
|
-
serverPageRange.value = clientFiltersVisitors
|
|
1685
|
-
? ""
|
|
1686
|
-
: String(responseRecord.pageRange ?? responseData.pageRange ?? "");
|
|
841
|
+
total.value = Number(responseRecord.total ?? responseData.total ?? users.value.length);
|
|
842
|
+
pages.value = Number(responseRecord.pages ?? responseData.pages ?? 1);
|
|
843
|
+
serverPageRange.value = String(responseRecord.pageRange ?? responseData.pageRange ?? "");
|
|
1687
844
|
if (props.cardManagement) {
|
|
1688
845
|
await loadVisibleUserCards(users.value);
|
|
1689
846
|
}
|
|
1690
847
|
} catch (error) {
|
|
1691
|
-
if (sequence !== loadUsersSequence.value) return;
|
|
1692
848
|
console.error("Unable to load HID reader users:", error);
|
|
1693
849
|
showToast("Unable to load HID users from reader.", "error");
|
|
1694
850
|
users.value = [];
|
|
@@ -1699,12 +855,11 @@ async function loadUsers() {
|
|
|
1699
855
|
pages.value = 1;
|
|
1700
856
|
serverPageRange.value = "";
|
|
1701
857
|
} finally {
|
|
1702
|
-
|
|
1703
|
-
// clear it while that newer one is still running.
|
|
1704
|
-
if (sequence === loadUsersSequence.value) loading.value = false;
|
|
858
|
+
loading.value = false;
|
|
1705
859
|
}
|
|
1706
860
|
}
|
|
1707
861
|
|
|
862
|
+
|
|
1708
863
|
function resetForm() {
|
|
1709
864
|
form.reader = selectedReaderId.value || readers.value[0]?._id || "";
|
|
1710
865
|
form.block = "";
|
|
@@ -1715,10 +870,6 @@ function resetForm() {
|
|
|
1715
870
|
form.registration = "";
|
|
1716
871
|
form.isAdministrator = false;
|
|
1717
872
|
form.accessPin = "";
|
|
1718
|
-
form.password = "";
|
|
1719
|
-
passwordSet.value = false;
|
|
1720
|
-
showPassword.value = false;
|
|
1721
|
-
removePasswordRequested.value = false;
|
|
1722
873
|
pinEnrolled.value = false;
|
|
1723
874
|
showPin.value = false;
|
|
1724
875
|
removePinRequested.value = false;
|
|
@@ -1726,201 +877,34 @@ function resetForm() {
|
|
|
1726
877
|
selectedPhotoFile.value = null;
|
|
1727
878
|
removePhotoRequested.value = false;
|
|
1728
879
|
facialEnrolled.value = false;
|
|
1729
|
-
|
|
1730
|
-
// a shift of guards should not re-pick Member for each one.
|
|
1731
|
-
form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
|
|
880
|
+
form.subjectCategory = "resident";
|
|
1732
881
|
form.subjectId = "";
|
|
1733
|
-
form.subjectRole = "";
|
|
1734
|
-
residentConflict.value = null;
|
|
1735
|
-
checkingResident.value = false;
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
/** Surface what the API actually said - a bare "unable to load" hides the
|
|
1739
|
-
* difference between a permission refusal, a bad id and a server fault. */
|
|
1740
|
-
function apiReason(error: unknown) {
|
|
1741
|
-
const record = toRecord(error);
|
|
1742
|
-
const data = toRecord(record.data);
|
|
1743
|
-
return (
|
|
1744
|
-
toText(data.message) ||
|
|
1745
|
-
toText(record.message) ||
|
|
1746
|
-
toText(record.statusMessage)
|
|
1747
|
-
);
|
|
1748
882
|
}
|
|
1749
883
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
if (!
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
const response = toRecord(
|
|
1756
|
-
await getOccupiedStructure(props.site, {
|
|
1757
|
-
type: OCCUPANT_TYPES,
|
|
1758
|
-
status: "active",
|
|
1759
|
-
})
|
|
1760
|
-
);
|
|
1761
|
-
const data = toRecord(response.data);
|
|
1762
|
-
const blocks = response.blocks ?? data.blocks ?? [];
|
|
1763
|
-
occupiedBlocks.value = (
|
|
1764
|
-
Array.isArray(blocks) ? blocks : []
|
|
1765
|
-
) as OccupiedBlock[];
|
|
1766
|
-
occupancyLoaded.value = true;
|
|
1767
|
-
} catch (error: unknown) {
|
|
1768
|
-
console.error("Unable to load occupied units:", error);
|
|
1769
|
-
occupiedBlocks.value = [];
|
|
1770
|
-
const reason = apiReason(error);
|
|
1771
|
-
showToast(
|
|
1772
|
-
reason
|
|
1773
|
-
? `Unable to load units: ${reason}`
|
|
1774
|
-
: "Unable to load units for this site.",
|
|
1775
|
-
"error"
|
|
1776
|
-
);
|
|
1777
|
-
} finally {
|
|
1778
|
-
loadingOccupancy.value = false;
|
|
884
|
+
async function loadSubjectCandidates() {
|
|
885
|
+
const readerId = form.reader || selectedReaderId.value;
|
|
886
|
+
if (!readerId || !orgId.value) {
|
|
887
|
+
permissionCandidates.value = [];
|
|
888
|
+
return;
|
|
1779
889
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
async function loadUnitPeople() {
|
|
1783
|
-
unitPeople.value = [];
|
|
1784
|
-
if (!form.unit) return;
|
|
1785
|
-
loadingUnitPeople.value = true;
|
|
890
|
+
loadingSubjects.value = true;
|
|
1786
891
|
try {
|
|
1787
|
-
const response = await
|
|
1788
|
-
|
|
1789
|
-
|
|
892
|
+
const response = await getPermissionCandidates(props.site, {
|
|
893
|
+
orgId: orgId.value,
|
|
894
|
+
readerId,
|
|
895
|
+
category: form.subjectCategory,
|
|
896
|
+
page: 1,
|
|
897
|
+
limit: 500,
|
|
1790
898
|
});
|
|
1791
|
-
|
|
1792
|
-
const rows = Array.isArray(response)
|
|
1793
|
-
? response
|
|
1794
|
-
: record.data ?? record.items ?? [];
|
|
1795
|
-
unitPeople.value = (Array.isArray(rows) ? rows : []) as UnitPerson[];
|
|
1796
|
-
} catch (error: unknown) {
|
|
1797
|
-
console.error("Unable to load residents for the unit:", error);
|
|
1798
|
-
const reason = apiReason(error);
|
|
1799
|
-
showToast(
|
|
1800
|
-
reason
|
|
1801
|
-
? `Unable to load residents: ${reason}`
|
|
1802
|
-
: "Unable to load residents for this unit.",
|
|
1803
|
-
"error"
|
|
1804
|
-
);
|
|
899
|
+
permissionCandidates.value = response.items ?? response.data?.items ?? [];
|
|
1805
900
|
} finally {
|
|
1806
|
-
|
|
901
|
+
loadingSubjects.value = false;
|
|
1807
902
|
}
|
|
1808
903
|
}
|
|
1809
904
|
|
|
1810
|
-
function
|
|
1811
|
-
form.level = "";
|
|
1812
|
-
form.unit = "";
|
|
1813
|
-
unitPeople.value = [];
|
|
1814
|
-
clearResidentSelection();
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
// Wrapper `@click`/`@focusin` on the Block select: load once, on first touch.
|
|
1818
|
-
async function onBlockOpened() {
|
|
1819
|
-
await ensureOccupancy();
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
function onLevelChanged() {
|
|
1823
|
-
form.unit = "";
|
|
1824
|
-
unitPeople.value = [];
|
|
1825
|
-
clearResidentSelection();
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
async function onUnitChanged() {
|
|
1829
|
-
clearResidentSelection();
|
|
1830
|
-
await loadUnitPeople();
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
function clearResidentSelection() {
|
|
905
|
+
async function onSubjectCategoryChanged() {
|
|
1834
906
|
form.subjectId = "";
|
|
1835
|
-
|
|
1836
|
-
// Name only ever mirrors a chosen resident, so it clears with the choice.
|
|
1837
|
-
if (!selectedUser.value) form.name = "";
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
// The auto-fill the whole cascade exists for.
|
|
1841
|
-
async function onResidentChanged() {
|
|
1842
|
-
const person = unitPeople.value.find(
|
|
1843
|
-
(item) => String(item._id) === String(form.subjectId)
|
|
1844
|
-
);
|
|
1845
|
-
form.name = String(person?.name ?? "");
|
|
1846
|
-
await checkSubjectEnrollment();
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
/**
|
|
1850
|
-
* The HID user this resident already holds on the selected reader, or null.
|
|
1851
|
-
*
|
|
1852
|
-
* `subject`, not `person`. The same resident is stored under different ids
|
|
1853
|
-
* depending on which screen wrote the row - the permissions screen keys on
|
|
1854
|
-
* their user account, this screen on the record the picker offered - so a
|
|
1855
|
-
* filter on one field alone reports "not enrolled" for somebody who plainly is.
|
|
1856
|
-
* `subject` follows the record to its account and matches every link field,
|
|
1857
|
-
* which is exactly what the server does before it accepts an enrollment.
|
|
1858
|
-
*
|
|
1859
|
-
* Reader-scoped by the endpoint itself.
|
|
1860
|
-
*/
|
|
1861
|
-
async function findResidentIdentity(readerId: string, personId: string) {
|
|
1862
|
-
const response = await getIdentities(readerId, {
|
|
1863
|
-
page: 1,
|
|
1864
|
-
// A resident should hold at most one, but ask for a few: on the edit screen
|
|
1865
|
-
// the first row back can be the very identity being edited, and skipping it
|
|
1866
|
-
// must not hide a real second one behind it.
|
|
1867
|
-
limit: 10,
|
|
1868
|
-
subject: personId,
|
|
1869
|
-
});
|
|
1870
|
-
const responseRecord = toRecord(response);
|
|
1871
|
-
const responseData = toRecord(responseRecord.data);
|
|
1872
|
-
const responseItems =
|
|
1873
|
-
responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
|
|
1874
|
-
const identities = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
1875
|
-
toHidUser
|
|
1876
|
-
);
|
|
1877
|
-
const editingId = String(selectedUser.value?._id ?? "");
|
|
1878
|
-
|
|
1879
|
-
return (
|
|
1880
|
-
identities.find(
|
|
1881
|
-
(identity) => !editingId || String(identity._id ?? "") !== editingId
|
|
1882
|
-
) ?? null
|
|
1883
|
-
);
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
/**
|
|
1887
|
-
* Sets `residentConflict` for the currently selected resident.
|
|
1888
|
-
*
|
|
1889
|
-
* Fails OPEN: if the lookup itself errors the operator is not blocked, because
|
|
1890
|
-
* the server refuses the duplicate anyway. This is a courtesy check that keeps
|
|
1891
|
-
* a doomed enrollment off the physical reader, not the guard itself.
|
|
1892
|
-
*/
|
|
1893
|
-
/**
|
|
1894
|
-
* Is this subject already enrolled on this reader?
|
|
1895
|
-
*
|
|
1896
|
-
* Category-agnostic, since staff can be enrolled too. `findResidentIdentity`
|
|
1897
|
-
* queries the server's `subject` parameter, which is matched against every link
|
|
1898
|
-
* field - `person`, `member`, `serviceProvider`, `user` - so a member needs no
|
|
1899
|
-
* separate lookup. The server refuses a duplicate either way; this only puts the
|
|
1900
|
-
* refusal in front of the operator before the device is touched.
|
|
1901
|
-
*/
|
|
1902
|
-
async function checkSubjectEnrollment() {
|
|
1903
|
-
residentConflict.value = null;
|
|
1904
|
-
const readerId =
|
|
1905
|
-
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
1906
|
-
if (!readerId || !form.subjectId) return null;
|
|
1907
|
-
|
|
1908
|
-
checkingResident.value = true;
|
|
1909
|
-
try {
|
|
1910
|
-
residentConflict.value = await findResidentIdentity(
|
|
1911
|
-
readerId,
|
|
1912
|
-
form.subjectId
|
|
1913
|
-
);
|
|
1914
|
-
return residentConflict.value;
|
|
1915
|
-
} catch (error: unknown) {
|
|
1916
|
-
console.error(
|
|
1917
|
-
"Unable to check whether this resident is already enrolled:",
|
|
1918
|
-
error
|
|
1919
|
-
);
|
|
1920
|
-
return null;
|
|
1921
|
-
} finally {
|
|
1922
|
-
checkingResident.value = false;
|
|
1923
|
-
}
|
|
907
|
+
await loadSubjectCandidates();
|
|
1924
908
|
}
|
|
1925
909
|
|
|
1926
910
|
async function openEnroll() {
|
|
@@ -1932,14 +916,8 @@ async function openEnroll() {
|
|
|
1932
916
|
]);
|
|
1933
917
|
form.hidUserId = hidUserId;
|
|
1934
918
|
form.registration = registration;
|
|
919
|
+
await loadSubjectCandidates();
|
|
1935
920
|
formDialog.value = true;
|
|
1936
|
-
|
|
1937
|
-
// `resetForm` restores the remembered type, so the dialog can open straight
|
|
1938
|
-
// onto Member with an empty list. Loaded after the dialog is up, not before:
|
|
1939
|
-
// the request must not delay it, and the picker carries its own loading
|
|
1940
|
-
// placeholder. Fresh each opening, because staff change between them.
|
|
1941
|
-
resetMemberCandidates();
|
|
1942
|
-
if (!isResidentSubject.value) await loadMemberCandidates();
|
|
1943
921
|
}
|
|
1944
922
|
|
|
1945
923
|
async function openEdit(user: HidUser) {
|
|
@@ -1953,10 +931,6 @@ async function openEdit(user: HidUser) {
|
|
|
1953
931
|
form.registration = user.registration ?? "";
|
|
1954
932
|
form.isAdministrator = hasAdministratorRule(user);
|
|
1955
933
|
form.accessPin = "";
|
|
1956
|
-
form.password = "";
|
|
1957
|
-
showPassword.value = false;
|
|
1958
|
-
removePasswordRequested.value = false;
|
|
1959
|
-
passwordSet.value = Boolean(user.metadata?.passwordSet);
|
|
1960
934
|
pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
|
|
1961
935
|
removePinRequested.value = false;
|
|
1962
936
|
form.photoPreview = "";
|
|
@@ -1964,39 +938,24 @@ async function openEdit(user: HidUser) {
|
|
|
1964
938
|
form.subjectCategory = user.person
|
|
1965
939
|
? "resident"
|
|
1966
940
|
: user.serviceProvider
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
form.subjectId = String(
|
|
1970
|
-
|
|
1971
|
-
);
|
|
1972
|
-
// The resident this user already holds is not a conflict with itself. A real
|
|
1973
|
-
// one only appears if the operator reassigns the row to somebody else.
|
|
1974
|
-
residentConflict.value = null;
|
|
1975
|
-
// The location is not drawn when editing, but it is still carried: these
|
|
1976
|
-
// three form fields are what `saveUser` writes back and what `buildUnitLabel`
|
|
1977
|
-
// resolves into the Unit column, so a save must not blank them.
|
|
1978
|
-
//
|
|
1979
|
-
// `ensureOccupancy` is what lets `buildUnitLabel` turn them back into names.
|
|
1980
|
-
// The unit's residents are loaded too, because the Resident select IS drawn
|
|
1981
|
-
// here and its options come from that list - without it the select shows the
|
|
1982
|
-
// stored id instead of a name.
|
|
1983
|
-
await ensureOccupancy();
|
|
1984
|
-
if (form.unit) await loadUnitPeople();
|
|
941
|
+
? "service_provider"
|
|
942
|
+
: "property_management";
|
|
943
|
+
form.subjectId = String(user.person || user.member || user.serviceProvider || "");
|
|
944
|
+
await loadSubjectCandidates();
|
|
1985
945
|
selectedPhotoFile.value = null;
|
|
1986
946
|
removePhotoRequested.value = false;
|
|
1987
947
|
formDialog.value = true;
|
|
1988
948
|
|
|
1989
949
|
try {
|
|
1990
|
-
const
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
pinEnrolled.value = Boolean(pinStatus?.data?.pinEnrolled);
|
|
1996
|
-
passwordSet.value = Boolean(passwordStatus?.data?.passwordSet);
|
|
950
|
+
const status = await getUserPinStatus(
|
|
951
|
+
form.reader,
|
|
952
|
+
toHidNumericId(user.hidUserId),
|
|
953
|
+
);
|
|
954
|
+
pinEnrolled.value = Boolean(status?.data?.pinEnrolled);
|
|
1997
955
|
} catch {
|
|
1998
956
|
// Preserve the last known status if the reader is temporarily offline.
|
|
1999
957
|
}
|
|
958
|
+
|
|
2000
959
|
}
|
|
2001
960
|
|
|
2002
961
|
function openView(user: HidUser) {
|
|
@@ -2071,53 +1030,16 @@ function onPhotoChange(event: Event) {
|
|
|
2071
1030
|
async function saveUser() {
|
|
2072
1031
|
const readerId =
|
|
2073
1032
|
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
// enrolled, and changing a photo, a PIN or a password on one of those is a
|
|
2077
|
-
// reader-side operation that has nothing to say about who they are. Demanding
|
|
2078
|
-
// a resident there refuses a save for a field the dialog does not even draw.
|
|
2079
|
-
const missing = !readerId
|
|
2080
|
-
? "a reader"
|
|
2081
|
-
: !selectedUser.value && !form.subjectId
|
|
2082
|
-
? isResidentSubject.value
|
|
2083
|
-
? "a resident"
|
|
2084
|
-
: "a member"
|
|
2085
|
-
: !form.name
|
|
2086
|
-
? "a name"
|
|
2087
|
-
: !form.hidUserId
|
|
2088
|
-
? "an ID"
|
|
2089
|
-
: !form.registration
|
|
2090
|
-
? "a registration number"
|
|
2091
|
-
: "";
|
|
2092
|
-
if (missing) {
|
|
2093
|
-
showToast(
|
|
2094
|
-
`This HID user needs ${missing} before it can be saved.`,
|
|
2095
|
-
"error"
|
|
2096
|
-
);
|
|
1033
|
+
if (!readerId || !form.name || !form.hidUserId || !form.registration || !form.subjectId) {
|
|
1034
|
+
showToast("Please complete the required HID user fields.", "error");
|
|
2097
1035
|
return;
|
|
2098
1036
|
}
|
|
2099
1037
|
if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
|
|
2100
|
-
showToast("PIN must contain numbers only.", "error");
|
|
2101
|
-
return;
|
|
2102
|
-
}
|
|
2103
|
-
if (
|
|
2104
|
-
selectedPhotoFile.value &&
|
|
2105
|
-
(!selectedReader.value?.portalId || !selectedReader.value?.portalName)
|
|
2106
|
-
) {
|
|
2107
|
-
showToast(
|
|
2108
|
-
"Reconnect this HID reader and select a portal before enrolling facial recognition.",
|
|
2109
|
-
"error"
|
|
2110
|
-
);
|
|
1038
|
+
showToast("Access PIN must contain numbers only.", "error");
|
|
2111
1039
|
return;
|
|
2112
1040
|
}
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
// somebody else enrolls the same resident, and the reader is only ever
|
|
2116
|
-
// written to below this line. A duplicate caught here costs one request; the
|
|
2117
|
-
// same duplicate caught by the server costs a created HID user and a rollback.
|
|
2118
|
-
const alreadyEnrolled = await checkSubjectEnrollment();
|
|
2119
|
-
if (alreadyEnrolled) {
|
|
2120
|
-
showToast(residentConflictMessage.value, "error");
|
|
1041
|
+
if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
|
|
1042
|
+
showToast("Reconnect this HID reader and select a portal before enrolling facial recognition.", "error");
|
|
2121
1043
|
return;
|
|
2122
1044
|
}
|
|
2123
1045
|
|
|
@@ -2134,37 +1056,22 @@ async function saveUser() {
|
|
|
2134
1056
|
return;
|
|
2135
1057
|
}
|
|
2136
1058
|
|
|
2137
|
-
// The link is sent only when the form actually holds one. Sending
|
|
2138
|
-
// `person: ""` is not "leave it alone", it is "clear it": the server reads
|
|
2139
|
-
// an empty string as a link being removed and then refuses the write for
|
|
2140
|
-
// having no subject at all. Omitting the keys is what makes it keep the
|
|
2141
|
-
// subject the record already has.
|
|
2142
|
-
const subjectLink = form.subjectId
|
|
2143
|
-
? {
|
|
2144
|
-
person: form.subjectCategory === "resident" ? form.subjectId : "",
|
|
2145
|
-
member:
|
|
2146
|
-
form.subjectCategory === "property_management"
|
|
2147
|
-
? form.subjectId
|
|
2148
|
-
: "",
|
|
2149
|
-
serviceProvider:
|
|
2150
|
-
form.subjectCategory === "service_provider" ? form.subjectId : "",
|
|
2151
|
-
type: (form.subjectCategory === "resident"
|
|
2152
|
-
? "resident"
|
|
2153
|
-
: form.subjectCategory === "service_provider"
|
|
2154
|
-
? "contractor"
|
|
2155
|
-
: "staff") as
|
|
2156
|
-
| "resident"
|
|
2157
|
-
| "staff"
|
|
2158
|
-
| "contractor"
|
|
2159
|
-
| "visitor"
|
|
2160
|
-
| "unknown",
|
|
2161
|
-
}
|
|
2162
|
-
: {};
|
|
2163
|
-
|
|
2164
1059
|
const payload = {
|
|
2165
1060
|
hidUserId: String(hidUser.id),
|
|
2166
1061
|
registration: form.registration,
|
|
2167
|
-
|
|
1062
|
+
person: form.subjectCategory === "resident" ? form.subjectId : "",
|
|
1063
|
+
member: form.subjectCategory === "property_management" ? form.subjectId : "",
|
|
1064
|
+
serviceProvider: form.subjectCategory === "service_provider" ? form.subjectId : "",
|
|
1065
|
+
type: (form.subjectCategory === "resident"
|
|
1066
|
+
? "resident"
|
|
1067
|
+
: form.subjectCategory === "service_provider"
|
|
1068
|
+
? "contractor"
|
|
1069
|
+
: "staff") as
|
|
1070
|
+
| "resident"
|
|
1071
|
+
| "staff"
|
|
1072
|
+
| "contractor"
|
|
1073
|
+
| "visitor"
|
|
1074
|
+
| "unknown",
|
|
2168
1075
|
status: "active" as const,
|
|
2169
1076
|
metadata: {
|
|
2170
1077
|
name: form.name,
|
|
@@ -2181,7 +1088,6 @@ async function saveUser() {
|
|
|
2181
1088
|
imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
|
|
2182
1089
|
facialScores: selectedUser.value?.metadata?.facialScores || {},
|
|
2183
1090
|
pinEnrolled: pinEnrolled.value,
|
|
2184
|
-
passwordSet: passwordSet.value,
|
|
2185
1091
|
hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
|
|
2186
1092
|
},
|
|
2187
1093
|
};
|
|
@@ -2191,22 +1097,17 @@ async function saveUser() {
|
|
|
2191
1097
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
2192
1098
|
applyFacialResult(payload.metadata, facialResult);
|
|
2193
1099
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
2194
|
-
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
2195
1100
|
if (selectedUser.value._id) {
|
|
2196
1101
|
await updateIdentity(selectedUser.value._id, payload);
|
|
2197
|
-
} else
|
|
1102
|
+
} else {
|
|
2198
1103
|
await saveIdentityOnReader(readerId, payload);
|
|
2199
1104
|
}
|
|
2200
|
-
// else: a user the reader holds and we have never linked. The photo, PIN
|
|
2201
|
-
// and password above are already on the device; creating an identity for
|
|
2202
|
-
// it would need a subject we deliberately no longer ask for here.
|
|
2203
1105
|
} else {
|
|
2204
1106
|
const created = await createHidUserOnReader(readerId, hidUser);
|
|
2205
1107
|
if (created) newlyCreatedHidUserId = hidUser.id;
|
|
2206
1108
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
2207
1109
|
applyFacialResult(payload.metadata, facialResult);
|
|
2208
1110
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
2209
|
-
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
2210
1111
|
await saveIdentityOnReader(readerId, payload);
|
|
2211
1112
|
selectedReaderId.value = readerId;
|
|
2212
1113
|
}
|
|
@@ -2227,10 +1128,7 @@ async function saveUser() {
|
|
|
2227
1128
|
where: { users: { id: newlyCreatedHidUserId } },
|
|
2228
1129
|
});
|
|
2229
1130
|
} catch (rollbackError) {
|
|
2230
|
-
console.error(
|
|
2231
|
-
"Unable to roll back the partially enrolled HID user:",
|
|
2232
|
-
rollbackError
|
|
2233
|
-
);
|
|
1131
|
+
console.error("Unable to roll back the partially enrolled HID user:", rollbackError);
|
|
2234
1132
|
}
|
|
2235
1133
|
}
|
|
2236
1134
|
console.error("Unable to save HID user:", error);
|
|
@@ -2245,25 +1143,6 @@ async function saveUser() {
|
|
|
2245
1143
|
}
|
|
2246
1144
|
}
|
|
2247
1145
|
|
|
2248
|
-
async function syncPassword(readerId: string, hidUserId: number) {
|
|
2249
|
-
// Mirrors syncAccessPin. Removal wins over a typed value, and a blank field
|
|
2250
|
-
// means "leave whatever is on the reader alone" - the hash cannot be read
|
|
2251
|
-
// back, so blank can never mean "clear it".
|
|
2252
|
-
if (removePasswordRequested.value) {
|
|
2253
|
-
await deleteUserPassword(readerId, hidUserId);
|
|
2254
|
-
passwordSet.value = false;
|
|
2255
|
-
form.password = "";
|
|
2256
|
-
return false;
|
|
2257
|
-
}
|
|
2258
|
-
if (form.password) {
|
|
2259
|
-
await setUserPassword(readerId, hidUserId, form.password);
|
|
2260
|
-
form.password = "";
|
|
2261
|
-
passwordSet.value = true;
|
|
2262
|
-
return true;
|
|
2263
|
-
}
|
|
2264
|
-
return passwordSet.value;
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
1146
|
async function syncAccessPin(readerId: string, hidUserId: number) {
|
|
2268
1147
|
if (removePinRequested.value) {
|
|
2269
1148
|
await deleteUserPin(readerId, hidUserId);
|
|
@@ -2331,59 +1210,11 @@ function applyFacialResult(
|
|
|
2331
1210
|
}
|
|
2332
1211
|
}
|
|
2333
1212
|
|
|
2334
|
-
/**
|
|
2335
|
-
* THE IDENTITY ROW BEHIND A ROSTER ROW.
|
|
2336
|
-
*
|
|
2337
|
-
* This list is read from the READER (`getReaderUsers`), and `listReaderUsers`
|
|
2338
|
-
* builds each row out of the device's own `users` columns - it carries no
|
|
2339
|
-
* identity `_id` at all, so `toHidUser` leaves `_id` undefined for every row on
|
|
2340
|
-
* screen. A delete that only acted when `_id` was present therefore never
|
|
2341
|
-
* touched the database: the device user was destroyed and the identity stayed
|
|
2342
|
-
* `active`, with no error, because the guard was an `if` rather than a failure.
|
|
2343
|
-
*
|
|
2344
|
-
* That left the row orphaned, and an orphan is not inert. `findIdentityBySubject`
|
|
2345
|
-
* excludes only `status: "deleted"`, so it still answers "this person is already
|
|
2346
|
-
* enrolled on this reader" and REFUSES to enrol somebody the operator can no
|
|
2347
|
-
* longer see. The reconcile reads it too: `findProfileIdentity` finds the active
|
|
2348
|
-
* row and reuses its stored `hidUserId`, so the device user is rebuilt with the
|
|
2349
|
-
* same UID the operator just deleted, from an access grant that was never
|
|
2350
|
-
* removed because `deleteIdentity` never ran.
|
|
2351
|
-
*
|
|
2352
|
-
* Resolved the same way `saveUser` already resolves it, by the reader's own keys.
|
|
2353
|
-
*/
|
|
2354
|
-
async function findIdentityIdForUser(readerId: string, user: HidUser) {
|
|
2355
|
-
if (user._id) return user._id;
|
|
2356
|
-
if (!readerId) return "";
|
|
2357
|
-
try {
|
|
2358
|
-
const identity = await findExistingIdentityOnReader(readerId, {
|
|
2359
|
-
hidUserId: String(user.hidUserId ?? ""),
|
|
2360
|
-
registration: String(user.registration ?? ""),
|
|
2361
|
-
cardNo: String(user.cardNo ?? ""),
|
|
2362
|
-
metadata: {},
|
|
2363
|
-
});
|
|
2364
|
-
return identity?._id ?? "";
|
|
2365
|
-
} catch (error: unknown) {
|
|
2366
|
-
// A lookup failure must not stop the device delete the operator asked for.
|
|
2367
|
-
// The row stays behind and the next delete can clear it.
|
|
2368
|
-
console.error(
|
|
2369
|
-
"Unable to resolve the stored identity for this HID user:",
|
|
2370
|
-
error
|
|
2371
|
-
);
|
|
2372
|
-
return "";
|
|
2373
|
-
}
|
|
2374
|
-
}
|
|
2375
|
-
|
|
2376
1213
|
async function deleteUser() {
|
|
2377
1214
|
if (!selectedUser.value) return;
|
|
2378
1215
|
|
|
2379
1216
|
deleting.value = true;
|
|
2380
1217
|
try {
|
|
2381
|
-
// Resolved BEFORE the device user is destroyed: this reads our own records,
|
|
2382
|
-
// and doing it first means a failure here costs nothing.
|
|
2383
|
-
const identityId = await findIdentityIdForUser(
|
|
2384
|
-
selectedReaderId.value,
|
|
2385
|
-
selectedUser.value
|
|
2386
|
-
);
|
|
2387
1218
|
await syncHidUserRole(
|
|
2388
1219
|
selectedReaderId.value,
|
|
2389
1220
|
toHidNumericId(selectedUser.value.hidUserId),
|
|
@@ -2391,13 +1222,11 @@ async function deleteUser() {
|
|
|
2391
1222
|
);
|
|
2392
1223
|
await deleteUserPin(
|
|
2393
1224
|
selectedReaderId.value,
|
|
2394
|
-
toHidNumericId(selectedUser.value.hidUserId)
|
|
1225
|
+
toHidNumericId(selectedUser.value.hidUserId),
|
|
2395
1226
|
);
|
|
2396
1227
|
await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
|
|
2397
|
-
if (
|
|
2398
|
-
|
|
2399
|
-
// through, which is what stops the next reconcile rebuilding them.
|
|
2400
|
-
await deleteIdentity(identityId);
|
|
1228
|
+
if (selectedUser.value._id) {
|
|
1229
|
+
await deleteIdentity(selectedUser.value._id);
|
|
2401
1230
|
}
|
|
2402
1231
|
deleteDialog.value = false;
|
|
2403
1232
|
await loadUsers();
|
|
@@ -2412,7 +1241,7 @@ async function deleteUser() {
|
|
|
2412
1241
|
|
|
2413
1242
|
async function saveIdentityOnReader(
|
|
2414
1243
|
readerId: string,
|
|
2415
|
-
payload: HidIdentityPayload
|
|
1244
|
+
payload: HidIdentityPayload,
|
|
2416
1245
|
) {
|
|
2417
1246
|
const existingIdentity = await findExistingIdentityOnReader(
|
|
2418
1247
|
readerId,
|
|
@@ -2424,11 +1253,7 @@ async function saveIdentityOnReader(
|
|
|
2424
1253
|
}
|
|
2425
1254
|
|
|
2426
1255
|
try {
|
|
2427
|
-
await createIdentity(readerId, {
|
|
2428
|
-
...payload,
|
|
2429
|
-
site: props.site,
|
|
2430
|
-
...(props.org ? { orgId: props.org } : {}),
|
|
2431
|
-
});
|
|
1256
|
+
await createIdentity(readerId, { ...payload, site: props.site });
|
|
2432
1257
|
} catch (error: unknown) {
|
|
2433
1258
|
const message = getHidErrorMessage(error);
|
|
2434
1259
|
if (!message.toLowerCase().includes("already exists")) throw error;
|
|
@@ -2444,7 +1269,7 @@ async function saveIdentityOnReader(
|
|
|
2444
1269
|
|
|
2445
1270
|
async function findExistingIdentityOnReader(
|
|
2446
1271
|
readerId: string,
|
|
2447
|
-
payload: HidIdentityPayload
|
|
1272
|
+
payload: HidIdentityPayload,
|
|
2448
1273
|
) {
|
|
2449
1274
|
const response = await getIdentities(readerId, {
|
|
2450
1275
|
page: 1,
|
|
@@ -2453,11 +1278,8 @@ async function findExistingIdentityOnReader(
|
|
|
2453
1278
|
});
|
|
2454
1279
|
const responseRecord = toRecord(response);
|
|
2455
1280
|
const responseData = toRecord(responseRecord.data);
|
|
2456
|
-
const responseItems =
|
|
2457
|
-
|
|
2458
|
-
const identities = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
2459
|
-
toHidUser
|
|
2460
|
-
);
|
|
1281
|
+
const responseItems = responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
|
|
1282
|
+
const identities = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
|
|
2461
1283
|
const hidUserId = String(payload.hidUserId || "");
|
|
2462
1284
|
const registration = String(payload.registration || "");
|
|
2463
1285
|
const cardNo = String(payload.cardNo || "");
|
|
@@ -2474,7 +1296,7 @@ async function findExistingIdentityOnReader(
|
|
|
2474
1296
|
|
|
2475
1297
|
async function createHidUserOnReader(
|
|
2476
1298
|
readerId: string,
|
|
2477
|
-
user: { id?: number; name: string; registration: string }
|
|
1299
|
+
user: { id?: number; name: string; registration: string },
|
|
2478
1300
|
) {
|
|
2479
1301
|
const existing = await getHidUserFromReader(readerId, user.id);
|
|
2480
1302
|
if (existing) {
|
|
@@ -2508,7 +1330,7 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
|
|
|
2508
1330
|
|
|
2509
1331
|
async function updateHidUserOnReader(
|
|
2510
1332
|
readerId: string,
|
|
2511
|
-
user: { id?: number; name: string; registration: string }
|
|
1333
|
+
user: { id?: number; name: string; registration: string },
|
|
2512
1334
|
) {
|
|
2513
1335
|
const { id, ...values } = user;
|
|
2514
1336
|
await runObjectOperation(readerId, {
|
|
@@ -2523,7 +1345,10 @@ async function updateHidUserOnReader(
|
|
|
2523
1345
|
});
|
|
2524
1346
|
}
|
|
2525
1347
|
|
|
2526
|
-
async function deleteHidUserFromReader(
|
|
1348
|
+
async function deleteHidUserFromReader(
|
|
1349
|
+
readerId: string,
|
|
1350
|
+
user: HidUser,
|
|
1351
|
+
) {
|
|
2527
1352
|
const id = toHidNumericId(user.hidUserId);
|
|
2528
1353
|
if (!readerId || !id) return;
|
|
2529
1354
|
|
|
@@ -2611,9 +1436,7 @@ async function getHidUserRole(readerId: string, userId: number) {
|
|
|
2611
1436
|
return Array.isArray(roles) ? roles[0] : undefined;
|
|
2612
1437
|
}
|
|
2613
1438
|
|
|
2614
|
-
function toHidUserObject(
|
|
2615
|
-
identity: Pick<HidUser, "hidUserId" | "name" | "registration">
|
|
2616
|
-
) {
|
|
1439
|
+
function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registration">) {
|
|
2617
1440
|
return {
|
|
2618
1441
|
id: toHidNumericId(identity.hidUserId),
|
|
2619
1442
|
name:
|
|
@@ -2624,23 +1447,6 @@ function toHidUserObject(
|
|
|
2624
1447
|
};
|
|
2625
1448
|
}
|
|
2626
1449
|
|
|
2627
|
-
function isVisitorUser(user: HidUser) {
|
|
2628
|
-
// The reader marks visitors with `user_type_id = 1`; a real user leaves the
|
|
2629
|
-
// field null. `metadata.hidUserTypeId` is what our API forwards it as.
|
|
2630
|
-
return Number(user.metadata?.hidUserTypeId ?? user.user_type_id) === 1;
|
|
2631
|
-
}
|
|
2632
|
-
|
|
2633
|
-
function isUnsupportedUserTypeFilter(error: unknown) {
|
|
2634
|
-
const record = toRecord(error);
|
|
2635
|
-
const data = toRecord(record.data);
|
|
2636
|
-
const message = toText(data.message) || toText(record.message);
|
|
2637
|
-
const normalized = message.toLowerCase();
|
|
2638
|
-
return (
|
|
2639
|
-
normalized.includes("usertype") &&
|
|
2640
|
-
(normalized.includes("not allowed") || normalized.includes("unknown"))
|
|
2641
|
-
);
|
|
2642
|
-
}
|
|
2643
|
-
|
|
2644
1450
|
function toHidNumericId(value: unknown) {
|
|
2645
1451
|
const raw = String(value ?? "").trim();
|
|
2646
1452
|
if (!raw) return undefined;
|
|
@@ -2669,19 +1475,16 @@ async function loadVisibleUserCards(items: HidUser[]) {
|
|
|
2669
1475
|
const response = await getUserCards(selectedReaderId.value, hidUserId);
|
|
2670
1476
|
const responseData = toRecord(toRecord(response).data);
|
|
2671
1477
|
const cards = responseData.cards;
|
|
2672
|
-
return [
|
|
2673
|
-
String(hidUserId),
|
|
2674
|
-
(Array.isArray(cards) ? cards : []).map(toHidCard),
|
|
2675
|
-
] as const;
|
|
1478
|
+
return [String(hidUserId), (Array.isArray(cards) ? cards : []).map(toHidCard)] as const;
|
|
2676
1479
|
} catch (error: unknown) {
|
|
2677
1480
|
cardErrors[String(hidUserId)] = getHidErrorMessage(error);
|
|
2678
1481
|
return [String(hidUserId), []] as const;
|
|
2679
1482
|
}
|
|
2680
|
-
})
|
|
1483
|
+
}),
|
|
2681
1484
|
);
|
|
2682
1485
|
|
|
2683
1486
|
physicalCardsByUserId.value = Object.fromEntries(
|
|
2684
|
-
entries.filter(([hidUserId]) => Boolean(hidUserId))
|
|
1487
|
+
entries.filter(([hidUserId]) => Boolean(hidUserId)),
|
|
2685
1488
|
);
|
|
2686
1489
|
physicalCardErrorsByUserId.value = cardErrors;
|
|
2687
1490
|
}
|
|
@@ -2693,16 +1496,12 @@ function getUserPhysicalCards(user: HidUser) {
|
|
|
2693
1496
|
|
|
2694
1497
|
function getUserCardLoadError(user: HidUser) {
|
|
2695
1498
|
const hidUserId = toHidNumericId(user.hidUserId);
|
|
2696
|
-
return hidUserId
|
|
2697
|
-
? physicalCardErrorsByUserId.value[String(hidUserId)] || ""
|
|
2698
|
-
: "";
|
|
1499
|
+
return hidUserId ? physicalCardErrorsByUserId.value[String(hidUserId)] || "" : "";
|
|
2699
1500
|
}
|
|
2700
1501
|
|
|
2701
1502
|
async function openCardManager(user: HidUser) {
|
|
2702
1503
|
cardUser.value = user;
|
|
2703
|
-
const reader = readers.value.find(
|
|
2704
|
-
(item) => String(item._id) === String(user.reader || selectedReaderId.value)
|
|
2705
|
-
);
|
|
1504
|
+
const reader = readers.value.find((item) => String(item._id) === String(user.reader || selectedReaderId.value));
|
|
2706
1505
|
cardForm.cardType = reader?.cardReadingMode === "pacs" ? "pacs" : "csn";
|
|
2707
1506
|
cardForm.facilityCode = "";
|
|
2708
1507
|
cardForm.cardNumber = "";
|
|
@@ -2713,9 +1512,7 @@ async function openCardManager(user: HidUser) {
|
|
|
2713
1512
|
}
|
|
2714
1513
|
|
|
2715
1514
|
function getCardReaderAndUser() {
|
|
2716
|
-
const readerId = String(
|
|
2717
|
-
cardUser.value?.reader || selectedReaderId.value || ""
|
|
2718
|
-
);
|
|
1515
|
+
const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
|
|
2719
1516
|
const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
|
|
2720
1517
|
return { readerId, hidUserId };
|
|
2721
1518
|
}
|
|
@@ -2738,11 +1535,7 @@ async function loadPhysicalCards() {
|
|
|
2738
1535
|
}
|
|
2739
1536
|
}
|
|
2740
1537
|
|
|
2741
|
-
function parsePhysicalCardInteger(
|
|
2742
|
-
value: string,
|
|
2743
|
-
label: string,
|
|
2744
|
-
maximum: bigint
|
|
2745
|
-
) {
|
|
1538
|
+
function parsePhysicalCardInteger(value: string, label: string, maximum: bigint) {
|
|
2746
1539
|
if (!/^\d+$/.test(value.trim())) {
|
|
2747
1540
|
showToast(`${label} must contain numbers only.`, "error");
|
|
2748
1541
|
return null;
|
|
@@ -2763,27 +1556,14 @@ async function assignPhysicalCard() {
|
|
|
2763
1556
|
| { cardType: "pacs"; facilityCode: string; cardNumber: string }
|
|
2764
1557
|
| { cardType: "csn"; cardValue: string };
|
|
2765
1558
|
if (cardForm.cardType === "pacs") {
|
|
2766
|
-
const facilityCode = parsePhysicalCardInteger(
|
|
2767
|
-
|
|
2768
|
-
"Facility Code",
|
|
2769
|
-
4294967295n
|
|
2770
|
-
);
|
|
2771
|
-
const cardNumber = parsePhysicalCardInteger(
|
|
2772
|
-
cardForm.cardNumber,
|
|
2773
|
-
"Card Number",
|
|
2774
|
-
4294967295n
|
|
2775
|
-
);
|
|
1559
|
+
const facilityCode = parsePhysicalCardInteger(cardForm.facilityCode, "Facility Code", 4294967295n);
|
|
1560
|
+
const cardNumber = parsePhysicalCardInteger(cardForm.cardNumber, "Card Number", 4294967295n);
|
|
2776
1561
|
if (facilityCode === null || cardNumber === null) return;
|
|
2777
1562
|
payload = { cardType: "pacs", facilityCode, cardNumber };
|
|
2778
1563
|
} else {
|
|
2779
|
-
const cardValue = parsePhysicalCardInteger(
|
|
2780
|
-
cardForm.cardValue,
|
|
2781
|
-
"CSN",
|
|
2782
|
-
18446744073709551615n
|
|
2783
|
-
);
|
|
1564
|
+
const cardValue = parsePhysicalCardInteger(cardForm.cardValue, "CSN", 18446744073709551615n);
|
|
2784
1565
|
if (cardValue === null || BigInt(cardValue) <= 0n) {
|
|
2785
|
-
if (cardValue === "0")
|
|
2786
|
-
showToast("CSN must be greater than zero.", "error");
|
|
1566
|
+
if (cardValue === "0") showToast("CSN must be greater than zero.", "error");
|
|
2787
1567
|
return;
|
|
2788
1568
|
}
|
|
2789
1569
|
payload = { cardType: "csn", cardValue };
|
|
@@ -2805,14 +1585,6 @@ async function assignPhysicalCard() {
|
|
|
2805
1585
|
}
|
|
2806
1586
|
}
|
|
2807
1587
|
|
|
2808
|
-
/**
|
|
2809
|
-
* Read a physical card by presenting it to the reader.
|
|
2810
|
-
*
|
|
2811
|
-
* The way most cards get assigned, because it is the only one that does not
|
|
2812
|
-
* require knowing the facility code and card number in advance - neither is
|
|
2813
|
-
* printed on the card, and an operator holding a blank fob has no way to look
|
|
2814
|
-
* them up. The reader listens for 30 seconds and reports what it read.
|
|
2815
|
-
*/
|
|
2816
1588
|
async function enrollPhysicalCard() {
|
|
2817
1589
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
2818
1590
|
if (!readerId || !hidUserId) return;
|
|
@@ -2825,8 +1597,6 @@ async function enrollPhysicalCard() {
|
|
|
2825
1597
|
await loadUsers();
|
|
2826
1598
|
showToast("Physical HID card read and enrolled on the reader.");
|
|
2827
1599
|
} catch (error: unknown) {
|
|
2828
|
-
// Cancelling rejects the request above. That is the operator getting what
|
|
2829
|
-
// they asked for, so it must not also be reported as a failure.
|
|
2830
1600
|
if (!cardEnrollmentCancelled.value) {
|
|
2831
1601
|
showToast(getHidErrorMessage(error), "error");
|
|
2832
1602
|
}
|
|
@@ -2835,55 +1605,20 @@ async function enrollPhysicalCard() {
|
|
|
2835
1605
|
}
|
|
2836
1606
|
}
|
|
2837
1607
|
|
|
2838
|
-
/**
|
|
2839
|
-
* Stop the reader listening.
|
|
2840
|
-
*
|
|
2841
|
-
* Told to the DEVICE, not just the screen: abandoning the request on this side
|
|
2842
|
-
* would leave the reader waiting out its 30 seconds and enrolling whatever card
|
|
2843
|
-
* was presented in the meantime.
|
|
2844
|
-
*/
|
|
2845
|
-
/**
|
|
2846
|
-
* Stop the reader listening, and hand the dialog straight back.
|
|
2847
|
-
*
|
|
2848
|
-
* Told to the DEVICE, not just the screen: abandoning the request on this side
|
|
2849
|
-
* would leave the reader waiting out its remaining seconds and enrolling
|
|
2850
|
-
* whatever card was presented in the meantime.
|
|
2851
|
-
*
|
|
2852
|
-
* The part worth understanding is what the ENROL request does next. The server
|
|
2853
|
-
* cannot tell a cancelled read from a failed one - the reader simply reports no
|
|
2854
|
-
* card - so the enrol that is still in flight ends as a 400, seconds after the
|
|
2855
|
-
* cancel. Waiting for it was the bug: Cancel appeared to do nothing until that
|
|
2856
|
-
* rejection finally arrived.
|
|
2857
|
-
*
|
|
2858
|
-
* So the dialog is released as soon as the DEVICE confirms it has stopped, and
|
|
2859
|
-
* the doomed enrol is left to land on its own. `cardEnrollmentCancelled` is set
|
|
2860
|
-
* before anything is awaited, which is what keeps its 400 silent.
|
|
2861
|
-
*/
|
|
2862
1608
|
async function cancelPhysicalCardEnrollment() {
|
|
2863
1609
|
const { readerId } = getCardReaderAndUser();
|
|
2864
1610
|
if (!readerId) return;
|
|
2865
|
-
// Set first, awaited second: the enrol can reject at any point from here.
|
|
2866
1611
|
cardEnrollmentCancelled.value = true;
|
|
2867
|
-
cancellingCard.value = true;
|
|
2868
1612
|
try {
|
|
2869
1613
|
await cancelUserCardEnrollment(readerId);
|
|
2870
|
-
// Only now, because the enrollment lock is released by the call above.
|
|
2871
|
-
// Re-enabling Read at Reader any earlier invites a second attempt that
|
|
2872
|
-
// the reader refuses with "already running".
|
|
2873
|
-
enrollingCard.value = false;
|
|
2874
1614
|
showToast("Physical HID card reading cancelled.");
|
|
2875
1615
|
} catch (error: unknown) {
|
|
2876
|
-
// The cancel itself failing is real and worth saying: the reader may still
|
|
2877
|
-
// be listening, so the dialog stays in its reading state.
|
|
2878
1616
|
showToast(getHidErrorMessage(error), "error");
|
|
2879
|
-
} finally {
|
|
2880
|
-
cancellingCard.value = false;
|
|
2881
1617
|
}
|
|
2882
1618
|
}
|
|
2883
1619
|
|
|
2884
1620
|
async function removePhysicalCard(card: HidCard) {
|
|
2885
|
-
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
|
|
2886
|
-
return;
|
|
1621
|
+
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`)) return;
|
|
2887
1622
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
2888
1623
|
if (!readerId || !hidUserId || !card?.id) return;
|
|
2889
1624
|
|
|
@@ -2909,38 +1644,13 @@ function formatPhysicalCard(card: HidCard) {
|
|
|
2909
1644
|
}
|
|
2910
1645
|
|
|
2911
1646
|
function getPhysicalCardSubtitle(card: HidCard) {
|
|
2912
|
-
const source =
|
|
2913
|
-
card?.source === "reader"
|
|
2914
|
-
? "Read at reader"
|
|
2915
|
-
: card?.source === "manual"
|
|
2916
|
-
? "Manual"
|
|
2917
|
-
: "On device";
|
|
1647
|
+
const source = card?.source === "reader" ? "Read at reader" : card?.source === "manual" ? "Manual" : "On device";
|
|
2918
1648
|
return `${source} - Raw value ${card?.value || "N/A"}`;
|
|
2919
1649
|
}
|
|
2920
1650
|
|
|
2921
1651
|
function buildUnitLabel() {
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
// stamp "665f.../665f.../665f..." onto the record and into the Unit column.
|
|
2925
|
-
const block = occupiedBlocks.value.find(
|
|
2926
|
-
(item) => String(item._id ?? "") === form.block
|
|
2927
|
-
);
|
|
2928
|
-
const level = (block?.levels ?? []).find(
|
|
2929
|
-
(item) => String(item._id ?? "") === form.level
|
|
2930
|
-
);
|
|
2931
|
-
const unit = (level?.units ?? []).find(
|
|
2932
|
-
(item) => String(item._id ?? "") === form.unit
|
|
2933
|
-
);
|
|
2934
|
-
const parts = [
|
|
2935
|
-
formatBlockTitle(block),
|
|
2936
|
-
String(level?.name ?? ""),
|
|
2937
|
-
String(unit?.name ?? ""),
|
|
2938
|
-
].filter(Boolean);
|
|
2939
|
-
// Editing a record enrolled before the cascade existed: keep its stored
|
|
2940
|
-
// label rather than blanking it.
|
|
2941
|
-
if (!parts.length)
|
|
2942
|
-
return String(selectedUser.value?.metadata?.unitLabel ?? "");
|
|
2943
|
-
return parts.join(" / ");
|
|
1652
|
+
const parts = [form.block, form.level, form.unit].filter(Boolean);
|
|
1653
|
+
return parts.length ? `BLK ${parts.join("/")}` : "";
|
|
2944
1654
|
}
|
|
2945
1655
|
|
|
2946
1656
|
function getName(user?: HidUser | null) {
|
|
@@ -3027,28 +1737,27 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
3027
1737
|
const responseRecord = toRecord(response);
|
|
3028
1738
|
const responseData = toRecord(responseRecord.data);
|
|
3029
1739
|
const nestedData = toRecord(responseData.data);
|
|
3030
|
-
const items =
|
|
3031
|
-
responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
|
|
1740
|
+
const items = responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
|
|
3032
1741
|
|
|
3033
1742
|
return Array.isArray(items)
|
|
3034
1743
|
? items.map((value) => {
|
|
3035
1744
|
const user = toRecord(value);
|
|
3036
1745
|
return {
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
1746
|
+
_rowKey: `reader-${user.id}`,
|
|
1747
|
+
hidUserId: String(user.id || ""),
|
|
1748
|
+
rawHidUserId: toText(user.id),
|
|
1749
|
+
registration: toText(user.registration),
|
|
1750
|
+
userTypeId: toText(user.user_type_id),
|
|
1751
|
+
cardNo: toText(user.card_value || user.cardNo),
|
|
1752
|
+
name: toText(user.name),
|
|
1753
|
+
metadata: {
|
|
3043
1754
|
name: toText(user.name),
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
hidReaderSource: true,
|
|
3051
|
-
},
|
|
1755
|
+
imageTimestamp: user.image_timestamp
|
|
1756
|
+
? String(user.image_timestamp)
|
|
1757
|
+
: "",
|
|
1758
|
+
lastAccessAt: fromUnixSeconds(user.last_access),
|
|
1759
|
+
hidReaderSource: true,
|
|
1760
|
+
},
|
|
3052
1761
|
};
|
|
3053
1762
|
})
|
|
3054
1763
|
: [];
|
|
@@ -3056,7 +1765,7 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
3056
1765
|
|
|
3057
1766
|
function mergeReaderUsersWithIdentities(
|
|
3058
1767
|
readerUsers: HidUser[],
|
|
3059
|
-
identities: HidUser[]
|
|
1768
|
+
identities: HidUser[],
|
|
3060
1769
|
) {
|
|
3061
1770
|
const identityById = new Map<string, HidUser>();
|
|
3062
1771
|
identities.forEach((identity) => {
|
|
@@ -3099,13 +1808,7 @@ function getMappingStatus(user: HidUser) {
|
|
|
3099
1808
|
}
|
|
3100
1809
|
|
|
3101
1810
|
function hasMappedRecord(user: HidUser) {
|
|
3102
|
-
return Boolean(
|
|
3103
|
-
user?.person ||
|
|
3104
|
-
user?.user ||
|
|
3105
|
-
user?.member ||
|
|
3106
|
-
user?.serviceProvider ||
|
|
3107
|
-
user?.visitor
|
|
3108
|
-
);
|
|
1811
|
+
return Boolean(user?.person || user?.user || user?.member || user?.serviceProvider || user?.visitor);
|
|
3109
1812
|
}
|
|
3110
1813
|
|
|
3111
1814
|
function formatDate(value?: string) {
|
|
@@ -3137,26 +1840,18 @@ function getHidErrorMessage(error: unknown) {
|
|
|
3137
1840
|
const cause = toRecord(errorRecord.cause);
|
|
3138
1841
|
const causeResponse = toRecord(cause.response);
|
|
3139
1842
|
const data = toRecord(
|
|
3140
|
-
errorRecord.data ||
|
|
3141
|
-
response._data ||
|
|
3142
|
-
response.data ||
|
|
3143
|
-
cause.data ||
|
|
3144
|
-
causeResponse._data
|
|
1843
|
+
errorRecord.data || response._data || response.data || cause.data || causeResponse._data,
|
|
3145
1844
|
);
|
|
3146
1845
|
|
|
3147
1846
|
return toText(
|
|
3148
|
-
data.message ||
|
|
3149
|
-
|
|
3150
|
-
data.statusMessage ||
|
|
3151
|
-
errorRecord.statusMessage ||
|
|
3152
|
-
errorRecord.message,
|
|
3153
|
-
"Unable to save HID user."
|
|
1847
|
+
data.message || data.error || data.statusMessage || errorRecord.statusMessage || errorRecord.message,
|
|
1848
|
+
"Unable to save HID user.",
|
|
3154
1849
|
);
|
|
3155
1850
|
}
|
|
3156
1851
|
|
|
3157
1852
|
function toRecord(value: unknown): Record<string, unknown> {
|
|
3158
1853
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
3159
|
-
?
|
|
1854
|
+
? value as Record<string, unknown>
|
|
3160
1855
|
: {};
|
|
3161
1856
|
}
|
|
3162
1857
|
|
|
@@ -3202,24 +1897,10 @@ function toHidCard(value: unknown): HidCard {
|
|
|
3202
1897
|
return {
|
|
3203
1898
|
...card,
|
|
3204
1899
|
id: toText(card.id) || undefined,
|
|
3205
|
-
cardType:
|
|
3206
|
-
|
|
3207
|
-
? card.cardType
|
|
3208
|
-
: undefined,
|
|
3209
|
-
source:
|
|
3210
|
-
card.source === "reader" ||
|
|
3211
|
-
card.source === "manual" ||
|
|
3212
|
-
card.source === "device"
|
|
3213
|
-
? card.source
|
|
3214
|
-
: undefined,
|
|
1900
|
+
cardType: card.cardType === "pacs" || card.cardType === "csn" ? card.cardType : undefined,
|
|
1901
|
+
source: card.source === "reader" || card.source === "manual" || card.source === "device" ? card.source : undefined,
|
|
3215
1902
|
};
|
|
3216
1903
|
}
|
|
3217
|
-
|
|
3218
|
-
/* Publish every change, including the seed above: a screen that had to fall
|
|
3219
|
-
back to the first reader should leave the others agreeing with it. */
|
|
3220
|
-
watch(selectedReaderId, (value) => {
|
|
3221
|
-
if (value) selectHidReader(props.site, value);
|
|
3222
|
-
});
|
|
3223
1904
|
</script>
|
|
3224
1905
|
|
|
3225
1906
|
<style scoped>
|
|
@@ -3256,6 +1937,12 @@ watch(selectedReaderId, (value) => {
|
|
|
3256
1937
|
max-width: 320px;
|
|
3257
1938
|
}
|
|
3258
1939
|
|
|
1940
|
+
.small-title {
|
|
1941
|
+
font-size: 14px;
|
|
1942
|
+
font-weight: 700;
|
|
1943
|
+
color: var(--text);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
3259
1946
|
/* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
|
|
3260
1947
|
.field-label {
|
|
3261
1948
|
color: var(--text2);
|
|
@@ -3277,42 +1964,12 @@ watch(selectedReaderId, (value) => {
|
|
|
3277
1964
|
line-height: 1.45;
|
|
3278
1965
|
}
|
|
3279
1966
|
|
|
3280
|
-
/* Same line, saying the field is refused rather than explained. `--err` is the
|
|
3281
|
-
token the required-field asterisk already uses. */
|
|
3282
|
-
.field-hint--error {
|
|
3283
|
-
color: var(--err);
|
|
3284
|
-
}
|
|
3285
|
-
|
|
3286
1967
|
.photo-wrap {
|
|
3287
1968
|
display: grid;
|
|
3288
1969
|
place-items: center;
|
|
3289
1970
|
margin-bottom: 14px;
|
|
3290
1971
|
}
|
|
3291
1972
|
|
|
3292
|
-
.photo-facial {
|
|
3293
|
-
margin: 10px 0 0;
|
|
3294
|
-
text-align: center;
|
|
3295
|
-
}
|
|
3296
|
-
|
|
3297
|
-
/* The same small muted word `.field-label` uses, so the caption reads as a
|
|
3298
|
-
field label rather than as a second heading. */
|
|
3299
|
-
.photo-facial__label {
|
|
3300
|
-
display: block;
|
|
3301
|
-
color: var(--muted);
|
|
3302
|
-
font-size: 12px;
|
|
3303
|
-
font-weight: 600;
|
|
3304
|
-
}
|
|
3305
|
-
|
|
3306
|
-
.photo-facial__value {
|
|
3307
|
-
display: block;
|
|
3308
|
-
margin-top: 2px;
|
|
3309
|
-
font-size: 13.5px;
|
|
3310
|
-
font-weight: 500;
|
|
3311
|
-
/* The reader's reference is a long unbroken number; let it wrap rather than
|
|
3312
|
-
widen the dialog. */
|
|
3313
|
-
word-break: break-all;
|
|
3314
|
-
}
|
|
3315
|
-
|
|
3316
1973
|
.photo-button {
|
|
3317
1974
|
width: 120px;
|
|
3318
1975
|
height: 120px;
|
|
@@ -3346,6 +2003,7 @@ watch(selectedReaderId, (value) => {
|
|
|
3346
2003
|
box-shadow: var(--shadow-menu);
|
|
3347
2004
|
}
|
|
3348
2005
|
|
|
2006
|
+
|
|
3349
2007
|
.admin-rule-indicator {
|
|
3350
2008
|
display: inline-flex;
|
|
3351
2009
|
vertical-align: middle;
|
|
@@ -3485,6 +2143,25 @@ watch(selectedReaderId, (value) => {
|
|
|
3485
2143
|
color: var(--muted);
|
|
3486
2144
|
}
|
|
3487
2145
|
|
|
2146
|
+
/* The split footer bar. The handoff does not draw one, so the SHAPE is the
|
|
2147
|
+
screen's own; only the colours change - `#062d42` was a fixed navy that sat
|
|
2148
|
+
on the dark card unchanged. Same treatment as panel 2. */
|
|
2149
|
+
.action-cancel,
|
|
2150
|
+
.action-submit {
|
|
2151
|
+
flex: 1;
|
|
2152
|
+
border-radius: 0;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
.action-cancel {
|
|
2156
|
+
background: var(--card);
|
|
2157
|
+
color: var(--text2);
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
.action-submit {
|
|
2161
|
+
background: var(--accent-strong);
|
|
2162
|
+
color: var(--on-accent-strong);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
3488
2165
|
@media (max-width: 960px) {
|
|
3489
2166
|
.hid-user-filters :deep(.app-field),
|
|
3490
2167
|
.hid-user-filters :deep(.app-select) {
|