@7365admin1/layer-common 4.81.1-staging.467 → 4.81.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +10 -4
  2. package/assets/css/primitives.css +0 -57
  3. package/components/AccessCardDetailsDialog.vue +4 -57
  4. package/components/AccessCardPreviewDialog.vue +50 -291
  5. package/components/AppSelect.vue +4 -143
  6. package/components/BuildingUnitFormEdit.vue +0 -6
  7. package/components/Dialog/UpdateMoreAction.vue +1 -11
  8. package/components/DocumentForm.vue +112 -407
  9. package/components/DocumentManagement.vue +12 -213
  10. package/components/Facility/BookingSetup.vue +0 -63
  11. package/components/HidAccessLogDashboard.vue +17 -74
  12. package/components/HidIntercomManagement.vue +14 -183
  13. package/components/HidQrCodeConfiguration.vue +195 -17
  14. package/components/HidReaderManagement.vue +0 -398
  15. package/components/HidReaderUserRoster.vue +3 -44
  16. package/components/HidUserEnrollment.vue +248 -1521
  17. package/components/InvitationClientForm.vue +1 -19
  18. package/components/VisitorForm.vue +0 -12
  19. package/components/VisitorManagement.vue +5 -267
  20. package/composables/useDocument.ts +0 -7
  21. package/composables/useFacility.ts +0 -11
  22. package/composables/useHidAmico.ts +0 -165
  23. package/composables/useHidNavigation.ts +1 -25
  24. package/composables/useMember.ts +0 -12
  25. package/composables/usePeople.ts +0 -19
  26. package/composables/useServiceProvider.ts +2 -40
  27. package/package.json +1 -1
  28. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  29. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  30. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  31. package/types/document.d.ts +0 -14
  32. package/types/facility.d.ts +0 -4
  33. package/types/member.d.ts +0 -4
  34. package/types/people.d.ts +1 -28
  35. package/types/service-provider.d.ts +0 -5
  36. package/components/BuildingUnitManagement.vue +0 -298
  37. package/components/HidAccessPermissions.vue +0 -424
  38. package/components/HidProfileQrCode.vue +0 -332
  39. package/composables/useHidReaderSelection.ts +0 -50
  40. package/composables/useSiteCategory.ts +0 -45
  41. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  42. package/utils/hid-enrolment-subject.ts +0 -144
  43. package/utils/hid-permission-assignments.ts +0 -190
  44. package/utils/hid-reader-selection.ts +0 -52
  45. package/utils/occupancy-role.ts +0 -109
  46. package/utils/service-type.ts +0 -30
@@ -25,11 +25,7 @@
25
25
  the old table card, so it stays above the shared one. -->
26
26
  <v-alert
27
27
  v-if="selectedReader"
28
- :type="
29
- selectedReader.portalId && selectedReader.portalName
30
- ? 'info'
31
- : 'warning'
32
- "
28
+ :type="selectedReader.portalId && selectedReader.portalName ? 'info' : 'warning'"
33
29
  variant="tonal"
34
30
  density="compact"
35
31
  class="mb-4"
@@ -54,9 +50,7 @@
54
50
  @update:page="goToPage"
55
51
  >
56
52
  <template v-if="!props.cardManagement" #actions>
57
- <AppButton icon="mdi-account-plus" @click="openEnroll"
58
- >Enroll</AppButton
59
- >
53
+ <AppButton icon="mdi-account-plus" @click="openEnroll">Enroll</AppButton>
60
54
  </template>
61
55
 
62
56
  <!-- The design puts filters on their own full-width row under the
@@ -73,8 +67,8 @@
73
67
  <AppField
74
68
  v-model="search"
75
69
  search
76
- placeholder="Search name, UID or registration no."
77
- @keyup.enter="onSearchSubmit"
70
+ placeholder="Search"
71
+ @keyup.enter="reloadUsersFromFirstPage"
78
72
  />
79
73
  <AppSelect
80
74
  v-if="!props.cardManagement"
@@ -99,15 +93,7 @@
99
93
  </template>
100
94
 
101
95
  <template #[`item.hidUserId`]="{ item }">
102
- <span class="app-cell--num">{{
103
- formatHidUid(item.hidUserId) || "N/A"
104
- }}</span>
105
- </template>
106
-
107
- <!-- The reader allocates this alongside the UID. A row the reader holds
108
- that we never enrolled has none, so it is not always present. -->
109
- <template #[`item.registration`]="{ item }">
110
- <span class="app-cell--num">{{ item.registration || "N/A" }}</span>
96
+ <span class="app-cell--num">{{ formatHidUid(item.hidUserId) || "N/A" }}</span>
111
97
  </template>
112
98
 
113
99
  <!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
@@ -127,10 +113,7 @@
127
113
  {{ formatPhysicalCard(card) }}
128
114
  </span>
129
115
  </div>
130
- <span
131
- v-else-if="getUserCardLoadError(item)"
132
- class="hid-card-load-error"
133
- >
116
+ <span v-else-if="getUserCardLoadError(item)" class="hid-card-load-error">
134
117
  Unable to load
135
118
  <v-tooltip activator="parent" location="top">
136
119
  {{ getUserCardLoadError(item) }}
@@ -150,20 +133,13 @@
150
133
  </AppButton>
151
134
  <v-menu v-else>
152
135
  <template #activator="{ props: menuProps }">
153
- <AppButton
154
- v-bind="menuProps"
155
- variant="row"
156
- icon="mdi-dots-vertical"
157
- />
136
+ <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
158
137
  </template>
159
138
 
160
139
  <v-list density="compact" min-width="150">
161
140
  <v-list-item title="View" @click="openView(item)" />
162
141
  <v-list-item title="Edit" @click="openEdit(item)" />
163
- <v-list-item
164
- title="Manage HID Cards"
165
- @click="openCardManager(item)"
166
- />
142
+ <v-list-item title="Manage HID Cards" @click="openCardManager(item)" />
167
143
  <v-list-item
168
144
  title="Delete"
169
145
  class="text-error"
@@ -179,31 +155,23 @@
179
155
  <template #no-data>
180
156
  <div class="table-card__empty">
181
157
  <v-icon
182
- :icon="
183
- props.cardManagement
184
- ? 'mdi-card-account-details-outline'
185
- : 'mdi-account-off-outline'
186
- "
158
+ :icon="props.cardManagement ? 'mdi-card-account-details-outline' : 'mdi-account-off-outline'"
187
159
  size="32"
188
160
  />
189
161
  <span>
190
- {{
191
- props.cardManagement
192
- ? "No HID users available for card assignment."
193
- : "No HID users enrolled yet."
194
- }}
162
+ {{ props.cardManagement ? "No HID users available for card assignment." : "No HID users enrolled yet." }}
195
163
  </span>
196
164
  </div>
197
165
  </template>
198
166
  </TableMain>
199
167
 
200
168
  <v-dialog v-model="formDialog" max-width="420" persistent>
201
- <v-card class="screen-modal">
202
- <v-card-title>
169
+ <v-card rounded="lg">
170
+ <v-card-title class="small-title">
203
171
  {{ selectedUser ? "Edit User Information" : "Enroll" }}
204
172
  </v-card-title>
205
173
 
206
- <v-card-text class="screen-modal__body">
174
+ <v-card-text>
207
175
  <div class="photo-wrap">
208
176
  <v-menu>
209
177
  <template #activator="{ props: menuProps }">
@@ -245,15 +213,6 @@
245
213
  />
246
214
  </v-list>
247
215
  </v-menu>
248
- <!-- Sits under the button because it describes what the button
249
- is reporting: the reader holds a face, and this is its
250
- reference. Nothing to show while enrolling - the value does not
251
- exist until the reader has accepted a photo. -->
252
- <p v-if="editFacialData" class="photo-facial">
253
- <span class="photo-facial__label">Facial Data</span>
254
- <span class="photo-facial__value">{{ editFacialData }}</span>
255
- </p>
256
-
257
216
  <input
258
217
  ref="photoInput"
259
218
  type="file"
@@ -263,197 +222,45 @@
263
222
  />
264
223
  </div>
265
224
 
266
- <!-- 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>
225
+ <div class="field-label">Name <span>*</span></div>
226
+ <AppField
227
+ v-model="form.name"
228
+ aria-label="Name"
229
+ placeholder="Enter name"
230
+ klass="mb-3"
231
+ />
232
+
233
+ <div class="field-label">Link To <span>*</span></div>
276
234
  <AppSelect
277
- v-if="!selectedUser"
278
235
  v-model="form.subjectCategory"
279
236
  :items="subjectCategoryOptions"
237
+ placeholder="Select person type"
280
238
  klass="mb-3"
281
239
  @update:model-value="onSubjectCategoryChanged"
282
240
  />
