@7365admin1/layer-common 4.97.0 → 4.97.1-staging.501

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 (62) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +57 -0
  3. package/assets/css/screens.css +8 -4
  4. package/components/AccessCardDetailsDialog.vue +59 -6
  5. package/components/AccessCardPreviewDialog.vue +371 -53
  6. package/components/AppSelect.vue +143 -4
  7. package/components/BuildingUnitFormEdit.vue +6 -0
  8. package/components/BuildingUnitManagement.vue +298 -0
  9. package/components/Dialog/UpdateMoreAction.vue +11 -1
  10. package/components/DocumentForm.vue +407 -112
  11. package/components/DocumentManagement.vue +213 -12
  12. package/components/Facility/BookingSetup.vue +63 -0
  13. package/components/HidAccessLogDashboard.vue +74 -17
  14. package/components/HidAccessPermissions.vue +532 -0
  15. package/components/HidIntercomManagement.vue +183 -14
  16. package/components/HidProfileQrCode.vue +332 -0
  17. package/components/HidQrCodeConfiguration.vue +17 -195
  18. package/components/HidReaderManagement.vue +398 -0
  19. package/components/HidReaderUserRoster.vue +44 -3
  20. package/components/HidUserEnrollment.vue +2242 -279
  21. package/components/InvitationClientForm.vue +19 -1
  22. package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
  23. package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
  24. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
  25. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
  26. package/components/ServiceProviderMain.vue +48 -0
  27. package/components/SiteProviderChange.vue +371 -0
  28. package/components/TableMain.vue +22 -7
  29. package/components/VisitorForm.vue +12 -0
  30. package/components/VisitorManagement.vue +267 -5
  31. package/composables/useAccessManagement.ts +17 -1
  32. package/composables/useCustomerSite.ts +29 -0
  33. package/composables/useDocument.ts +7 -0
  34. package/composables/useFacility.ts +11 -0
  35. package/composables/useHidAmico.ts +165 -0
  36. package/composables/useHidNavigation.ts +25 -1
  37. package/composables/useHidReaderSelection.ts +50 -0
  38. package/composables/useMember.ts +12 -0
  39. package/composables/useNFCPatrolReport.ts +36 -32
  40. package/composables/useNFCPatrolReportFilters.ts +21 -3
  41. package/composables/useNFCPatrolRoute.ts +10 -3
  42. package/composables/usePeople.ts +19 -0
  43. package/composables/useServiceProvider.ts +45 -2
  44. package/composables/useSiteCategory.ts +45 -0
  45. package/package.json +1 -1
  46. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  47. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  48. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  49. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  50. package/types/document.d.ts +14 -0
  51. package/types/facility.d.ts +4 -0
  52. package/types/member.d.ts +4 -0
  53. package/types/nfc-patrol-report.ts +0 -2
  54. package/types/people.d.ts +28 -1
  55. package/types/service-provider.d.ts +5 -0
  56. package/types/site.d.ts +7 -1
  57. package/utils/hid-enrolment-subject.ts +366 -0
  58. package/utils/hid-permission-assignments.ts +259 -0
  59. package/utils/hid-reader-selection.ts +52 -0
  60. package/utils/nfc-patrol-time.ts +99 -0
  61. package/utils/occupancy-role.ts +109 -0
  62. package/utils/service-type.ts +30 -0
@@ -25,7 +25,11 @@
25
25
  the old table card, so it stays above the shared one. -->
26
26
  <v-alert
27
27
  v-if="selectedReader"
28
- :type="selectedReader.portalId && selectedReader.portalName ? 'info' : 'warning'"
28
+ :type="
29
+ selectedReader.portalId && selectedReader.portalName
30
+ ? 'info'
31
+ : 'warning'
32
+ "
29
33
  variant="tonal"
30
34
  density="compact"
31
35
  class="mb-4"
@@ -50,7 +54,9 @@
50
54
  @update:page="goToPage"
51
55
  >
52
56
  <template v-if="!props.cardManagement" #actions>
53
- <AppButton icon="mdi-account-plus" @click="openEnroll">Enroll</AppButton>
57
+ <AppButton icon="mdi-account-plus" @click="openEnroll"
58
+ >Enroll</AppButton
59
+ >
54
60
  </template>
55
61
 
56
62
  <!-- The design puts filters on their own full-width row under the
@@ -67,8 +73,8 @@
67
73
  <AppField
68
74
  v-model="search"
69
75
  search
70
- placeholder="Search"
71
- @keyup.enter="reloadUsersFromFirstPage"
76
+ placeholder="Search name, UID or registration no."
77
+ @keyup.enter="onSearchSubmit"
72
78
  />
73
79
  <AppSelect
74
80
  v-if="!props.cardManagement"
@@ -93,7 +99,15 @@
93
99
  </template>
94
100
 
95
101
  <template #[`item.hidUserId`]="{ item }">
96
- <span class="app-cell--num">{{ formatHidUid(item.hidUserId) || "N/A" }}</span>
102
+ <span class="app-cell--num">{{
103
+ formatHidUid(item.hidUserId) || "N/A"
104
+ }}</span>
105
+ </template>
106
+
107
+ <!-- The reader allocates this alongside the UID. A row the reader holds
108
+ that we never enrolled has none, so it is not always present. -->
109
+ <template #[`item.registration`]="{ item }">
110
+ <span class="app-cell--num">{{ item.registration || "N/A" }}</span>
97
111
  </template>
98
112
 
99
113
  <!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
@@ -113,7 +127,10 @@
113
127
  {{ formatPhysicalCard(card) }}
114
128
  </span>
115
129
  </div>
116
- <span v-else-if="getUserCardLoadError(item)" class="hid-card-load-error">
130
+ <span
131
+ v-else-if="getUserCardLoadError(item)"
132
+ class="hid-card-load-error"
133
+ >
117
134
  Unable to load
118
135
  <v-tooltip activator="parent" location="top">
119
136
  {{ getUserCardLoadError(item) }}
@@ -133,13 +150,20 @@
133
150
  </AppButton>
134
151
  <v-menu v-else>
135
152
  <template #activator="{ props: menuProps }">
136
- <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
153
+ <AppButton
154
+ v-bind="menuProps"
155
+ variant="row"
156
+ icon="mdi-dots-vertical"
157
+ />
137
158
  </template>
138
159
 
139
160
  <v-list density="compact" min-width="150">
140
161
  <v-list-item title="View" @click="openView(item)" />
141
162
  <v-list-item title="Edit" @click="openEdit(item)" />
142
- <v-list-item title="Manage HID Cards" @click="openCardManager(item)" />
163
+ <v-list-item
164
+ title="Manage HID Cards"
165
+ @click="openCardManager(item)"
166
+ />
143
167
  <v-list-item
144
168
  title="Delete"
145
169
  class="text-error"
@@ -155,23 +179,31 @@
155
179
  <template #no-data>
156
180
  <div class="table-card__empty">
157
181
  <v-icon
158
- :icon="props.cardManagement ? 'mdi-card-account-details-outline' : 'mdi-account-off-outline'"
182
+ :icon="
183
+ props.cardManagement
184
+ ? 'mdi-card-account-details-outline'
185
+ : 'mdi-account-off-outline'
186
+ "
159
187
  size="32"
160
188
  />
161
189
  <span>
162
- {{ props.cardManagement ? "No HID users available for card assignment." : "No HID users enrolled yet." }}
190
+ {{
191
+ props.cardManagement
192
+ ? "No HID users available for card assignment."
193
+ : "No HID users enrolled yet."
194
+ }}
163
195
  </span>
164
196
  </div>
165
197
  </template>
166
198
  </TableMain>
167
199
 
168
200
  <v-dialog v-model="formDialog" max-width="420" persistent>
169
- <v-card rounded="lg">
170
- <v-card-title class="small-title">
201
+ <v-card class="screen-modal">
202
+ <v-card-title>
171
203
  {{ selectedUser ? "Edit User Information" : "Enroll" }}
172
204
  </v-card-title>
173
205
 
174
- <v-card-text>
206
+ <v-card-text class="screen-modal__body">
175
207
  <div class="photo-wrap">
176
208
  <v-menu>
177
209
  <template #activator="{ props: menuProps }">
@@ -213,6 +245,15 @@
213
245
  />
214
246
  </v-list>
215
247
  </v-menu>
248
+ <!-- Sits under the button because it describes what the button
249
+ is reporting: the reader holds a face, and this is its
250
+ reference. Nothing to show while enrolling - the value does not
251
+ exist until the reader has accepted a photo. -->
252
+ <p v-if="editFacialData" class="photo-facial">
253
+ <span class="photo-facial__label">Facial Data</span>
254
+ <span class="photo-facial__value">{{ editFacialData }}</span>
255
+ </p>
256
+
216
257
  <input
217
258
  ref="photoInput"
218
259
  type="file"
@@ -222,45 +263,252 @@
222
263
  />
223
264
  </div>
224
265
 
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>
266
+ <!-- WHO IS BEING ENROLLED.
267
+ The write path has always carried three subject categories -
268
+ `subjectLink` below maps them onto `person`, `member` or
269
+ `serviceProvider` - but the form only ever offered residents.
270
+ Staff hold no unit, so choosing Member replaces the whole
271
+ block/level/unit cascade with a single picker.
272
+ Enrolment only: an identity's subject cannot change category
273
+ after the fact, so an edit STATES the type rather than offering
274
+ it. -->
275
+ <div class="field-label">Enrolling <span>*</span></div>
234
276
  <AppSelect
277
+ v-if="!selectedUser"
235
278
  v-model="form.subjectCategory"
236
279
  :items="subjectCategoryOptions"
237
- placeholder="Select person type"
238
280
  klass="mb-3"
239
281
  @update:model-value="onSubjectCategoryChanged"
240
282
  />
283
+ <p v-else class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
284
+
285
+ <!-- Block / Level / Unit / Resident. Enrolment only.
286
+ Enrollment is resident-only and the resident must already exist
287
+ in iService365 - you pick a person, you do not type one. Name is
288
+ therefore a mirror of that record, never an input, so the reader
289
+ can never end up holding a name the app does not know.
290
+ Every option is derived from the residents themselves, so a
291
+ block, level or unit only appears when somebody lives there.
292
+ The list loads on first touch of Block, not on dialog open.
293
+ Enrolment only, with no exception. Editing does not move
294
+ anybody: the record already carries its block, level and unit,
295
+ `openEdit` loads them into the form and `saveUser` writes them
296
+ back untouched, so the location survives an edit without being
297
+ asked for. A row that never had one keeps not having one; that
298
+ is not a reason to make somebody fill in a location before they
299
+ can change a PIN. -->
300
+ <template v-if="!selectedUser && isResidentSubject">
301
+ <div class="field-label">Block <span>*</span></div>
302
+ <!-- The listener sits on a wrapper, NOT on AppSelect: AppSelect
303
+ spreads `$attrs` over its v-menu activator AFTER the activator's
304
+ own bindings, so an `@click` here would replace the handler that
305
+ opens the dropdown. `focusin` covers keyboard users, and both
306
+ are idempotent - `ensureOccupancy()` fetches once.
307
+ AppSelect has no `loading` prop either; the placeholder carries
308
+ the loading and empty states instead. -->
309
+ <div @click="onBlockOpened" @focusin="onBlockOpened">
310
+ <AppSelect
311
+ v-model="form.block"
312
+ :items="blockOptions"
313
+ :placeholder="blockPlaceholder"
314
+ klass="mb-3"
315
+ @update:model-value="onBlockChanged"
316
+ />
317
+ </div>
241
318
 
242
- <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"
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 && isMemberSubject">
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
+ <!-- SERVICE PROVIDER STAFF. One person from a provider company, and
393
+ never the company: granting a company puts every employee of the
394
+ firm on the door, including the ones who do not attend this site.
395
+ The list, the service filter and the filter's "All services"
396
+ default are the Members > Service Providers screen's own, from the
397
+ same endpoint and through the same `filterProviderMembers` rule,
398
+ so the two screens cannot disagree about who works here.
399
+ Unpaged - the endpoint returns the site's people in one response -
400
+ so searching and filtering cost no request. -->
401
+ <template v-if="!selectedUser && isProviderSubject">
402
+ <div class="field-label">Service</div>
403
+ <AppSelect
404
+ v-model="providerService"
405
+ :items="providerServiceOptions"
406
+ klass="mb-3"
407
+ @update:model-value="onProviderServiceChanged"
408
+ />
409
+
410
+ <div class="field-label">Person <span>*</span></div>
411
+ <AppSelect
412
+ v-model="form.subjectId"
413
+ :items="providerOptions"
414
+ searchable
415
+ search-placeholder="Search by name, company or role"
416
+ :placeholder="providerPlaceholder"
417
+ klass="mb-3"
418
+ @update:model-value="onProviderChanged"
419
+ />
420
+
421
+ <!-- ACCESS ONLY, and the button says so. Shown here and nowhere
422
+ else: it is the one subject type that arrives a shift at a time,
423
+ and the people it adds fill in their own credentials from their
424
+ app afterwards. -->
425
+ <div class="hid-bulk-cta mb-3">
426
+ <AppButton
427
+ variant="ghost"
428
+ icon="mdi-account-multiple-plus-outline"
429
+ :disabled="loadingProviders"
430
+ @click="openBulkAdd"
431
+ >
432
+ Add Users in Bulk
433
+ </AppButton>
434
+ <span class="field-hint">
435
+ Gives access to many people at once. They add their own face, QR
436
+ or PIN from their app.
437
+ </span>
438
+ </div>
439
+
440
+ <p v-if="hiddenProviderNote" class="field-hint mb-3">
441
+ {{ hiddenProviderNote }}
442
+ </p>
443
+ </template>
444
+
445
+ <!-- The duplicate check. It is reader-scoped: this resident holding a
446
+ HID user on another gate is normal and says nothing here. -->
447
+ <p v-if="checkingResident" class="field-hint">
448
+ Checking enrollment...
449
+ </p>
450
+ <p
451
+ v-else-if="residentConflict"
452
+ class="field-hint field-hint--error mb-3"
453
+ >
454
+ {{ residentConflictMessage }}
455
+ </p>
456
+ <p
457
+ v-else-if="isResidentSubject && unitResidentNote"
458
+ class="field-hint mb-3"
459
+ >
460
+ {{ unitResidentNote }}
461
+ </p>
462
+
463
+ <!-- Read-only: it restates the chosen resident, it does not collect
464
+ anything. `selectedUser` is the edit case, where the name is
465
+ whatever the reader already holds. -->
466
+ <!-- A mirror of the chosen member's role, on the same rule as Name:
467
+ restated from the record, never collected. -->
468
+ <template v-if="!selectedUser && isPersonSubject">
469
+ <div class="field-label">{{ subjectMirrorLabel }}</div>
470
+ <AppField
471
+ v-model="form.subjectRole"
472
+ :aria-label="subjectMirrorLabel"
473
+ :placeholder="subjectMirrorPlaceholder"
474
+ readonly
475
+ klass="mb-3"
476
+ />
477
+ </template>
478
+
479
+ <div class="field-label">Name</div>
480
+ <AppField
481
+ v-model="form.name"
482
+ aria-label="Name"
483
+ :placeholder="
484
+ isResidentSubject
485
+ ? 'Select a resident above'
486
+ : isProviderSubject
487
+ ? 'Select a person above'
488
+ : 'Select a member above'
489
+ "
490
+ readonly
248
491
  klass="mb-3"
249
492
  />
250
493
 
251
494
  <div class="field-label">ID</div>
252
- <AppField v-model="form.hidUserId" aria-label="ID" readonly klass="mb-3" />
495
+ <AppField
496
+ v-model="form.hidUserId"
497
+ aria-label="ID"
498
+ readonly
499
+ klass="mb-3"
500
+ />
253
501
 
254
502
  <div class="field-label">Registration No. <span>*</span></div>
255
503
  <AppField
256
504
  v-model="form.registration"
257
505
  aria-label="Registration No."
258
506
  placeholder="Enter registration no."
259
- :readonly="!selectedUser"
507
+ readonly
260
508
  klass="mb-3"
261
509
  />
262
510
 
263
- <div class="field-label">Access PIN (optional)</div>
511
+ <div class="field-label">PIN (optional)</div>
264
512
  <!--
