@7365admin1/layer-common 4.82.0 → 4.82.1-staging.469
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 +4 -4
- package/assets/css/primitives.css +57 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +57 -4
- package/components/AccessCardPreviewDialog.vue +291 -50
- package/components/AppSelect.vue +143 -4
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +1521 -248
- package/components/InvitationClientForm.vue +19 -1
- package/components/TableMain.vue +22 -7
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useMember.ts +12 -0
- package/composables/usePeople.ts +19 -0
- package/composables/useServiceProvider.ts +40 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- 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-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/utils/hid-enrolment-subject.ts +144 -0
- package/utils/hid-permission-assignments.ts +190 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
|
@@ -25,7 +25,11 @@
|
|
|
25
25
|
the old table card, so it stays above the shared one. -->
|
|
26
26
|
<v-alert
|
|
27
27
|
v-if="selectedReader"
|
|
28
|
-
:type="
|
|
28
|
+
:type="
|
|
29
|
+
selectedReader.portalId && selectedReader.portalName
|
|
30
|
+
? 'info'
|
|
31
|
+
: 'warning'
|
|
32
|
+
"
|
|
29
33
|
variant="tonal"
|
|
30
34
|
density="compact"
|
|
31
35
|
class="mb-4"
|
|
@@ -50,7 +54,9 @@
|
|
|
50
54
|
@update:page="goToPage"
|
|
51
55
|
>
|
|
52
56
|
<template v-if="!props.cardManagement" #actions>
|
|
53
|
-
<AppButton icon="mdi-account-plus" @click="openEnroll"
|
|
57
|
+
<AppButton icon="mdi-account-plus" @click="openEnroll"
|
|
58
|
+
>Enroll</AppButton
|
|
59
|
+
>
|
|
54
60
|
</template>
|
|
55
61
|
|
|
56
62
|
<!-- The design puts filters on their own full-width row under the
|
|
@@ -67,8 +73,8 @@
|
|
|
67
73
|
<AppField
|
|
68
74
|
v-model="search"
|
|
69
75
|
search
|
|
70
|
-
placeholder="Search"
|
|
71
|
-
@keyup.enter="
|
|
76
|
+
placeholder="Search name, UID or registration no."
|
|
77
|
+
@keyup.enter="onSearchSubmit"
|
|
72
78
|
/>
|
|
73
79
|
<AppSelect
|
|
74
80
|
v-if="!props.cardManagement"
|
|
@@ -93,7 +99,15 @@
|
|
|
93
99
|
</template>
|
|
94
100
|
|
|
95
101
|
<template #[`item.hidUserId`]="{ item }">
|
|
96
|
-
<span class="app-cell--num">{{
|
|
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>
|
|
97
111
|
</template>
|
|
98
112
|
|
|
99
113
|
<!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
|
|
@@ -113,7 +127,10 @@
|
|
|
113
127
|
{{ formatPhysicalCard(card) }}
|
|
114
128
|
</span>
|
|
115
129
|
</div>
|
|
116
|
-
<span
|
|
130
|
+
<span
|
|
131
|
+
v-else-if="getUserCardLoadError(item)"
|
|
132
|
+
class="hid-card-load-error"
|
|
133
|
+
>
|
|
117
134
|
Unable to load
|
|
118
135
|
<v-tooltip activator="parent" location="top">
|
|
119
136
|
{{ getUserCardLoadError(item) }}
|
|
@@ -133,13 +150,20 @@
|
|
|
133
150
|
</AppButton>
|
|
134
151
|
<v-menu v-else>
|
|
135
152
|
<template #activator="{ props: menuProps }">
|
|
136
|
-
<AppButton
|
|
153
|
+
<AppButton
|
|
154
|
+
v-bind="menuProps"
|
|
155
|
+
variant="row"
|
|
156
|
+
icon="mdi-dots-vertical"
|
|
157
|
+
/>
|
|
137
158
|
</template>
|
|
138
159
|
|
|
139
160
|
<v-list density="compact" min-width="150">
|
|
140
161
|
<v-list-item title="View" @click="openView(item)" />
|
|
141
162
|
<v-list-item title="Edit" @click="openEdit(item)" />
|
|
142
|
-
<v-list-item
|
|
163
|
+
<v-list-item
|
|
164
|
+
title="Manage HID Cards"
|
|
165
|
+
@click="openCardManager(item)"
|
|
166
|
+
/>
|
|
143
167
|
<v-list-item
|
|
144
168
|
title="Delete"
|
|
145
169
|
class="text-error"
|
|
@@ -155,23 +179,31 @@
|
|
|
155
179
|
<template #no-data>
|
|
156
180
|
<div class="table-card__empty">
|
|
157
181
|
<v-icon
|
|
158
|
-
:icon="
|
|
182
|
+
:icon="
|
|
183
|
+
props.cardManagement
|
|
184
|
+
? 'mdi-card-account-details-outline'
|
|
185
|
+
: 'mdi-account-off-outline'
|
|
186
|
+
"
|
|
159
187
|
size="32"
|
|
160
188
|
/>
|
|
161
189
|
<span>
|
|
162
|
-
{{
|
|
190
|
+
{{
|
|
191
|
+
props.cardManagement
|
|
192
|
+
? "No HID users available for card assignment."
|
|
193
|
+
: "No HID users enrolled yet."
|
|
194
|
+
}}
|
|
163
195
|
</span>
|
|
164
196
|
</div>
|
|
165
197
|
</template>
|
|
166
198
|
</TableMain>
|
|
167
199
|
|
|
168
200
|
<v-dialog v-model="formDialog" max-width="420" persistent>
|
|
169
|
-
<v-card
|
|
170
|
-
<v-card-title
|
|
201
|
+
<v-card class="screen-modal">
|
|
202
|
+
<v-card-title>
|
|
171
203
|
{{ selectedUser ? "Edit User Information" : "Enroll" }}
|
|
172
204
|
</v-card-title>
|
|
173
205
|
|
|
174
|
-
<v-card-text>
|
|
206
|
+
<v-card-text class="screen-modal__body">
|
|
175
207
|
<div class="photo-wrap">
|
|
176
208
|
<v-menu>
|
|
177
209
|
<template #activator="{ props: menuProps }">
|
|
@@ -213,6 +245,15 @@
|
|
|
213
245
|
/>
|
|
214
246
|
</v-list>
|
|
215
247
|
</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
|
+
|
|
216
257
|
<input
|
|
217
258
|
ref="photoInput"
|
|
218
259
|
type="file"
|
|
@@ -222,45 +263,197 @@
|
|
|
222
263
|
/>
|
|
223
264
|
</div>
|
|
224
265
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
266
|
+
<!-- WHO IS BEING ENROLLED.
|
|
267
|
+
The write path has always carried three subject categories -
|
|
268
|
+
`subjectLink` below maps them onto `person`, `member` or
|
|
269
|
+
`serviceProvider` - but the form only ever offered residents.
|
|
270
|
+
Staff hold no unit, so choosing Member replaces the whole
|
|
271
|
+
block/level/unit cascade with a single picker.
|
|
272
|
+
Enrolment only: an identity's subject cannot change category
|
|
273
|
+
after the fact, so an edit STATES the type rather than offering
|
|
274
|
+
it. -->
|
|
275
|
+
<div class="field-label">Enrolling <span>*</span></div>
|
|
234
276
|
<AppSelect
|
|
277
|
+
v-if="!selectedUser"
|
|
235
278
|
v-model="form.subjectCategory"
|
|
236
279
|
:items="subjectCategoryOptions"
|
|
237
|
-
placeholder="Select person type"
|
|
238
280
|
klass="mb-3"
|
|
239
281
|
@update:model-value="onSubjectCategoryChanged"
|
|
240
282
|
/>
|
|
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>
|
|
241
318
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
|
|
392
|
+
<!-- The duplicate check. It is reader-scoped: this resident holding a
|
|
393
|
+
HID user on another gate is normal and says nothing here. -->
|
|
394
|
+
<p v-if="checkingResident" class="field-hint">
|
|
395
|
+
Checking enrollment...
|
|
396
|
+
</p>
|
|
397
|
+
<p
|
|
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
|
|
248
436
|
klass="mb-3"
|
|
249
437
|
/>
|
|
250
438
|
|
|
251
439
|
<div class="field-label">ID</div>
|
|
252
|
-
<AppField
|
|
440
|
+
<AppField
|
|
441
|
+
v-model="form.hidUserId"
|
|
442
|
+
aria-label="ID"
|
|
443
|
+
readonly
|
|
444
|
+
klass="mb-3"
|
|
445
|
+
/>
|
|
253
446
|
|
|
254
447
|
<div class="field-label">Registration No. <span>*</span></div>
|
|
255
448
|
<AppField
|
|
256
449
|
v-model="form.registration"
|
|
257
450
|
aria-label="Registration No."
|
|
258
451
|
placeholder="Enter registration no."
|
|
259
|
-
|
|
452
|
+
readonly
|
|
260
453
|
klass="mb-3"
|
|
261
454
|
/>
|
|
262
455
|
|
|
263
|
-
<div class="field-label">
|
|
456
|
+
<div class="field-label">PIN (optional)</div>
|
|
264
457
|
<!--
|
|
265
458
|
The handoff draws no password field anywhere (the sign-in screens
|
|
266
459
|
are the owner's own), so the reveal toggle is this product's, not
|
|
@@ -270,8 +463,8 @@
|
|
|
270
463
|
-->
|
|
271
464
|
<AppField
|
|
272
465
|
v-model="form.accessPin"
|
|
273
|
-
aria-label="
|
|
274
|
-
placeholder="Enter
|
|
466
|
+
aria-label="PIN"
|
|
467
|
+
placeholder="Enter pin"
|
|
275
468
|
inputmode="numeric"
|
|
276
469
|
autocomplete="new-password"
|
|
277
470
|
:type="showPin ? 'text' : 'password'"
|
|
@@ -280,7 +473,7 @@
|
|
|
280
473
|
<button
|
|
281
474
|
type="button"
|
|
282
475
|
class="app-field__trailing"
|
|
283
|
-
:aria-label="showPin ? 'Hide
|
|
476
|
+
:aria-label="showPin ? 'Hide PIN' : 'Show PIN'"
|
|
284
477
|
@click="showPin = !showPin"
|
|
285
478
|
>
|
|
286
479
|
<v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
|
|
@@ -290,33 +483,82 @@
|
|
|
290
483
|
<!-- `persistent-hint` was Vuetify's; the design's settings module
|
|
291
484
|
draws an explanatory line as 12-13px/600 in `--muted`. -->
|
|
292
485
|
<p class="field-hint">
|
|
293
|
-
{{
|
|
486
|
+
{{
|
|
487
|
+
selectedUser && pinEnrolled
|
|
488
|
+
? "Leave blank to keep the current PIN."
|
|
489
|
+
: "PIN works as an alternative to face or QR."
|
|
490
|
+
}}
|
|
294
491
|
</p>
|
|
295
492
|
<v-checkbox
|
|
296
493
|
v-if="selectedUser && pinEnrolled"
|
|
297
494
|
v-model="removePinRequested"
|
|
298
|
-
label="Remove current
|
|
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"
|
|
299
540
|
density="compact"
|
|
300
541
|
hide-details
|
|
301
542
|
/>
|
|
302
543
|
</v-card-text>
|
|
303
544
|
|
|
304
|
-
<!-- The
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
545
|
+
<!-- The shared modal footer: `screen-btn-ghost` + `screen-btn-primary`
|
|
546
|
+
on `v-btn`, the same pair every other module's dialog uses
|
|
547
|
+
(`assets/css/screens.css`). `v-btn` stays because the submit needs
|
|
548
|
+
`:loading`, which `AppButton` does not carry. -->
|
|
549
|
+
<v-card-actions class="screen-modal__footer">
|
|
308
550
|
<v-btn
|
|
309
|
-
class="text-none
|
|
551
|
+
class="text-none screen-btn-ghost"
|
|
310
552
|
variant="flat"
|
|
311
|
-
height="44"
|
|
312
553
|
@click="formDialog = false"
|
|
313
|
-
>Cancel</v-btn
|
|
314
554
|
>
|
|
555
|
+
Cancel
|
|
556
|
+
</v-btn>
|
|
315
557
|
<v-btn
|
|
316
|
-
class="text-none
|
|
558
|
+
class="text-none screen-btn-primary"
|
|
317
559
|
variant="flat"
|
|
318
|
-
height="44"
|
|
319
560
|
:loading="saving"
|
|
561
|
+
:disabled="Boolean(residentConflict) || checkingResident"
|
|
320
562
|
@click="saveUser"
|
|
321
563
|
>
|
|
322
564
|
Save
|
|
@@ -328,9 +570,9 @@
|
|
|
328
570
|
<HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
|
|
329
571
|
|
|
330
572
|
<v-dialog v-model="viewDialog" max-width="430">
|
|
331
|
-
<v-card
|
|
332
|
-
<v-card-title
|
|
333
|
-
<v-card-text>
|
|
573
|
+
<v-card class="screen-modal">
|
|
574
|
+
<v-card-title>User Information</v-card-title>
|
|
575
|
+
<v-card-text class="screen-modal__body">
|
|
334
576
|
<div class="detail-grid">
|
|
335
577
|
<span>Name</span><strong>{{ getName(selectedUser) }}</strong>
|
|
336
578
|
<span>User ID</span
|
|
@@ -347,21 +589,28 @@
|
|
|
347
589
|
><strong>{{
|
|
348
590
|
selectedUser ? getMappingStatus(selectedUser) : "N/A"
|
|
349
591
|
}}</strong>
|
|
350
|
-
<span>
|
|
351
|
-
><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>
|
|
352
600
|
</div>
|
|
353
601
|
</v-card-text>
|
|
354
|
-
<v-card-actions>
|
|
355
|
-
<
|
|
356
|
-
|
|
602
|
+
<v-card-actions class="screen-modal__footer">
|
|
603
|
+
<AppButton variant="ghost" @click="viewDialog = false"
|
|
604
|
+
>Close</AppButton
|
|
605
|
+
>
|
|
357
606
|
</v-card-actions>
|
|
358
607
|
</v-card>
|
|
359
608
|
</v-dialog>
|
|
360
609
|
|
|
361
610
|
<v-dialog v-model="cardDialog" max-width="560" persistent>
|
|
362
|
-
<v-card
|
|
363
|
-
<v-card-title
|
|
364
|
-
<v-card-text>
|
|
611
|
+
<v-card class="screen-modal">
|
|
612
|
+
<v-card-title>Physical HID Cards</v-card-title>
|
|
613
|
+
<v-card-text class="screen-modal__body">
|
|
365
614
|
<p class="hid-card-user">
|
|
366
615
|
{{ getName(cardUser) }}
|
|
367
616
|
<span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
|
|
@@ -369,7 +618,11 @@
|
|
|
369
618
|
|
|
370
619
|
<v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
|
|
371
620
|
<div v-else-if="physicalCards.length" class="hid-card-list mb-4">
|
|
372
|
-
<div
|
|
621
|
+
<div
|
|
622
|
+
v-for="card in physicalCards"
|
|
623
|
+
:key="card.id"
|
|
624
|
+
class="hid-card-row"
|
|
625
|
+
>
|
|
373
626
|
<v-icon icon="mdi-card-account-details-outline" size="22" />
|
|
374
627
|
<div class="hid-card-row__content">
|
|
375
628
|
<strong>{{ formatPhysicalCard(card) }}</strong>
|
|
@@ -383,7 +636,13 @@
|
|
|
383
636
|
/>
|
|
384
637
|
</div>
|
|
385
638
|
</div>
|
|
386
|
-
<v-alert
|
|
639
|
+
<v-alert
|
|
640
|
+
v-else
|
|
641
|
+
type="info"
|
|
642
|
+
variant="tonal"
|
|
643
|
+
density="compact"
|
|
644
|
+
class="mb-4"
|
|
645
|
+
>
|
|
387
646
|
No physical HID card is assigned to this user.
|
|
388
647
|
</v-alert>
|
|
389
648
|
|
|
@@ -397,7 +656,8 @@
|
|
|
397
656
|
{{ cardProfileLabel }}
|
|
398
657
|
</v-alert>
|
|
399
658
|
<p class="field-hint hid-card-type-hint">
|
|
400
|
-
PACS uses Facility Code + Card Number. CSN uses the raw serial
|
|
659
|
+
PACS uses Facility Code + Card Number. CSN uses the raw serial
|
|
660
|
+
number read from the physical card.
|
|
401
661
|
</p>
|
|
402
662
|
<template v-if="hybridCardProfile">
|
|
403
663
|
<div class="field-label mt-3">Presented Card Type</div>
|
|
@@ -408,11 +668,13 @@
|
|
|
408
668
|
class="mb-3"
|
|
409
669
|
/>
|
|
410
670
|
<p class="field-hint hid-card-type-hint">
|
|
411
|
-
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
|
|
671
|
+
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
|
|
672
|
+
card in this reader mode.
|
|
412
673
|
</p>
|
|
413
674
|
</template>
|
|
414
675
|
<p class="field-hint hid-card-access-hint">
|
|
415
|
-
The card uses this HID user's existing portal and access rules;
|
|
676
|
+
The card uses this HID user's existing portal and access rules;
|
|
677
|
+
assigning a card does not grant new access.
|
|
416
678
|
</p>
|
|
417
679
|
|
|
418
680
|
<div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
|
|
@@ -449,9 +711,15 @@
|
|
|
449
711
|
density="compact"
|
|
450
712
|
class="mt-4"
|
|
451
713
|
>
|
|
452
|
-
Present the physical card to the selected Amico reader. The request
|
|
714
|
+
Present the physical card to the selected Amico reader. The request
|
|
715
|
+
will time out after 30 seconds.
|
|
453
716
|
</v-alert>
|
|
454
717
|
|
|
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. -->
|
|
455
723
|
<div class="hid-card-actions mt-4">
|
|
456
724
|
<AppButton
|
|
457
725
|
variant="secondary"
|
|
@@ -470,15 +738,21 @@
|
|
|
470
738
|
<AppButton
|
|
471
739
|
v-if="enrollingCard"
|
|
472
740
|
variant="ghost"
|
|
741
|
+
:disabled="cancellingCard"
|
|
473
742
|
@click="cancelPhysicalCardEnrollment"
|
|
474
743
|
>
|
|
475
|
-
Cancel Reading
|
|
744
|
+
{{ cancellingCard ? "Cancelling…" : "Cancel Reading" }}
|
|
476
745
|
</AppButton>
|
|
477
746
|
</div>
|
|
478
747
|
</v-card-text>
|
|
479
|
-
<v-card-actions>
|
|
480
|
-
|
|
481
|
-
|
|
748
|
+
<v-card-actions class="screen-modal__footer">
|
|
749
|
+
<!-- Closing mid-read would leave the reader listening with nothing
|
|
750
|
+
to receive the card it reads. -->
|
|
751
|
+
<AppButton
|
|
752
|
+
variant="ghost"
|
|
753
|
+
:disabled="enrollingCard"
|
|
754
|
+
@click="cardDialog = false"
|
|
755
|
+
>
|
|
482
756
|
Close
|
|
483
757
|
</AppButton>
|
|
484
758
|
</v-card-actions>
|
|
@@ -486,31 +760,31 @@
|
|
|
486
760
|
</v-dialog>
|
|
487
761
|
|
|
488
762
|
<v-dialog v-model="deleteDialog" max-width="360" persistent>
|
|
489
|
-
<v-card
|
|
490
|
-
<v-card-title
|
|
491
|
-
<v-card-text class="text-body-2">
|
|
763
|
+
<v-card class="screen-modal">
|
|
764
|
+
<v-card-title>Delete</v-card-title>
|
|
765
|
+
<v-card-text class="screen-modal__body text-body-2">
|
|
492
766
|
Are you sure you want to permanently delete this HID user?
|
|
493
767
|
<div class="text-caption text-medium-emphasis mt-2">
|
|
494
768
|
This action will remove the user from the HID reader and mark the
|
|
495
769
|
identity as deleted.
|
|
496
770
|
</div>
|
|
497
771
|
</v-card-text>
|
|
498
|
-
<v-card-actions class="
|
|
772
|
+
<v-card-actions class="screen-modal__footer">
|
|
499
773
|
<v-btn
|
|
500
|
-
class="text-none
|
|
774
|
+
class="text-none screen-btn-ghost"
|
|
501
775
|
variant="flat"
|
|
502
|
-
height="44"
|
|
503
776
|
@click="deleteDialog = false"
|
|
504
|
-
>Cancel</v-btn
|
|
505
777
|
>
|
|
778
|
+
Cancel
|
|
779
|
+
</v-btn>
|
|
506
780
|
<v-btn
|
|
507
|
-
class="text-none
|
|
781
|
+
class="text-none screen-btn-primary"
|
|
508
782
|
variant="flat"
|
|
509
|
-
height="44"
|
|
510
783
|
:loading="deleting"
|
|
511
784
|
@click="deleteUser"
|
|
512
|
-
>Delete</v-btn
|
|
513
785
|
>
|
|
786
|
+
Delete
|
|
787
|
+
</v-btn>
|
|
514
788
|
</v-card-actions>
|
|
515
789
|
</v-card>
|
|
516
790
|
</v-dialog>
|
|
@@ -522,11 +796,30 @@
|
|
|
522
796
|
</template>
|
|
523
797
|
|
|
524
798
|
<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
|
+
|
|
525
811
|
const props = defineProps({
|
|
526
812
|
site: {
|
|
527
813
|
type: String,
|
|
528
814
|
required: true,
|
|
529
815
|
},
|
|
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
|
+
},
|
|
530
823
|
cardManagement: {
|
|
531
824
|
type: Boolean,
|
|
532
825
|
default: false,
|
|
@@ -547,12 +840,16 @@ const {
|
|
|
547
840
|
getUserPinStatus,
|
|
548
841
|
setUserPin,
|
|
549
842
|
deleteUserPin,
|
|
843
|
+
getUserPasswordStatus,
|
|
844
|
+
setUserPassword,
|
|
845
|
+
deleteUserPassword,
|
|
550
846
|
runObjectOperation,
|
|
551
847
|
createIdentity,
|
|
552
848
|
updateIdentity,
|
|
553
849
|
deleteIdentity,
|
|
554
|
-
getPermissionCandidates,
|
|
555
850
|
} = useHidAmico();
|
|
851
|
+
const { getAll: getAllMembers } = useMember();
|
|
852
|
+
const runtimeConfig = useRuntimeConfig();
|
|
556
853
|
|
|
557
854
|
type HidMetadata = Record<string, unknown> & {
|
|
558
855
|
name?: string;
|
|
@@ -566,6 +863,7 @@ type HidMetadata = Record<string, unknown> & {
|
|
|
566
863
|
lastAccessAt?: string;
|
|
567
864
|
facialScores?: Record<string, unknown>;
|
|
568
865
|
pinEnrolled?: boolean;
|
|
866
|
+
passwordSet?: boolean;
|
|
569
867
|
hidPhysicalCards?: HidCard[];
|
|
570
868
|
};
|
|
571
869
|
|
|
@@ -616,6 +914,7 @@ type HidIdentityPayload = Record<string, unknown> & {
|
|
|
616
914
|
const readers = ref<HidReader[]>([]);
|
|
617
915
|
const users = ref<HidUser[]>([]);
|
|
618
916
|
const administratorUserIds = ref<Set<number>>(new Set());
|
|
917
|
+
const { selectHidReader, resolveForSite } = useHidReaderSelection();
|
|
619
918
|
const selectedReaderId = ref("");
|
|
620
919
|
const search = ref("");
|
|
621
920
|
const status = ref("");
|
|
@@ -639,7 +938,14 @@ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
|
|
|
639
938
|
const loadingCards = ref(false);
|
|
640
939
|
const savingCard = ref(false);
|
|
641
940
|
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
|
+
*/
|
|
642
946
|
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);
|
|
643
949
|
const deletingCardId = ref("");
|
|
644
950
|
const showPin = ref(false);
|
|
645
951
|
const pinEnrolled = ref(false);
|
|
@@ -657,17 +963,79 @@ const facialEnrolled = ref(false);
|
|
|
657
963
|
function hasFacialEnrollment(user?: HidUser | null) {
|
|
658
964
|
return Boolean(
|
|
659
965
|
user?.metadata?.facialEnrolled ||
|
|
660
|
-
|
|
661
|
-
|
|
966
|
+
user?.metadata?.facialData ||
|
|
967
|
+
user?.metadata?.imageTimestamp
|
|
662
968
|
);
|
|
663
969
|
}
|
|
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
|
+
});
|
|
664
985
|
const removePinRequested = ref(false);
|
|
986
|
+
const passwordSet = ref(false);
|
|
987
|
+
const showPassword = ref(false);
|
|
988
|
+
const removePasswordRequested = ref(false);
|
|
665
989
|
const snackbar = reactive({
|
|
666
990
|
show: false,
|
|
667
991
|
color: "success",
|
|
668
992
|
message: "",
|
|
669
993
|
});
|
|
670
994
|
|
|
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
|
+
|
|
671
1039
|
const form = reactive({
|
|
672
1040
|
reader: "",
|
|
673
1041
|
block: "",
|
|
@@ -678,24 +1046,385 @@ const form = reactive({
|
|
|
678
1046
|
registration: "",
|
|
679
1047
|
isAdministrator: false,
|
|
680
1048
|
accessPin: "",
|
|
1049
|
+
password: "",
|
|
681
1050
|
photoPreview: "",
|
|
682
|
-
subjectCategory:
|
|
1051
|
+
subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
|
|
683
1052
|
subjectId: "",
|
|
1053
|
+
/** A mirror of the chosen member's role. Never sent; display only. */
|
|
1054
|
+
subjectRole: "",
|
|
684
1055
|
});
|
|
685
1056
|
|
|
686
1057
|
const route = useRoute();
|
|
687
1058
|
const orgId = computed(() => String(route.params.org || ""));
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1059
|
+
|
|
1060
|
+
// The cascade runs on People Management's own endpoints, the same pair the
|
|
1061
|
+
// resident forms use:
|
|
1062
|
+
// GET /api/people/site/:site/occupied-structure - blocks/levels/units that
|
|
1063
|
+
// actually have an occupant, so the picker cannot offer an empty unit
|
|
1064
|
+
// GET /api/people/unit/:unit - who is in the chosen one
|
|
1065
|
+
// Both are fetched on FIRST USE, never when the dialog opens.
|
|
1066
|
+
const { getOccupiedStructure, getPeopleByUnit } = usePeople();
|
|
1067
|
+
|
|
1068
|
+
// `resident,tenant` matches the occupancy endpoint's own default, so the units
|
|
1069
|
+
// it offers and the people this returns are the same set. Asking the two
|
|
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
|
+
});
|
|
699
1428
|
|
|
700
1429
|
const cardForm = reactive({
|
|
701
1430
|
cardType: "pacs" as "pacs" | "csn",
|
|
@@ -705,23 +1434,37 @@ const cardForm = reactive({
|
|
|
705
1434
|
});
|
|
706
1435
|
|
|
707
1436
|
const cardReader = computed(() => {
|
|
708
|
-
const readerId = String(
|
|
709
|
-
|
|
1437
|
+
const readerId = String(
|
|
1438
|
+
cardUser.value?.reader || selectedReaderId.value || ""
|
|
1439
|
+
);
|
|
1440
|
+
return (
|
|
1441
|
+
readers.value.find((reader) => String(reader._id) === readerId) || null
|
|
1442
|
+
);
|
|
710
1443
|
});
|
|
711
|
-
const cardProfileConfigured = computed(() =>
|
|
712
|
-
|
|
713
|
-
)
|
|
714
|
-
|
|
1444
|
+
const cardProfileConfigured = computed(() =>
|
|
1445
|
+
["pacs", "csn", "csn_for_mifare"].includes(
|
|
1446
|
+
String(cardReader.value?.cardReadingMode || "")
|
|
1447
|
+
)
|
|
1448
|
+
);
|
|
1449
|
+
const hybridCardProfile = computed(
|
|
1450
|
+
() => cardReader.value?.cardReadingMode === "csn_for_mifare"
|
|
1451
|
+
);
|
|
715
1452
|
const hybridCardTypeOptions = [
|
|
716
1453
|
{ title: "MIFARE card (CSN)", value: "csn" },
|
|
717
1454
|
{ title: "Other card (PACS)", value: "pacs" },
|
|
718
1455
|
];
|
|
719
1456
|
const cardProfileLabel = computed(() => {
|
|
720
1457
|
const mode = String(cardReader.value?.cardReadingMode || "unconfigured");
|
|
721
|
-
if (mode === "pacs")
|
|
722
|
-
|
|
1458
|
+
if (mode === "pacs")
|
|
1459
|
+
return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
|
|
1460
|
+
if (mode === "csn")
|
|
1461
|
+
return `CSN · ${(
|
|
1462
|
+
cardReader.value?.csnByteOrder || "msb"
|
|
1463
|
+
).toUpperCase()} first`;
|
|
723
1464
|
if (mode === "csn_for_mifare") {
|
|
724
|
-
return `PACS for non-MIFARE cards · CSN for MIFARE (${(
|
|
1465
|
+
return `PACS for non-MIFARE cards · CSN for MIFARE (${(
|
|
1466
|
+
cardReader.value?.csnByteOrder || "msb"
|
|
1467
|
+
).toUpperCase()} first)`;
|
|
725
1468
|
}
|
|
726
1469
|
return "Configure the card data mode on the HID Reader screen before assigning a card.";
|
|
727
1470
|
});
|
|
@@ -738,6 +1481,8 @@ const userHeaders = [
|
|
|
738
1481
|
{ title: "Name", value: "name", sortable: false },
|
|
739
1482
|
{ title: "Facial Data", value: "facialData", sortable: false },
|
|
740
1483
|
{ 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 },
|
|
741
1486
|
{ title: "Status", value: "status", sortable: false },
|
|
742
1487
|
{ title: "", value: "action-table", sortable: false },
|
|
743
1488
|
];
|
|
@@ -749,8 +1494,12 @@ const cardHeaders = [
|
|
|
749
1494
|
{ title: "", value: "action-table", sortable: false },
|
|
750
1495
|
];
|
|
751
1496
|
|
|
752
|
-
const tableTitle = computed(() =>
|
|
753
|
-
|
|
1497
|
+
const tableTitle = computed(() =>
|
|
1498
|
+
props.cardManagement ? "HID Cards" : "HID Users"
|
|
1499
|
+
);
|
|
1500
|
+
const tableHeaders = computed(() =>
|
|
1501
|
+
props.cardManagement ? cardHeaders : userHeaders
|
|
1502
|
+
);
|
|
754
1503
|
|
|
755
1504
|
/**
|
|
756
1505
|
* The list is paged on the SERVER, so the shared pager only moves the cursor -
|
|
@@ -767,14 +1516,45 @@ function reloadUsersFromFirstPage() {
|
|
|
767
1516
|
loadUsers();
|
|
768
1517
|
}
|
|
769
1518
|
|
|
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
|
+
|
|
770
1545
|
const readerOptions = computed(() =>
|
|
771
1546
|
readers.value.map((reader) => ({
|
|
772
|
-
title: `${reader.name || reader.deviceId || reader._id} — ${
|
|
1547
|
+
title: `${reader.name || reader.deviceId || reader._id} — ${
|
|
1548
|
+
reader.portalName || "Portal not configured"
|
|
1549
|
+
}`,
|
|
773
1550
|
value: reader._id,
|
|
774
1551
|
}))
|
|
775
1552
|
);
|
|
776
|
-
const selectedReader = computed(
|
|
777
|
-
|
|
1553
|
+
const selectedReader = computed(
|
|
1554
|
+
() =>
|
|
1555
|
+
readers.value.find(
|
|
1556
|
+
(reader) => reader._id === (selectedReaderId.value || form.reader)
|
|
1557
|
+
) || null
|
|
778
1558
|
);
|
|
779
1559
|
|
|
780
1560
|
const pageRange = computed(() => {
|
|
@@ -801,15 +1581,28 @@ async function loadReaders() {
|
|
|
801
1581
|
const response = await getReaders({ site: props.site, page: 1, limit: 100 });
|
|
802
1582
|
const responseRecord = toRecord(response);
|
|
803
1583
|
const responseData = toRecord(responseRecord.data);
|
|
804
|
-
const items =
|
|
1584
|
+
const items =
|
|
1585
|
+
responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
|
|
805
1586
|
readers.value = (Array.isArray(items) ? items : [])
|
|
806
1587
|
.map(toHidReader)
|
|
807
1588
|
.filter((reader): reader is HidReader => reader !== null);
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1589
|
+
/* Shared with every other HID screen — see `useHidReaderSelection`. The
|
|
1590
|
+
resolver refuses a remembered reader that is not this site's, so a site
|
|
1591
|
+
switch or a deleted reader falls back to the first rather than querying
|
|
1592
|
+
an id the site does not own. */
|
|
1593
|
+
selectedReaderId.value = resolveForSite(props.site, readers.value);
|
|
811
1594
|
}
|
|
812
1595
|
|
|
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
|
+
|
|
813
1606
|
async function loadUsers() {
|
|
814
1607
|
if (!selectedReaderId.value) {
|
|
815
1608
|
users.value = [];
|
|
@@ -819,32 +1612,83 @@ async function loadUsers() {
|
|
|
819
1612
|
return;
|
|
820
1613
|
}
|
|
821
1614
|
|
|
1615
|
+
const sequence = loadUsersSequence.value + 1;
|
|
1616
|
+
loadUsersSequence.value = sequence;
|
|
822
1617
|
loading.value = true;
|
|
823
1618
|
try {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1619
|
+
// `userType: "user"` is REQUIRED here, not a nicety. The reader keeps
|
|
1620
|
+
// visitors in the same `users` table, marked `user_type_id = 1`, and the
|
|
1621
|
+
// API's default is `all`. Visitor rows are written by our own visitor-QR
|
|
1622
|
+
// issuance and are never removed - revoke only pushes `end_time` into the
|
|
1623
|
+
// past - so without this filter the enrollment roster counts every visitor
|
|
1624
|
+
// pass ever issued and drifts further from the reader's own Users screen
|
|
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
|
+
}
|
|
830
1651
|
const responseRecord = toRecord(response);
|
|
831
1652
|
const responseData = toRecord(responseRecord.data);
|
|
832
1653
|
const responseItems = responseRecord.items ?? responseData.items ?? [];
|
|
833
|
-
const
|
|
1654
|
+
const allItems = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
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;
|
|
834
1665
|
administratorUserIds.value = new Set(
|
|
835
1666
|
pageItems
|
|
836
1667
|
.filter((user) => user.isAdministrator === true)
|
|
837
1668
|
.map((user) => toHidNumericId(user.hidUserId))
|
|
838
|
-
.filter((userId: number | undefined): userId is number =>
|
|
1669
|
+
.filter((userId: number | undefined): userId is number =>
|
|
1670
|
+
Boolean(userId)
|
|
1671
|
+
)
|
|
839
1672
|
);
|
|
840
1673
|
users.value = pageItems;
|
|
841
|
-
total.value =
|
|
842
|
-
|
|
843
|
-
|
|
1674
|
+
total.value = clientFiltersVisitors
|
|
1675
|
+
? matchingItems.length
|
|
1676
|
+
: Number(
|
|
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 ?? "");
|
|
844
1687
|
if (props.cardManagement) {
|
|
845
1688
|
await loadVisibleUserCards(users.value);
|
|
846
1689
|
}
|
|
847
1690
|
} catch (error) {
|
|
1691
|
+
if (sequence !== loadUsersSequence.value) return;
|
|
848
1692
|
console.error("Unable to load HID reader users:", error);
|
|
849
1693
|
showToast("Unable to load HID users from reader.", "error");
|
|
850
1694
|
users.value = [];
|
|
@@ -855,11 +1699,12 @@ async function loadUsers() {
|
|
|
855
1699
|
pages.value = 1;
|
|
856
1700
|
serverPageRange.value = "";
|
|
857
1701
|
} finally {
|
|
858
|
-
|
|
1702
|
+
// Only the newest load owns the spinner; an older one finishing must not
|
|
1703
|
+
// clear it while that newer one is still running.
|
|
1704
|
+
if (sequence === loadUsersSequence.value) loading.value = false;
|
|
859
1705
|
}
|
|
860
1706
|
}
|
|
861
1707
|
|
|
862
|
-
|
|
863
1708
|
function resetForm() {
|
|
864
1709
|
form.reader = selectedReaderId.value || readers.value[0]?._id || "";
|
|
865
1710
|
form.block = "";
|
|
@@ -870,6 +1715,10 @@ function resetForm() {
|
|
|
870
1715
|
form.registration = "";
|
|
871
1716
|
form.isAdministrator = false;
|
|
872
1717
|
form.accessPin = "";
|
|
1718
|
+
form.password = "";
|
|
1719
|
+
passwordSet.value = false;
|
|
1720
|
+
showPassword.value = false;
|
|
1721
|
+
removePasswordRequested.value = false;
|
|
873
1722
|
pinEnrolled.value = false;
|
|
874
1723
|
showPin.value = false;
|
|
875
1724
|
removePinRequested.value = false;
|
|
@@ -877,34 +1726,201 @@ function resetForm() {
|
|
|
877
1726
|
selectedPhotoFile.value = null;
|
|
878
1727
|
removePhotoRequested.value = false;
|
|
879
1728
|
facialEnrolled.value = false;
|
|
880
|
-
|
|
1729
|
+
// The type the operator last enrolled, not a hard "resident": somebody adding
|
|
1730
|
+
// a shift of guards should not re-pick Member for each one.
|
|
1731
|
+
form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
|
|
881
1732
|
form.subjectId = "";
|
|
1733
|
+
form.subjectRole = "";
|
|
1734
|
+
residentConflict.value = null;
|
|
1735
|
+
checkingResident.value = false;
|
|
882
1736
|
}
|
|
883
1737
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Called when the Block select is first touched, not when the dialog opens.
|
|
1751
|
+
async function ensureOccupancy() {
|
|
1752
|
+
if (!props.site || occupancyLoaded.value || loadingOccupancy.value) return;
|
|
1753
|
+
loadingOccupancy.value = true;
|
|
1754
|
+
try {
|
|
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;
|
|
889
1779
|
}
|
|
890
|
-
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
async function loadUnitPeople() {
|
|
1783
|
+
unitPeople.value = [];
|
|
1784
|
+
if (!form.unit) return;
|
|
1785
|
+
loadingUnitPeople.value = true;
|
|
891
1786
|
try {
|
|
892
|
-
const response = await
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
category: form.subjectCategory,
|
|
896
|
-
page: 1,
|
|
897
|
-
limit: 500,
|
|
1787
|
+
const response = await getPeopleByUnit(form.unit, {
|
|
1788
|
+
status: "active",
|
|
1789
|
+
type: OCCUPANT_TYPES,
|
|
898
1790
|
});
|
|
899
|
-
|
|
1791
|
+
const record = toRecord(response);
|
|
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
|
+
);
|
|
900
1805
|
} finally {
|
|
901
|
-
|
|
1806
|
+
loadingUnitPeople.value = false;
|
|
902
1807
|
}
|
|
903
1808
|
}
|
|
904
1809
|
|
|
905
|
-
|
|
1810
|
+
function onBlockChanged() {
|
|
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() {
|
|
906
1834
|
form.subjectId = "";
|
|
907
|
-
|
|
1835
|
+
residentConflict.value = null;
|
|
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
|
+
}
|
|
908
1924
|
}
|
|
909
1925
|
|
|
910
1926
|
async function openEnroll() {
|
|
@@ -916,8 +1932,14 @@ async function openEnroll() {
|
|
|
916
1932
|
]);
|
|
917
1933
|
form.hidUserId = hidUserId;
|
|
918
1934
|
form.registration = registration;
|
|
919
|
-
await loadSubjectCandidates();
|
|
920
1935
|
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();
|
|
921
1943
|
}
|
|
922
1944
|
|
|
923
1945
|
async function openEdit(user: HidUser) {
|
|
@@ -931,6 +1953,10 @@ async function openEdit(user: HidUser) {
|
|
|
931
1953
|
form.registration = user.registration ?? "";
|
|
932
1954
|
form.isAdministrator = hasAdministratorRule(user);
|
|
933
1955
|
form.accessPin = "";
|
|
1956
|
+
form.password = "";
|
|
1957
|
+
showPassword.value = false;
|
|
1958
|
+
removePasswordRequested.value = false;
|
|
1959
|
+
passwordSet.value = Boolean(user.metadata?.passwordSet);
|
|
934
1960
|
pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
|
|
935
1961
|
removePinRequested.value = false;
|
|
936
1962
|
form.photoPreview = "";
|
|
@@ -938,24 +1964,39 @@ async function openEdit(user: HidUser) {
|
|
|
938
1964
|
form.subjectCategory = user.person
|
|
939
1965
|
? "resident"
|
|
940
1966
|
: user.serviceProvider
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
form.subjectId = String(
|
|
944
|
-
|
|
1967
|
+
? "service_provider"
|
|
1968
|
+
: "property_management";
|
|
1969
|
+
form.subjectId = String(
|
|
1970
|
+
user.person || user.member || user.serviceProvider || ""
|
|
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();
|
|
945
1985
|
selectedPhotoFile.value = null;
|
|
946
1986
|
removePhotoRequested.value = false;
|
|
947
1987
|
formDialog.value = true;
|
|
948
1988
|
|
|
949
1989
|
try {
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1990
|
+
const hidUserId = toHidNumericId(user.hidUserId);
|
|
1991
|
+
const [pinStatus, passwordStatus] = await Promise.all([
|
|
1992
|
+
getUserPinStatus(form.reader, hidUserId),
|
|
1993
|
+
getUserPasswordStatus(form.reader, hidUserId),
|
|
1994
|
+
]);
|
|
1995
|
+
pinEnrolled.value = Boolean(pinStatus?.data?.pinEnrolled);
|
|
1996
|
+
passwordSet.value = Boolean(passwordStatus?.data?.passwordSet);
|
|
955
1997
|
} catch {
|
|
956
1998
|
// Preserve the last known status if the reader is temporarily offline.
|
|
957
1999
|
}
|
|
958
|
-
|
|
959
2000
|
}
|
|
960
2001
|
|
|
961
2002
|
function openView(user: HidUser) {
|
|
@@ -1030,16 +2071,53 @@ function onPhotoChange(event: Event) {
|
|
|
1030
2071
|
async function saveUser() {
|
|
1031
2072
|
const readerId =
|
|
1032
2073
|
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
1033
|
-
|
|
1034
|
-
|
|
2074
|
+
// A resident is required to ENROL, because an identity has to be linked to
|
|
2075
|
+
// somebody. It is not required to edit: the reader holds users we never
|
|
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
|
+
);
|
|
1035
2097
|
return;
|
|
1036
2098
|
}
|
|
1037
2099
|
if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
|
|
1038
|
-
showToast("
|
|
2100
|
+
showToast("PIN must contain numbers only.", "error");
|
|
1039
2101
|
return;
|
|
1040
2102
|
}
|
|
1041
|
-
if (
|
|
1042
|
-
|
|
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
|
+
);
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// Re-asked here, not trusted from the select: the dialog can sit open while
|
|
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");
|
|
1043
2121
|
return;
|
|
1044
2122
|
}
|
|
1045
2123
|
|
|
@@ -1056,22 +2134,37 @@ async function saveUser() {
|
|
|
1056
2134
|
return;
|
|
1057
2135
|
}
|
|
1058
2136
|
|
|
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
|
+
|
|
1059
2164
|
const payload = {
|
|
1060
2165
|
hidUserId: String(hidUser.id),
|
|
1061
2166
|
registration: form.registration,
|
|
1062
|
-
|
|
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",
|
|
2167
|
+
...subjectLink,
|
|
1075
2168
|
status: "active" as const,
|
|
1076
2169
|
metadata: {
|
|
1077
2170
|
name: form.name,
|
|
@@ -1088,6 +2181,7 @@ async function saveUser() {
|
|
|
1088
2181
|
imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
|
|
1089
2182
|
facialScores: selectedUser.value?.metadata?.facialScores || {},
|
|
1090
2183
|
pinEnrolled: pinEnrolled.value,
|
|
2184
|
+
passwordSet: passwordSet.value,
|
|
1091
2185
|
hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
|
|
1092
2186
|
},
|
|
1093
2187
|
};
|
|
@@ -1097,17 +2191,22 @@ async function saveUser() {
|
|
|
1097
2191
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
1098
2192
|
applyFacialResult(payload.metadata, facialResult);
|
|
1099
2193
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
2194
|
+
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
1100
2195
|
if (selectedUser.value._id) {
|
|
1101
2196
|
await updateIdentity(selectedUser.value._id, payload);
|
|
1102
|
-
} else {
|
|
2197
|
+
} else if (form.subjectId) {
|
|
1103
2198
|
await saveIdentityOnReader(readerId, payload);
|
|
1104
2199
|
}
|
|
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.
|
|
1105
2203
|
} else {
|
|
1106
2204
|
const created = await createHidUserOnReader(readerId, hidUser);
|
|
1107
2205
|
if (created) newlyCreatedHidUserId = hidUser.id;
|
|
1108
2206
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
1109
2207
|
applyFacialResult(payload.metadata, facialResult);
|
|
1110
2208
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
2209
|
+
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
1111
2210
|
await saveIdentityOnReader(readerId, payload);
|
|
1112
2211
|
selectedReaderId.value = readerId;
|
|
1113
2212
|
}
|
|
@@ -1128,7 +2227,10 @@ async function saveUser() {
|
|
|
1128
2227
|
where: { users: { id: newlyCreatedHidUserId } },
|
|
1129
2228
|
});
|
|
1130
2229
|
} catch (rollbackError) {
|
|
1131
|
-
console.error(
|
|
2230
|
+
console.error(
|
|
2231
|
+
"Unable to roll back the partially enrolled HID user:",
|
|
2232
|
+
rollbackError
|
|
2233
|
+
);
|
|
1132
2234
|
}
|
|
1133
2235
|
}
|
|
1134
2236
|
console.error("Unable to save HID user:", error);
|
|
@@ -1143,6 +2245,25 @@ async function saveUser() {
|
|
|
1143
2245
|
}
|
|
1144
2246
|
}
|
|
1145
2247
|
|
|
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
|
+
|
|
1146
2267
|
async function syncAccessPin(readerId: string, hidUserId: number) {
|
|
1147
2268
|
if (removePinRequested.value) {
|
|
1148
2269
|
await deleteUserPin(readerId, hidUserId);
|
|
@@ -1222,7 +2343,7 @@ async function deleteUser() {
|
|
|
1222
2343
|
);
|
|
1223
2344
|
await deleteUserPin(
|
|
1224
2345
|
selectedReaderId.value,
|
|
1225
|
-
toHidNumericId(selectedUser.value.hidUserId)
|
|
2346
|
+
toHidNumericId(selectedUser.value.hidUserId)
|
|
1226
2347
|
);
|
|
1227
2348
|
await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
|
|
1228
2349
|
if (selectedUser.value._id) {
|
|
@@ -1241,7 +2362,7 @@ async function deleteUser() {
|
|
|
1241
2362
|
|
|
1242
2363
|
async function saveIdentityOnReader(
|
|
1243
2364
|
readerId: string,
|
|
1244
|
-
payload: HidIdentityPayload
|
|
2365
|
+
payload: HidIdentityPayload
|
|
1245
2366
|
) {
|
|
1246
2367
|
const existingIdentity = await findExistingIdentityOnReader(
|
|
1247
2368
|
readerId,
|
|
@@ -1253,7 +2374,11 @@ async function saveIdentityOnReader(
|
|
|
1253
2374
|
}
|
|
1254
2375
|
|
|
1255
2376
|
try {
|
|
1256
|
-
await createIdentity(readerId, {
|
|
2377
|
+
await createIdentity(readerId, {
|
|
2378
|
+
...payload,
|
|
2379
|
+
site: props.site,
|
|
2380
|
+
...(props.org ? { orgId: props.org } : {}),
|
|
2381
|
+
});
|
|
1257
2382
|
} catch (error: unknown) {
|
|
1258
2383
|
const message = getHidErrorMessage(error);
|
|
1259
2384
|
if (!message.toLowerCase().includes("already exists")) throw error;
|
|
@@ -1269,7 +2394,7 @@ async function saveIdentityOnReader(
|
|
|
1269
2394
|
|
|
1270
2395
|
async function findExistingIdentityOnReader(
|
|
1271
2396
|
readerId: string,
|
|
1272
|
-
payload: HidIdentityPayload
|
|
2397
|
+
payload: HidIdentityPayload
|
|
1273
2398
|
) {
|
|
1274
2399
|
const response = await getIdentities(readerId, {
|
|
1275
2400
|
page: 1,
|
|
@@ -1278,8 +2403,11 @@ async function findExistingIdentityOnReader(
|
|
|
1278
2403
|
});
|
|
1279
2404
|
const responseRecord = toRecord(response);
|
|
1280
2405
|
const responseData = toRecord(responseRecord.data);
|
|
1281
|
-
const responseItems =
|
|
1282
|
-
|
|
2406
|
+
const responseItems =
|
|
2407
|
+
responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
|
|
2408
|
+
const identities = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
2409
|
+
toHidUser
|
|
2410
|
+
);
|
|
1283
2411
|
const hidUserId = String(payload.hidUserId || "");
|
|
1284
2412
|
const registration = String(payload.registration || "");
|
|
1285
2413
|
const cardNo = String(payload.cardNo || "");
|
|
@@ -1296,7 +2424,7 @@ async function findExistingIdentityOnReader(
|
|
|
1296
2424
|
|
|
1297
2425
|
async function createHidUserOnReader(
|
|
1298
2426
|
readerId: string,
|
|
1299
|
-
user: { id?: number; name: string; registration: string }
|
|
2427
|
+
user: { id?: number; name: string; registration: string }
|
|
1300
2428
|
) {
|
|
1301
2429
|
const existing = await getHidUserFromReader(readerId, user.id);
|
|
1302
2430
|
if (existing) {
|
|
@@ -1330,7 +2458,7 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
|
|
|
1330
2458
|
|
|
1331
2459
|
async function updateHidUserOnReader(
|
|
1332
2460
|
readerId: string,
|
|
1333
|
-
user: { id?: number; name: string; registration: string }
|
|
2461
|
+
user: { id?: number; name: string; registration: string }
|
|
1334
2462
|
) {
|
|
1335
2463
|
const { id, ...values } = user;
|
|
1336
2464
|
await runObjectOperation(readerId, {
|
|
@@ -1345,10 +2473,7 @@ async function updateHidUserOnReader(
|
|
|
1345
2473
|
});
|
|
1346
2474
|
}
|
|
1347
2475
|
|
|
1348
|
-
async function deleteHidUserFromReader(
|
|
1349
|
-
readerId: string,
|
|
1350
|
-
user: HidUser,
|
|
1351
|
-
) {
|
|
2476
|
+
async function deleteHidUserFromReader(readerId: string, user: HidUser) {
|
|
1352
2477
|
const id = toHidNumericId(user.hidUserId);
|
|
1353
2478
|
if (!readerId || !id) return;
|
|
1354
2479
|
|
|
@@ -1436,7 +2561,9 @@ async function getHidUserRole(readerId: string, userId: number) {
|
|
|
1436
2561
|
return Array.isArray(roles) ? roles[0] : undefined;
|
|
1437
2562
|
}
|
|
1438
2563
|
|
|
1439
|
-
function toHidUserObject(
|
|
2564
|
+
function toHidUserObject(
|
|
2565
|
+
identity: Pick<HidUser, "hidUserId" | "name" | "registration">
|
|
2566
|
+
) {
|
|
1440
2567
|
return {
|
|
1441
2568
|
id: toHidNumericId(identity.hidUserId),
|
|
1442
2569
|
name:
|
|
@@ -1447,6 +2574,23 @@ function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registr
|
|
|
1447
2574
|
};
|
|
1448
2575
|
}
|
|
1449
2576
|
|
|
2577
|
+
function isVisitorUser(user: HidUser) {
|
|
2578
|
+
// The reader marks visitors with `user_type_id = 1`; a real user leaves the
|
|
2579
|
+
// field null. `metadata.hidUserTypeId` is what our API forwards it as.
|
|
2580
|
+
return Number(user.metadata?.hidUserTypeId ?? user.user_type_id) === 1;
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
function isUnsupportedUserTypeFilter(error: unknown) {
|
|
2584
|
+
const record = toRecord(error);
|
|
2585
|
+
const data = toRecord(record.data);
|
|
2586
|
+
const message = toText(data.message) || toText(record.message);
|
|
2587
|
+
const normalized = message.toLowerCase();
|
|
2588
|
+
return (
|
|
2589
|
+
normalized.includes("usertype") &&
|
|
2590
|
+
(normalized.includes("not allowed") || normalized.includes("unknown"))
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
1450
2594
|
function toHidNumericId(value: unknown) {
|
|
1451
2595
|
const raw = String(value ?? "").trim();
|
|
1452
2596
|
if (!raw) return undefined;
|
|
@@ -1475,16 +2619,19 @@ async function loadVisibleUserCards(items: HidUser[]) {
|
|
|
1475
2619
|
const response = await getUserCards(selectedReaderId.value, hidUserId);
|
|
1476
2620
|
const responseData = toRecord(toRecord(response).data);
|
|
1477
2621
|
const cards = responseData.cards;
|
|
1478
|
-
return [
|
|
2622
|
+
return [
|
|
2623
|
+
String(hidUserId),
|
|
2624
|
+
(Array.isArray(cards) ? cards : []).map(toHidCard),
|
|
2625
|
+
] as const;
|
|
1479
2626
|
} catch (error: unknown) {
|
|
1480
2627
|
cardErrors[String(hidUserId)] = getHidErrorMessage(error);
|
|
1481
2628
|
return [String(hidUserId), []] as const;
|
|
1482
2629
|
}
|
|
1483
|
-
})
|
|
2630
|
+
})
|
|
1484
2631
|
);
|
|
1485
2632
|
|
|
1486
2633
|
physicalCardsByUserId.value = Object.fromEntries(
|
|
1487
|
-
entries.filter(([hidUserId]) => Boolean(hidUserId))
|
|
2634
|
+
entries.filter(([hidUserId]) => Boolean(hidUserId))
|
|
1488
2635
|
);
|
|
1489
2636
|
physicalCardErrorsByUserId.value = cardErrors;
|
|
1490
2637
|
}
|
|
@@ -1496,12 +2643,16 @@ function getUserPhysicalCards(user: HidUser) {
|
|
|
1496
2643
|
|
|
1497
2644
|
function getUserCardLoadError(user: HidUser) {
|
|
1498
2645
|
const hidUserId = toHidNumericId(user.hidUserId);
|
|
1499
|
-
return hidUserId
|
|
2646
|
+
return hidUserId
|
|
2647
|
+
? physicalCardErrorsByUserId.value[String(hidUserId)] || ""
|
|
2648
|
+
: "";
|
|
1500
2649
|
}
|
|
1501
2650
|
|
|
1502
2651
|
async function openCardManager(user: HidUser) {
|
|
1503
2652
|
cardUser.value = user;
|
|
1504
|
-
const reader = readers.value.find(
|
|
2653
|
+
const reader = readers.value.find(
|
|
2654
|
+
(item) => String(item._id) === String(user.reader || selectedReaderId.value)
|
|
2655
|
+
);
|
|
1505
2656
|
cardForm.cardType = reader?.cardReadingMode === "pacs" ? "pacs" : "csn";
|
|
1506
2657
|
cardForm.facilityCode = "";
|
|
1507
2658
|
cardForm.cardNumber = "";
|
|
@@ -1512,7 +2663,9 @@ async function openCardManager(user: HidUser) {
|
|
|
1512
2663
|
}
|
|
1513
2664
|
|
|
1514
2665
|
function getCardReaderAndUser() {
|
|
1515
|
-
const readerId = String(
|
|
2666
|
+
const readerId = String(
|
|
2667
|
+
cardUser.value?.reader || selectedReaderId.value || ""
|
|
2668
|
+
);
|
|
1516
2669
|
const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
|
|
1517
2670
|
return { readerId, hidUserId };
|
|
1518
2671
|
}
|
|
@@ -1535,7 +2688,11 @@ async function loadPhysicalCards() {
|
|
|
1535
2688
|
}
|
|
1536
2689
|
}
|
|
1537
2690
|
|
|
1538
|
-
function parsePhysicalCardInteger(
|
|
2691
|
+
function parsePhysicalCardInteger(
|
|
2692
|
+
value: string,
|
|
2693
|
+
label: string,
|
|
2694
|
+
maximum: bigint
|
|
2695
|
+
) {
|
|
1539
2696
|
if (!/^\d+$/.test(value.trim())) {
|
|
1540
2697
|
showToast(`${label} must contain numbers only.`, "error");
|
|
1541
2698
|
return null;
|
|
@@ -1556,14 +2713,27 @@ async function assignPhysicalCard() {
|
|
|
1556
2713
|
| { cardType: "pacs"; facilityCode: string; cardNumber: string }
|
|
1557
2714
|
| { cardType: "csn"; cardValue: string };
|
|
1558
2715
|
if (cardForm.cardType === "pacs") {
|
|
1559
|
-
const facilityCode = parsePhysicalCardInteger(
|
|
1560
|
-
|
|
2716
|
+
const facilityCode = parsePhysicalCardInteger(
|
|
2717
|
+
cardForm.facilityCode,
|
|
2718
|
+
"Facility Code",
|
|
2719
|
+
4294967295n
|
|
2720
|
+
);
|
|
2721
|
+
const cardNumber = parsePhysicalCardInteger(
|
|
2722
|
+
cardForm.cardNumber,
|
|
2723
|
+
"Card Number",
|
|
2724
|
+
4294967295n
|
|
2725
|
+
);
|
|
1561
2726
|
if (facilityCode === null || cardNumber === null) return;
|
|
1562
2727
|
payload = { cardType: "pacs", facilityCode, cardNumber };
|
|
1563
2728
|
} else {
|
|
1564
|
-
const cardValue = parsePhysicalCardInteger(
|
|
2729
|
+
const cardValue = parsePhysicalCardInteger(
|
|
2730
|
+
cardForm.cardValue,
|
|
2731
|
+
"CSN",
|
|
2732
|
+
18446744073709551615n
|
|
2733
|
+
);
|
|
1565
2734
|
if (cardValue === null || BigInt(cardValue) <= 0n) {
|
|
1566
|
-
if (cardValue === "0")
|
|
2735
|
+
if (cardValue === "0")
|
|
2736
|
+
showToast("CSN must be greater than zero.", "error");
|
|
1567
2737
|
return;
|
|
1568
2738
|
}
|
|
1569
2739
|
payload = { cardType: "csn", cardValue };
|
|
@@ -1585,6 +2755,14 @@ async function assignPhysicalCard() {
|
|
|
1585
2755
|
}
|
|
1586
2756
|
}
|
|
1587
2757
|
|
|
2758
|
+
/**
|
|
2759
|
+
* Read a physical card by presenting it to the reader.
|
|
2760
|
+
*
|
|
2761
|
+
* The way most cards get assigned, because it is the only one that does not
|
|
2762
|
+
* require knowing the facility code and card number in advance - neither is
|
|
2763
|
+
* printed on the card, and an operator holding a blank fob has no way to look
|
|
2764
|
+
* them up. The reader listens for 30 seconds and reports what it read.
|
|
2765
|
+
*/
|
|
1588
2766
|
async function enrollPhysicalCard() {
|
|
1589
2767
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
1590
2768
|
if (!readerId || !hidUserId) return;
|
|
@@ -1597,6 +2775,8 @@ async function enrollPhysicalCard() {
|
|
|
1597
2775
|
await loadUsers();
|
|
1598
2776
|
showToast("Physical HID card read and enrolled on the reader.");
|
|
1599
2777
|
} catch (error: unknown) {
|
|
2778
|
+
// Cancelling rejects the request above. That is the operator getting what
|
|
2779
|
+
// they asked for, so it must not also be reported as a failure.
|
|
1600
2780
|
if (!cardEnrollmentCancelled.value) {
|
|
1601
2781
|
showToast(getHidErrorMessage(error), "error");
|
|
1602
2782
|
}
|
|
@@ -1605,20 +2785,55 @@ async function enrollPhysicalCard() {
|
|
|
1605
2785
|
}
|
|
1606
2786
|
}
|
|
1607
2787
|
|
|
2788
|
+
/**
|
|
2789
|
+
* Stop the reader listening.
|
|
2790
|
+
*
|
|
2791
|
+
* Told to the DEVICE, not just the screen: abandoning the request on this side
|
|
2792
|
+
* would leave the reader waiting out its 30 seconds and enrolling whatever card
|
|
2793
|
+
* was presented in the meantime.
|
|
2794
|
+
*/
|
|
2795
|
+
/**
|
|
2796
|
+
* Stop the reader listening, and hand the dialog straight back.
|
|
2797
|
+
*
|
|
2798
|
+
* Told to the DEVICE, not just the screen: abandoning the request on this side
|
|
2799
|
+
* would leave the reader waiting out its remaining seconds and enrolling
|
|
2800
|
+
* whatever card was presented in the meantime.
|
|
2801
|
+
*
|
|
2802
|
+
* The part worth understanding is what the ENROL request does next. The server
|
|
2803
|
+
* cannot tell a cancelled read from a failed one - the reader simply reports no
|
|
2804
|
+
* card - so the enrol that is still in flight ends as a 400, seconds after the
|
|
2805
|
+
* cancel. Waiting for it was the bug: Cancel appeared to do nothing until that
|
|
2806
|
+
* rejection finally arrived.
|
|
2807
|
+
*
|
|
2808
|
+
* So the dialog is released as soon as the DEVICE confirms it has stopped, and
|
|
2809
|
+
* the doomed enrol is left to land on its own. `cardEnrollmentCancelled` is set
|
|
2810
|
+
* before anything is awaited, which is what keeps its 400 silent.
|
|
2811
|
+
*/
|
|
1608
2812
|
async function cancelPhysicalCardEnrollment() {
|
|
1609
2813
|
const { readerId } = getCardReaderAndUser();
|
|
1610
2814
|
if (!readerId) return;
|
|
2815
|
+
// Set first, awaited second: the enrol can reject at any point from here.
|
|
1611
2816
|
cardEnrollmentCancelled.value = true;
|
|
2817
|
+
cancellingCard.value = true;
|
|
1612
2818
|
try {
|
|
1613
2819
|
await cancelUserCardEnrollment(readerId);
|
|
2820
|
+
// Only now, because the enrollment lock is released by the call above.
|
|
2821
|
+
// Re-enabling Read at Reader any earlier invites a second attempt that
|
|
2822
|
+
// the reader refuses with "already running".
|
|
2823
|
+
enrollingCard.value = false;
|
|
1614
2824
|
showToast("Physical HID card reading cancelled.");
|
|
1615
2825
|
} catch (error: unknown) {
|
|
2826
|
+
// The cancel itself failing is real and worth saying: the reader may still
|
|
2827
|
+
// be listening, so the dialog stays in its reading state.
|
|
1616
2828
|
showToast(getHidErrorMessage(error), "error");
|
|
2829
|
+
} finally {
|
|
2830
|
+
cancellingCard.value = false;
|
|
1617
2831
|
}
|
|
1618
2832
|
}
|
|
1619
2833
|
|
|
1620
2834
|
async function removePhysicalCard(card: HidCard) {
|
|
1621
|
-
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
|
|
2835
|
+
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
|
|
2836
|
+
return;
|
|
1622
2837
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
1623
2838
|
if (!readerId || !hidUserId || !card?.id) return;
|
|
1624
2839
|
|
|
@@ -1644,13 +2859,38 @@ function formatPhysicalCard(card: HidCard) {
|
|
|
1644
2859
|
}
|
|
1645
2860
|
|
|
1646
2861
|
function getPhysicalCardSubtitle(card: HidCard) {
|
|
1647
|
-
const source =
|
|
2862
|
+
const source =
|
|
2863
|
+
card?.source === "reader"
|
|
2864
|
+
? "Read at reader"
|
|
2865
|
+
: card?.source === "manual"
|
|
2866
|
+
? "Manual"
|
|
2867
|
+
: "On device";
|
|
1648
2868
|
return `${source} - Raw value ${card?.value || "N/A"}`;
|
|
1649
2869
|
}
|
|
1650
2870
|
|
|
1651
2871
|
function buildUnitLabel() {
|
|
1652
|
-
|
|
1653
|
-
|
|
2872
|
+
// `form.block/level/unit` hold ObjectIds, so the label has to resolve them
|
|
2873
|
+
// back to names through the occupancy tree. Joining the raw values would
|
|
2874
|
+
// stamp "665f.../665f.../665f..." onto the record and into the Unit column.
|
|
2875
|
+
const block = occupiedBlocks.value.find(
|
|
2876
|
+
(item) => String(item._id ?? "") === form.block
|
|
2877
|
+
);
|
|
2878
|
+
const level = (block?.levels ?? []).find(
|
|
2879
|
+
(item) => String(item._id ?? "") === form.level
|
|
2880
|
+
);
|
|
2881
|
+
const unit = (level?.units ?? []).find(
|
|
2882
|
+
(item) => String(item._id ?? "") === form.unit
|
|
2883
|
+
);
|
|
2884
|
+
const parts = [
|
|
2885
|
+
formatBlockTitle(block),
|
|
2886
|
+
String(level?.name ?? ""),
|
|
2887
|
+
String(unit?.name ?? ""),
|
|
2888
|
+
].filter(Boolean);
|
|
2889
|
+
// Editing a record enrolled before the cascade existed: keep its stored
|
|
2890
|
+
// label rather than blanking it.
|
|
2891
|
+
if (!parts.length)
|
|
2892
|
+
return String(selectedUser.value?.metadata?.unitLabel ?? "");
|
|
2893
|
+
return parts.join(" / ");
|
|
1654
2894
|
}
|
|
1655
2895
|
|
|
1656
2896
|
function getName(user?: HidUser | null) {
|
|
@@ -1737,27 +2977,28 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
1737
2977
|
const responseRecord = toRecord(response);
|
|
1738
2978
|
const responseData = toRecord(responseRecord.data);
|
|
1739
2979
|
const nestedData = toRecord(responseData.data);
|
|
1740
|
-
const items =
|
|
2980
|
+
const items =
|
|
2981
|
+
responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
|
|
1741
2982
|
|
|
1742
2983
|
return Array.isArray(items)
|
|
1743
2984
|
? items.map((value) => {
|
|
1744
2985
|
const user = toRecord(value);
|
|
1745
2986
|
return {
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
name: toText(user.name),
|
|
1753
|
-
metadata: {
|
|
2987
|
+
_rowKey: `reader-${user.id}`,
|
|
2988
|
+
hidUserId: String(user.id || ""),
|
|
2989
|
+
rawHidUserId: toText(user.id),
|
|
2990
|
+
registration: toText(user.registration),
|
|
2991
|
+
userTypeId: toText(user.user_type_id),
|
|
2992
|
+
cardNo: toText(user.card_value || user.cardNo),
|
|
1754
2993
|
name: toText(user.name),
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
:
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
2994
|
+
metadata: {
|
|
2995
|
+
name: toText(user.name),
|
|
2996
|
+
imageTimestamp: user.image_timestamp
|
|
2997
|
+
? String(user.image_timestamp)
|
|
2998
|
+
: "",
|
|
2999
|
+
lastAccessAt: fromUnixSeconds(user.last_access),
|
|
3000
|
+
hidReaderSource: true,
|
|
3001
|
+
},
|
|
1761
3002
|
};
|
|
1762
3003
|
})
|
|
1763
3004
|
: [];
|
|
@@ -1765,7 +3006,7 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
1765
3006
|
|
|
1766
3007
|
function mergeReaderUsersWithIdentities(
|
|
1767
3008
|
readerUsers: HidUser[],
|
|
1768
|
-
identities: HidUser[]
|
|
3009
|
+
identities: HidUser[]
|
|
1769
3010
|
) {
|
|
1770
3011
|
const identityById = new Map<string, HidUser>();
|
|
1771
3012
|
identities.forEach((identity) => {
|
|
@@ -1808,7 +3049,13 @@ function getMappingStatus(user: HidUser) {
|
|
|
1808
3049
|
}
|
|
1809
3050
|
|
|
1810
3051
|
function hasMappedRecord(user: HidUser) {
|
|
1811
|
-
return Boolean(
|
|
3052
|
+
return Boolean(
|
|
3053
|
+
user?.person ||
|
|
3054
|
+
user?.user ||
|
|
3055
|
+
user?.member ||
|
|
3056
|
+
user?.serviceProvider ||
|
|
3057
|
+
user?.visitor
|
|
3058
|
+
);
|
|
1812
3059
|
}
|
|
1813
3060
|
|
|
1814
3061
|
function formatDate(value?: string) {
|
|
@@ -1840,18 +3087,26 @@ function getHidErrorMessage(error: unknown) {
|
|
|
1840
3087
|
const cause = toRecord(errorRecord.cause);
|
|
1841
3088
|
const causeResponse = toRecord(cause.response);
|
|
1842
3089
|
const data = toRecord(
|
|
1843
|
-
errorRecord.data ||
|
|
3090
|
+
errorRecord.data ||
|
|
3091
|
+
response._data ||
|
|
3092
|
+
response.data ||
|
|
3093
|
+
cause.data ||
|
|
3094
|
+
causeResponse._data
|
|
1844
3095
|
);
|
|
1845
3096
|
|
|
1846
3097
|
return toText(
|
|
1847
|
-
data.message ||
|
|
1848
|
-
|
|
3098
|
+
data.message ||
|
|
3099
|
+
data.error ||
|
|
3100
|
+
data.statusMessage ||
|
|
3101
|
+
errorRecord.statusMessage ||
|
|
3102
|
+
errorRecord.message,
|
|
3103
|
+
"Unable to save HID user."
|
|
1849
3104
|
);
|
|
1850
3105
|
}
|
|
1851
3106
|
|
|
1852
3107
|
function toRecord(value: unknown): Record<string, unknown> {
|
|
1853
3108
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
1854
|
-
? value as Record<string, unknown>
|
|
3109
|
+
? (value as Record<string, unknown>)
|
|
1855
3110
|
: {};
|
|
1856
3111
|
}
|
|
1857
3112
|
|
|
@@ -1897,10 +3152,24 @@ function toHidCard(value: unknown): HidCard {
|
|
|
1897
3152
|
return {
|
|
1898
3153
|
...card,
|
|
1899
3154
|
id: toText(card.id) || undefined,
|
|
1900
|
-
cardType:
|
|
1901
|
-
|
|
3155
|
+
cardType:
|
|
3156
|
+
card.cardType === "pacs" || card.cardType === "csn"
|
|
3157
|
+
? card.cardType
|
|
3158
|
+
: undefined,
|
|
3159
|
+
source:
|
|
3160
|
+
card.source === "reader" ||
|
|
3161
|
+
card.source === "manual" ||
|
|
3162
|
+
card.source === "device"
|
|
3163
|
+
? card.source
|
|
3164
|
+
: undefined,
|
|
1902
3165
|
};
|
|
1903
3166
|
}
|
|
3167
|
+
|
|
3168
|
+
/* Publish every change, including the seed above: a screen that had to fall
|
|
3169
|
+
back to the first reader should leave the others agreeing with it. */
|
|
3170
|
+
watch(selectedReaderId, (value) => {
|
|
3171
|
+
if (value) selectHidReader(props.site, value);
|
|
3172
|
+
});
|
|
1904
3173
|
</script>
|
|
1905
3174
|
|
|
1906
3175
|
<style scoped>
|
|
@@ -1937,12 +3206,6 @@ function toHidCard(value: unknown): HidCard {
|
|
|
1937
3206
|
max-width: 320px;
|
|
1938
3207
|
}
|
|
1939
3208
|
|
|
1940
|
-
.small-title {
|
|
1941
|
-
font-size: 14px;
|
|
1942
|
-
font-weight: 700;
|
|
1943
|
-
color: var(--text);
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
3209
|
/* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
|
|
1947
3210
|
.field-label {
|
|
1948
3211
|
color: var(--text2);
|
|
@@ -1964,12 +3227,42 @@ function toHidCard(value: unknown): HidCard {
|
|
|
1964
3227
|
line-height: 1.45;
|
|
1965
3228
|
}
|
|
1966
3229
|
|
|
3230
|
+
/* Same line, saying the field is refused rather than explained. `--err` is the
|
|
3231
|
+
token the required-field asterisk already uses. */
|
|
3232
|
+
.field-hint--error {
|
|
3233
|
+
color: var(--err);
|
|
3234
|
+
}
|
|
3235
|
+
|
|
1967
3236
|
.photo-wrap {
|
|
1968
3237
|
display: grid;
|
|
1969
3238
|
place-items: center;
|
|
1970
3239
|
margin-bottom: 14px;
|
|
1971
3240
|
}
|
|
1972
3241
|
|
|
3242
|
+
.photo-facial {
|
|
3243
|
+
margin: 10px 0 0;
|
|
3244
|
+
text-align: center;
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
/* The same small muted word `.field-label` uses, so the caption reads as a
|
|
3248
|
+
field label rather than as a second heading. */
|
|
3249
|
+
.photo-facial__label {
|
|
3250
|
+
display: block;
|
|
3251
|
+
color: var(--muted);
|
|
3252
|
+
font-size: 12px;
|
|
3253
|
+
font-weight: 600;
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
.photo-facial__value {
|
|
3257
|
+
display: block;
|
|
3258
|
+
margin-top: 2px;
|
|
3259
|
+
font-size: 13.5px;
|
|
3260
|
+
font-weight: 500;
|
|
3261
|
+
/* The reader's reference is a long unbroken number; let it wrap rather than
|
|
3262
|
+
widen the dialog. */
|
|
3263
|
+
word-break: break-all;
|
|
3264
|
+
}
|
|
3265
|
+
|
|
1973
3266
|
.photo-button {
|
|
1974
3267
|
width: 120px;
|
|
1975
3268
|
height: 120px;
|
|
@@ -2003,7 +3296,6 @@ function toHidCard(value: unknown): HidCard {
|
|
|
2003
3296
|
box-shadow: var(--shadow-menu);
|
|
2004
3297
|
}
|
|
2005
3298
|
|
|
2006
|
-
|
|
2007
3299
|
.admin-rule-indicator {
|
|
2008
3300
|
display: inline-flex;
|
|
2009
3301
|
vertical-align: middle;
|
|
@@ -2143,25 +3435,6 @@ function toHidCard(value: unknown): HidCard {
|
|
|
2143
3435
|
color: var(--muted);
|
|
2144
3436
|
}
|
|
2145
3437
|
|
|
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
|
-
|
|
2165
3438
|
@media (max-width: 960px) {
|
|
2166
3439
|
.hid-user-filters :deep(.app-field),
|
|
2167
3440
|
.hid-user-filters :deep(.app-select) {
|