283
- <p v-else class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
284
-
285
- <!-- Block / Level / Unit / Resident. Enrolment only.
286
- Enrollment is resident-only and the resident must already exist
287
- in iService365 - you pick a person, you do not type one. Name is
288
- therefore a mirror of that record, never an input, so the reader
289
- can never end up holding a name the app does not know.
290
- Every option is derived from the residents themselves, so a
291
- block, level or unit only appears when somebody lives there.
292
- The list loads on first touch of Block, not on dialog open.
293
- Enrolment only, with no exception. Editing does not move
294
- anybody: the record already carries its block, level and unit,
295
- `openEdit` loads them into the form and `saveUser` writes them
296
- back untouched, so the location survives an edit without being
297
- asked for. A row that never had one keeps not having one; that
298
- is not a reason to make somebody fill in a location before they
299
- can change a PIN. -->
300
- <template v-if="!selectedUser && isResidentSubject">
301
- <div class="field-label">Block <span>*</span></div>
302
- <!-- The listener sits on a wrapper, NOT on AppSelect: AppSelect
303
- spreads `$attrs` over its v-menu activator AFTER the activator's
304
- own bindings, so an `@click` here would replace the handler that
305
- opens the dropdown. `focusin` covers keyboard users, and both
306
- are idempotent - `ensureOccupancy()` fetches once.
307
- AppSelect has no `loading` prop either; the placeholder carries
308
- the loading and empty states instead. -->
309
- <div @click="onBlockOpened" @focusin="onBlockOpened">
310
- <AppSelect
311
- v-model="form.block"
312
- :items="blockOptions"
313
- :placeholder="blockPlaceholder"
314
- klass="mb-3"
315
- @update:model-value="onBlockChanged"
316
- />
317
- </div>
318
-
319
- <div class="field-label">Level <span>*</span></div>
320
- <AppSelect
321
- v-model="form.level"
322
- :items="levelOptions"
323
- :disabled="!form.block"
324
- placeholder="Select level"
325
- klass="mb-3"
326
- @update:model-value="onLevelChanged"
327
- />
328
-
329
- <div class="field-label">Unit <span>*</span></div>
330
- <AppSelect
331
- v-model="form.unit"
332
- :items="unitOptions"
333
- :disabled="!form.level"
334
- placeholder="Select unit"
335
- klass="mb-3"
336
- @update:model-value="onUnitChanged"
337
- />
338
- </template>
339
-
340
- <!-- Drawn only where it can offer something: while enrolling, or
341
- while editing a record whose stored unit gives it a list. A row
342
- with no unit has no candidates to show, and the dialog is then
343
- purely the reader's own credentials - photo, PIN, password -
344
- which is exactly the edit that must not be blocked. -->
345
- <template v-if="isResidentSubject && (!selectedUser || form.unit)">
346
- <div class="field-label">Resident <span>*</span></div>
347
- <AppSelect
348
- v-model="form.subjectId"
349
- :items="subjectOptions"
350
- :disabled="!form.unit"
351
- :placeholder="unitResidentPlaceholder"
352
- klass="mb-3"
353
- @update:model-value="onResidentChanged"
354
- />
355
- </template>
356
-
357
- <!-- STAFF. One picker, because a member has no unit to narrow by.
358
- The candidates are the permission subjects the Access Permissions
359
- screen already reads - active members of this site or its
360
- organisation, each carrying its role as a subtitle. Filtering is
361
- AppSelect's own, over the loaded page, so typing costs no request.
362
- A member with no app account is not in the list at all: reader
363
- access is bound by user id, so there is nothing to bind. The note
364
- below says how many were left out rather than letting a missing
365
- colleague read as a bug. -->
366
- <template v-if="!selectedUser && !isResidentSubject">
367
- <div class="field-label">Member <span>*</span></div>
368
- <AppSelect
369
- v-model="form.subjectId"
370
- :items="memberOptions"
371
- searchable
372
- search-placeholder="Search staff by name or role"
373
- :placeholder="memberPlaceholder"
374
- :has-more="hasMoreMembers"
375
- :loading-more="loadingMoreMembers"
376
- klass="mb-3"
377
- @update:model-value="onMemberChanged"
378
- @load-more="loadMoreMembers"
379
- />
380
- <!-- Ten at a time, because that page size shares the Members
381
- menu's cache entry. The rest arrive as the list is scrolled, so
382
- this only has to SAY where the list has got to - and warn that
383
- the search box filters what is loaded, not the whole estate. -->
384
- <p v-if="memberRangeNote" class="field-hint mb-3">
385
- {{ memberRangeNote }}
386
- </p>
387
- <p v-if="hiddenMemberNote" class="field-hint mb-3">
388
- {{ hiddenMemberNote }}
389
- </p>
390
- </template>
391
-
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
241
 
426
- <div class="field-label">Name</div>
427
- <AppField
428
- v-model="form.name"
429
- aria-label="Name"
430
- :placeholder="
431
- isResidentSubject
432
- ? 'Select a resident above'
433
- : 'Select a member above'
434
- "
435
- readonly
242
+ <div class="field-label">Resident / Staff / Provider <span>*</span></div>
243
+ <AppSelect
244
+ v-model="form.subjectId"
245
+ :items="subjectOptions"
246
+ :loading="loadingSubjects"
247
+ placeholder="Select an existing site record"
436
248
  klass="mb-3"
437
249
  />
438
250
 
439
251
  <div class="field-label">ID</div>
440
- <AppField
441
- v-model="form.hidUserId"
442
- aria-label="ID"
443
- readonly
444
- klass="mb-3"
445
- />
252
+ <AppField v-model="form.hidUserId" aria-label="ID" readonly klass="mb-3" />
446
253
 
447
254
  <div class="field-label">Registration No. <span>*</span></div>
448
255
  <AppField
449
256
  v-model="form.registration"
450
257
  aria-label="Registration No."
451
258
  placeholder="Enter registration no."
452
- readonly
259
+ :readonly="!selectedUser"
453
260
  klass="mb-3"
454
261
  />
455
262
 
456
- <div class="field-label">PIN (optional)</div>
263
+ <div class="field-label">Access PIN (optional)</div>
457
264
  <!--