265
513
  The handoff draws no password field anywhere (the sign-in screens
266
514
  are the owner's own), so the reveal toggle is this product's, not
@@ -270,8 +518,8 @@
270
518
  -->
271
519
  <AppField
272
520
  v-model="form.accessPin"
273
- aria-label="Access PIN"
274
- placeholder="Enter numeric access PIN"
521
+ aria-label="PIN"
522
+ placeholder="Enter pin"
275
523
  inputmode="numeric"
276
524
  autocomplete="new-password"
277
525
  :type="showPin ? 'text' : 'password'"
@@ -280,7 +528,7 @@
280
528
  <button
281
529
  type="button"
282
530
  class="app-field__trailing"
283
- :aria-label="showPin ? 'Hide access PIN' : 'Show access PIN'"
531
+ :aria-label="showPin ? 'Hide PIN' : 'Show PIN'"
284
532
  @click="showPin = !showPin"
285
533
  >
286
534
  <v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
@@ -290,33 +538,82 @@
290
538
  <!-- `persistent-hint` was Vuetify's; the design's settings module
291
539
  draws an explanatory line as 12-13px/600 in `--muted`. -->
292
540
  <p class="field-hint">
293
- {{ selectedUser && pinEnrolled ? "Leave blank to keep the current PIN." : "PIN works as an alternative to face or QR." }}
541
+ {{
542
+ selectedUser && pinEnrolled
543
+ ? "Leave blank to keep the current PIN."
544
+ : "PIN works as an alternative to face or QR."
545
+ }}
294
546
  </p>
295
547
  <v-checkbox
296
548
  v-if="selectedUser && pinEnrolled"
297
549
  v-model="removePinRequested"
298
- label="Remove current access PIN"
550
+ label="Remove current PIN"
551
+ density="compact"
552
+ hide-details
553
+ />
554
+
555
+ <!-- Password: the reader stores only a hash plus its salt, so there
556
+ is nothing to read back and the field is always empty on open.
557
+ Same reveal toggle as the PIN above. -->
558
+ <div class="field-label mt-3">Password (optional)</div>
559
+ <AppField
560
+ v-model="form.password"
561
+ aria-label="Password"
562
+ :placeholder="
563
+ selectedUser && passwordSet
564
+ ? 'Enter a new password'
565
+ : 'Enter password'
566
+ "
567
+ autocomplete="new-password"
568
+ :type="showPassword ? 'text' : 'password'"
569
+ >
570
+ <template #trailing>
571
+ <button
572
+ type="button"
573
+ class="app-field__trailing"
574
+ :aria-label="showPassword ? 'Hide password' : 'Show password'"
575
+ @click="showPassword = !showPassword"
576
+ >
577
+ <v-icon
578
+ :icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
579
+ size="18"
580
+ />
581
+ </button>
582
+ </template>
583
+ </AppField>
584
+ <p class="field-hint">
585
+ {{
586
+ selectedUser && passwordSet
587
+ ? "A password is set. Leave blank to keep it."
588
+ : "Used with the user ID on the reader keypad."
589
+ }}
590
+ </p>
591
+ <v-checkbox
592
+ v-if="selectedUser && passwordSet"
593
+ v-model="removePasswordRequested"
594
+ label="Remove current password"
299
595
  density="compact"
300
596
  hide-details
301
597
  />
302
598
  </v-card-text>
303
599
 
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">
600
+ <!-- The shared modal footer: `screen-btn-ghost` + `screen-btn-primary`
601
+ on `v-btn`, the same pair every other module's dialog uses
602
+ (`assets/css/screens.css`). `v-btn` stays because the submit needs
603
+ `:loading`, which `AppButton` does not carry. -->
604
+ <v-card-actions class="screen-modal__footer">
308
605
  <v-btn
309
- class="text-none action-cancel"
606
+ class="text-none screen-btn-ghost"
310
607
  variant="flat"
311
- height="44"
312
608
  @click="formDialog = false"
313
- >Cancel</v-btn
314
609
  >
610
+ Cancel
611
+ </v-btn>
315
612
  <v-btn
316
- class="text-none action-submit"
613
+ class="text-none screen-btn-primary"
317
614
  variant="flat"
318
- height="44"
319
615
  :loading="saving"
616
+ :disabled="Boolean(residentConflict) || checkingResident"
320
617
  @click="saveUser"
321
618
  >
322
619
  Save
@@ -325,12 +622,121 @@
325
622
  </v-card>
326
623
  </v-dialog>
327
624
 
625
+ <!-- ADD USERS IN BULK.
626
+ Over the enrolment form, not instead of it: the operator came here to
627
+ enrol, and this is the shortcut for the case where a whole shift arrives
628
+ at once. It grants ACCESS only - the people it adds are recognised by the
629
+ reader once they add a face, QR or PIN from their own app - which is why
630
+ it collects no photo, PIN or password.
631
+ The table is the same list as the picker behind it, under the same
632
+ service filter, with Company / Name / Service and nothing else: the
633
+ Members screen's other columns say nothing about whether to open a door
634
+ for somebody. -->
635
+ <v-dialog v-model="bulkDialog" max-width="640" persistent>
636
+ <v-card class="screen-modal">
637
+ <v-card-title>Add Users in Bulk</v-card-title>
638
+ <v-card-text class="screen-modal__body">
639
+ <p class="field-hint mb-3">
640
+ Gives these people access to
641
+ <strong>{{ bulkReaderLabel }}</strong>. They add their own face, QR
642
+ or PIN from their app - nothing is enrolled on the reader here.
643
+ </p>
644
+
645
+ <AppField
646
+ v-model="bulkSearch"
647
+ aria-label="Search people"
648
+ placeholder="Search by name, company or role"
649
+ klass="mb-3"
650
+ />
651
+
652
+ <v-progress-linear v-if="bulkLoading" indeterminate class="mb-3" />
653
+
654
+ <template v-else-if="bulkRows.length">
655
+ <v-table density="compact" class="hid-bulk-table mb-2">
656
+ <thead>
657
+ <tr>
658
+ <th class="hid-bulk-table__check">
659
+ <!-- Acts on the rows in front of you - what the service
660
+ filter and the search have left - never on the whole
661
+ estate. -->
662
+ <v-checkbox
663
+ :model-value="bulkAllSelected"
664
+ :disabled="!bulkSelectableRows.length"
665
+ hide-details
666
+ density="compact"
667
+ aria-label="Select everyone listed"
668
+ @update:model-value="toggleBulkAll"
669
+ />
670
+ </th>
671
+ <th>Company</th>
672
+ <th>Name</th>
673
+ <th>Service</th>
674
+ </tr>
675
+ </thead>
676
+ <tbody>
677
+ <tr v-for="row in bulkRows" :key="row.subjectId">
678
+ <td class="hid-bulk-table__check">
679
+ <!-- Already on the door: ticked and locked. Unticking here
680
+ would revoke access, and this dialog does not revoke. -->
681
+ <v-checkbox
682
+ :model-value="row.granted || isBulkSelected(row.subjectId)"
683
+ :disabled="row.granted"
684
+ hide-details
685
+ density="compact"
686
+ :aria-label="`Select ${row.name}`"
687
+ @update:model-value="toggleBulkRow(row.subjectId, $event)"
688
+ />
689
+ </td>
690
+ <td>{{ row.company || "—" }}</td>
691
+ <td>
692
+ {{ row.name }}
693
+ <span v-if="row.granted" class="hid-bulk-granted">
694
+ Already has access
695
+ </span>
696
+ </td>
697
+ <td>{{ row.service || "—" }}</td>
698
+ </tr>
699
+ </tbody>
700
+ </v-table>
701
+ <p class="field-hint">
702
+ {{ bulkSelectedCount }} selected<template v-if="bulkGrantedCount">
703
+ · {{ bulkGrantedCount }} already have access</template
704
+ >
705
+ </p>
706
+ </template>
707
+
708
+ <v-alert v-else type="info" variant="tonal" density="compact">
709
+ {{ bulkEmptyText }}
710
+ </v-alert>
711
+ </v-card-text>
712
+ <v-card-actions class="screen-modal__footer">
713
+ <v-btn
714
+ class="text-none screen-btn-ghost"
715
+ variant="flat"
716
+ :disabled="bulkSaving"
717
+ @click="bulkDialog = false"
718
+ >
719
+ Cancel
720
+ </v-btn>
721
+ <v-btn
722
+ class="text-none screen-btn-primary"
723
+ variant="flat"
724
+ :loading="bulkSaving"
725
+ :disabled="!bulkSelectedCount"
726
+ @click="saveBulkAdd"
727
+ >
728
+ Give access
729
+ </v-btn>
730
+ </v-card-actions>
731
+ </v-card>
732
+ </v-dialog>
733
+
328
734
  <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
329
735
 
330
736
  <v-dialog v-model="viewDialog" max-width="430">
331
- <v-card rounded="lg">
332
- <v-card-title class="small-title">User Information</v-card-title>
333
- <v-card-text>
737
+ <v-card class="screen-modal">
738
+ <v-card-title>User Information</v-card-title>
739
+ <v-card-text class="screen-modal__body">
334
740
  <div class="detail-grid">
335
741
  <span>Name</span><strong>{{ getName(selectedUser) }}</strong>
336
742
  <span>User ID</span
@@ -347,21 +753,28 @@
347
753
  ><strong>{{
348
754
  selectedUser ? getMappingStatus(selectedUser) : "N/A"
349
755
  }}</strong>
350
- <span>Access PIN</span
351
- ><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
756
+ <span>PIN</span
757
+ ><strong>{{
758
+ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A"
759
+ }}</strong>
760
+ <span>Password</span
761
+ ><strong>{{
762
+ selectedUser?.metadata?.passwordSet ? "Configured" : "N/A"
763
+ }}</strong>
352
764
  </div>
353
765
  </v-card-text>
354
- <v-card-actions>
355
- <v-spacer />
356
- <AppButton variant="ghost" @click="viewDialog = false">Close</AppButton>
766
+ <v-card-actions class="screen-modal__footer">
767
+ <AppButton variant="ghost" @click="viewDialog = false"
768
+ >Close</AppButton
769
+ >
357
770
  </v-card-actions>
358
771
  </v-card>
359
772
  </v-dialog>
360
773
 
361
774
  <v-dialog v-model="cardDialog" max-width="560" persistent>
362
- <v-card rounded="lg">
363
- <v-card-title class="small-title">Physical HID Cards</v-card-title>
364
- <v-card-text>
775
+ <v-card class="screen-modal">
776
+ <v-card-title>Physical HID Cards</v-card-title>
777
+ <v-card-text class="screen-modal__body">
365
778
  <p class="hid-card-user">
366
779
  {{ getName(cardUser) }}
367
780
  <span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
@@ -369,7 +782,11 @@
369
782
 
370
783
  <v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
371
784
  <div v-else-if="physicalCards.length" class="hid-card-list mb-4">
372
- <div v-for="card in physicalCards" :key="card.id" class="hid-card-row">
785
+ <div
786
+ v-for="card in physicalCards"
787
+ :key="card.id"
788
+ class="hid-card-row"
789
+ >
373
790
  <v-icon icon="mdi-card-account-details-outline" size="22" />
374
791
  <div class="hid-card-row__content">
375
792
  <strong>{{ formatPhysicalCard(card) }}</strong>
@@ -383,7 +800,13 @@
383
800
  />
384
801
  </div>
385
802
  </div>
386
- <v-alert v-else type="info" variant="tonal" density="compact" class="mb-4">
803
+ <v-alert
804
+ v-else
805
+ type="info"
806
+ variant="tonal"
807
+ density="compact"
808
+ class="mb-4"
809
+ >
387
810
  No physical HID card is assigned to this user.
388
811
  </v-alert>
389
812
 
@@ -397,7 +820,8 @@
397
820
  {{ cardProfileLabel }}
398
821
  </v-alert>
399
822
  <p class="field-hint hid-card-type-hint">
400
- PACS uses Facility Code + Card Number. CSN uses the raw serial number read from the physical card.
823
+ PACS uses Facility Code + Card Number. CSN uses the raw serial
824
+ number read from the physical card.
401
825
  </p>
402
826
  <template v-if="hybridCardProfile">
403
827
  <div class="field-label mt-3">Presented Card Type</div>
@@ -408,11 +832,13 @@
408
832
  class="mb-3"
409
833
  />
410
834
  <p class="field-hint hid-card-type-hint">
411
- Choose MIFARE CSN for a MIFARE card. Choose PACS for any other card in this reader mode.
835
+ Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
836
+ card in this reader mode.
412
837
  </p>
413
838
  </template>
414
839
  <p class="field-hint hid-card-access-hint">
415
- The card uses this HID user's existing portal and access rules; assigning a card does not grant new access.
840
+ The card uses this HID user's existing portal and access rules;
841
+ assigning a card does not grant new access.
416
842
  </p>
417
843
 
418
844
  <div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
@@ -449,9 +875,15 @@
449
875
  density="compact"
450
876
  class="mt-4"
451
877
  >
452
- Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
878
+ Present the physical card to the selected Amico reader. The request
879
+ will time out after 30 seconds.
453
880
  </v-alert>
454
881
 
882
+ <!-- Reading the card at the reader is the primary way in: it is the
883
+ only one that does not require knowing the facility code and card
884
+ number up front, which are printed on nothing the operator holds.
885
+ Assigning by hand stays for the cards whose values are already
886
+ known. -->
455
887
  <div class="hid-card-actions mt-4">
456
888
  <AppButton
457
889
  variant="secondary"
@@ -470,15 +902,21 @@
470
902
  <AppButton
471
903
  v-if="enrollingCard"
472
904
  variant="ghost"
905
+ :disabled="cancellingCard"
473
906
  @click="cancelPhysicalCardEnrollment"
474
907
  >
475
- Cancel Reading
908
+ {{ cancellingCard ? "Cancelling…" : "Cancel Reading" }}
476
909
  </AppButton>
477
910
  </div>
478
911
  </v-card-text>
479
- <v-card-actions>
480
- <v-spacer />
481
- <AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
912
+ <v-card-actions class="screen-modal__footer">
913
+ <!-- Closing mid-read would leave the reader listening with nothing
914
+ to receive the card it reads. -->
915
+ <AppButton
916
+ variant="ghost"
917
+ :disabled="enrollingCard"
918
+ @click="cardDialog = false"
919
+ >
482
920
  Close
483
921
  </AppButton>
484
922
  </v-card-actions>
@@ -486,31 +924,31 @@
486
924
  </v-dialog>
487
925
 
488
926
  <v-dialog v-model="deleteDialog" max-width="360" persistent>
489
- <v-card rounded="lg">
490
- <v-card-title class="small-title">Delete</v-card-title>
491
- <v-card-text class="text-body-2">
927
+ <v-card class="screen-modal">
928
+ <v-card-title>Delete</v-card-title>
929
+ <v-card-text class="screen-modal__body text-body-2">
492
930
  Are you sure you want to permanently delete this HID user?
493
931
  <div class="text-caption text-medium-emphasis mt-2">
494
932
  This action will remove the user from the HID reader and mark the
495
933
  identity as deleted.
496
934
  </div>
497
935
  </v-card-text>
498
- <v-card-actions class="pa-0">
936
+ <v-card-actions class="screen-modal__footer">
499
937
  <v-btn
500
- class="text-none action-cancel"
938
+ class="text-none screen-btn-ghost"
501
939
  variant="flat"
502
- height="44"
503
940
  @click="deleteDialog = false"
504
- >Cancel</v-btn
505
941
  >
942
+ Cancel
943
+ </v-btn>
506
944
  <v-btn
507
- class="text-none action-submit"
945
+ class="text-none screen-btn-primary"
508
946
  variant="flat"
509
- height="44"
510
947
  :loading="deleting"
511
948
  @click="deleteUser"
512
- >Delete</v-btn
513
949
  >
950
+ Delete
951
+ </v-btn>
514
952
  </v-card-actions>
515
953
  </v-card>
516
954
  </v-dialog>
@@ -522,11 +960,57 @@
522
960
  </template>
523
961
 
524
962
  <script setup lang="ts">
963
+ // Shared with the Access Permissions screen: `paginate` never echoes the page it
964
+ // was asked for, and `items` sits at the root on some of these endpoints and
965
+ // under `data` on others. This reads both, so the mistake is made once.
966
+ import {
967
+ HID_ENROLMENT_SUBJECTS,
968
+ hiddenSubjectNote,
969
+ memberCandidateOf,
970
+ memberHasAccount,
971
+ HID_ALL_SERVICES,
972
+ enrolmentSubjectOfCategory,
973
+ permissionCategoryOf,
974
+ providerAccountsKnown,
975
+ providerCandidateOf,
976
+ providerEnrollableRows,
977
+ readMemberPage,
978
+ serviceFilterOf,
979
+ subjectCategoryLabel as labelForSubjectCategory,
980
+ subjectLinkOf,
981
+ type HidEnrolmentSubject,
982
+ } from "../utils/hid-enrolment-subject";
983
+ /* The SAME filter the Members > Service Providers screen applies, not a second
984
+ copy of the rule: a picker that disagrees with the screen the operator was
985
+ just looking at is a picker they cannot trust. Core applies it to the Excel
986
+ export too (`filterSiteProviderMembers`). */
987
+ import {
988
+ filterProviderMembers,
989
+ serviceTypeOptions,
990
+ } from "../utils/site-provider-members";
991
+ /* Bulk access is granted through the permissions document, and the PUT REPLACES
992
+ this reader's whole assignment set. These three are what keep a bulk add from
993
+ revoking everybody who was already on the door - see the file's own header. */
994
+ import {
995
+ seedAssignments,
996
+ setAssignment,
997
+ subjectOfIdentity,
998
+ toAssignmentPayload,
999
+ type HidAssignmentState,
1000
+ } from "../utils/hid-permission-assignments";
1001
+
525
1002
  const props = defineProps({
526
1003
  site: {
527
1004
  type: String,
528
1005
  required: true,
529
1006
  },
1007
+ // Which organisation the access this enrolment grants belongs to. Saving now
1008
+ // assigns the person to the reader, and the assignment is stored per
1009
+ // organisation - see `schemaCreateHidAmicoIdentity`.
1010
+ org: {
1011
+ type: String,
1012
+ default: "",
1013
+ },
530
1014
  cardManagement: {
531
1015
  type: Boolean,
532
1016
  default: false,
@@ -547,12 +1031,21 @@ const {
547
1031
  getUserPinStatus,
548
1032
  setUserPin,
549
1033
  deleteUserPin,
1034
+ getUserPasswordStatus,
1035
+ setUserPassword,
1036
+ deleteUserPassword,
550
1037
  runObjectOperation,
551
1038
  createIdentity,
552
1039
  updateIdentity,
553
1040
  deleteIdentity,
554
- getPermissionCandidates,
1041
+ getSitePermissions,
1042
+ updateSitePermissions,
555
1043
  } = useHidAmico();
1044
+ const { getAll: getAllMembers } = useMember();
1045
+ /* Members > Service Providers reads this same call, so the enrolment picker
1046
+ offers exactly the people that screen lists. Site-scoped by the server. */
1047
+ const { getSiteMembers } = useServiceProvider();
1048
+ const runtimeConfig = useRuntimeConfig();
556
1049
 
557
1050
  type HidMetadata = Record<string, unknown> & {
558
1051
  name?: string;
@@ -566,6 +1059,7 @@ type HidMetadata = Record<string, unknown> & {
566
1059
  lastAccessAt?: string;
567
1060
  facialScores?: Record<string, unknown>;
568
1061
  pinEnrolled?: boolean;
1062
+ passwordSet?: boolean;
569
1063
  hidPhysicalCards?: HidCard[];
570
1064
  };
571
1065
 
@@ -616,6 +1110,7 @@ type HidIdentityPayload = Record<string, unknown> & {
616
1110
  const readers = ref<HidReader[]>([]);
617
1111
  const users = ref<HidUser[]>([]);
618
1112
  const administratorUserIds = ref<Set<number>>(new Set());
1113
+ const { selectHidReader, resolveForSite } = useHidReaderSelection();
619
1114
  const selectedReaderId = ref("");
620
1115
  const search = ref("");
621
1116
  const status = ref("");
@@ -639,7 +1134,14 @@ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
639
1134
  const loadingCards = ref(false);
640
1135
  const savingCard = ref(false);
641
1136
  const enrollingCard = ref(false);
1137
+ /*
1138
+ * Cancelling makes the in-flight enrol REJECT, and that rejection is expected
1139
+ * rather than a failure. Without this flag the cancel raises an error toast on
1140
+ * top of the one confirming it, which reads as the cancel having gone wrong.
1141
+ */
642
1142
  const cardEnrollmentCancelled = ref(false);
1143
+ /** The cancel's own round trip to the reader — a second or two, not the enrol's. */
1144
+ const cancellingCard = ref(false);
643
1145
  const deletingCardId = ref("");
644
1146
  const showPin = ref(false);
645
1147
  const pinEnrolled = ref(false);
@@ -648,54 +1150,889 @@ const cameraDialog = ref(false);
648
1150
  const selectedPhotoFile = ref<File | null>(null);
649
1151
  const removePhotoRequested = ref(false);
650
1152
  /**
651
- * Client requirement (2026-08-26): the photo is neither shown nor kept once registration
652
- * completes, so the form tracks only WHETHER this person is enrolled. Re-enrolling needs a
653
- * fresh capture -- there is no stored image to re-send.
1153
+ * Client requirement (2026-08-26): the photo is neither shown nor kept once registration
1154
+ * completes, so the form tracks only WHETHER this person is enrolled. Re-enrolling needs a
1155
+ * fresh capture -- there is no stored image to re-send.
1156
+ */
1157
+ const facialEnrolled = ref(false);
1158
+
1159
+ function hasFacialEnrollment(user?: HidUser | null) {
1160
+ return Boolean(
1161
+ user?.metadata?.facialEnrolled ||
1162
+ user?.metadata?.facialData ||
1163
+ user?.metadata?.imageTimestamp
1164
+ );
1165
+ }
1166
+
1167
+ /**
1168
+ * The reader's own reference for the enrolled face, shown while editing.
1169
+ *
1170
+ * We hold neither the image nor the biometric template - the reader does - so
1171
+ * this value is the only handle on the enrollment there is. `getFacialData` is
1172
+ * the roster's Facial Data column, reused here so the dialog and the table
1173
+ * cannot disagree; it answers "N/A" when there is nothing, which is the case
1174
+ * this must not draw a row for.
1175
+ */
1176
+ const editFacialData = computed(() => {
1177
+ if (!selectedUser.value) return "";
1178
+ const value = String(getFacialData(selectedUser.value) ?? "").trim();
1179
+ return value && value !== "N/A" ? value : "";
1180
+ });
1181
+ const removePinRequested = ref(false);
1182
+ const passwordSet = ref(false);
1183
+ const showPassword = ref(false);
1184
+ const removePasswordRequested = ref(false);
1185
+ const snackbar = reactive({
1186
+ show: false,
1187
+ color: "success",
1188
+ message: "",
1189
+ });
1190
+
1191
+ type OccupiedUnit = { _id: string; name: string; people: number };
1192
+ type OccupiedLevel = { _id: string; name: string; units?: OccupiedUnit[] };
1193
+ type OccupiedBlock = {
1194
+ _id: string;
1195
+ name: string;
1196
+ block: number | null;
1197
+ levels?: OccupiedLevel[];
1198
+ };
1199
+ type UnitPerson = {
1200
+ _id: string;
1201
+ name?: string;
1202
+ unitName?: string;
1203
+ /**
1204
+ * The resident's app account, and the reason this field is read here.
1205
+ *
1206
+ * A HID identity is refused unless its subject is a valid permission subject
1207
+ * at the site, and for a resident that means a `site.people` row WITH a
1208
+ * linked user - `assertPermissionSubject` in `iservice365-core` filters on
1209
+ * exactly this field. The permission model has no way to represent anyone
1210
+ * else: `resolvePermissionUserBindings` binds reader access by user id and
1211
+ * drops every row without one, so a resident enrolled without an account
1212
+ * would end up on the reader holding no access rules at all.
1213
+ *
1214
+ * The picker this replaced was fed by that same server-side function, so it
1215
+ * could never offer an unenrollable resident. The unit cascade asks People
1216
+ * Management instead, which has no such filter - hence this one.
1217
+ */
1218
+ user?: string | null;
1219
+ };
1220
+
1221
+ /**
1222
+ * THE SUBJECT TYPE THIS SCREEN WAS LAST USED FOR.
1223
+ *
1224
+ * Module scope, not component scope, and deliberately: the dialog is rebuilt on
1225
+ * every open and the component itself is unmounted when the operator moves
1226
+ * between HID screens, so a local ref would forget the choice immediately.
1227
+ * Enrolling ten guards in a row should not mean picking "Member" ten times.
1228
+ *
1229
+ * Not persisted. Surviving a hard refresh is a smaller benefit than surviving
1230
+ * the dialog, and `useHidReaderSelection` already set the precedent for where
1231
+ * that line sits.
1232
+ */
1233
+ /* The dropdown's values, plus `service_provider` - which the dropdown does not
1234
+ offer but `openEdit` resolves from records that name a COMPANY. */
1235
+ type TEnrolmentSubjectValue = HidEnrolmentSubject | "service_provider";
1236
+
1237
+ const REMEMBERED_SUBJECT_CATEGORY = ref<HidEnrolmentSubject>("resident");
1238
+
1239
+ const form = reactive({
1240
+ reader: "",
1241
+ block: "",
1242
+ level: "",
1243
+ unit: "",
1244
+ name: "",
1245
+ hidUserId: "",
1246
+ registration: "",
1247
+ isAdministrator: false,
1248
+ accessPin: "",
1249
+ password: "",
1250
+ photoPreview: "",
1251
+ subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value as TEnrolmentSubjectValue,
1252
+ subjectId: "",
1253
+ /** A mirror of the chosen member's role. Never sent; display only. */
1254
+ subjectRole: "",
1255
+ });
1256
+
1257
+ const route = useRoute();
1258
+ const orgId = computed(() => String(route.params.org || ""));
1259
+
1260
+ // The cascade runs on People Management's own endpoints, the same pair the
1261
+ // resident forms use:
1262
+ // GET /api/people/site/:site/occupied-structure - blocks/levels/units that
1263
+ // actually have an occupant, so the picker cannot offer an empty unit
1264
+ // GET /api/people/unit/:unit - who is in the chosen one
1265
+ // Both are fetched on FIRST USE, never when the dialog opens.
1266
+ const { getOccupiedStructure, getPeopleByUnit } = usePeople();
1267
+
1268
+ // `resident,tenant` matches the occupancy endpoint's own default, so the units
1269
+ // it offers and the people this returns are the same set. Asking the two
1270
+ // halves different questions is how a unit ends up offered but empty.
1271
+ const OCCUPANT_TYPES = "resident,tenant";
1272
+
1273
+ const occupiedBlocks = ref<OccupiedBlock[]>([]);
1274
+ const occupancyLoaded = ref(false);
1275
+ const loadingOccupancy = ref(false);
1276
+ const unitPeople = ref<UnitPerson[]>([]);
1277
+ const loadingUnitPeople = ref(false);
1278
+
1279
+ // "Is this resident already enrolled on THIS reader?" - the reader keeps its own
1280
+ // user table, so the same person at a different gate is normal and does not
1281
+ // count. Held here rather than derived, because it is the answer to a request.
1282
+ //
1283
+ // The server refuses the duplicate as well (`createIdentity` in
1284
+ // `iservice365-core`), but by the time it answers, `saveUser` has already
1285
+ // created a user on the physical device and has to roll it back. Asking here
1286
+ // keeps that off the reader entirely.
1287
+ const residentConflict = ref<HidUser | null>(null);
1288
+ const checkingResident = ref(false);
1289
+
1290
+ // One spelling of a block, used by the picker and by the label stamped onto the
1291
+ // record. Two copies of this expression is how the dropdown and the Unit column
1292
+ // drift apart.
1293
+ function formatBlockTitle(block?: OccupiedBlock | null) {
1294
+ if (!block) return "";
1295
+ return block.name
1296
+ ? `Block ${block.block} (${block.name})`
1297
+ : `Block ${block.block ?? ""}`.trim();
1298
+ }
1299
+
1300
+ const blockOptions = computed(() =>
1301
+ occupiedBlocks.value.map((block) => ({
1302
+ title: formatBlockTitle(block),
1303
+ value: String(block._id ?? ""),
1304
+ }))
1305
+ );
1306
+
1307
+ const levelOptions = computed(() => {
1308
+ const block = occupiedBlocks.value.find(
1309
+ (item) => String(item._id ?? "") === form.block
1310
+ );
1311
+ return (block?.levels ?? []).map((level) => ({
1312
+ title: String(level.name ?? ""),
1313
+ value: String(level._id ?? ""),
1314
+ }));
1315
+ });
1316
+
1317
+ const unitOptions = computed(() => {
1318
+ const block = occupiedBlocks.value.find(
1319
+ (item) => String(item._id ?? "") === form.block
1320
+ );
1321
+ const level = (block?.levels ?? []).find(
1322
+ (item) => String(item._id ?? "") === form.level
1323
+ );
1324
+ return (level?.units ?? []).map((unit) => ({
1325
+ title: String(unit.name ?? ""),
1326
+ value: String(unit._id ?? ""),
1327
+ }));
1328
+ });
1329
+
1330
+ // Only the residents the server will actually accept - see `UnitPerson.user`.
1331
+ // Offering the rest produced "The linked HID identity subject is not active at
1332
+ // this site." at the very end of enrollment, after a user had already been
1333
+ // written to the reader.
1334
+ const enrollableUnitPeople = computed(() =>
1335
+ unitPeople.value.filter((person) => Boolean(person.user))
1336
+ );
1337
+
1338
+ const unenrollableUnitPeopleCount = computed(
1339
+ () => unitPeople.value.length - enrollableUnitPeople.value.length
1340
+ );
1341
+
1342
+ // `_id` here is `site.people._id`, which is exactly the `person` that
1343
+ // `createIdentity` maps a HID user to.
1344
+ const subjectOptions = computed(() =>
1345
+ enrollableUnitPeople.value.map((person) => ({
1346
+ title: String(person.name ?? "Resident"),
1347
+ value: String(person._id ?? ""),
1348
+ }))
1349
+ );
1350
+
1351
+ // Says why somebody the operator can see in People Management is missing here.
1352
+ // Without it the unit just looks empty and the screen looks broken.
1353
+ const unitResidentNote = computed(() => {
1354
+ if (
1355
+ !form.unit ||
1356
+ loadingUnitPeople.value ||
1357
+ !unenrollableUnitPeopleCount.value
1358
+ )
1359
+ return "";
1360
+ const count = unenrollableUnitPeopleCount.value;
1361
+ const subject = count === 1 ? "1 resident" : `${count} residents`;
1362
+ const verb = count === 1 ? "is" : "are";
1363
+ return `${subject} in this unit ${verb} not listed: HID enrollment needs a resident app account, and ${
1364
+ count === 1 ? "that person has" : "they have"
1365
+ } none yet.`;
1366
+ });
1367
+
1368
+ /* ── THE SUBJECT TYPE ──────────────────────────────────────────────────── */
1369
+
1370
+ const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
1371
+
1372
+ const isResidentSubject = computed(() => form.subjectCategory === "resident");
1373
+ /* Members and provider staff both pick ONE PERSON from a flat list, but from
1374
+ different lists, so they are separate branches rather than "not a resident". */
1375
+ const isMemberSubject = computed(
1376
+ () => form.subjectCategory === "property_management"
1377
+ );
1378
+ const isProviderSubject = computed(
1379
+ () => form.subjectCategory === "service_provider_staff"
1380
+ );
1381
+ /** Either of the two person pickers - what the Role mirror follows. */
1382
+ const isPersonSubject = computed(
1383
+ () => isMemberSubject.value || isProviderSubject.value
1384
+ );
1385
+
1386
+ /* The mirror's label follows the list it mirrors. A member's subtitle is their
1387
+ role at the site; a contractor's is the firm they work for, and calling that
1388
+ "Role" would read as a mistake. */
1389
+ const subjectMirrorLabel = computed(() =>
1390
+ isProviderSubject.value ? "Company" : "Role"
1391
+ );
1392
+ const subjectMirrorPlaceholder = computed(() =>
1393
+ isProviderSubject.value ? "Select a person above" : "Select a member above"
1394
+ );
1395
+
1396
+ /* What an EDIT shows in place of the control, since the type cannot change.
1397
+ Looked up in the util, not in the switch's own options: records carry
1398
+ `service_provider` too, and the options list would have called those
1399
+ "Resident". */
1400
+ const subjectCategoryLabel = computed(() =>
1401
+ labelForSubjectCategory(form.subjectCategory)
1402
+ );
1403
+
1404
+ /* ── STAFF CANDIDATES ─────────────────────────────────────────────────────
1405
+ *
1406
+ * Read from `GET /api/members` — the SAME endpoint, with the same filters, that
1407
+ * the Members menu reads. That is deliberate and was the fix for a real bug: the
1408
+ * first version used the HID permission-candidate endpoint, which applies NO
1409
+ * `type` filter, so it offered every active members row at the site including
1410
+ * service providers and residents. A picker that disagrees with the Members menu
1411
+ * is a picker the operator cannot trust.
1412
+ *
1413
+ * `type` is the running app, the same way `HidQrCodeConfiguration` reads it, so
1414
+ * the property app lists its own staff and the security app lists its own. The
1415
+ * members endpoint expands legacy spellings of a type itself (`appTypeQuery`).
1416
+ *
1417
+ * Service providers get their own option next; they are not silently folded in
1418
+ * here.
1419
+ */
1420
+ /*
1421
+ * TEN, to share the Members menu's cache key.
1422
+ *
1423
+ * `member.repo.ts` caches each page under a key that INCLUDES `limit`, so a
1424
+ * different limit is a different cache entry with its own 15-minute life. This
1425
+ * picker asked for 50 and got its own rarely-touched slot, which went stale and
1426
+ * showed six members where the Members menu showed nine - the same query, the
1427
+ * same instant, two answers. Asking for the menu's own page size means the two
1428
+ * read the same entry and cannot disagree.
1429
+ */
1430
+ const MEMBER_PAGE_LIMIT = 10;
1431
+
1432
+ type MemberCandidate = { subjectId: string; name: string; subtitle: string };
1433
+
1434
+ const memberCandidates = ref<MemberCandidate[]>([]);
1435
+ const loadingMembers = ref(false);
1436
+ const loadingMoreMembers = ref(false);
1437
+ const membersLoaded = ref(false);
1438
+ /** The last page successfully loaded; 0 before the first. */
1439
+ const memberPage = ref(0);
1440
+ /** Rows fetched so far, INCLUDING those hidden for having no account. */
1441
+ const memberRowsLoaded = ref(0);
1442
+ /** What the server says matches the query, across every page. */
1443
+ const memberTotalRows = ref(0);
1444
+ /* Active staff dropped for having no app account. Counted here rather than
1445
+ * asked of the server, because this endpoint returns the `user` field and so
1446
+ * the answer is already in hand - see `hiddenMemberNote`. */
1447
+ const hiddenMemberCount = ref(0);
1448
+
1449
+ const hasMoreMembers = computed(
1450
+ () => memberRowsLoaded.value < memberTotalRows.value
1451
+ );
1452
+
1453
+ /*
1454
+ * Bumped on every request, so a response that is no longer the current one is
1455
+ * dropped. Scroll-loading makes this matter: a page still in flight when the
1456
+ * dialog reopens, or when the type is switched away and back, would otherwise
1457
+ * append onto a list that has since been replaced. Same guard, and the same
1458
+ * reason, as `cardRequestSeq` in `EntryPassInformation.vue`.
1459
+ */
1460
+ let memberRequestSeq = 0;
1461
+
1462
+ const memberOptions = computed(() =>
1463
+ memberCandidates.value.map((candidate) => ({
1464
+ // Role in the title as well as the mirror below: two people can share a name,
1465
+ // and the popover is where they have to be told apart.
1466
+ title: candidate.subtitle
1467
+ ? `${candidate.name} — ${candidate.subtitle}`
1468
+ : candidate.name,
1469
+ value: candidate.subjectId,
1470
+ }))
1471
+ );
1472
+
1473
+ const memberPlaceholder = computed(() => {
1474
+ if (loadingMembers.value) return "Loading staff...";
1475
+ if (!membersLoaded.value) return "Select a member";
1476
+ if (memberOptions.value.length) return "Select a member";
1477
+ return hiddenMemberCount.value
1478
+ ? "No staff here can be enrolled yet"
1479
+ : "No staff at this site";
1480
+ });
1481
+
1482
+ /* The same courtesy the resident cascade pays: say why somebody the operator
1483
+ * can see in the Members menu is not in this list. */
1484
+ const hiddenMemberNote = computed(() => {
1485
+ // Only once everything is loaded. Half a list gives half a count, and
1486
+ // "3 members at this site" would then be a number about this PAGE wearing the
1487
+ // word "site" - the range note below carries the partial truth instead.
1488
+ if (loadingMembers.value || hasMoreMembers.value) return "";
1489
+ return hiddenSubjectNote(hiddenMemberCount.value, "member");
1490
+ });
1491
+
1492
+ /* What is loaded against what exists - and the warning that the picker's own
1493
+ search only ever sees what has been fetched. */
1494
+ const memberRangeNote = computed(() =>
1495
+ hasMoreMembers.value
1496
+ ? `Showing ${memberRowsLoaded.value} of ${memberTotalRows.value}. Search looks only at these.`
1497
+ : ""
1498
+ );
1499
+
1500
+ /** Back to "nothing loaded", so the next open starts from page one. */
1501
+ function resetMemberCandidates() {
1502
+ // Anything still in flight belongs to the list being thrown away.
1503
+ memberRequestSeq += 1;
1504
+ memberCandidates.value = [];
1505
+ hiddenMemberCount.value = 0;
1506
+ memberPage.value = 0;
1507
+ memberRowsLoaded.value = 0;
1508
+ memberTotalRows.value = 0;
1509
+ membersLoaded.value = false;
1510
+ }
1511
+
1512
+ /**
1513
+ * One page of this site's active staff.
1514
+ *
1515
+ * Paged rather than fetched whole: a page of ten is what the Members menu asks
1516
+ * for, and sharing that page size shares its cache entry. `append` is what
1517
+ * separates the first load from Load more - the first replaces, the rest add.
1518
+ *
1519
+ * NEVER THROWS. The list is left as it is and the placeholder says so, because
1520
+ * choosing "Member" must not break the dialog.
1521
+ */
1522
+ async function fetchMemberPage(page: number, append: boolean) {
1523
+ const memberType = String(runtimeConfig.public.APP ?? "").trim();
1524
+ // "App" is the nuxt config's own placeholder default. Without a real app there
1525
+ // is no member type to ask for, and a typeless query returns nothing anyway.
1526
+ if (!props.site || !props.org || !memberType || memberType === "App") {
1527
+ resetMemberCandidates();
1528
+ membersLoaded.value = true;
1529
+ return;
1530
+ }
1531
+
1532
+ const seq = (memberRequestSeq += 1);
1533
+ const busy = append ? loadingMoreMembers : loadingMembers;
1534
+ busy.value = true;
1535
+ try {
1536
+ const response = await getAllMembers({
1537
+ org: props.org,
1538
+ siteId: props.site,
1539
+ type: memberType,
1540
+ status: "active",
1541
+ page,
1542
+ limit: MEMBER_PAGE_LIMIT,
1543
+ });
1544
+ if (seq !== memberRequestSeq) return;
1545
+ const parsed = readMemberPage(response);
1546
+ const enrollable = parsed.items.filter(memberHasAccount);
1547
+ const candidates = enrollable.map(memberCandidateOf);
1548
+
1549
+ memberCandidates.value = append
1550
+ ? [...memberCandidates.value, ...candidates]
1551
+ : candidates;
1552
+ const hiddenOnPage = parsed.items.length - enrollable.length;
1553
+ hiddenMemberCount.value = append
1554
+ ? hiddenMemberCount.value + hiddenOnPage
1555
+ : hiddenOnPage;
1556
+ memberRowsLoaded.value = append
1557
+ ? memberRowsLoaded.value + parsed.items.length
1558
+ : parsed.items.length;
1559
+ memberTotalRows.value = parsed.total;
1560
+ memberPage.value = page;
1561
+ membersLoaded.value = true;
1562
+ } catch (error: unknown) {
1563
+ if (seq !== memberRequestSeq) return;
1564
+ console.error("Unable to load staff for HID enrollment:", error);
1565
+ // An "append" that failed keeps what is already on screen; a first load has
1566
+ // nothing to keep. Either way the dialog stays usable.
1567
+ if (!append) resetMemberCandidates();
1568
+ membersLoaded.value = true;
1569
+ } finally {
1570
+ // Always its OWN flag, superseded or not: a request that leaves its spinner
1571
+ // set strands the list behind a wait that will never end.
1572
+ busy.value = false;
1573
+ }
1574
+ }
1575
+
1576
+ /** The first page, replacing whatever was there. */
1577
+ function loadMemberCandidates() {
1578
+ return fetchMemberPage(1, false);
1579
+ }
1580
+
1581
+ /** The next page, added to the list. */
1582
+ function loadMoreMembers() {
1583
+ if (loadingMembers.value || loadingMoreMembers.value || !hasMoreMembers.value)
1584
+ return;
1585
+ return fetchMemberPage(memberPage.value + 1, true);
1586
+ }
1587
+
1588
+ /* ── SERVICE PROVIDER STAFF ───────────────────────────────────────────────
1589
+ *
1590
+ * ONE PERSON from a provider company, never the company.
1591
+ *
1592
+ * Read from `GET /api/service-providers/site-members` - the same call behind
1593
+ * Members > Service Providers, with the same `filterProviderMembers` rule - so
1594
+ * the picker lists exactly the people that screen lists. The server scopes it to
1595
+ * the site; these are the contractors who attend THIS site, not every employee
1596
+ * of every firm engaged with the organisation.
1597
+ *
1598
+ * Unpaged, deliberately, unlike the staff picker above: this endpoint returns
1599
+ * the site's people in one response (capped at 1000 by the repository) and has
1600
+ * no per-page cache entry to disagree with. The service filter and the search
1601
+ * box both run over the loaded list, so neither costs a request.
1602
+ */
1603
+ const providerPeople = ref<Record<string, unknown>[]>([]);
1604
+ const providerCompanyCount = ref(0);
1605
+ const loadingProviders = ref(false);
1606
+ const providersLoaded = ref(false);
1607
+ /**
1608
+ * All services is the default, the same as the Members screen - but it carries a
1609
+ * REAL value here, not "". `AppSelect`'s `hasSingleValue` is false for an empty
1610
+ * string, so an empty-valued option draws the placeholder in placeholder grey and
1611
+ * is never marked as chosen: the filter worked and the field looked blank. See
1612
+ * `serviceFilterOf`.
1613
+ */
1614
+ const providerService = ref<string>(HID_ALL_SERVICES);
1615
+
1616
+ /** What the filter actually receives: "" for All services. */
1617
+ const providerServiceFilter = computed(() => serviceFilterOf(providerService.value));
1618
+ let providerRequestSeq = 0;
1619
+
1620
+ /** Everyone who can actually be given a door: an app account, and active. */
1621
+ const providerEnrollable = computed(() =>
1622
+ providerEnrollableRows(providerPeople.value)
1623
+ );
1624
+
1625
+ /* Whether the endpoint told us who holds an app account. It only began
1626
+ returning `user` in core 3.130, and this layer can be deployed ahead of it. */
1627
+ const providerAccountsVisible = computed(() =>
1628
+ providerAccountsKnown(providerPeople.value)
1629
+ );
1630
+
1631
+ /** People the operator can see on the Members screen but cannot enrol here. */
1632
+ const hiddenProviderCount = computed(
1633
+ () => providerPeople.value.length - providerEnrollable.value.length
1634
+ );
1635
+
1636
+ const providerServiceOptions = computed(() => [
1637
+ { title: "All services", value: HID_ALL_SERVICES },
1638
+ // From the ENROLLABLE set, so choosing a service never lands on an empty list.
1639
+ ...serviceTypeOptions(providerEnrollable.value as Array<{ type?: string; typeLabel?: string }>),
1640
+ ]);
1641
+
1642
+ /** The filtered list - what both the picker and the bulk table draw. */
1643
+ const providerRows = computed(() =>
1644
+ filterProviderMembers(providerEnrollable.value as Array<Record<string, any>>, {
1645
+ type: providerServiceFilter.value,
1646
+ })
1647
+ );
1648
+
1649
+ const providerOptions = computed(() =>
1650
+ providerRows.value.map((row) => {
1651
+ const candidate = providerCandidateOf(row);
1652
+ return {
1653
+ // The firm in the title as well as the mirror: two contractors sharing a
1654
+ // first name is the normal case, and the popover is where they have to be
1655
+ // told apart.
1656
+ title: candidate.subtitle
1657
+ ? `${candidate.name} — ${candidate.subtitle}`
1658
+ : candidate.name,
1659
+ value: candidate.subjectId,
1660
+ };
1661
+ })
1662
+ );
1663
+
1664
+ const providerPlaceholder = computed(() => {
1665
+ if (loadingProviders.value) return "Loading people...";
1666
+ if (!providersLoaded.value) return "Select a person";
1667
+ if (providerOptions.value.length) return "Select a person";
1668
+ if (providerServiceFilter.value) return "Nobody in this service can be enrolled yet";
1669
+ if (hiddenProviderCount.value) return "Nobody here can be enrolled yet";
1670
+ return providerCompanyCount.value
1671
+ ? "No service provider people at this site"
1672
+ : "No service providers work at this site yet";
1673
+ });
1674
+
1675
+ /* Say why somebody on the Members screen is not in this list, rather than
1676
+ letting a missing colleague read as a broken dialog. */
1677
+ const hiddenProviderNote = computed(() => {
1678
+ if (loadingProviders.value || !hiddenProviderCount.value) return "";
1679
+ const count = hiddenProviderCount.value;
1680
+ const subject = count === 1 ? "1 person" : `${count} people`;
1681
+ const verb = count === 1 ? "is" : "are";
1682
+ const holder = count === 1 ? "that person is" : "they are";
1683
+ const needs = providerAccountsVisible.value
1684
+ ? "an active membership with an app account"
1685
+ : "an active membership at this site";
1686
+ return `${subject} from this site's service providers ${verb} not listed:`
1687
+ + ` HID enrollment needs ${needs}, and ${holder} without one.`;
1688
+ });
1689
+
1690
+ /** Back to "nothing loaded", so the next open starts clean. */
1691
+ function resetProviderCandidates() {
1692
+ providerRequestSeq += 1;
1693
+ providerPeople.value = [];
1694
+ providerCompanyCount.value = 0;
1695
+ providerService.value = HID_ALL_SERVICES;
1696
+ providersLoaded.value = false;
1697
+ }
1698
+
1699
+ /**
1700
+ * This site's service provider people.
1701
+ *
1702
+ * NEVER THROWS, on the same rule as the staff loader: choosing Service provider
1703
+ * must not break the dialog. The placeholder carries the failure.
1704
+ */
1705
+ async function loadProviderCandidates() {
1706
+ if (!props.site) {
1707
+ resetProviderCandidates();
1708
+ providersLoaded.value = true;
1709
+ return;
1710
+ }
1711
+ const seq = (providerRequestSeq += 1);
1712
+ loadingProviders.value = true;
1713
+ try {
1714
+ const data = await getSiteMembers(props.site);
1715
+ // A response that arrived after the dialog was reopened belongs to a list
1716
+ // that no longer exists - the same guard the staff pager uses.
1717
+ if (seq !== providerRequestSeq) return;
1718
+ providerPeople.value = Array.isArray(data?.items)
1719
+ ? (data.items as unknown as Record<string, unknown>[])
1720
+ : [];
1721
+ providerCompanyCount.value = Array.isArray(data?.companies)
1722
+ ? data.companies.length
1723
+ : 0;
1724
+ providersLoaded.value = true;
1725
+ } catch (error: unknown) {
1726
+ if (seq !== providerRequestSeq) return;
1727
+ console.error("Unable to load service provider people for HID enrollment:", error);
1728
+ providerPeople.value = [];
1729
+ providerCompanyCount.value = 0;
1730
+ providersLoaded.value = true;
1731
+ } finally {
1732
+ loadingProviders.value = false;
1733
+ }
1734
+ }
1735
+
1736
+ /** The chosen person, restated into Name and the Company mirror. */
1737
+ function onProviderChanged() {
1738
+ // Compared on the TRIMMED id, because that is what the option's value carries
1739
+ // (`providerCandidateOf` trims) - matching the raw field would silently find
1740
+ // nobody and blank the name the operator just picked.
1741
+ const row = providerRows.value.find(
1742
+ (item) => String(item._id ?? "").trim() === form.subjectId
1743
+ );
1744
+ const candidate = row ? providerCandidateOf(row) : null;
1745
+ form.name = candidate?.name ?? "";
1746
+ form.subjectRole = candidate?.subtitle ?? "";
1747
+ void checkSubjectEnrollment();
1748
+ }
1749
+
1750
+ /* Changing the service can take the chosen person out of the list. Leaving a
1751
+ selection that is no longer on screen is how a save ends up naming somebody
1752
+ the operator cannot see. */
1753
+ function onProviderServiceChanged() {
1754
+ if (!form.subjectId) return;
1755
+ const stillThere = providerRows.value.some(
1756
+ (item) => String(item._id ?? "").trim() === form.subjectId
1757
+ );
1758
+ if (stillThere) return;
1759
+ form.subjectId = "";
1760
+ form.name = "";
1761
+ form.subjectRole = "";
1762
+ residentConflict.value = null;
1763
+ }
1764
+
1765
+ /* ── ADD USERS IN BULK ────────────────────────────────────────────────────
1766
+ *
1767
+ * ACCESS, NOT CREDENTIALS. This grants a reader group to many people at once and
1768
+ * stops there - no face, no card, no PIN. Each person then adds their own
1769
+ * credential from their app, which attaches to the HID user this grant created
1770
+ * (`getProfileEnrollmentContext` reuses the existing profile identity), so
1771
+ * nobody has to be enrolled twice.
1772
+ *
1773
+ * It writes through `PUT /sites/:siteId/permissions`, the one call that writes
1774
+ * group membership, and the device users are created by the reconcile that call
1775
+ * triggers: `provisionPermissionUser` writes `{ name, registration }` and
1776
+ * nothing else. That is exactly "user record and group, credentials empty".
1777
+ *
1778
+ * THE TRAP, and the reason the saved set is fetched first: that PUT REPLACES
1779
+ * this reader's whole assignment set. Sending only the ticked contractors would
1780
+ * revoke every resident and every member on the door. The payload is always
1781
+ * built from the reader's existing assignments PLUS the selection.
1782
+ */
1783
+ const bulkDialog = ref(false);
1784
+ const bulkLoading = ref(false);
1785
+ const bulkSaving = ref(false);
1786
+ const bulkSearch = ref("");
1787
+ /** The reader's CURRENT assignments, across every category. The merge base. */
1788
+ const bulkSaved = ref<HidAssignmentState>(new Map());
1789
+ /** Who is being added now. Already-granted people are never in here. */
1790
+ const bulkSelected = ref<Set<string>>(new Set());
1791
+
1792
+ /* NAME THE DOOR. A bulk grant is reader-scoped and the reader was chosen on the
1793
+ screen behind two dialogs, so the confirmation says which one rather than
1794
+ leaving the operator to remember. */
1795
+ const bulkReaderLabel = computed(() => {
1796
+ const reader = selectedReader.value;
1797
+ const name = String(reader?.name ?? "").trim() || "this HID reader";
1798
+ const location = String(reader?.location ?? "").trim();
1799
+ return location ? `${name} · ${location}` : name;
1800
+ });
1801
+
1802
+ /** The same people the picker offers, narrowed by this dialog's own search. */
1803
+ const bulkRows = computed(() => {
1804
+ const rows = filterProviderMembers(
1805
+ providerRows.value as Array<Record<string, any>>,
1806
+ { search: bulkSearch.value }
1807
+ );
1808
+ return rows.map((row) => {
1809
+ const candidate = providerCandidateOf(row);
1810
+ const id = candidate.subjectId;
1811
+ return {
1812
+ subjectId: id,
1813
+ name: candidate.name,
1814
+ company: String(row.company ?? "").trim(),
1815
+ service: String(row.typeLabel ?? row.type ?? "").trim(),
1816
+ // Already on the door. Ticked and locked rather than hidden: hiding them
1817
+ // would make "select all" look like it had missed people, and unticking
1818
+ // one here would revoke access this dialog is not for revoking.
1819
+ granted: bulkSaved.value.has(id),
1820
+ };
1821
+ });
1822
+ });
1823
+
1824
+ const bulkSelectableRows = computed(() =>
1825
+ bulkRows.value.filter((row) => !row.granted)
1826
+ );
1827
+ const bulkSelectedCount = computed(() => bulkSelected.value.size);
1828
+ const bulkAllSelected = computed(
1829
+ () =>
1830
+ bulkSelectableRows.value.length > 0 &&
1831
+ bulkSelectableRows.value.every((row) => bulkSelected.value.has(row.subjectId))
1832
+ );
1833
+ const bulkGrantedCount = computed(
1834
+ () => bulkRows.value.filter((row) => row.granted).length
1835
+ );
1836
+
1837
+ const bulkEmptyText = computed(() => {
1838
+ if (bulkLoading.value) return "Loading...";
1839
+ if (bulkSearch.value.trim()) return "No one matches this search.";
1840
+ if (providerServiceFilter.value) return "No one in this service can be enrolled yet.";
1841
+ return "No service provider people can be enrolled at this site yet.";
1842
+ });
1843
+
1844
+ function isBulkSelected(subjectId: string) {
1845
+ return bulkSelected.value.has(subjectId);
1846
+ }
1847
+
1848
+ function toggleBulkRow(subjectId: string, selected: boolean) {
1849
+ const next = new Set(bulkSelected.value);
1850
+ if (selected) next.add(subjectId);
1851
+ else next.delete(subjectId);
1852
+ bulkSelected.value = next;
1853
+ }
1854
+
1855
+ /* Select-all acts on the ROWS IN FRONT OF THE OPERATOR - what the service filter
1856
+ and the search have left - never on the whole estate. A tick box cannot
1857
+ quietly grant a door to people who are not on screen. */
1858
+ function toggleBulkAll(selected: boolean) {
1859
+ const next = new Set(bulkSelected.value);
1860
+ for (const row of bulkSelectableRows.value) {
1861
+ if (selected) next.add(row.subjectId);
1862
+ else next.delete(row.subjectId);
1863
+ }
1864
+ bulkSelected.value = next;
1865
+ }
1866
+
1867
+ /**
1868
+ * Open the bulk dialog, after reading what the reader already holds.
1869
+ *
1870
+ * The saved set is fetched EVERY time rather than cached: it is the merge base
1871
+ * for a write that replaces the reader's assignments, and a stale one would
1872
+ * revoke whoever was granted in between.
1873
+ */
1874
+ async function openBulkAdd() {
1875
+ const readerId =
1876
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
1877
+ if (!readerId) {
1878
+ showToast("Select a HID reader before adding people in bulk.", "error");
1879
+ return;
1880
+ }
1881
+ /* The permissions endpoints REQUIRE an org - `schemaHidPermissionScopeQuery`
1882
+ has it as `required()`, unlike `createIdentity`, which falls back to the
1883
+ site's own. Both pages that mount this component pass one, so this is a
1884
+ guard against a 400 that would read as a server fault. */
1885
+ if (!props.org) {
1886
+ showToast(
1887
+ "This screen cannot grant access in bulk without an organization.",
1888
+ "error"
1889
+ );
1890
+ return;
1891
+ }
1892
+ bulkSearch.value = "";
1893
+ bulkSelected.value = new Set();
1894
+ bulkSaved.value = new Map();
1895
+ bulkDialog.value = true;
1896
+ bulkLoading.value = true;
1897
+ try {
1898
+ if (!providersLoaded.value) await loadProviderCandidates();
1899
+ const response = await getSitePermissions(props.site, props.org, readerId);
1900
+ bulkSaved.value = seedAssignments(response?.data?.assignments ?? []);
1901
+ } catch (error: unknown) {
1902
+ // Closed rather than left open on an empty merge base: saving from here
1903
+ // would send only the ticked people and revoke everybody else.
1904
+ bulkDialog.value = false;
1905
+ showToast(
1906
+ apiReason(error) || "Unable to read this reader's current access.",
1907
+ "error"
1908
+ );
1909
+ } finally {
1910
+ bulkLoading.value = false;
1911
+ }
1912
+ }
1913
+
1914
+ /** Grant the ticked people, keeping everyone the reader already admits. */
1915
+ async function saveBulkAdd() {
1916
+ if (bulkSaving.value || !bulkSelected.value.size) return;
1917
+ const readerId =
1918
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
1919
+ if (!readerId) {
1920
+ showToast("Select a HID reader before adding people in bulk.", "error");
1921
+ return;
1922
+ }
1923
+
1924
+ bulkSaving.value = true;
1925
+ try {
1926
+ // Seeded from what the server holds, so every category the reader already
1927
+ // admits survives this write.
1928
+ let draft: HidAssignmentState = new Map(bulkSaved.value);
1929
+ for (const subjectId of bulkSelected.value) {
1930
+ draft = setAssignment(
1931
+ draft,
1932
+ bulkSaved.value,
1933
+ subjectId,
1934
+ // A provider employee's grant is stored under the STAFF category: core's
1935
+ // `permissionSubjectOfIdentity` reads the `member` link and cannot see
1936
+ // this dropdown, and that category's subject list already includes these
1937
+ // people. See `permissionCategoryOf`.
1938
+ permissionCategoryOf("service_provider_staff") as THidPermissionCategory,
1939
+ true
1940
+ );
1941
+ }
1942
+ const added = bulkSelected.value.size;
1943
+ await updateSitePermissions(
1944
+ props.site,
1945
+ props.org,
1946
+ readerId,
1947
+ toAssignmentPayload(draft)
1948
+ );
1949
+ // Both dialogs close and the list is re-read: the reconcile has just created
1950
+ // a HID user for each person, so the roster behind this dialog is stale.
1951
+ bulkDialog.value = false;
1952
+ formDialog.value = false;
1953
+ await loadUsers();
1954
+ showToast(
1955
+ `${added} ${added === 1 ? "person" : "people"} can now use this reader. `
1956
+ + "They add their own face, QR or PIN from their app.",
1957
+ "success"
1958
+ );
1959
+ } catch (error: unknown) {
1960
+ showToast(
1961
+ apiReason(error) || "Unable to give these people access to this reader.",
1962
+ "error"
1963
+ );
1964
+ } finally {
1965
+ bulkSaving.value = false;
1966
+ }
1967
+ }
1968
+
1969
+ /**
1970
+ * Switching type clears the chosen subject, and nothing else.
1971
+ *
1972
+ * The id spaces do not overlap - a `site.people._id` submitted as a `member`
1973
+ * would be refused by the server, and worse, a stale one could name a real
1974
+ * member - so it is cleared rather than carried. Name and Role mirror the
1975
+ * choice, so they clear with it.
654
1976
  */
655
- const facialEnrolled = ref(false);
1977
+ async function onSubjectCategoryChanged() {
1978
+ REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory as HidEnrolmentSubject;
1979
+ form.subjectId = "";
1980
+ form.subjectRole = "";
1981
+ form.name = "";
1982
+ residentConflict.value = null;
656
1983
 
657
- function hasFacialEnrollment(user?: HidUser | null) {
658
- return Boolean(
659
- user?.metadata?.facialEnrolled ||
660
- user?.metadata?.facialData ||
661
- user?.metadata?.imageTimestamp
1984
+ // Each list is fetched on first use and then kept, so flipping between the
1985
+ // three options costs one request each rather than one per flip.
1986
+ if (isMemberSubject.value && !membersLoaded.value) {
1987
+ await loadMemberCandidates();
1988
+ }
1989
+ if (isProviderSubject.value && !providersLoaded.value) {
1990
+ await loadProviderCandidates();
1991
+ }
1992
+ }
1993
+
1994
+ /* The staff equivalent of `onResidentChanged`. */
1995
+ async function onMemberChanged() {
1996
+ const candidate = memberCandidates.value.find(
1997
+ (item) => String(item.subjectId) === String(form.subjectId)
662
1998
  );
1999
+ form.name = String(candidate?.name ?? "");
2000
+ form.subjectRole = String(candidate?.subtitle ?? "");
2001
+ await checkSubjectEnrollment();
663
2002
  }
664
- const removePinRequested = ref(false);
665
- const snackbar = reactive({
666
- show: false,
667
- color: "success",
668
- message: "",
2003
+
2004
+ const blockPlaceholder = computed(() => {
2005
+ if (loadingOccupancy.value) return "Loading...";
2006
+ if (occupancyLoaded.value && !blockOptions.value.length) {
2007
+ return "No occupied units at this site";
2008
+ }
2009
+ return "Select block";
669
2010
  });
670
2011
 
671
- const form = reactive({
672
- reader: "",
673
- block: "",
674
- level: "",
675
- unit: "",
676
- name: "",
677
- hidUserId: "",
678
- registration: "",
679
- isAdministrator: false,
680
- accessPin: "",
681
- photoPreview: "",
682
- subjectCategory: "resident" as THidPermissionCategory,
683
- subjectId: "",
2012
+ const unitResidentPlaceholder = computed(() => {
2013
+ if (!form.unit) return "Select a unit first";
2014
+ if (loadingUnitPeople.value) return "Loading residents...";
2015
+ if (enrollableUnitPeople.value.length) return "Select a resident";
2016
+ // The two empty cases are different problems with different fixes, so they
2017
+ // do not share a sentence: nobody lives here, versus nobody here can be
2018
+ // enrolled yet. `unitResidentNote` carries the detail for the second.
2019
+ return unitPeople.value.length
2020
+ ? "No resident here can be enrolled yet"
2021
+ : "No residents in this unit";
684
2022
  });
685
2023
 
686
- const route = useRoute();
687
- const orgId = computed(() => String(route.params.org || ""));
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
- })));
2024
+ // Names the HID user that is in the way, so the operator can go and edit it
2025
+ // rather than being told only that something is wrong.
2026
+ const residentConflictMessage = computed(() => {
2027
+ const conflict = residentConflict.value;
2028
+ if (!conflict) return "";
2029
+ const label = [getName(conflict), formatHidUid(conflict.hidUserId)]
2030
+ .filter(Boolean)
2031
+ .join(" · ");
2032
+ return label
2033
+ ? `Already enrolled on this reader as ${label}. Edit that HID user instead of enrolling again.`
2034
+ : "Already enrolled on this reader. Edit that HID user instead of enrolling again.";
2035
+ });
699
2036
 
700
2037
  const cardForm = reactive({
701
2038
  cardType: "pacs" as "pacs" | "csn",
@@ -705,23 +2042,37 @@ const cardForm = reactive({
705
2042
  });
706
2043
 
707
2044
  const cardReader = computed(() => {
708
- const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
709
- return readers.value.find((reader) => String(reader._id) === readerId) || null;
2045
+ const readerId = String(
2046
+ cardUser.value?.reader || selectedReaderId.value || ""
2047
+ );
2048
+ return (
2049
+ readers.value.find((reader) => String(reader._id) === readerId) || null
2050
+ );
710
2051
  });
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");
2052
+ const cardProfileConfigured = computed(() =>
2053
+ ["pacs", "csn", "csn_for_mifare"].includes(
2054
+ String(cardReader.value?.cardReadingMode || "")
2055
+ )
2056
+ );
2057
+ const hybridCardProfile = computed(
2058
+ () => cardReader.value?.cardReadingMode === "csn_for_mifare"
2059
+ );
715
2060
  const hybridCardTypeOptions = [
716
2061
  { title: "MIFARE card (CSN)", value: "csn" },
717
2062
  { title: "Other card (PACS)", value: "pacs" },
718
2063
  ];
719
2064
  const cardProfileLabel = computed(() => {
720
2065
  const mode = String(cardReader.value?.cardReadingMode || "unconfigured");
721
- if (mode === "pacs") return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
722
- if (mode === "csn") return `CSN · ${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first`;
2066
+ if (mode === "pacs")
2067
+ return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
2068
+ if (mode === "csn")
2069
+ return `CSN · ${(
2070
+ cardReader.value?.csnByteOrder || "msb"
2071
+ ).toUpperCase()} first`;
723
2072
  if (mode === "csn_for_mifare") {
724
- return `PACS for non-MIFARE cards · CSN for MIFARE (${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first)`;
2073
+ return `PACS for non-MIFARE cards · CSN for MIFARE (${(
2074
+ cardReader.value?.csnByteOrder || "msb"
2075
+ ).toUpperCase()} first)`;
725
2076
  }
726
2077
  return "Configure the card data mode on the HID Reader screen before assigning a card.";
727
2078
  });
@@ -738,6 +2089,8 @@ const userHeaders = [
738
2089
  { title: "Name", value: "name", sortable: false },
739
2090
  { title: "Facial Data", value: "facialData", sortable: false },
740
2091
  { title: "UID", value: "hidUserId", sortable: false },
2092
+ // Reads in the same order the dialog asks for them: UID then registration.
2093
+ { title: "Registration No.", value: "registration", sortable: false },
741
2094
  { title: "Status", value: "status", sortable: false },
742
2095
  { title: "", value: "action-table", sortable: false },
743
2096
  ];
@@ -749,8 +2102,12 @@ const cardHeaders = [
749
2102
  { title: "", value: "action-table", sortable: false },
750
2103
  ];
751
2104
 
752
- const tableTitle = computed(() => props.cardManagement ? "HID Cards" : "HID Users");
753
- const tableHeaders = computed(() => props.cardManagement ? cardHeaders : userHeaders);
2105
+ const tableTitle = computed(() =>
2106
+ props.cardManagement ? "HID Cards" : "HID Users"
2107
+ );
2108
+ const tableHeaders = computed(() =>
2109
+ props.cardManagement ? cardHeaders : userHeaders
2110
+ );
754
2111
 
755
2112
  /**
756
2113
  * The list is paged on the SERVER, so the shared pager only moves the cursor -
@@ -767,14 +2124,45 @@ function reloadUsersFromFirstPage() {
767
2124
  loadUsers();
768
2125
  }
769
2126
 
2127
+ /**
2128
+ * Typing is the search.
2129
+ *
2130
+ * The field used to reload on `@keyup.enter` and on nothing else, so a screen
2131
+ * that looks like every other search box did nothing at all unless you guessed
2132
+ * that Enter was required - no request left the browser.
2133
+ *
2134
+ * Debounced, because each reload is a login/load/logout round trip to a reader
2135
+ * over a VPN, not a database query. Enter still works and skips the wait.
2136
+ */
2137
+ const SEARCH_DEBOUNCE_MS = 400;
2138
+ let searchDebounce: ReturnType<typeof setTimeout> | undefined;
2139
+
2140
+ function onSearchInput() {
2141
+ clearTimeout(searchDebounce);
2142
+ searchDebounce = setTimeout(reloadUsersFromFirstPage, SEARCH_DEBOUNCE_MS);
2143
+ }
2144
+
2145
+ function onSearchSubmit() {
2146
+ clearTimeout(searchDebounce);
2147
+ reloadUsersFromFirstPage();
2148
+ }
2149
+
2150
+ watch(search, onSearchInput);
2151
+ onBeforeUnmount(() => clearTimeout(searchDebounce));
2152
+
770
2153
  const readerOptions = computed(() =>
771
2154
  readers.value.map((reader) => ({
772
- title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
2155
+ title: `${reader.name || reader.deviceId || reader._id} — ${
2156
+ reader.portalName || "Portal not configured"
2157
+ }`,
773
2158
  value: reader._id,
774
2159
  }))
775
2160
  );
776
- const selectedReader = computed(() =>
777
- readers.value.find((reader) => reader._id === (selectedReaderId.value || form.reader)) || null,
2161
+ const selectedReader = computed(
2162
+ () =>
2163
+ readers.value.find(
2164
+ (reader) => reader._id === (selectedReaderId.value || form.reader)
2165
+ ) || null
778
2166
  );
779
2167
 
780
2168
  const pageRange = computed(() => {
@@ -801,15 +2189,28 @@ async function loadReaders() {
801
2189
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
802
2190
  const responseRecord = toRecord(response);
803
2191
  const responseData = toRecord(responseRecord.data);
804
- const items = responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
2192
+ const items =
2193
+ responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
805
2194
  readers.value = (Array.isArray(items) ? items : [])
806
2195
  .map(toHidReader)
807
2196
  .filter((reader): reader is HidReader => reader !== null);
808
- if (!selectedReaderId.value && readers.value.length) {
809
- selectedReaderId.value = readers.value[0]._id;
810
- }
2197
+ /* Shared with every other HID screen — see `useHidReaderSelection`. The
2198
+ resolver refuses a remembered reader that is not this site's, so a site
2199
+ switch or a deleted reader falls back to the first rather than querying
2200
+ an id the site does not own. */
2201
+ selectedReaderId.value = resolveForSite(props.site, readers.value);
811
2202
  }
812
2203
 
2204
+ /**
2205
+ * Discards a load whose answer arrives after a newer one started.
2206
+ *
2207
+ * Each reload is a slow round trip to a reader over a VPN, and typing now fires
2208
+ * them, so "abc" can have three in flight at once. Without this the reply to
2209
+ * "a" can land last and repopulate the table with the wrong rows. Same monotonic
2210
+ * counter `HidAccessLogDashboard` uses.
2211
+ */
2212
+ const loadUsersSequence = ref(0);
2213
+
813
2214
  async function loadUsers() {
814
2215
  if (!selectedReaderId.value) {
815
2216
  users.value = [];
@@ -819,32 +2220,83 @@ async function loadUsers() {
819
2220
  return;
820
2221
  }
821
2222
 
2223
+ const sequence = loadUsersSequence.value + 1;
2224
+ loadUsersSequence.value = sequence;
822
2225
  loading.value = true;
823
2226
  try {
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
- });
2227
+ // `userType: "user"` is REQUIRED here, not a nicety. The reader keeps
2228
+ // visitors in the same `users` table, marked `user_type_id = 1`, and the
2229
+ // API's default is `all`. Visitor rows are written by our own visitor-QR
2230
+ // issuance and are never removed - revoke only pushes `end_time` into the
2231
+ // past - so without this filter the enrollment roster counts every visitor
2232
+ // pass ever issued and drifts further from the reader's own Users screen
2233
+ // with each one. This screen enrolls and edits real people; visitors are
2234
+ // owned by the visitor module.
2235
+ let clientFiltersVisitors = false;
2236
+ let response: unknown;
2237
+ try {
2238
+ response = await getReaderUsers(selectedReaderId.value, {
2239
+ page: page.value,
2240
+ limit: limit.value,
2241
+ search: search.value.trim(),
2242
+ status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
2243
+ userType: "user",
2244
+ });
2245
+ } catch (error: unknown) {
2246
+ if (!isUnsupportedUserTypeFilter(error)) throw error;
2247
+
2248
+ // A deployed API can lag behind this shared layer. Keep the roster
2249
+ // correct on one that has not added `userType` yet, the same way
2250
+ // HidReaderUserRoster does, rather than failing the whole screen.
2251
+ clientFiltersVisitors = true;
2252
+ response = await getReaderUsers(selectedReaderId.value, {
2253
+ page: 1,
2254
+ limit: 100,
2255
+ search: search.value.trim(),
2256
+ status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
2257
+ });
2258
+ }
830
2259
  const responseRecord = toRecord(response);
831
2260
  const responseData = toRecord(responseRecord.data);
832
2261
  const responseItems = responseRecord.items ?? responseData.items ?? [];
833
- const pageItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
2262
+ const allItems = (Array.isArray(responseItems) ? responseItems : []).map(
2263
+ toHidUser
2264
+ );
2265
+ const matchingItems = clientFiltersVisitors
2266
+ ? allItems.filter((user) => !isVisitorUser(user))
2267
+ : allItems;
2268
+ const pageStart = (page.value - 1) * limit.value;
2269
+ const pageItems = clientFiltersVisitors
2270
+ ? matchingItems.slice(pageStart, pageStart + limit.value)
2271
+ : matchingItems;
2272
+ if (sequence !== loadUsersSequence.value) return;
834
2273
  administratorUserIds.value = new Set(
835
2274
  pageItems
836
2275
  .filter((user) => user.isAdministrator === true)
837
2276
  .map((user) => toHidNumericId(user.hidUserId))
838
- .filter((userId: number | undefined): userId is number => Boolean(userId)),
2277
+ .filter((userId: number | undefined): userId is number =>
2278
+ Boolean(userId)
2279
+ )
839
2280
  );
840
2281
  users.value = pageItems;
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 ?? "");
2282
+ total.value = clientFiltersVisitors
2283
+ ? matchingItems.length
2284
+ : Number(
2285
+ responseRecord.total ?? responseData.total ?? users.value.length
2286
+ );
2287
+ pages.value = clientFiltersVisitors
2288
+ ? Math.max(1, Math.ceil(total.value / limit.value))
2289
+ : Number(responseRecord.pages ?? responseData.pages ?? 1);
2290
+ // The server's `pageRange` counts the unfiltered page, so it cannot be
2291
+ // trusted once this screen has done the filtering itself.
2292
+ serverPageRange.value = clientFiltersVisitors
2293
+ ? ""
2294
+ : String(responseRecord.pageRange ?? responseData.pageRange ?? "");
844
2295
  if (props.cardManagement) {
845
2296
  await loadVisibleUserCards(users.value);
846
2297
  }
847
2298
  } catch (error) {
2299
+ if (sequence !== loadUsersSequence.value) return;
848
2300
  console.error("Unable to load HID reader users:", error);
849
2301
  showToast("Unable to load HID users from reader.", "error");
850
2302
  users.value = [];
@@ -855,11 +2307,12 @@ async function loadUsers() {
855
2307
  pages.value = 1;
856
2308
  serverPageRange.value = "";
857
2309
  } finally {
858
- loading.value = false;
2310
+ // Only the newest load owns the spinner; an older one finishing must not
2311
+ // clear it while that newer one is still running.
2312
+ if (sequence === loadUsersSequence.value) loading.value = false;
859
2313
  }
860
2314
  }
861
2315
 
862
-
863
2316
  function resetForm() {
864
2317
  form.reader = selectedReaderId.value || readers.value[0]?._id || "";
865
2318
  form.block = "";
@@ -870,6 +2323,10 @@ function resetForm() {
870
2323
  form.registration = "";
871
2324
  form.isAdministrator = false;
872
2325
  form.accessPin = "";
2326
+ form.password = "";
2327
+ passwordSet.value = false;
2328
+ showPassword.value = false;
2329
+ removePasswordRequested.value = false;
873
2330
  pinEnrolled.value = false;
874
2331
  showPin.value = false;
875
2332
  removePinRequested.value = false;
@@ -877,34 +2334,201 @@ function resetForm() {
877
2334
  selectedPhotoFile.value = null;
878
2335
  removePhotoRequested.value = false;
879
2336
  facialEnrolled.value = false;
880
- form.subjectCategory = "resident";
2337
+ // The type the operator last enrolled, not a hard "resident": somebody adding
2338
+ // a shift of guards should not re-pick Member for each one.
2339
+ form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
881
2340
  form.subjectId = "";
2341
+ form.subjectRole = "";
2342
+ residentConflict.value = null;
2343
+ checkingResident.value = false;
882
2344
  }
883
2345
 
884
- async function loadSubjectCandidates() {
885
- const readerId = form.reader || selectedReaderId.value;
886
- if (!readerId || !orgId.value) {
887
- permissionCandidates.value = [];
888
- return;
2346
+ /** Surface what the API actually said - a bare "unable to load" hides the
2347
+ * difference between a permission refusal, a bad id and a server fault. */
2348
+ function apiReason(error: unknown) {
2349
+ const record = toRecord(error);
2350
+ const data = toRecord(record.data);
2351
+ return (
2352
+ toText(data.message) ||
2353
+ toText(record.message) ||
2354
+ toText(record.statusMessage)
2355
+ );
2356
+ }
2357
+
2358
+ // Called when the Block select is first touched, not when the dialog opens.
2359
+ async function ensureOccupancy() {
2360
+ if (!props.site || occupancyLoaded.value || loadingOccupancy.value) return;
2361
+ loadingOccupancy.value = true;
2362
+ try {
2363
+ const response = toRecord(
2364
+ await getOccupiedStructure(props.site, {
2365
+ type: OCCUPANT_TYPES,
2366
+ status: "active",
2367
+ })
2368
+ );
2369
+ const data = toRecord(response.data);
2370
+ const blocks = response.blocks ?? data.blocks ?? [];
2371
+ occupiedBlocks.value = (
2372
+ Array.isArray(blocks) ? blocks : []
2373
+ ) as OccupiedBlock[];
2374
+ occupancyLoaded.value = true;
2375
+ } catch (error: unknown) {
2376
+ console.error("Unable to load occupied units:", error);
2377
+ occupiedBlocks.value = [];
2378
+ const reason = apiReason(error);
2379
+ showToast(
2380
+ reason
2381
+ ? `Unable to load units: ${reason}`
2382
+ : "Unable to load units for this site.",
2383
+ "error"
2384
+ );
2385
+ } finally {
2386
+ loadingOccupancy.value = false;
889
2387
  }
890
- loadingSubjects.value = true;
2388
+ }
2389
+
2390
+ async function loadUnitPeople() {
2391
+ unitPeople.value = [];
2392
+ if (!form.unit) return;
2393
+ loadingUnitPeople.value = true;
891
2394
  try {
892
- const response = await getPermissionCandidates(props.site, {
893
- orgId: orgId.value,
894
- readerId,
895
- category: form.subjectCategory,
896
- page: 1,
897
- limit: 500,
2395
+ const response = await getPeopleByUnit(form.unit, {
2396
+ status: "active",
2397
+ type: OCCUPANT_TYPES,
898
2398
  });
899
- permissionCandidates.value = response.items ?? response.data?.items ?? [];
2399
+ const record = toRecord(response);
2400
+ const rows = Array.isArray(response)
2401
+ ? response
2402
+ : record.data ?? record.items ?? [];
2403
+ unitPeople.value = (Array.isArray(rows) ? rows : []) as UnitPerson[];
2404
+ } catch (error: unknown) {
2405
+ console.error("Unable to load residents for the unit:", error);
2406
+ const reason = apiReason(error);
2407
+ showToast(
2408
+ reason
2409
+ ? `Unable to load residents: ${reason}`
2410
+ : "Unable to load residents for this unit.",
2411
+ "error"
2412
+ );
900
2413
  } finally {
901
- loadingSubjects.value = false;
2414
+ loadingUnitPeople.value = false;
902
2415
  }
903
2416
  }
904
2417
 
905
- async function onSubjectCategoryChanged() {
2418
+ function onBlockChanged() {
2419
+ form.level = "";
2420
+ form.unit = "";
2421
+ unitPeople.value = [];
2422
+ clearResidentSelection();
2423
+ }
2424
+
2425
+ // Wrapper `@click`/`@focusin` on the Block select: load once, on first touch.
2426
+ async function onBlockOpened() {
2427
+ await ensureOccupancy();
2428
+ }
2429
+
2430
+ function onLevelChanged() {
2431
+ form.unit = "";
2432
+ unitPeople.value = [];
2433
+ clearResidentSelection();
2434
+ }
2435
+
2436
+ async function onUnitChanged() {
2437
+ clearResidentSelection();
2438
+ await loadUnitPeople();
2439
+ }
2440
+
2441
+ function clearResidentSelection() {
906
2442
  form.subjectId = "";
907
- await loadSubjectCandidates();
2443
+ residentConflict.value = null;
2444
+ // Name only ever mirrors a chosen resident, so it clears with the choice.
2445
+ if (!selectedUser.value) form.name = "";
2446
+ }
2447
+
2448
+ // The auto-fill the whole cascade exists for.
2449
+ async function onResidentChanged() {
2450
+ const person = unitPeople.value.find(
2451
+ (item) => String(item._id) === String(form.subjectId)
2452
+ );
2453
+ form.name = String(person?.name ?? "");
2454
+ await checkSubjectEnrollment();
2455
+ }
2456
+
2457
+ /**
2458
+ * The HID user this resident already holds on the selected reader, or null.
2459
+ *
2460
+ * `subject`, not `person`. The same resident is stored under different ids
2461
+ * depending on which screen wrote the row - the permissions screen keys on
2462
+ * their user account, this screen on the record the picker offered - so a
2463
+ * filter on one field alone reports "not enrolled" for somebody who plainly is.
2464
+ * `subject` follows the record to its account and matches every link field,
2465
+ * which is exactly what the server does before it accepts an enrollment.
2466
+ *
2467
+ * Reader-scoped by the endpoint itself.
2468
+ */
2469
+ async function findResidentIdentity(readerId: string, personId: string) {
2470
+ const response = await getIdentities(readerId, {
2471
+ page: 1,
2472
+ // A resident should hold at most one, but ask for a few: on the edit screen
2473
+ // the first row back can be the very identity being edited, and skipping it
2474
+ // must not hide a real second one behind it.
2475
+ limit: 10,
2476
+ subject: personId,
2477
+ });
2478
+ const responseRecord = toRecord(response);
2479
+ const responseData = toRecord(responseRecord.data);
2480
+ const responseItems =
2481
+ responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
2482
+ const identities = (Array.isArray(responseItems) ? responseItems : []).map(
2483
+ toHidUser
2484
+ );
2485
+ const editingId = String(selectedUser.value?._id ?? "");
2486
+
2487
+ return (
2488
+ identities.find(
2489
+ (identity) => !editingId || String(identity._id ?? "") !== editingId
2490
+ ) ?? null
2491
+ );
2492
+ }
2493
+
2494
+ /**
2495
+ * Sets `residentConflict` for the currently selected resident.
2496
+ *
2497
+ * Fails OPEN: if the lookup itself errors the operator is not blocked, because
2498
+ * the server refuses the duplicate anyway. This is a courtesy check that keeps
2499
+ * a doomed enrollment off the physical reader, not the guard itself.
2500
+ */
2501
+ /**
2502
+ * Is this subject already enrolled on this reader?
2503
+ *
2504
+ * Category-agnostic, since staff can be enrolled too. `findResidentIdentity`
2505
+ * queries the server's `subject` parameter, which is matched against every link
2506
+ * field - `person`, `member`, `serviceProvider`, `user` - so a member needs no
2507
+ * separate lookup. The server refuses a duplicate either way; this only puts the
2508
+ * refusal in front of the operator before the device is touched.
2509
+ */
2510
+ async function checkSubjectEnrollment() {
2511
+ residentConflict.value = null;
2512
+ const readerId =
2513
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
2514
+ if (!readerId || !form.subjectId) return null;
2515
+
2516
+ checkingResident.value = true;
2517
+ try {
2518
+ residentConflict.value = await findResidentIdentity(
2519
+ readerId,
2520
+ form.subjectId
2521
+ );
2522
+ return residentConflict.value;
2523
+ } catch (error: unknown) {
2524
+ console.error(
2525
+ "Unable to check whether this resident is already enrolled:",
2526
+ error
2527
+ );
2528
+ return null;
2529
+ } finally {
2530
+ checkingResident.value = false;
2531
+ }
908
2532
  }
909
2533
 
910
2534
  async function openEnroll() {
@@ -916,8 +2540,16 @@ async function openEnroll() {
916
2540
  ]);
917
2541
  form.hidUserId = hidUserId;
918
2542
  form.registration = registration;
919
- await loadSubjectCandidates();
920
2543
  formDialog.value = true;
2544
+
2545
+ // `resetForm` restores the remembered type, so the dialog can open straight
2546
+ // onto Member with an empty list. Loaded after the dialog is up, not before:
2547
+ // the request must not delay it, and the picker carries its own loading
2548
+ // placeholder. Fresh each opening, because staff change between them.
2549
+ resetMemberCandidates();
2550
+ resetProviderCandidates();
2551
+ if (isMemberSubject.value) await loadMemberCandidates();
2552
+ if (isProviderSubject.value) await loadProviderCandidates();
921
2553
  }
922
2554
 
923
2555
  async function openEdit(user: HidUser) {
@@ -931,31 +2563,57 @@ async function openEdit(user: HidUser) {
931
2563
  form.registration = user.registration ?? "";
932
2564
  form.isAdministrator = hasAdministratorRule(user);
933
2565
  form.accessPin = "";
2566
+ form.password = "";
2567
+ showPassword.value = false;
2568
+ removePasswordRequested.value = false;
2569
+ passwordSet.value = Boolean(user.metadata?.passwordSet);
934
2570
  pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
935
2571
  removePinRequested.value = false;
936
2572
  form.photoPreview = "";
937
2573
  facialEnrolled.value = hasFacialEnrollment(user);
938
- form.subjectCategory = user.person
939
- ? "resident"
940
- : user.serviceProvider
941
- ? "service_provider"
942
- : "property_management";
943
- form.subjectId = String(user.person || user.member || user.serviceProvider || "");
944
- await loadSubjectCandidates();
2574
+ /*
2575
+ * ONE reading of the links, shared with the Access Permissions screen.
2576
+ * `subjectOfIdentity` knows that a `member` link is how BOTH a
2577
+ * property-management member and a service provider's own employee are stored,
2578
+ * and uses the identity's `type` to tell them apart - without which a
2579
+ * contractor's edit would be headed "Member".
2580
+ *
2581
+ * This was the same ternary written out inline. The other copy of it went
2582
+ * stale, so neither is written out any more.
2583
+ */
2584
+ const editedSubject = subjectOfIdentity(user as Record<string, unknown>);
2585
+ form.subjectCategory = enrolmentSubjectOfCategory(
2586
+ editedSubject?.category
2587
+ ) as TEnrolmentSubjectValue;
2588
+ form.subjectId = editedSubject?.subjectId ?? "";
2589
+ // The resident this user already holds is not a conflict with itself. A real
2590
+ // one only appears if the operator reassigns the row to somebody else.
2591
+ residentConflict.value = null;
2592
+ // The location is not drawn when editing, but it is still carried: these
2593
+ // three form fields are what `saveUser` writes back and what `buildUnitLabel`
2594
+ // resolves into the Unit column, so a save must not blank them.
2595
+ //
2596
+ // `ensureOccupancy` is what lets `buildUnitLabel` turn them back into names.
2597
+ // The unit's residents are loaded too, because the Resident select IS drawn
2598
+ // here and its options come from that list - without it the select shows the
2599
+ // stored id instead of a name.
2600
+ await ensureOccupancy();
2601
+ if (form.unit) await loadUnitPeople();
945
2602
  selectedPhotoFile.value = null;
946
2603
  removePhotoRequested.value = false;
947
2604
  formDialog.value = true;
948
2605
 
949
2606
  try {
950
- const status = await getUserPinStatus(
951
- form.reader,
952
- toHidNumericId(user.hidUserId),
953
- );
954
- pinEnrolled.value = Boolean(status?.data?.pinEnrolled);
2607
+ const hidUserId = toHidNumericId(user.hidUserId);
2608
+ const [pinStatus, passwordStatus] = await Promise.all([
2609
+ getUserPinStatus(form.reader, hidUserId),
2610
+ getUserPasswordStatus(form.reader, hidUserId),
2611
+ ]);
2612
+ pinEnrolled.value = Boolean(pinStatus?.data?.pinEnrolled);
2613
+ passwordSet.value = Boolean(passwordStatus?.data?.passwordSet);
955
2614
  } catch {
956
2615
  // Preserve the last known status if the reader is temporarily offline.
957
2616
  }
958
-
959
2617
  }
960
2618
 
961
2619
  function openView(user: HidUser) {
@@ -1030,16 +2688,55 @@ function onPhotoChange(event: Event) {
1030
2688
  async function saveUser() {
1031
2689
  const readerId =
1032
2690
  selectedReaderId.value || form.reader || readers.value[0]?._id;
1033
- if (!readerId || !form.name || !form.hidUserId || !form.registration || !form.subjectId) {
1034
- showToast("Please complete the required HID user fields.", "error");
2691
+ // A resident is required to ENROL, because an identity has to be linked to
2692
+ // somebody. It is not required to edit: the reader holds users we never
2693
+ // enrolled, and changing a photo, a PIN or a password on one of those is a
2694
+ // reader-side operation that has nothing to say about who they are. Demanding
2695
+ // a resident there refuses a save for a field the dialog does not even draw.
2696
+ const missing = !readerId
2697
+ ? "a reader"
2698
+ : !selectedUser.value && !form.subjectId
2699
+ ? isResidentSubject.value
2700
+ ? "a resident"
2701
+ : isProviderSubject.value
2702
+ ? "a person"
2703
+ : "a member"
2704
+ : !form.name
2705
+ ? "a name"
2706
+ : !form.hidUserId
2707
+ ? "an ID"
2708
+ : !form.registration
2709
+ ? "a registration number"
2710
+ : "";
2711
+ if (missing) {
2712
+ showToast(
2713
+ `This HID user needs ${missing} before it can be saved.`,
2714
+ "error"
2715
+ );
1035
2716
  return;
1036
2717
  }
1037
2718
  if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
1038
- showToast("Access PIN must contain numbers only.", "error");
2719
+ showToast("PIN must contain numbers only.", "error");
2720
+ return;
2721
+ }
2722
+ if (
2723
+ selectedPhotoFile.value &&
2724
+ (!selectedReader.value?.portalId || !selectedReader.value?.portalName)
2725
+ ) {
2726
+ showToast(
2727
+ "Reconnect this HID reader and select a portal before enrolling facial recognition.",
2728
+ "error"
2729
+ );
1039
2730
  return;
1040
2731
  }
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");
2732
+
2733
+ // Re-asked here, not trusted from the select: the dialog can sit open while
2734
+ // somebody else enrolls the same resident, and the reader is only ever
2735
+ // written to below this line. A duplicate caught here costs one request; the
2736
+ // same duplicate caught by the server costs a created HID user and a rollback.
2737
+ const alreadyEnrolled = await checkSubjectEnrollment();
2738
+ if (alreadyEnrolled) {
2739
+ showToast(residentConflictMessage.value, "error");
1043
2740
  return;
1044
2741
  }
1045
2742
 
@@ -1056,22 +2753,17 @@ async function saveUser() {
1056
2753
  return;
1057
2754
  }
1058
2755
 
2756
+ // The link is sent only when the form actually holds one. Sending
2757
+ // `person: ""` is not "leave it alone", it is "clear it": the server reads
2758
+ // an empty string as a link being removed and then refuses the write for
2759
+ // having no subject at all. Omitting the keys is what makes it keep the
2760
+ // subject the record already has.
2761
+ const subjectLink = subjectLinkOf(form.subjectCategory, form.subjectId);
2762
+
1059
2763
  const payload = {
1060
2764
  hidUserId: String(hidUser.id),
1061
2765
  registration: form.registration,
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",
2766
+ ...subjectLink,
1075
2767
  status: "active" as const,
1076
2768
  metadata: {
1077
2769
  name: form.name,
@@ -1088,6 +2780,7 @@ async function saveUser() {
1088
2780
  imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
1089
2781
  facialScores: selectedUser.value?.metadata?.facialScores || {},
1090
2782
  pinEnrolled: pinEnrolled.value,
2783
+ passwordSet: passwordSet.value,
1091
2784
  hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
1092
2785
  },
1093
2786
  };
@@ -1097,17 +2790,22 @@ async function saveUser() {
1097
2790
  const facialResult = await syncFacialImage(readerId, hidUser.id);
1098
2791
  applyFacialResult(payload.metadata, facialResult);
1099
2792
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
2793
+ payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
1100
2794
  if (selectedUser.value._id) {
1101
2795
  await updateIdentity(selectedUser.value._id, payload);
1102
- } else {
2796
+ } else if (form.subjectId) {
1103
2797
  await saveIdentityOnReader(readerId, payload);
1104
2798
  }
2799
+ // else: a user the reader holds and we have never linked. The photo, PIN
2800
+ // and password above are already on the device; creating an identity for
2801
+ // it would need a subject we deliberately no longer ask for here.
1105
2802
  } else {
1106
2803
  const created = await createHidUserOnReader(readerId, hidUser);
1107
2804
  if (created) newlyCreatedHidUserId = hidUser.id;
1108
2805
  const facialResult = await syncFacialImage(readerId, hidUser.id);
1109
2806
  applyFacialResult(payload.metadata, facialResult);
1110
2807
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
2808
+ payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
1111
2809
  await saveIdentityOnReader(readerId, payload);
1112
2810
  selectedReaderId.value = readerId;
1113
2811
  }
@@ -1128,7 +2826,10 @@ async function saveUser() {
1128
2826
  where: { users: { id: newlyCreatedHidUserId } },
1129
2827
  });
1130
2828
  } catch (rollbackError) {
1131
- console.error("Unable to roll back the partially enrolled HID user:", rollbackError);
2829
+ console.error(
2830
+ "Unable to roll back the partially enrolled HID user:",
2831
+ rollbackError
2832
+ );
1132
2833
  }
1133
2834
  }
1134
2835
  console.error("Unable to save HID user:", error);
@@ -1143,6 +2844,25 @@ async function saveUser() {
1143
2844
  }
1144
2845
  }
1145
2846
 
2847
+ async function syncPassword(readerId: string, hidUserId: number) {
2848
+ // Mirrors syncAccessPin. Removal wins over a typed value, and a blank field
2849
+ // means "leave whatever is on the reader alone" - the hash cannot be read
2850
+ // back, so blank can never mean "clear it".
2851
+ if (removePasswordRequested.value) {
2852
+ await deleteUserPassword(readerId, hidUserId);
2853
+ passwordSet.value = false;
2854
+ form.password = "";
2855
+ return false;
2856
+ }
2857
+ if (form.password) {
2858
+ await setUserPassword(readerId, hidUserId, form.password);
2859
+ form.password = "";
2860
+ passwordSet.value = true;
2861
+ return true;
2862
+ }
2863
+ return passwordSet.value;
2864
+ }
2865
+
1146
2866
  async function syncAccessPin(readerId: string, hidUserId: number) {
1147
2867
  if (removePinRequested.value) {
1148
2868
  await deleteUserPin(readerId, hidUserId);
@@ -1210,11 +2930,59 @@ function applyFacialResult(
1210
2930
  }
1211
2931
  }
1212
2932
 
2933
+ /**
2934
+ * THE IDENTITY ROW BEHIND A ROSTER ROW.
2935
+ *
2936
+ * This list is read from the READER (`getReaderUsers`), and `listReaderUsers`
2937
+ * builds each row out of the device's own `users` columns - it carries no
2938
+ * identity `_id` at all, so `toHidUser` leaves `_id` undefined for every row on
2939
+ * screen. A delete that only acted when `_id` was present therefore never
2940
+ * touched the database: the device user was destroyed and the identity stayed
2941
+ * `active`, with no error, because the guard was an `if` rather than a failure.
2942
+ *
2943
+ * That left the row orphaned, and an orphan is not inert. `findIdentityBySubject`
2944
+ * excludes only `status: "deleted"`, so it still answers "this person is already
2945
+ * enrolled on this reader" and REFUSES to enrol somebody the operator can no
2946
+ * longer see. The reconcile reads it too: `findProfileIdentity` finds the active
2947
+ * row and reuses its stored `hidUserId`, so the device user is rebuilt with the
2948
+ * same UID the operator just deleted, from an access grant that was never
2949
+ * removed because `deleteIdentity` never ran.
2950
+ *
2951
+ * Resolved the same way `saveUser` already resolves it, by the reader's own keys.
2952
+ */
2953
+ async function findIdentityIdForUser(readerId: string, user: HidUser) {
2954
+ if (user._id) return user._id;
2955
+ if (!readerId) return "";
2956
+ try {
2957
+ const identity = await findExistingIdentityOnReader(readerId, {
2958
+ hidUserId: String(user.hidUserId ?? ""),
2959
+ registration: String(user.registration ?? ""),
2960
+ cardNo: String(user.cardNo ?? ""),
2961
+ metadata: {},
2962
+ });
2963
+ return identity?._id ?? "";
2964
+ } catch (error: unknown) {
2965
+ // A lookup failure must not stop the device delete the operator asked for.
2966
+ // The row stays behind and the next delete can clear it.
2967
+ console.error(
2968
+ "Unable to resolve the stored identity for this HID user:",
2969
+ error
2970
+ );
2971
+ return "";
2972
+ }
2973
+ }
2974
+
1213
2975
  async function deleteUser() {
1214
2976
  if (!selectedUser.value) return;
1215
2977
 
1216
2978
  deleting.value = true;
1217
2979
  try {
2980
+ // Resolved BEFORE the device user is destroyed: this reads our own records,
2981
+ // and doing it first means a failure here costs nothing.
2982
+ const identityId = await findIdentityIdForUser(
2983
+ selectedReaderId.value,
2984
+ selectedUser.value
2985
+ );
1218
2986
  await syncHidUserRole(
1219
2987
  selectedReaderId.value,
1220
2988
  toHidNumericId(selectedUser.value.hidUserId),
@@ -1222,11 +2990,13 @@ async function deleteUser() {
1222
2990
  );
1223
2991
  await deleteUserPin(
1224
2992
  selectedReaderId.value,
1225
- toHidNumericId(selectedUser.value.hidUserId),
2993
+ toHidNumericId(selectedUser.value.hidUserId)
1226
2994
  );
1227
2995
  await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
1228
- if (selectedUser.value._id) {
1229
- await deleteIdentity(selectedUser.value._id);
2996
+ if (identityId) {
2997
+ // Soft-deletes the row AND removes the access grant that let this person
2998
+ // through, which is what stops the next reconcile rebuilding them.
2999
+ await deleteIdentity(identityId);
1230
3000
  }
1231
3001
  deleteDialog.value = false;
1232
3002
  await loadUsers();
@@ -1241,7 +3011,7 @@ async function deleteUser() {
1241
3011
 
1242
3012
  async function saveIdentityOnReader(
1243
3013
  readerId: string,
1244
- payload: HidIdentityPayload,
3014
+ payload: HidIdentityPayload
1245
3015
  ) {
1246
3016
  const existingIdentity = await findExistingIdentityOnReader(
1247
3017
  readerId,
@@ -1253,7 +3023,11 @@ async function saveIdentityOnReader(
1253
3023
  }
1254
3024
 
1255
3025
  try {
1256
- await createIdentity(readerId, { ...payload, site: props.site });
3026
+ await createIdentity(readerId, {
3027
+ ...payload,
3028
+ site: props.site,
3029
+ ...(props.org ? { orgId: props.org } : {}),
3030
+ });
1257
3031
  } catch (error: unknown) {
1258
3032
  const message = getHidErrorMessage(error);
1259
3033
  if (!message.toLowerCase().includes("already exists")) throw error;
@@ -1269,7 +3043,7 @@ async function saveIdentityOnReader(
1269
3043
 
1270
3044
  async function findExistingIdentityOnReader(
1271
3045
  readerId: string,
1272
- payload: HidIdentityPayload,
3046
+ payload: HidIdentityPayload
1273
3047
  ) {
1274
3048
  const response = await getIdentities(readerId, {
1275
3049
  page: 1,
@@ -1278,8 +3052,11 @@ async function findExistingIdentityOnReader(
1278
3052
  });
1279
3053
  const responseRecord = toRecord(response);
1280
3054
  const responseData = toRecord(responseRecord.data);
1281
- const responseItems = responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
1282
- const identities = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
3055
+ const responseItems =
3056
+ responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
3057
+ const identities = (Array.isArray(responseItems) ? responseItems : []).map(
3058
+ toHidUser
3059
+ );
1283
3060
  const hidUserId = String(payload.hidUserId || "");
1284
3061
  const registration = String(payload.registration || "");
1285
3062
  const cardNo = String(payload.cardNo || "");
@@ -1296,7 +3073,7 @@ async function findExistingIdentityOnReader(
1296
3073
 
1297
3074
  async function createHidUserOnReader(
1298
3075
  readerId: string,
1299
- user: { id?: number; name: string; registration: string },
3076
+ user: { id?: number; name: string; registration: string }
1300
3077
  ) {
1301
3078
  const existing = await getHidUserFromReader(readerId, user.id);
1302
3079
  if (existing) {
@@ -1330,7 +3107,7 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
1330
3107
 
1331
3108
  async function updateHidUserOnReader(
1332
3109
  readerId: string,
1333
- user: { id?: number; name: string; registration: string },
3110
+ user: { id?: number; name: string; registration: string }
1334
3111
  ) {
1335
3112
  const { id, ...values } = user;
1336
3113
  await runObjectOperation(readerId, {
@@ -1345,10 +3122,7 @@ async function updateHidUserOnReader(
1345
3122
  });
1346
3123
  }
1347
3124
 
1348
- async function deleteHidUserFromReader(
1349
- readerId: string,
1350
- user: HidUser,
1351
- ) {
3125
+ async function deleteHidUserFromReader(readerId: string, user: HidUser) {
1352
3126
  const id = toHidNumericId(user.hidUserId);
1353
3127
  if (!readerId || !id) return;
1354
3128
 
@@ -1436,7 +3210,9 @@ async function getHidUserRole(readerId: string, userId: number) {
1436
3210
  return Array.isArray(roles) ? roles[0] : undefined;
1437
3211
  }
1438
3212
 
1439
- function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registration">) {
3213
+ function toHidUserObject(
3214
+ identity: Pick<HidUser, "hidUserId" | "name" | "registration">
3215
+ ) {
1440
3216
  return {
1441
3217
  id: toHidNumericId(identity.hidUserId),
1442
3218
  name:
@@ -1447,6 +3223,23 @@ function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registr
1447
3223
  };
1448
3224
  }
1449
3225
 
3226
+ function isVisitorUser(user: HidUser) {
3227
+ // The reader marks visitors with `user_type_id = 1`; a real user leaves the
3228
+ // field null. `metadata.hidUserTypeId` is what our API forwards it as.
3229
+ return Number(user.metadata?.hidUserTypeId ?? user.user_type_id) === 1;
3230
+ }
3231
+
3232
+ function isUnsupportedUserTypeFilter(error: unknown) {
3233
+ const record = toRecord(error);
3234
+ const data = toRecord(record.data);
3235
+ const message = toText(data.message) || toText(record.message);
3236
+ const normalized = message.toLowerCase();
3237
+ return (
3238
+ normalized.includes("usertype") &&
3239
+ (normalized.includes("not allowed") || normalized.includes("unknown"))
3240
+ );
3241
+ }
3242
+
1450
3243
  function toHidNumericId(value: unknown) {
1451
3244
  const raw = String(value ?? "").trim();
1452
3245
  if (!raw) return undefined;
@@ -1475,16 +3268,19 @@ async function loadVisibleUserCards(items: HidUser[]) {
1475
3268
  const response = await getUserCards(selectedReaderId.value, hidUserId);
1476
3269
  const responseData = toRecord(toRecord(response).data);
1477
3270
  const cards = responseData.cards;
1478
- return [String(hidUserId), (Array.isArray(cards) ? cards : []).map(toHidCard)] as const;
3271
+ return [
3272
+ String(hidUserId),
3273
+ (Array.isArray(cards) ? cards : []).map(toHidCard),
3274
+ ] as const;
1479
3275
  } catch (error: unknown) {
1480
3276
  cardErrors[String(hidUserId)] = getHidErrorMessage(error);
1481
3277
  return [String(hidUserId), []] as const;
1482
3278
  }
1483
- }),
3279
+ })
1484
3280
  );
1485
3281
 
1486
3282
  physicalCardsByUserId.value = Object.fromEntries(
1487
- entries.filter(([hidUserId]) => Boolean(hidUserId)),
3283
+ entries.filter(([hidUserId]) => Boolean(hidUserId))
1488
3284
  );
1489
3285
  physicalCardErrorsByUserId.value = cardErrors;
1490
3286
  }
@@ -1496,12 +3292,16 @@ function getUserPhysicalCards(user: HidUser) {
1496
3292
 
1497
3293
  function getUserCardLoadError(user: HidUser) {
1498
3294
  const hidUserId = toHidNumericId(user.hidUserId);
1499
- return hidUserId ? physicalCardErrorsByUserId.value[String(hidUserId)] || "" : "";
3295
+ return hidUserId
3296
+ ? physicalCardErrorsByUserId.value[String(hidUserId)] || ""
3297
+ : "";
1500
3298
  }
1501
3299
 
1502
3300
  async function openCardManager(user: HidUser) {
1503
3301
  cardUser.value = user;
1504
- const reader = readers.value.find((item) => String(item._id) === String(user.reader || selectedReaderId.value));
3302
+ const reader = readers.value.find(
3303
+ (item) => String(item._id) === String(user.reader || selectedReaderId.value)
3304
+ );
1505
3305
  cardForm.cardType = reader?.cardReadingMode === "pacs" ? "pacs" : "csn";
1506
3306
  cardForm.facilityCode = "";
1507
3307
  cardForm.cardNumber = "";
@@ -1512,7 +3312,9 @@ async function openCardManager(user: HidUser) {
1512
3312
  }
1513
3313
 
1514
3314
  function getCardReaderAndUser() {
1515
- const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
3315
+ const readerId = String(
3316
+ cardUser.value?.reader || selectedReaderId.value || ""
3317
+ );
1516
3318
  const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
1517
3319
  return { readerId, hidUserId };
1518
3320
  }
@@ -1535,7 +3337,11 @@ async function loadPhysicalCards() {
1535
3337
  }
1536
3338
  }
1537
3339
 
1538
- function parsePhysicalCardInteger(value: string, label: string, maximum: bigint) {
3340
+ function parsePhysicalCardInteger(
3341
+ value: string,
3342
+ label: string,
3343
+ maximum: bigint
3344
+ ) {
1539
3345
  if (!/^\d+$/.test(value.trim())) {
1540
3346
  showToast(`${label} must contain numbers only.`, "error");
1541
3347
  return null;
@@ -1556,14 +3362,27 @@ async function assignPhysicalCard() {
1556
3362
  | { cardType: "pacs"; facilityCode: string; cardNumber: string }
1557
3363
  | { cardType: "csn"; cardValue: string };
1558
3364
  if (cardForm.cardType === "pacs") {
1559
- const facilityCode = parsePhysicalCardInteger(cardForm.facilityCode, "Facility Code", 4294967295n);
1560
- const cardNumber = parsePhysicalCardInteger(cardForm.cardNumber, "Card Number", 4294967295n);
3365
+ const facilityCode = parsePhysicalCardInteger(
3366
+ cardForm.facilityCode,
3367
+ "Facility Code",
3368
+ 4294967295n
3369
+ );
3370
+ const cardNumber = parsePhysicalCardInteger(
3371
+ cardForm.cardNumber,
3372
+ "Card Number",
3373
+ 4294967295n
3374
+ );
1561
3375
  if (facilityCode === null || cardNumber === null) return;
1562
3376
  payload = { cardType: "pacs", facilityCode, cardNumber };
1563
3377
  } else {
1564
- const cardValue = parsePhysicalCardInteger(cardForm.cardValue, "CSN", 18446744073709551615n);
3378
+ const cardValue = parsePhysicalCardInteger(
3379
+ cardForm.cardValue,
3380
+ "CSN",
3381
+ 18446744073709551615n
3382
+ );
1565
3383
  if (cardValue === null || BigInt(cardValue) <= 0n) {
1566
- if (cardValue === "0") showToast("CSN must be greater than zero.", "error");
3384
+ if (cardValue === "0")
3385
+ showToast("CSN must be greater than zero.", "error");
1567
3386
  return;
1568
3387
  }
1569
3388
  payload = { cardType: "csn", cardValue };
@@ -1585,6 +3404,14 @@ async function assignPhysicalCard() {
1585
3404
  }
1586
3405
  }
1587
3406
 
3407
+ /**
3408
+ * Read a physical card by presenting it to the reader.
3409
+ *
3410
+ * The way most cards get assigned, because it is the only one that does not
3411
+ * require knowing the facility code and card number in advance - neither is
3412
+ * printed on the card, and an operator holding a blank fob has no way to look
3413
+ * them up. The reader listens for 30 seconds and reports what it read.
3414
+ */
1588
3415
  async function enrollPhysicalCard() {
1589
3416
  const { readerId, hidUserId } = getCardReaderAndUser();
1590
3417
  if (!readerId || !hidUserId) return;
@@ -1597,6 +3424,8 @@ async function enrollPhysicalCard() {
1597
3424
  await loadUsers();
1598
3425
  showToast("Physical HID card read and enrolled on the reader.");
1599
3426
  } catch (error: unknown) {
3427
+ // Cancelling rejects the request above. That is the operator getting what
3428
+ // they asked for, so it must not also be reported as a failure.
1600
3429
  if (!cardEnrollmentCancelled.value) {
1601
3430
  showToast(getHidErrorMessage(error), "error");
1602
3431
  }
@@ -1605,20 +3434,55 @@ async function enrollPhysicalCard() {
1605
3434
  }
1606
3435
  }
1607
3436
 
3437
+ /**
3438
+ * Stop the reader listening.
3439
+ *
3440
+ * Told to the DEVICE, not just the screen: abandoning the request on this side
3441
+ * would leave the reader waiting out its 30 seconds and enrolling whatever card
3442
+ * was presented in the meantime.
3443
+ */
3444
+ /**
3445
+ * Stop the reader listening, and hand the dialog straight back.
3446
+ *
3447
+ * Told to the DEVICE, not just the screen: abandoning the request on this side
3448
+ * would leave the reader waiting out its remaining seconds and enrolling
3449
+ * whatever card was presented in the meantime.
3450
+ *
3451
+ * The part worth understanding is what the ENROL request does next. The server
3452
+ * cannot tell a cancelled read from a failed one - the reader simply reports no
3453
+ * card - so the enrol that is still in flight ends as a 400, seconds after the
3454
+ * cancel. Waiting for it was the bug: Cancel appeared to do nothing until that
3455
+ * rejection finally arrived.
3456
+ *
3457
+ * So the dialog is released as soon as the DEVICE confirms it has stopped, and
3458
+ * the doomed enrol is left to land on its own. `cardEnrollmentCancelled` is set
3459
+ * before anything is awaited, which is what keeps its 400 silent.
3460
+ */
1608
3461
  async function cancelPhysicalCardEnrollment() {
1609
3462
  const { readerId } = getCardReaderAndUser();
1610
3463
  if (!readerId) return;
3464
+ // Set first, awaited second: the enrol can reject at any point from here.
1611
3465
  cardEnrollmentCancelled.value = true;
3466
+ cancellingCard.value = true;
1612
3467
  try {
1613
3468
  await cancelUserCardEnrollment(readerId);
3469
+ // Only now, because the enrollment lock is released by the call above.
3470
+ // Re-enabling Read at Reader any earlier invites a second attempt that
3471
+ // the reader refuses with "already running".
3472
+ enrollingCard.value = false;
1614
3473
  showToast("Physical HID card reading cancelled.");
1615
3474
  } catch (error: unknown) {
3475
+ // The cancel itself failing is real and worth saying: the reader may still
3476
+ // be listening, so the dialog stays in its reading state.
1616
3477
  showToast(getHidErrorMessage(error), "error");
3478
+ } finally {
3479
+ cancellingCard.value = false;
1617
3480
  }
1618
3481
  }
1619
3482
 
1620
3483
  async function removePhysicalCard(card: HidCard) {
1621
- if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`)) return;
3484
+ if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
3485
+ return;
1622
3486
  const { readerId, hidUserId } = getCardReaderAndUser();
1623
3487
  if (!readerId || !hidUserId || !card?.id) return;
1624
3488
 
@@ -1644,13 +3508,38 @@ function formatPhysicalCard(card: HidCard) {
1644
3508
  }
1645
3509
 
1646
3510
  function getPhysicalCardSubtitle(card: HidCard) {
1647
- const source = card?.source === "reader" ? "Read at reader" : card?.source === "manual" ? "Manual" : "On device";
3511
+ const source =
3512
+ card?.source === "reader"
3513
+ ? "Read at reader"
3514
+ : card?.source === "manual"
3515
+ ? "Manual"
3516
+ : "On device";
1648
3517
  return `${source} - Raw value ${card?.value || "N/A"}`;
1649
3518
  }
1650
3519
 
1651
3520
  function buildUnitLabel() {
1652
- const parts = [form.block, form.level, form.unit].filter(Boolean);
1653
- return parts.length ? `BLK ${parts.join("/")}` : "";
3521
+ // `form.block/level/unit` hold ObjectIds, so the label has to resolve them
3522
+ // back to names through the occupancy tree. Joining the raw values would
3523
+ // stamp "665f.../665f.../665f..." onto the record and into the Unit column.
3524
+ const block = occupiedBlocks.value.find(
3525
+ (item) => String(item._id ?? "") === form.block
3526
+ );
3527
+ const level = (block?.levels ?? []).find(
3528
+ (item) => String(item._id ?? "") === form.level
3529
+ );
3530
+ const unit = (level?.units ?? []).find(
3531
+ (item) => String(item._id ?? "") === form.unit
3532
+ );
3533
+ const parts = [
3534
+ formatBlockTitle(block),
3535
+ String(level?.name ?? ""),
3536
+ String(unit?.name ?? ""),
3537
+ ].filter(Boolean);
3538
+ // Editing a record enrolled before the cascade existed: keep its stored
3539
+ // label rather than blanking it.
3540
+ if (!parts.length)
3541
+ return String(selectedUser.value?.metadata?.unitLabel ?? "");
3542
+ return parts.join(" / ");
1654
3543
  }
1655
3544
 
1656
3545
  function getName(user?: HidUser | null) {
@@ -1737,27 +3626,28 @@ function normalizeHidUsers(response: unknown): HidUser[] {
1737
3626
  const responseRecord = toRecord(response);
1738
3627
  const responseData = toRecord(responseRecord.data);
1739
3628
  const nestedData = toRecord(responseData.data);
1740
- const items = responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
3629
+ const items =
3630
+ responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
1741
3631
 
1742
3632
  return Array.isArray(items)
1743
3633
  ? items.map((value) => {
1744
3634
  const user = toRecord(value);
1745
3635
  return {
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: {
3636
+ _rowKey: `reader-${user.id}`,
3637
+ hidUserId: String(user.id || ""),
3638
+ rawHidUserId: toText(user.id),
3639
+ registration: toText(user.registration),
3640
+ userTypeId: toText(user.user_type_id),
3641
+ cardNo: toText(user.card_value || user.cardNo),
1754
3642
  name: toText(user.name),
1755
- imageTimestamp: user.image_timestamp
1756
- ? String(user.image_timestamp)
1757
- : "",
1758
- lastAccessAt: fromUnixSeconds(user.last_access),
1759
- hidReaderSource: true,
1760
- },
3643
+ metadata: {
3644
+ name: toText(user.name),
3645
+ imageTimestamp: user.image_timestamp
3646
+ ? String(user.image_timestamp)
3647
+ : "",
3648
+ lastAccessAt: fromUnixSeconds(user.last_access),
3649
+ hidReaderSource: true,
3650
+ },
1761
3651
  };
1762
3652
  })
1763
3653
  : [];
@@ -1765,7 +3655,7 @@ function normalizeHidUsers(response: unknown): HidUser[] {
1765
3655
 
1766
3656
  function mergeReaderUsersWithIdentities(
1767
3657
  readerUsers: HidUser[],
1768
- identities: HidUser[],
3658
+ identities: HidUser[]
1769
3659
  ) {
1770
3660
  const identityById = new Map<string, HidUser>();
1771
3661
  identities.forEach((identity) => {
@@ -1808,7 +3698,13 @@ function getMappingStatus(user: HidUser) {
1808
3698
  }
1809
3699
 
1810
3700
  function hasMappedRecord(user: HidUser) {
1811
- return Boolean(user?.person || user?.user || user?.member || user?.serviceProvider || user?.visitor);
3701
+ return Boolean(
3702
+ user?.person ||
3703
+ user?.user ||
3704
+ user?.member ||
3705
+ user?.serviceProvider ||
3706
+ user?.visitor
3707
+ );
1812
3708
  }
1813
3709
 
1814
3710
  function formatDate(value?: string) {
@@ -1840,18 +3736,26 @@ function getHidErrorMessage(error: unknown) {
1840
3736
  const cause = toRecord(errorRecord.cause);
1841
3737
  const causeResponse = toRecord(cause.response);
1842
3738
  const data = toRecord(
1843
- errorRecord.data || response._data || response.data || cause.data || causeResponse._data,
3739
+ errorRecord.data ||
3740
+ response._data ||
3741
+ response.data ||
3742
+ cause.data ||
3743
+ causeResponse._data
1844
3744
  );
1845
3745
 
1846
3746
  return toText(
1847
- data.message || data.error || data.statusMessage || errorRecord.statusMessage || errorRecord.message,
1848
- "Unable to save HID user.",
3747
+ data.message ||
3748
+ data.error ||
3749
+ data.statusMessage ||
3750
+ errorRecord.statusMessage ||
3751
+ errorRecord.message,
3752
+ "Unable to save HID user."
1849
3753
  );
1850
3754
  }
1851
3755
 
1852
3756
  function toRecord(value: unknown): Record<string, unknown> {
1853
3757
  return typeof value === "object" && value !== null && !Array.isArray(value)
1854
- ? value as Record<string, unknown>
3758
+ ? (value as Record<string, unknown>)
1855
3759
  : {};
1856
3760
  }
1857
3761
 
@@ -1897,10 +3801,24 @@ function toHidCard(value: unknown): HidCard {
1897
3801
  return {
1898
3802
  ...card,
1899
3803
  id: toText(card.id) || 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,
3804
+ cardType:
3805
+ card.cardType === "pacs" || card.cardType === "csn"
3806
+ ? card.cardType
3807
+ : undefined,
3808
+ source:
3809
+ card.source === "reader" ||
3810
+ card.source === "manual" ||
3811
+ card.source === "device"
3812
+ ? card.source
3813
+ : undefined,
1902
3814
  };
1903
3815
  }
3816
+
3817
+ /* Publish every change, including the seed above: a screen that had to fall
3818
+ back to the first reader should leave the others agreeing with it. */
3819
+ watch(selectedReaderId, (value) => {
3820
+ if (value) selectHidReader(props.site, value);
3821
+ });
1904
3822
  </script>
1905
3823
 
1906
3824
  <style scoped>
@@ -1937,12 +3855,6 @@ function toHidCard(value: unknown): HidCard {
1937
3855
  max-width: 320px;
1938
3856
  }
1939
3857
 
1940
- .small-title {
1941
- font-size: 14px;
1942
- font-weight: 700;
1943
- color: var(--text);
1944
- }
1945
-
1946
3858
  /* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
1947
3859
  .field-label {
1948
3860
  color: var(--text2);
@@ -1964,12 +3876,83 @@ function toHidCard(value: unknown): HidCard {
1964
3876
  line-height: 1.45;
1965
3877
  }
1966
3878
 
3879
+ /* Same line, saying the field is refused rather than explained. `--err` is the
3880
+ token the required-field asterisk already uses. */
3881
+ .field-hint--error {
3882
+ color: var(--err);
3883
+ }
3884
+
3885
+ /* The bulk button and the sentence that says what it does. Stacked rather than
3886
+ side by side: the sentence is the part that stops somebody expecting this to
3887
+ enrol faces, so it must not be squeezed into a margin at narrow widths. */
3888
+ .hid-bulk-cta {
3889
+ display: flex;
3890
+ flex-direction: column;
3891
+ align-items: flex-start;
3892
+ gap: 4px;
3893
+ }
3894
+
3895
+ .hid-bulk-cta .field-hint {
3896
+ margin: 0;
3897
+ }
3898
+
3899
+ /* Checkbox column only as wide as the control, so the three columns that carry
3900
+ meaning keep the width. */
3901
+ .hid-bulk-table__check {
3902
+ width: 44px;
3903
+ padding-right: 0 !important;
3904
+ }
3905
+
3906
+ .hid-bulk-table :deep(th) {
3907
+ font-size: 12px;
3908
+ font-weight: 700;
3909
+ color: var(--muted);
3910
+ white-space: nowrap;
3911
+ }
3912
+
3913
+ .hid-bulk-table :deep(td) {
3914
+ font-size: 13px;
3915
+ }
3916
+
3917
+ /* Why this row cannot be unticked, on the row itself. A disabled checkbox with
3918
+ no reason beside it reads as a broken control. */
3919
+ .hid-bulk-granted {
3920
+ display: block;
3921
+ color: var(--muted);
3922
+ font-size: 11px;
3923
+ font-weight: 600;
3924
+ }
3925
+
1967
3926
  .photo-wrap {
1968
3927
  display: grid;
1969
3928
  place-items: center;
1970
3929
  margin-bottom: 14px;
1971
3930
  }
1972
3931
 
3932
+ .photo-facial {
3933
+ margin: 10px 0 0;
3934
+ text-align: center;
3935
+ }
3936
+
3937
+ /* The same small muted word `.field-label` uses, so the caption reads as a
3938
+ field label rather than as a second heading. */
3939
+ .photo-facial__label {
3940
+ display: block;
3941
+ color: var(--muted);
3942
+ font-size: 12px;
3943
+ font-weight: 600;
3944
+ }
3945
+
3946
+ .photo-facial__value {
3947
+ display: block;
3948
+ margin-top: 2px;
3949
+ font-size: 13.5px;
3950
+ font-weight: 500;
3951
+ /* The reader's reference is a long unbroken number; let it wrap rather than
3952
+ widen the dialog. */
3953
+ word-break: break-all;
3954
+ }
3955
+
1973
3956
  .photo-button {
1974
3957
  width: 120px;
1975
3958
  height: 120px;
@@ -2003,7 +3986,6 @@ function toHidCard(value: unknown): HidCard {
2003
3986
  box-shadow: var(--shadow-menu);
2004
3987
  }
2005
3988
 
2006
-
2007
3989
  .admin-rule-indicator {
2008
3990
  display: inline-flex;
2009
3991
  vertical-align: middle;
@@ -2143,25 +4125,6 @@ function toHidCard(value: unknown): HidCard {
2143
4125
  color: var(--muted);
2144
4126
  }
2145
4127
 
2146
- /* The split footer bar. The handoff does not draw one, so the SHAPE is the
2147
- screen's own; only the colours change - `#062d42` was a fixed navy that sat
2148
- on the dark card unchanged. Same treatment as panel 2. */
2149
- .action-cancel,
2150
- .action-submit {
2151
- flex: 1;
2152
- border-radius: 0;
2153
- }
2154
-
2155
- .action-cancel {
2156
- background: var(--card);
2157
- color: var(--text2);
2158
- }
2159
-
2160
- .action-submit {
2161
- background: var(--accent-strong);
2162
- color: var(--on-accent-strong);
2163
- }
2164
-
2165
4128
  @media (max-width: 960px) {
2166
4129
  .hid-user-filters :deep(.app-field),
2167
4130
  .hid-user-filters :deep(.app-select) {