458
265
  The handoff draws no password field anywhere (the sign-in screens
459
266
  are the owner's own), so the reveal toggle is this product's, not
@@ -463,8 +270,8 @@
463
270
  -->
464
271
  <AppField
465
272
  v-model="form.accessPin"
466
- aria-label="PIN"
467
- placeholder="Enter pin"
273
+ aria-label="Access PIN"
274
+ placeholder="Enter numeric access PIN"
468
275
  inputmode="numeric"
469
276
  autocomplete="new-password"
470
277
  :type="showPin ? 'text' : 'password'"
@@ -473,7 +280,7 @@
473
280
  <button
474
281
  type="button"
475
282
  class="app-field__trailing"
476
- :aria-label="showPin ? 'Hide PIN' : 'Show PIN'"
283
+ :aria-label="showPin ? 'Hide access PIN' : 'Show access PIN'"
477
284
  @click="showPin = !showPin"
478
285
  >
479
286
  <v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
@@ -483,82 +290,33 @@
483
290
  <!-- `persistent-hint` was Vuetify's; the design's settings module
484
291
  draws an explanatory line as 12-13px/600 in `--muted`. -->
485
292
  <p class="field-hint">
486
- {{
487
- selectedUser && pinEnrolled
488
- ? "Leave blank to keep the current PIN."
489
- : "PIN works as an alternative to face or QR."
490
- }}
293
+ {{ selectedUser && pinEnrolled ? "Leave blank to keep the current PIN." : "PIN works as an alternative to face or QR." }}
491
294
  </p>
492
295
  <v-checkbox
493
296
  v-if="selectedUser && pinEnrolled"
494
297
  v-model="removePinRequested"
495
- label="Remove current PIN"
496
- density="compact"
497
- hide-details
498
- />
499
-
500
- <!-- Password: the reader stores only a hash plus its salt, so there
501
- is nothing to read back and the field is always empty on open.
502
- Same reveal toggle as the PIN above. -->
503
- <div class="field-label mt-3">Password (optional)</div>
504
- <AppField
505
- v-model="form.password"
506
- aria-label="Password"
507
- :placeholder="
508
- selectedUser && passwordSet
509
- ? 'Enter a new password'
510
- : 'Enter password'
511
- "
512
- autocomplete="new-password"
513
- :type="showPassword ? 'text' : 'password'"
514
- >
515
- <template #trailing>
516
- <button
517
- type="button"
518
- class="app-field__trailing"
519
- :aria-label="showPassword ? 'Hide password' : 'Show password'"
520
- @click="showPassword = !showPassword"
521
- >
522
- <v-icon
523
- :icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
524
- size="18"
525
- />
526
- </button>
527
- </template>
528
- </AppField>
529
- <p class="field-hint">
530
- {{
531
- selectedUser && passwordSet
532
- ? "A password is set. Leave blank to keep it."
533
- : "Used with the user ID on the reader keypad."
534
- }}
535
- </p>
536
- <v-checkbox
537
- v-if="selectedUser && passwordSet"
538
- v-model="removePasswordRequested"
539
- label="Remove current password"
298
+ label="Remove current access PIN"
540
299
  density="compact"
541
300
  hide-details
542
301
  />
543
302
  </v-card-text>
544
303
 
545
- <!-- The 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">
304
+ <!-- The split footer bar, as panel 2 keeps it: `v-btn` because it needs
305
+ `:loading` and a full-bleed shape the handoff does not draw. Only
306
+ the colours change - they were fixed hexes on both themes. -->
307
+ <v-card-actions class="pa-0">
550
308
  <v-btn
551
- class="text-none screen-btn-ghost"
309
+ class="text-none action-cancel"
552
310
  variant="flat"
311
+ height="44"
553
312
  @click="formDialog = false"
313
+ >Cancel</v-btn
554
314
  >
555
- Cancel
556
- </v-btn>
557
315
  <v-btn
558
- class="text-none screen-btn-primary"
316
+ class="text-none action-submit"
559
317
  variant="flat"
318
+ height="44"
560
319
  :loading="saving"
561
- :disabled="Boolean(residentConflict) || checkingResident"
562
320
  @click="saveUser"
563
321
  >
564
322
  Save
@@ -570,9 +328,9 @@
570
328
  <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
571
329
 
572
330
  <v-dialog v-model="viewDialog" max-width="430">
573
- <v-card class="screen-modal">
574
- <v-card-title>User Information</v-card-title>
575
- <v-card-text class="screen-modal__body">
331
+ <v-card rounded="lg">
332
+ <v-card-title class="small-title">User Information</v-card-title>
333
+ <v-card-text>
576
334
  <div class="detail-grid">
577
335
  <span>Name</span><strong>{{ getName(selectedUser) }}</strong>
578
336
  <span>User ID</span
@@ -589,28 +347,21 @@
589
347
  ><strong>{{
590
348
  selectedUser ? getMappingStatus(selectedUser) : "N/A"
591
349
  }}</strong>
592
- <span>PIN</span
593
- ><strong>{{
594
- selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A"
595
- }}</strong>
596
- <span>Password</span
597
- ><strong>{{
598
- selectedUser?.metadata?.passwordSet ? "Configured" : "N/A"
599
- }}</strong>
350
+ <span>Access PIN</span
351
+ ><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
600
352
  </div>
601
353
  </v-card-text>
602
- <v-card-actions class="screen-modal__footer">
603
- <AppButton variant="ghost" @click="viewDialog = false"
604
- >Close</AppButton
605
- >
354
+ <v-card-actions>
355
+ <v-spacer />
356
+ <AppButton variant="ghost" @click="viewDialog = false">Close</AppButton>
606
357
  </v-card-actions>
607
358
  </v-card>
608
359
  </v-dialog>
609
360
 
610
361
  <v-dialog v-model="cardDialog" max-width="560" persistent>
611
- <v-card class="screen-modal">
612
- <v-card-title>Physical HID Cards</v-card-title>
613
- <v-card-text class="screen-modal__body">
362
+ <v-card rounded="lg">
363
+ <v-card-title class="small-title">Physical HID Cards</v-card-title>
364
+ <v-card-text>
614
365
  <p class="hid-card-user">
615
366
  {{ getName(cardUser) }}
616
367
  <span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
@@ -618,11 +369,7 @@
618
369
 
619
370
  <v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
620
371
  <div v-else-if="physicalCards.length" class="hid-card-list mb-4">
621
- <div
622
- v-for="card in physicalCards"
623
- :key="card.id"
624
- class="hid-card-row"
625
- >
372
+ <div v-for="card in physicalCards" :key="card.id" class="hid-card-row">
626
373
  <v-icon icon="mdi-card-account-details-outline" size="22" />
627
374
  <div class="hid-card-row__content">
628
375
  <strong>{{ formatPhysicalCard(card) }}</strong>
@@ -636,13 +383,7 @@
636
383
  />
637
384
  </div>
638
385
  </div>
639
- <v-alert
640
- v-else
641
- type="info"
642
- variant="tonal"
643
- density="compact"
644
- class="mb-4"
645
- >
386
+ <v-alert v-else type="info" variant="tonal" density="compact" class="mb-4">
646
387
  No physical HID card is assigned to this user.
647
388
  </v-alert>
648
389
 
@@ -656,8 +397,7 @@
656
397
  {{ cardProfileLabel }}
657
398
  </v-alert>
658
399
  <p class="field-hint hid-card-type-hint">
659
- PACS uses Facility Code + Card Number. CSN uses the raw serial
660
- number read from the physical card.
400
+ PACS uses Facility Code + Card Number. CSN uses the raw serial number read from the physical card.
661
401
  </p>
662
402
  <template v-if="hybridCardProfile">
663
403
  <div class="field-label mt-3">Presented Card Type</div>
@@ -668,13 +408,11 @@
668
408
  class="mb-3"
669
409
  />
670
410
  <p class="field-hint hid-card-type-hint">
671
- Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
672
- card in this reader mode.
411
+ Choose MIFARE CSN for a MIFARE card. Choose PACS for any other card in this reader mode.
673
412
  </p>
674
413
  </template>
675
414
  <p class="field-hint hid-card-access-hint">
676
- The card uses this HID user's existing portal and access rules;
677
- assigning a card does not grant new access.
415
+ The card uses this HID user's existing portal and access rules; assigning a card does not grant new access.
678
416
  </p>
679
417
 
680
418
  <div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
@@ -711,15 +449,9 @@
711
449
  density="compact"
712
450
  class="mt-4"
713
451
  >
714
- Present the physical card to the selected Amico reader. The request
715
- will time out after 30 seconds.
452
+ Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
716
453
  </v-alert>
717
454
 
718
- <!-- Reading the card at the reader is the primary way in: it is the
719
- only one that does not require knowing the facility code and card
720
- number up front, which are printed on nothing the operator holds.
721
- Assigning by hand stays for the cards whose values are already
722
- known. -->
723
455
  <div class="hid-card-actions mt-4">
724
456
  <AppButton
725
457
  variant="secondary"
@@ -738,21 +470,15 @@
738
470
  <AppButton
739
471
  v-if="enrollingCard"
740
472
  variant="ghost"
741
- :disabled="cancellingCard"
742
473
  @click="cancelPhysicalCardEnrollment"
743
474
  >
744
- {{ cancellingCard ? "Cancelling…" : "Cancel Reading" }}
475
+ Cancel Reading
745
476
  </AppButton>
746
477
  </div>
747
478
  </v-card-text>
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
- >
479
+ <v-card-actions>
480
+ <v-spacer />
481
+ <AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
756
482
  Close
757
483
  </AppButton>
758
484
  </v-card-actions>
@@ -760,31 +486,31 @@
760
486
  </v-dialog>
761
487
 
762
488
  <v-dialog v-model="deleteDialog" max-width="360" persistent>
763
- <v-card class="screen-modal">
764
- <v-card-title>Delete</v-card-title>
765
- <v-card-text class="screen-modal__body text-body-2">
489
+ <v-card rounded="lg">
490
+ <v-card-title class="small-title">Delete</v-card-title>
491
+ <v-card-text class="text-body-2">
766
492
  Are you sure you want to permanently delete this HID user?
767
493
  <div class="text-caption text-medium-emphasis mt-2">
768
494
  This action will remove the user from the HID reader and mark the
769
495
  identity as deleted.
770
496
  </div>
771
497
  </v-card-text>
772
- <v-card-actions class="screen-modal__footer">
498
+ <v-card-actions class="pa-0">
773
499
  <v-btn
774
- class="text-none screen-btn-ghost"
500
+ class="text-none action-cancel"
775
501
  variant="flat"
502
+ height="44"
776
503
  @click="deleteDialog = false"
504
+ >Cancel</v-btn
777
505
  >
778
- Cancel
779
- </v-btn>
780
506
  <v-btn
781
- class="text-none screen-btn-primary"
507
+ class="text-none action-submit"
782
508
  variant="flat"
509
+ height="44"
783
510
  :loading="deleting"
784
511
  @click="deleteUser"
512
+ >Delete</v-btn
785
513
  >
786
- Delete
787
- </v-btn>
788
514
  </v-card-actions>
789
515
  </v-card>
790
516
  </v-dialog>
@@ -796,30 +522,11 @@
796
522
  </template>
797
523
 
798
524
  <script setup lang="ts">
799
- // Shared with the Access Permissions screen: `paginate` never echoes the page it
800
- // was asked for, and `items` sits at the root on some of these endpoints and
801
- // under `data` on others. This reads both, so the mistake is made once.
802
- import {
803
- HID_ENROLMENT_SUBJECTS,
804
- hiddenSubjectNote,
805
- memberCandidateOf,
806
- memberHasAccount,
807
- readMemberPage,
808
- subjectCategoryLabel as labelForSubjectCategory,
809
- } from "../utils/hid-enrolment-subject";
810
-
811
525
  const props = defineProps({
812
526
  site: {
813
527
  type: String,
814
528
  required: true,
815
529
  },
816
- // Which organisation the access this enrolment grants belongs to. Saving now
817
- // assigns the person to the reader, and the assignment is stored per
818
- // organisation - see `schemaCreateHidAmicoIdentity`.
819
- org: {
820
- type: String,
821
- default: "",
822
- },
823
530
  cardManagement: {
824
531
  type: Boolean,
825
532
  default: false,
@@ -840,16 +547,12 @@ const {
840
547
  getUserPinStatus,
841
548
  setUserPin,
842
549
  deleteUserPin,
843
- getUserPasswordStatus,
844
- setUserPassword,
845
- deleteUserPassword,
846
550
  runObjectOperation,
847
551
  createIdentity,
848
552
  updateIdentity,
849
553
  deleteIdentity,
554
+ getPermissionCandidates,
850
555
  } = useHidAmico();
851
- const { getAll: getAllMembers } = useMember();
852
- const runtimeConfig = useRuntimeConfig();
853
556
 
854
557
  type HidMetadata = Record<string, unknown> & {
855
558
  name?: string;
@@ -863,7 +566,6 @@ type HidMetadata = Record<string, unknown> & {
863
566
  lastAccessAt?: string;
864
567
  facialScores?: Record<string, unknown>;
865
568
  pinEnrolled?: boolean;
866
- passwordSet?: boolean;
867
569
  hidPhysicalCards?: HidCard[];
868
570
  };
869
571
 
@@ -914,7 +616,6 @@ type HidIdentityPayload = Record<string, unknown> & {
914
616
  const readers = ref<HidReader[]>([]);
915
617
  const users = ref<HidUser[]>([]);
916
618
  const administratorUserIds = ref<Set<number>>(new Set());
917
- const { selectHidReader, resolveForSite } = useHidReaderSelection();
918
619
  const selectedReaderId = ref("");
919
620
  const search = ref("");
920
621
  const status = ref("");
@@ -938,14 +639,7 @@ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
938
639
  const loadingCards = ref(false);
939
640
  const savingCard = ref(false);
940
641
  const enrollingCard = ref(false);
941
- /*
942
- * Cancelling makes the in-flight enrol REJECT, and that rejection is expected
943
- * rather than a failure. Without this flag the cancel raises an error toast on
944
- * top of the one confirming it, which reads as the cancel having gone wrong.
945
- */
946
642
  const cardEnrollmentCancelled = ref(false);
947
- /** The cancel's own round trip to the reader — a second or two, not the enrol's. */
948
- const cancellingCard = ref(false);
949
643
  const deletingCardId = ref("");
950
644
  const showPin = ref(false);
951
645
  const pinEnrolled = ref(false);
@@ -963,79 +657,17 @@ const facialEnrolled = ref(false);
963
657
  function hasFacialEnrollment(user?: HidUser | null) {
964
658
  return Boolean(
965
659
  user?.metadata?.facialEnrolled ||
966
- user?.metadata?.facialData ||
967
- user?.metadata?.imageTimestamp
660
+ user?.metadata?.facialData ||
661
+ user?.metadata?.imageTimestamp
968
662
  );
969
663
  }
970
-
971
- /**
972
- * The reader's own reference for the enrolled face, shown while editing.
973
- *
974
- * We hold neither the image nor the biometric template - the reader does - so
975
- * this value is the only handle on the enrollment there is. `getFacialData` is
976
- * the roster's Facial Data column, reused here so the dialog and the table
977
- * cannot disagree; it answers "N/A" when there is nothing, which is the case
978
- * this must not draw a row for.
979
- */
980
- const editFacialData = computed(() => {
981
- if (!selectedUser.value) return "";
982
- const value = String(getFacialData(selectedUser.value) ?? "").trim();
983
- return value && value !== "N/A" ? value : "";
984
- });
985
664
  const removePinRequested = ref(false);
986
- const passwordSet = ref(false);
987
- const showPassword = ref(false);
988
- const removePasswordRequested = ref(false);
989
665
  const snackbar = reactive({
990
666
  show: false,
991
667
  color: "success",
992
668
  message: "",
993
669
  });
994
670
 
995
- type OccupiedUnit = { _id: string; name: string; people: number };
996
- type OccupiedLevel = { _id: string; name: string; units?: OccupiedUnit[] };
997
- type OccupiedBlock = {
998
- _id: string;
999
- name: string;
1000
- block: number | null;
1001
- levels?: OccupiedLevel[];
1002
- };
1003
- type UnitPerson = {
1004
- _id: string;
1005
- name?: string;
1006
- unitName?: string;
1007
- /**
1008
- * The resident's app account, and the reason this field is read here.
1009
- *
1010
- * A HID identity is refused unless its subject is a valid permission subject
1011
- * at the site, and for a resident that means a `site.people` row WITH a
1012
- * linked user - `assertPermissionSubject` in `iservice365-core` filters on
1013
- * exactly this field. The permission model has no way to represent anyone
1014
- * else: `resolvePermissionUserBindings` binds reader access by user id and
1015
- * drops every row without one, so a resident enrolled without an account
1016
- * would end up on the reader holding no access rules at all.
1017
- *
1018
- * The picker this replaced was fed by that same server-side function, so it
1019
- * could never offer an unenrollable resident. The unit cascade asks People
1020
- * Management instead, which has no such filter - hence this one.
1021
- */
1022
- user?: string | null;
1023
- };
1024
-
1025
- /**
1026
- * THE SUBJECT TYPE THIS SCREEN WAS LAST USED FOR.
1027
- *
1028
- * Module scope, not component scope, and deliberately: the dialog is rebuilt on
1029
- * every open and the component itself is unmounted when the operator moves
1030
- * between HID screens, so a local ref would forget the choice immediately.
1031
- * Enrolling ten guards in a row should not mean picking "Member" ten times.
1032
- *
1033
- * Not persisted. Surviving a hard refresh is a smaller benefit than surviving
1034
- * the dialog, and `useHidReaderSelection` already set the precedent for where
1035
- * that line sits.
1036
- */
1037
- const REMEMBERED_SUBJECT_CATEGORY = ref<THidPermissionCategory>("resident");
1038
-
1039
671
  const form = reactive({
1040
672
  reader: "",
1041
673
  block: "",
@@ -1046,385 +678,24 @@ const form = reactive({
1046
678
  registration: "",
1047
679
  isAdministrator: false,
1048
680
  accessPin: "",
1049
- password: "",
1050
681
  photoPreview: "",
1051
- subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
682
+ subjectCategory: "resident" as THidPermissionCategory,
1052
683
  subjectId: "",
1053
- /** A mirror of the chosen member's role. Never sent; display only. */
1054
- subjectRole: "",
1055
684
  });
1056
685
 
1057
686
  const route = useRoute();
1058
687
  const orgId = computed(() => String(route.params.org || ""));
1059
-
1060
- // 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
- });
688
+ const permissionCandidates = ref<THidPermissionCandidate[]>([]);
689
+ const loadingSubjects = ref(false);
690
+ const subjectCategoryOptions = [
691
+ { title: "Resident", value: "resident" },
692
+ { title: "Property management", value: "property_management" },
693
+ { title: "Service provider", value: "service_provider" },
694
+ ];
695
+ const subjectOptions = computed(() => permissionCandidates.value.map((candidate) => ({
696
+ title: candidate.subtitle ? `${candidate.name} · ${candidate.subtitle}` : candidate.name,
697
+ value: candidate.subjectId,
698
+ })));
1428
699
 
1429
700
  const cardForm = reactive({
1430
701
  cardType: "pacs" as "pacs" | "csn",
@@ -1434,37 +705,23 @@ const cardForm = reactive({
1434
705
  });
1435
706
 
1436
707
  const cardReader = computed(() => {
1437
- const readerId = String(
1438
- cardUser.value?.reader || selectedReaderId.value || ""
1439
- );
1440
- return (
1441
- readers.value.find((reader) => String(reader._id) === readerId) || null
1442
- );
708
+ const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
709
+ return readers.value.find((reader) => String(reader._id) === readerId) || null;
1443
710
  });
1444
- const cardProfileConfigured = computed(() =>
1445
- ["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
- );
711
+ const cardProfileConfigured = computed(() => ["pacs", "csn", "csn_for_mifare"].includes(
712
+ String(cardReader.value?.cardReadingMode || ""),
713
+ ));
714
+ const hybridCardProfile = computed(() => cardReader.value?.cardReadingMode === "csn_for_mifare");
1452
715
  const hybridCardTypeOptions = [
1453
716
  { title: "MIFARE card (CSN)", value: "csn" },
1454
717
  { title: "Other card (PACS)", value: "pacs" },
1455
718
  ];
1456
719
  const cardProfileLabel = computed(() => {
1457
720
  const mode = String(cardReader.value?.cardReadingMode || "unconfigured");
1458
- if (mode === "pacs")
1459
- return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
1460
- if (mode === "csn")
1461
- return `CSN · ${(
1462
- cardReader.value?.csnByteOrder || "msb"
1463
- ).toUpperCase()} first`;
721
+ if (mode === "pacs") return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
722
+ if (mode === "csn") return `CSN · ${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first`;
1464
723
  if (mode === "csn_for_mifare") {
1465
- return `PACS for non-MIFARE cards · CSN for MIFARE (${(
1466
- cardReader.value?.csnByteOrder || "msb"
1467
- ).toUpperCase()} first)`;
724
+ return `PACS for non-MIFARE cards · CSN for MIFARE (${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first)`;
1468
725
  }
1469
726
  return "Configure the card data mode on the HID Reader screen before assigning a card.";
1470
727
  });
@@ -1481,8 +738,6 @@ const userHeaders = [
1481
738
  { title: "Name", value: "name", sortable: false },
1482
739
  { title: "Facial Data", value: "facialData", sortable: false },
1483
740
  { title: "UID", value: "hidUserId", sortable: false },
1484
- // Reads in the same order the dialog asks for them: UID then registration.
1485
- { title: "Registration No.", value: "registration", sortable: false },
1486
741
  { title: "Status", value: "status", sortable: false },
1487
742
  { title: "", value: "action-table", sortable: false },
1488
743
  ];
@@ -1494,12 +749,8 @@ const cardHeaders = [
1494
749
  { title: "", value: "action-table", sortable: false },
1495
750
  ];
1496
751
 
1497
- const tableTitle = computed(() =>
1498
- props.cardManagement ? "HID Cards" : "HID Users"
1499
- );
1500
- const tableHeaders = computed(() =>
1501
- props.cardManagement ? cardHeaders : userHeaders
1502
- );
752
+ const tableTitle = computed(() => props.cardManagement ? "HID Cards" : "HID Users");
753
+ const tableHeaders = computed(() => props.cardManagement ? cardHeaders : userHeaders);
1503
754
 
1504
755
  /**
1505
756
  * The list is paged on the SERVER, so the shared pager only moves the cursor -
@@ -1516,45 +767,14 @@ function reloadUsersFromFirstPage() {
1516
767
  loadUsers();
1517
768
  }
1518
769
 
1519
- /**
1520
- * Typing is the search.
1521
- *
1522
- * The field used to reload on `@keyup.enter` and on nothing else, so a screen
1523
- * that looks like every other search box did nothing at all unless you guessed
1524
- * that Enter was required - no request left the browser.
1525
- *
1526
- * Debounced, because each reload is a login/load/logout round trip to a reader
1527
- * over a VPN, not a database query. Enter still works and skips the wait.
1528
- */
1529
- const SEARCH_DEBOUNCE_MS = 400;
1530
- let searchDebounce: ReturnType<typeof setTimeout> | undefined;
1531
-
1532
- function onSearchInput() {
1533
- clearTimeout(searchDebounce);
1534
- searchDebounce = setTimeout(reloadUsersFromFirstPage, SEARCH_DEBOUNCE_MS);
1535
- }
1536
-
1537
- function onSearchSubmit() {
1538
- clearTimeout(searchDebounce);
1539
- reloadUsersFromFirstPage();
1540
- }
1541
-
1542
- watch(search, onSearchInput);
1543
- onBeforeUnmount(() => clearTimeout(searchDebounce));
1544
-
1545
770
  const readerOptions = computed(() =>
1546
771
  readers.value.map((reader) => ({
1547
- title: `${reader.name || reader.deviceId || reader._id} — ${
1548
- reader.portalName || "Portal not configured"
1549
- }`,
772
+ title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
1550
773
  value: reader._id,
1551
774
  }))
1552
775
  );
1553
- const selectedReader = computed(
1554
- () =>
1555
- readers.value.find(
1556
- (reader) => reader._id === (selectedReaderId.value || form.reader)
1557
- ) || null
776
+ const selectedReader = computed(() =>
777
+ readers.value.find((reader) => reader._id === (selectedReaderId.value || form.reader)) || null,
1558
778
  );
1559
779
 
1560
780
  const pageRange = computed(() => {
@@ -1581,28 +801,15 @@ async function loadReaders() {
1581
801
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
1582
802
  const responseRecord = toRecord(response);
1583
803
  const responseData = toRecord(responseRecord.data);
1584
- const items =
1585
- responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
804
+ const items = responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
1586
805
  readers.value = (Array.isArray(items) ? items : [])
1587
806
  .map(toHidReader)
1588
807
  .filter((reader): reader is HidReader => reader !== null);
1589
- /* 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);
808
+ if (!selectedReaderId.value && readers.value.length) {
809
+ selectedReaderId.value = readers.value[0]._id;
810
+ }
1594
811
  }
1595
812
 
1596
- /**
1597
- * Discards a load whose answer arrives after a newer one started.
1598
- *
1599
- * Each reload is a slow round trip to a reader over a VPN, and typing now fires
1600
- * them, so "abc" can have three in flight at once. Without this the reply to
1601
- * "a" can land last and repopulate the table with the wrong rows. Same monotonic
1602
- * counter `HidAccessLogDashboard` uses.
1603
- */
1604
- const loadUsersSequence = ref(0);
1605
-
1606
813
  async function loadUsers() {
1607
814
  if (!selectedReaderId.value) {
1608
815
  users.value = [];
@@ -1612,83 +819,32 @@ async function loadUsers() {
1612
819
  return;
1613
820
  }
1614
821
 
1615
- const sequence = loadUsersSequence.value + 1;
1616
- loadUsersSequence.value = sequence;
1617
822
  loading.value = true;
1618
823
  try {
1619
- // `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
- }
824
+ const response = await getReaderUsers(selectedReaderId.value, {
825
+ page: page.value,
826
+ limit: limit.value,
827
+ search: search.value.trim(),
828
+ status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
829
+ });
1651
830
  const responseRecord = toRecord(response);
1652
831
  const responseData = toRecord(responseRecord.data);
1653
832
  const responseItems = responseRecord.items ?? responseData.items ?? [];
1654
- const 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;
833
+ const pageItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
1665
834
  administratorUserIds.value = new Set(
1666
835
  pageItems
1667
836
  .filter((user) => user.isAdministrator === true)
1668
837
  .map((user) => toHidNumericId(user.hidUserId))
1669
- .filter((userId: number | undefined): userId is number =>
1670
- Boolean(userId)
1671
- )
838
+ .filter((userId: number | undefined): userId is number => Boolean(userId)),
1672
839
  );
1673
840
  users.value = pageItems;
1674
- total.value = 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 ?? "");
841
+ total.value = Number(responseRecord.total ?? responseData.total ?? users.value.length);
842
+ pages.value = Number(responseRecord.pages ?? responseData.pages ?? 1);
843
+ serverPageRange.value = String(responseRecord.pageRange ?? responseData.pageRange ?? "");
1687
844
  if (props.cardManagement) {
1688
845
  await loadVisibleUserCards(users.value);
1689
846
  }
1690
847
  } catch (error) {
1691
- if (sequence !== loadUsersSequence.value) return;
1692
848
  console.error("Unable to load HID reader users:", error);
1693
849
  showToast("Unable to load HID users from reader.", "error");
1694
850
  users.value = [];
@@ -1699,12 +855,11 @@ async function loadUsers() {
1699
855
  pages.value = 1;
1700
856
  serverPageRange.value = "";
1701
857
  } finally {
1702
- // 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;
858
+ loading.value = false;
1705
859
  }
1706
860
  }
1707
861
 
862
+
1708
863
  function resetForm() {
1709
864
  form.reader = selectedReaderId.value || readers.value[0]?._id || "";
1710
865
  form.block = "";
@@ -1715,10 +870,6 @@ function resetForm() {
1715
870
  form.registration = "";
1716
871
  form.isAdministrator = false;
1717
872
  form.accessPin = "";
1718
- form.password = "";
1719
- passwordSet.value = false;
1720
- showPassword.value = false;
1721
- removePasswordRequested.value = false;
1722
873
  pinEnrolled.value = false;
1723
874
  showPin.value = false;
1724
875
  removePinRequested.value = false;
@@ -1726,201 +877,34 @@ function resetForm() {
1726
877
  selectedPhotoFile.value = null;
1727
878
  removePhotoRequested.value = false;
1728
879
  facialEnrolled.value = false;
1729
- // 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;
880
+ form.subjectCategory = "resident";
1732
881
  form.subjectId = "";
1733
- form.subjectRole = "";
1734
- residentConflict.value = null;
1735
- checkingResident.value = false;
1736
- }
1737
-
1738
- /** Surface what the API actually said - a bare "unable to load" hides the
1739
- * difference between a permission refusal, a bad id and a server fault. */
1740
- function apiReason(error: unknown) {
1741
- const record = toRecord(error);
1742
- const data = toRecord(record.data);
1743
- return (
1744
- toText(data.message) ||
1745
- toText(record.message) ||
1746
- toText(record.statusMessage)
1747
- );
1748
882
  }
1749
883
 
1750
- // 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;
884
+ async function loadSubjectCandidates() {
885
+ const readerId = form.reader || selectedReaderId.value;
886
+ if (!readerId || !orgId.value) {
887
+ permissionCandidates.value = [];
888
+ return;
1779
889
  }
1780
- }
1781
-
1782
- async function loadUnitPeople() {
1783
- unitPeople.value = [];
1784
- if (!form.unit) return;
1785
- loadingUnitPeople.value = true;
890
+ loadingSubjects.value = true;
1786
891
  try {
1787
- const response = await getPeopleByUnit(form.unit, {
1788
- status: "active",
1789
- type: OCCUPANT_TYPES,
892
+ const response = await getPermissionCandidates(props.site, {
893
+ orgId: orgId.value,
894
+ readerId,
895
+ category: form.subjectCategory,
896
+ page: 1,
897
+ limit: 500,
1790
898
  });
1791
- 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
- );
899
+ permissionCandidates.value = response.items ?? response.data?.items ?? [];
1805
900
  } finally {
1806
- loadingUnitPeople.value = false;
901
+ loadingSubjects.value = false;
1807
902
  }
1808
903
  }
1809
904
 
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() {
905
+ async function onSubjectCategoryChanged() {
1834
906
  form.subjectId = "";
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
- }
907
+ await loadSubjectCandidates();
1924
908
  }
1925
909
 
1926
910
  async function openEnroll() {
@@ -1932,14 +916,8 @@ async function openEnroll() {
1932
916
  ]);
1933
917
  form.hidUserId = hidUserId;
1934
918
  form.registration = registration;
919
+ await loadSubjectCandidates();
1935
920
  formDialog.value = true;
1936
-
1937
- // `resetForm` restores the remembered type, so the dialog can open straight
1938
- // onto Member with an empty list. Loaded after the dialog is up, not before:
1939
- // the request must not delay it, and the picker carries its own loading
1940
- // placeholder. Fresh each opening, because staff change between them.
1941
- resetMemberCandidates();
1942
- if (!isResidentSubject.value) await loadMemberCandidates();
1943
921
  }
1944
922
 
1945
923
  async function openEdit(user: HidUser) {
@@ -1953,10 +931,6 @@ async function openEdit(user: HidUser) {
1953
931
  form.registration = user.registration ?? "";
1954
932
  form.isAdministrator = hasAdministratorRule(user);
1955
933
  form.accessPin = "";
1956
- form.password = "";
1957
- showPassword.value = false;
1958
- removePasswordRequested.value = false;
1959
- passwordSet.value = Boolean(user.metadata?.passwordSet);
1960
934
  pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
1961
935
  removePinRequested.value = false;
1962
936
  form.photoPreview = "";
@@ -1964,39 +938,24 @@ async function openEdit(user: HidUser) {
1964
938
  form.subjectCategory = user.person
1965
939
  ? "resident"
1966
940
  : user.serviceProvider
1967
- ? "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();
941
+ ? "service_provider"
942
+ : "property_management";
943
+ form.subjectId = String(user.person || user.member || user.serviceProvider || "");
944
+ await loadSubjectCandidates();
1985
945
  selectedPhotoFile.value = null;
1986
946
  removePhotoRequested.value = false;
1987
947
  formDialog.value = true;
1988
948
 
1989
949
  try {
1990
- const 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);
950
+ const status = await getUserPinStatus(
951
+ form.reader,
952
+ toHidNumericId(user.hidUserId),
953
+ );
954
+ pinEnrolled.value = Boolean(status?.data?.pinEnrolled);
1997
955
  } catch {
1998
956
  // Preserve the last known status if the reader is temporarily offline.
1999
957
  }
958
+
2000
959
  }
2001
960
 
2002
961
  function openView(user: HidUser) {
@@ -2071,53 +1030,16 @@ function onPhotoChange(event: Event) {
2071
1030
  async function saveUser() {
2072
1031
  const readerId =
2073
1032
  selectedReaderId.value || form.reader || readers.value[0]?._id;
2074
- // 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
- );
1033
+ if (!readerId || !form.name || !form.hidUserId || !form.registration || !form.subjectId) {
1034
+ showToast("Please complete the required HID user fields.", "error");
2097
1035
  return;
2098
1036
  }
2099
1037
  if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
2100
- showToast("PIN must contain numbers only.", "error");
1038
+ showToast("Access PIN must contain numbers only.", "error");
2101
1039
  return;
2102
1040
  }
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");
1041
+ if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
1042
+ showToast("Reconnect this HID reader and select a portal before enrolling facial recognition.", "error");
2121
1043
  return;
2122
1044
  }
2123
1045
 
@@ -2134,37 +1056,22 @@ async function saveUser() {
2134
1056
  return;
2135
1057
  }
2136
1058
 
2137
- // The link is sent only when the form actually holds one. Sending
2138
- // `person: ""` is not "leave it alone", it is "clear it": the server reads
2139
- // an empty string as a link being removed and then refuses the write for
2140
- // having no subject at all. Omitting the keys is what makes it keep the
2141
- // subject the record already has.
2142
- const subjectLink = form.subjectId
2143
- ? {
2144
- person: form.subjectCategory === "resident" ? form.subjectId : "",
2145
- member:
2146
- form.subjectCategory === "property_management"
2147
- ? form.subjectId
2148
- : "",
2149
- serviceProvider:
2150
- form.subjectCategory === "service_provider" ? form.subjectId : "",
2151
- type: (form.subjectCategory === "resident"
2152
- ? "resident"
2153
- : form.subjectCategory === "service_provider"
2154
- ? "contractor"
2155
- : "staff") as
2156
- | "resident"
2157
- | "staff"
2158
- | "contractor"
2159
- | "visitor"
2160
- | "unknown",
2161
- }
2162
- : {};
2163
-
2164
1059
  const payload = {
2165
1060
  hidUserId: String(hidUser.id),
2166
1061
  registration: form.registration,
2167
- ...subjectLink,
1062
+ person: form.subjectCategory === "resident" ? form.subjectId : "",
1063
+ member: form.subjectCategory === "property_management" ? form.subjectId : "",
1064
+ serviceProvider: form.subjectCategory === "service_provider" ? form.subjectId : "",
1065
+ type: (form.subjectCategory === "resident"
1066
+ ? "resident"
1067
+ : form.subjectCategory === "service_provider"
1068
+ ? "contractor"
1069
+ : "staff") as
1070
+ | "resident"
1071
+ | "staff"
1072
+ | "contractor"
1073
+ | "visitor"
1074
+ | "unknown",
2168
1075
  status: "active" as const,
2169
1076
  metadata: {
2170
1077
  name: form.name,
@@ -2181,7 +1088,6 @@ async function saveUser() {
2181
1088
  imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
2182
1089
  facialScores: selectedUser.value?.metadata?.facialScores || {},
2183
1090
  pinEnrolled: pinEnrolled.value,
2184
- passwordSet: passwordSet.value,
2185
1091
  hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
2186
1092
  },
2187
1093
  };
@@ -2191,22 +1097,17 @@ async function saveUser() {
2191
1097
  const facialResult = await syncFacialImage(readerId, hidUser.id);
2192
1098
  applyFacialResult(payload.metadata, facialResult);
2193
1099
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
2194
- payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
2195
1100
  if (selectedUser.value._id) {
2196
1101
  await updateIdentity(selectedUser.value._id, payload);
2197
- } else if (form.subjectId) {
1102
+ } else {
2198
1103
  await saveIdentityOnReader(readerId, payload);
2199
1104
  }
2200
- // else: a user the reader holds and we have never linked. The photo, PIN
2201
- // and password above are already on the device; creating an identity for
2202
- // it would need a subject we deliberately no longer ask for here.
2203
1105
  } else {
2204
1106
  const created = await createHidUserOnReader(readerId, hidUser);
2205
1107
  if (created) newlyCreatedHidUserId = hidUser.id;
2206
1108
  const facialResult = await syncFacialImage(readerId, hidUser.id);
2207
1109
  applyFacialResult(payload.metadata, facialResult);
2208
1110
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
2209
- payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
2210
1111
  await saveIdentityOnReader(readerId, payload);
2211
1112
  selectedReaderId.value = readerId;
2212
1113
  }
@@ -2227,10 +1128,7 @@ async function saveUser() {
2227
1128
  where: { users: { id: newlyCreatedHidUserId } },
2228
1129
  });
2229
1130
  } catch (rollbackError) {
2230
- console.error(
2231
- "Unable to roll back the partially enrolled HID user:",
2232
- rollbackError
2233
- );
1131
+ console.error("Unable to roll back the partially enrolled HID user:", rollbackError);
2234
1132
  }
2235
1133
  }
2236
1134
  console.error("Unable to save HID user:", error);
@@ -2245,25 +1143,6 @@ async function saveUser() {
2245
1143
  }
2246
1144
  }
2247
1145
 
2248
- async function syncPassword(readerId: string, hidUserId: number) {
2249
- // Mirrors syncAccessPin. Removal wins over a typed value, and a blank field
2250
- // means "leave whatever is on the reader alone" - the hash cannot be read
2251
- // back, so blank can never mean "clear it".
2252
- if (removePasswordRequested.value) {
2253
- await deleteUserPassword(readerId, hidUserId);
2254
- passwordSet.value = false;
2255
- form.password = "";
2256
- return false;
2257
- }
2258
- if (form.password) {
2259
- await setUserPassword(readerId, hidUserId, form.password);
2260
- form.password = "";
2261
- passwordSet.value = true;
2262
- return true;
2263
- }
2264
- return passwordSet.value;
2265
- }
2266
-
2267
1146
  async function syncAccessPin(readerId: string, hidUserId: number) {
2268
1147
  if (removePinRequested.value) {
2269
1148
  await deleteUserPin(readerId, hidUserId);
@@ -2343,7 +1222,7 @@ async function deleteUser() {
2343
1222
  );
2344
1223
  await deleteUserPin(
2345
1224
  selectedReaderId.value,
2346
- toHidNumericId(selectedUser.value.hidUserId)
1225
+ toHidNumericId(selectedUser.value.hidUserId),
2347
1226
  );
2348
1227
  await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
2349
1228
  if (selectedUser.value._id) {
@@ -2362,7 +1241,7 @@ async function deleteUser() {
2362
1241
 
2363
1242
  async function saveIdentityOnReader(
2364
1243
  readerId: string,
2365
- payload: HidIdentityPayload
1244
+ payload: HidIdentityPayload,
2366
1245
  ) {
2367
1246
  const existingIdentity = await findExistingIdentityOnReader(
2368
1247
  readerId,
@@ -2374,11 +1253,7 @@ async function saveIdentityOnReader(
2374
1253
  }
2375
1254
 
2376
1255
  try {
2377
- await createIdentity(readerId, {
2378
- ...payload,
2379
- site: props.site,
2380
- ...(props.org ? { orgId: props.org } : {}),
2381
- });
1256
+ await createIdentity(readerId, { ...payload, site: props.site });
2382
1257
  } catch (error: unknown) {
2383
1258
  const message = getHidErrorMessage(error);
2384
1259
  if (!message.toLowerCase().includes("already exists")) throw error;
@@ -2394,7 +1269,7 @@ async function saveIdentityOnReader(
2394
1269
 
2395
1270
  async function findExistingIdentityOnReader(
2396
1271
  readerId: string,
2397
- payload: HidIdentityPayload
1272
+ payload: HidIdentityPayload,
2398
1273
  ) {
2399
1274
  const response = await getIdentities(readerId, {
2400
1275
  page: 1,
@@ -2403,11 +1278,8 @@ async function findExistingIdentityOnReader(
2403
1278
  });
2404
1279
  const responseRecord = toRecord(response);
2405
1280
  const responseData = toRecord(responseRecord.data);
2406
- const responseItems =
2407
- responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
2408
- const identities = (Array.isArray(responseItems) ? responseItems : []).map(
2409
- toHidUser
2410
- );
1281
+ const responseItems = responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
1282
+ const identities = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
2411
1283
  const hidUserId = String(payload.hidUserId || "");
2412
1284
  const registration = String(payload.registration || "");
2413
1285
  const cardNo = String(payload.cardNo || "");
@@ -2424,7 +1296,7 @@ async function findExistingIdentityOnReader(
2424
1296
 
2425
1297
  async function createHidUserOnReader(
2426
1298
  readerId: string,
2427
- user: { id?: number; name: string; registration: string }
1299
+ user: { id?: number; name: string; registration: string },
2428
1300
  ) {
2429
1301
  const existing = await getHidUserFromReader(readerId, user.id);
2430
1302
  if (existing) {
@@ -2458,7 +1330,7 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
2458
1330
 
2459
1331
  async function updateHidUserOnReader(
2460
1332
  readerId: string,
2461
- user: { id?: number; name: string; registration: string }
1333
+ user: { id?: number; name: string; registration: string },
2462
1334
  ) {
2463
1335
  const { id, ...values } = user;
2464
1336
  await runObjectOperation(readerId, {
@@ -2473,7 +1345,10 @@ async function updateHidUserOnReader(
2473
1345
  });
2474
1346
  }
2475
1347
 
2476
- async function deleteHidUserFromReader(readerId: string, user: HidUser) {
1348
+ async function deleteHidUserFromReader(
1349
+ readerId: string,
1350
+ user: HidUser,
1351
+ ) {
2477
1352
  const id = toHidNumericId(user.hidUserId);
2478
1353
  if (!readerId || !id) return;
2479
1354
 
@@ -2561,9 +1436,7 @@ async function getHidUserRole(readerId: string, userId: number) {
2561
1436
  return Array.isArray(roles) ? roles[0] : undefined;
2562
1437
  }
2563
1438
 
2564
- function toHidUserObject(
2565
- identity: Pick<HidUser, "hidUserId" | "name" | "registration">
2566
- ) {
1439
+ function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registration">) {
2567
1440
  return {
2568
1441
  id: toHidNumericId(identity.hidUserId),
2569
1442
  name:
@@ -2574,23 +1447,6 @@ function toHidUserObject(
2574
1447
  };
2575
1448
  }
2576
1449
 
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
-
2594
1450
  function toHidNumericId(value: unknown) {
2595
1451
  const raw = String(value ?? "").trim();
2596
1452
  if (!raw) return undefined;
@@ -2619,19 +1475,16 @@ async function loadVisibleUserCards(items: HidUser[]) {
2619
1475
  const response = await getUserCards(selectedReaderId.value, hidUserId);
2620
1476
  const responseData = toRecord(toRecord(response).data);
2621
1477
  const cards = responseData.cards;
2622
- return [
2623
- String(hidUserId),
2624
- (Array.isArray(cards) ? cards : []).map(toHidCard),
2625
- ] as const;
1478
+ return [String(hidUserId), (Array.isArray(cards) ? cards : []).map(toHidCard)] as const;
2626
1479
  } catch (error: unknown) {
2627
1480
  cardErrors[String(hidUserId)] = getHidErrorMessage(error);
2628
1481
  return [String(hidUserId), []] as const;
2629
1482
  }
2630
- })
1483
+ }),
2631
1484
  );
2632
1485
 
2633
1486
  physicalCardsByUserId.value = Object.fromEntries(
2634
- entries.filter(([hidUserId]) => Boolean(hidUserId))
1487
+ entries.filter(([hidUserId]) => Boolean(hidUserId)),
2635
1488
  );
2636
1489
  physicalCardErrorsByUserId.value = cardErrors;
2637
1490
  }
@@ -2643,16 +1496,12 @@ function getUserPhysicalCards(user: HidUser) {
2643
1496
 
2644
1497
  function getUserCardLoadError(user: HidUser) {
2645
1498
  const hidUserId = toHidNumericId(user.hidUserId);
2646
- return hidUserId
2647
- ? physicalCardErrorsByUserId.value[String(hidUserId)] || ""
2648
- : "";
1499
+ return hidUserId ? physicalCardErrorsByUserId.value[String(hidUserId)] || "" : "";
2649
1500
  }
2650
1501
 
2651
1502
  async function openCardManager(user: HidUser) {
2652
1503
  cardUser.value = user;
2653
- const reader = readers.value.find(
2654
- (item) => String(item._id) === String(user.reader || selectedReaderId.value)
2655
- );
1504
+ const reader = readers.value.find((item) => String(item._id) === String(user.reader || selectedReaderId.value));
2656
1505
  cardForm.cardType = reader?.cardReadingMode === "pacs" ? "pacs" : "csn";
2657
1506
  cardForm.facilityCode = "";
2658
1507
  cardForm.cardNumber = "";
@@ -2663,9 +1512,7 @@ async function openCardManager(user: HidUser) {
2663
1512
  }
2664
1513
 
2665
1514
  function getCardReaderAndUser() {
2666
- const readerId = String(
2667
- cardUser.value?.reader || selectedReaderId.value || ""
2668
- );
1515
+ const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
2669
1516
  const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
2670
1517
  return { readerId, hidUserId };
2671
1518
  }
@@ -2688,11 +1535,7 @@ async function loadPhysicalCards() {
2688
1535
  }
2689
1536
  }
2690
1537
 
2691
- function parsePhysicalCardInteger(
2692
- value: string,
2693
- label: string,
2694
- maximum: bigint
2695
- ) {
1538
+ function parsePhysicalCardInteger(value: string, label: string, maximum: bigint) {
2696
1539
  if (!/^\d+$/.test(value.trim())) {
2697
1540
  showToast(`${label} must contain numbers only.`, "error");
2698
1541
  return null;
@@ -2713,27 +1556,14 @@ async function assignPhysicalCard() {
2713
1556
  | { cardType: "pacs"; facilityCode: string; cardNumber: string }
2714
1557
  | { cardType: "csn"; cardValue: string };
2715
1558
  if (cardForm.cardType === "pacs") {
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
- );
1559
+ const facilityCode = parsePhysicalCardInteger(cardForm.facilityCode, "Facility Code", 4294967295n);
1560
+ const cardNumber = parsePhysicalCardInteger(cardForm.cardNumber, "Card Number", 4294967295n);
2726
1561
  if (facilityCode === null || cardNumber === null) return;
2727
1562
  payload = { cardType: "pacs", facilityCode, cardNumber };
2728
1563
  } else {
2729
- const cardValue = parsePhysicalCardInteger(
2730
- cardForm.cardValue,
2731
- "CSN",
2732
- 18446744073709551615n
2733
- );
1564
+ const cardValue = parsePhysicalCardInteger(cardForm.cardValue, "CSN", 18446744073709551615n);
2734
1565
  if (cardValue === null || BigInt(cardValue) <= 0n) {
2735
- if (cardValue === "0")
2736
- showToast("CSN must be greater than zero.", "error");
1566
+ if (cardValue === "0") showToast("CSN must be greater than zero.", "error");
2737
1567
  return;
2738
1568
  }
2739
1569
  payload = { cardType: "csn", cardValue };
@@ -2755,14 +1585,6 @@ async function assignPhysicalCard() {
2755
1585
  }
2756
1586
  }
2757
1587
 
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
- */
2766
1588
  async function enrollPhysicalCard() {
2767
1589
  const { readerId, hidUserId } = getCardReaderAndUser();
2768
1590
  if (!readerId || !hidUserId) return;
@@ -2775,8 +1597,6 @@ async function enrollPhysicalCard() {
2775
1597
  await loadUsers();
2776
1598
  showToast("Physical HID card read and enrolled on the reader.");
2777
1599
  } 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.
2780
1600
  if (!cardEnrollmentCancelled.value) {
2781
1601
  showToast(getHidErrorMessage(error), "error");
2782
1602
  }
@@ -2785,55 +1605,20 @@ async function enrollPhysicalCard() {
2785
1605
  }
2786
1606
  }
2787
1607
 
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
- */
2812
1608
  async function cancelPhysicalCardEnrollment() {
2813
1609
  const { readerId } = getCardReaderAndUser();
2814
1610
  if (!readerId) return;
2815
- // Set first, awaited second: the enrol can reject at any point from here.
2816
1611
  cardEnrollmentCancelled.value = true;
2817
- cancellingCard.value = true;
2818
1612
  try {
2819
1613
  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;
2824
1614
  showToast("Physical HID card reading cancelled.");
2825
1615
  } 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.
2828
1616
  showToast(getHidErrorMessage(error), "error");
2829
- } finally {
2830
- cancellingCard.value = false;
2831
1617
  }
2832
1618
  }
2833
1619
 
2834
1620
  async function removePhysicalCard(card: HidCard) {
2835
- if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
2836
- return;
1621
+ if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`)) return;
2837
1622
  const { readerId, hidUserId } = getCardReaderAndUser();
2838
1623
  if (!readerId || !hidUserId || !card?.id) return;
2839
1624
 
@@ -2859,38 +1644,13 @@ function formatPhysicalCard(card: HidCard) {
2859
1644
  }
2860
1645
 
2861
1646
  function getPhysicalCardSubtitle(card: HidCard) {
2862
- const source =
2863
- card?.source === "reader"
2864
- ? "Read at reader"
2865
- : card?.source === "manual"
2866
- ? "Manual"
2867
- : "On device";
1647
+ const source = card?.source === "reader" ? "Read at reader" : card?.source === "manual" ? "Manual" : "On device";
2868
1648
  return `${source} - Raw value ${card?.value || "N/A"}`;
2869
1649
  }
2870
1650
 
2871
1651
  function buildUnitLabel() {
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(" / ");
1652
+ const parts = [form.block, form.level, form.unit].filter(Boolean);
1653
+ return parts.length ? `BLK ${parts.join("/")}` : "";
2894
1654
  }
2895
1655
 
2896
1656
  function getName(user?: HidUser | null) {
@@ -2977,28 +1737,27 @@ function normalizeHidUsers(response: unknown): HidUser[] {
2977
1737
  const responseRecord = toRecord(response);
2978
1738
  const responseData = toRecord(responseRecord.data);
2979
1739
  const nestedData = toRecord(responseData.data);
2980
- const items =
2981
- responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
1740
+ const items = responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
2982
1741
 
2983
1742
  return Array.isArray(items)
2984
1743
  ? items.map((value) => {
2985
1744
  const user = toRecord(value);
2986
1745
  return {
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),
1746
+ _rowKey: `reader-${user.id}`,
1747
+ hidUserId: String(user.id || ""),
1748
+ rawHidUserId: toText(user.id),
1749
+ registration: toText(user.registration),
1750
+ userTypeId: toText(user.user_type_id),
1751
+ cardNo: toText(user.card_value || user.cardNo),
1752
+ name: toText(user.name),
1753
+ metadata: {
2993
1754
  name: toText(user.name),
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
- },
1755
+ imageTimestamp: user.image_timestamp
1756
+ ? String(user.image_timestamp)
1757
+ : "",
1758
+ lastAccessAt: fromUnixSeconds(user.last_access),
1759
+ hidReaderSource: true,
1760
+ },
3002
1761
  };
3003
1762
  })
3004
1763
  : [];
@@ -3006,7 +1765,7 @@ function normalizeHidUsers(response: unknown): HidUser[] {
3006
1765
 
3007
1766
  function mergeReaderUsersWithIdentities(
3008
1767
  readerUsers: HidUser[],
3009
- identities: HidUser[]
1768
+ identities: HidUser[],
3010
1769
  ) {
3011
1770
  const identityById = new Map<string, HidUser>();
3012
1771
  identities.forEach((identity) => {
@@ -3049,13 +1808,7 @@ function getMappingStatus(user: HidUser) {
3049
1808
  }
3050
1809
 
3051
1810
  function hasMappedRecord(user: HidUser) {
3052
- return Boolean(
3053
- user?.person ||
3054
- user?.user ||
3055
- user?.member ||
3056
- user?.serviceProvider ||
3057
- user?.visitor
3058
- );
1811
+ return Boolean(user?.person || user?.user || user?.member || user?.serviceProvider || user?.visitor);
3059
1812
  }
3060
1813
 
3061
1814
  function formatDate(value?: string) {
@@ -3087,26 +1840,18 @@ function getHidErrorMessage(error: unknown) {
3087
1840
  const cause = toRecord(errorRecord.cause);
3088
1841
  const causeResponse = toRecord(cause.response);
3089
1842
  const data = toRecord(
3090
- errorRecord.data ||
3091
- response._data ||
3092
- response.data ||
3093
- cause.data ||
3094
- causeResponse._data
1843
+ errorRecord.data || response._data || response.data || cause.data || causeResponse._data,
3095
1844
  );
3096
1845
 
3097
1846
  return toText(
3098
- data.message ||
3099
- data.error ||
3100
- data.statusMessage ||
3101
- errorRecord.statusMessage ||
3102
- errorRecord.message,
3103
- "Unable to save HID user."
1847
+ data.message || data.error || data.statusMessage || errorRecord.statusMessage || errorRecord.message,
1848
+ "Unable to save HID user.",
3104
1849
  );
3105
1850
  }
3106
1851
 
3107
1852
  function toRecord(value: unknown): Record<string, unknown> {
3108
1853
  return typeof value === "object" && value !== null && !Array.isArray(value)
3109
- ? (value as Record<string, unknown>)
1854
+ ? value as Record<string, unknown>
3110
1855
  : {};
3111
1856
  }
3112
1857
 
@@ -3152,24 +1897,10 @@ function toHidCard(value: unknown): HidCard {
3152
1897
  return {
3153
1898
  ...card,
3154
1899
  id: toText(card.id) || undefined,
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,
1900
+ cardType: card.cardType === "pacs" || card.cardType === "csn" ? card.cardType : undefined,
1901
+ source: card.source === "reader" || card.source === "manual" || card.source === "device" ? card.source : undefined,
3165
1902
  };
3166
1903
  }
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
- });
3173
1904
  </script>
3174
1905
 
3175
1906
  <style scoped>
@@ -3206,6 +1937,12 @@ watch(selectedReaderId, (value) => {
3206
1937
  max-width: 320px;
3207
1938
  }
3208
1939
 
1940
+ .small-title {
1941
+ font-size: 14px;
1942
+ font-weight: 700;
1943
+ color: var(--text);
1944
+ }
1945
+
3209
1946
  /* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
3210
1947
  .field-label {
3211
1948
  color: var(--text2);
@@ -3227,42 +1964,12 @@ watch(selectedReaderId, (value) => {
3227
1964
  line-height: 1.45;
3228
1965
  }
3229
1966
 
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
-
3236
1967
  .photo-wrap {
3237
1968
  display: grid;
3238
1969
  place-items: center;
3239
1970
  margin-bottom: 14px;
3240
1971
  }
3241
1972
 
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
-
3266
1973
  .photo-button {
3267
1974
  width: 120px;
3268
1975
  height: 120px;
@@ -3296,6 +2003,7 @@ watch(selectedReaderId, (value) => {
3296
2003
  box-shadow: var(--shadow-menu);
3297
2004
  }
3298
2005
 
2006
+
3299
2007
  .admin-rule-indicator {
3300
2008
  display: inline-flex;
3301
2009
  vertical-align: middle;
@@ -3435,6 +2143,25 @@ watch(selectedReaderId, (value) => {
3435
2143
  color: var(--muted);
3436
2144
  }
3437
2145
 
2146
+ /* The split footer bar. The handoff does not draw one, so the SHAPE is the
2147
+ screen's own; only the colours change - `#062d42` was a fixed navy that sat
2148
+ on the dark card unchanged. Same treatment as panel 2. */
2149
+ .action-cancel,
2150
+ .action-submit {
2151
+ flex: 1;
2152
+ border-radius: 0;
2153
+ }
2154
+
2155
+ .action-cancel {
2156
+ background: var(--card);
2157
+ color: var(--text2);
2158
+ }
2159
+
2160
+ .action-submit {
2161
+ background: var(--accent-strong);
2162
+ color: var(--on-accent-strong);
2163
+ }
2164
+
3438
2165
  @media (max-width: 960px) {
3439
2166
  .hid-user-filters :deep(.app-field),
3440
2167
  .hid-user-filters :deep(.app-select) {