@7365admin1/layer-common 4.96.0 → 4.97.1-staging.500

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 (65) hide show
  1. package/CHANGELOG.md +10 -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 +424 -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 +2239 -276
  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/VehicleQrStickerDialog.vue +64 -4
  30. package/components/VisitorForm.vue +12 -0
  31. package/components/VisitorManagement.vue +267 -5
  32. package/composables/useAccessManagement.ts +17 -1
  33. package/composables/useCustomerSite.ts +29 -0
  34. package/composables/useDocument.ts +7 -0
  35. package/composables/useFacility.ts +11 -0
  36. package/composables/useHidAmico.ts +165 -0
  37. package/composables/useHidNavigation.ts +25 -1
  38. package/composables/useHidReaderSelection.ts +50 -0
  39. package/composables/useMember.ts +12 -0
  40. package/composables/useNFCPatrolReport.ts +36 -32
  41. package/composables/useNFCPatrolReportFilters.ts +21 -3
  42. package/composables/useNFCPatrolRoute.ts +10 -3
  43. package/composables/usePeople.ts +19 -0
  44. package/composables/useServiceProvider.ts +45 -2
  45. package/composables/useSiteCategory.ts +45 -0
  46. package/package.json +1 -1
  47. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  48. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  49. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  50. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  51. package/types/document.d.ts +14 -0
  52. package/types/facility.d.ts +4 -0
  53. package/types/member.d.ts +4 -0
  54. package/types/nfc-patrol-report.ts +0 -2
  55. package/types/people.d.ts +28 -1
  56. package/types/service-provider.d.ts +5 -0
  57. package/types/site.d.ts +7 -1
  58. package/utils/hid-enrolment-subject.ts +344 -0
  59. package/utils/hid-permission-assignments.ts +198 -0
  60. package/utils/hid-reader-selection.ts +52 -0
  61. package/utils/nfc-patrol-time.ts +99 -0
  62. package/utils/occupancy-role.ts +109 -0
  63. package/utils/service-type.ts +30 -0
  64. package/utils/vehicle-qr-design.ts +304 -20
  65. package/utils/vehicle-qr-sticker.ts +126 -20
@@ -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,55 @@
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
+ permissionCategoryOf,
973
+ providerAccountsKnown,
974
+ providerCandidateOf,
975
+ providerEnrollableRows,
976
+ readMemberPage,
977
+ serviceFilterOf,
978
+ subjectCategoryLabel as labelForSubjectCategory,
979
+ subjectLinkOf,
980
+ type HidEnrolmentSubject,
981
+ } from "../utils/hid-enrolment-subject";
982
+ /* The SAME filter the Members > Service Providers screen applies, not a second
983
+ copy of the rule: a picker that disagrees with the screen the operator was
984
+ just looking at is a picker they cannot trust. Core applies it to the Excel
985
+ export too (`filterSiteProviderMembers`). */
986
+ import {
987
+ filterProviderMembers,
988
+ serviceTypeOptions,
989
+ } from "../utils/site-provider-members";
990
+ /* Bulk access is granted through the permissions document, and the PUT REPLACES
991
+ this reader's whole assignment set. These three are what keep a bulk add from
992
+ revoking everybody who was already on the door - see the file's own header. */
993
+ import {
994
+ seedAssignments,
995
+ setAssignment,
996
+ toAssignmentPayload,
997
+ type HidAssignmentState,
998
+ } from "../utils/hid-permission-assignments";
999
+
525
1000
  const props = defineProps({
526
1001
  site: {
527
1002
  type: String,
528
1003
  required: true,
529
1004
  },
1005
+ // Which organisation the access this enrolment grants belongs to. Saving now
1006
+ // assigns the person to the reader, and the assignment is stored per
1007
+ // organisation - see `schemaCreateHidAmicoIdentity`.
1008
+ org: {
1009
+ type: String,
1010
+ default: "",
1011
+ },
530
1012
  cardManagement: {
531
1013
  type: Boolean,
532
1014
  default: false,
@@ -547,12 +1029,21 @@ const {
547
1029
  getUserPinStatus,
548
1030
  setUserPin,
549
1031
  deleteUserPin,
1032
+ getUserPasswordStatus,
1033
+ setUserPassword,
1034
+ deleteUserPassword,
550
1035
  runObjectOperation,
551
1036
  createIdentity,
552
1037
  updateIdentity,
553
1038
  deleteIdentity,
554
- getPermissionCandidates,
1039
+ getSitePermissions,
1040
+ updateSitePermissions,
555
1041
  } = useHidAmico();
1042
+ const { getAll: getAllMembers } = useMember();
1043
+ /* Members > Service Providers reads this same call, so the enrolment picker
1044
+ offers exactly the people that screen lists. Site-scoped by the server. */
1045
+ const { getSiteMembers } = useServiceProvider();
1046
+ const runtimeConfig = useRuntimeConfig();
556
1047
 
557
1048
  type HidMetadata = Record<string, unknown> & {
558
1049
  name?: string;
@@ -566,6 +1057,7 @@ type HidMetadata = Record<string, unknown> & {
566
1057
  lastAccessAt?: string;
567
1058
  facialScores?: Record<string, unknown>;
568
1059
  pinEnrolled?: boolean;
1060
+ passwordSet?: boolean;
569
1061
  hidPhysicalCards?: HidCard[];
570
1062
  };
571
1063
 
@@ -616,6 +1108,7 @@ type HidIdentityPayload = Record<string, unknown> & {
616
1108
  const readers = ref<HidReader[]>([]);
617
1109
  const users = ref<HidUser[]>([]);
618
1110
  const administratorUserIds = ref<Set<number>>(new Set());
1111
+ const { selectHidReader, resolveForSite } = useHidReaderSelection();
619
1112
  const selectedReaderId = ref("");
620
1113
  const search = ref("");
621
1114
  const status = ref("");
@@ -639,7 +1132,14 @@ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
639
1132
  const loadingCards = ref(false);
640
1133
  const savingCard = ref(false);
641
1134
  const enrollingCard = ref(false);
1135
+ /*
1136
+ * Cancelling makes the in-flight enrol REJECT, and that rejection is expected
1137
+ * rather than a failure. Without this flag the cancel raises an error toast on
1138
+ * top of the one confirming it, which reads as the cancel having gone wrong.
1139
+ */
642
1140
  const cardEnrollmentCancelled = ref(false);
1141
+ /** The cancel's own round trip to the reader — a second or two, not the enrol's. */
1142
+ const cancellingCard = ref(false);
643
1143
  const deletingCardId = ref("");
644
1144
  const showPin = ref(false);
645
1145
  const pinEnrolled = ref(false);
@@ -648,54 +1148,889 @@ const cameraDialog = ref(false);
648
1148
  const selectedPhotoFile = ref<File | null>(null);
649
1149
  const removePhotoRequested = ref(false);
650
1150
  /**
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.
1151
+ * Client requirement (2026-08-26): the photo is neither shown nor kept once registration
1152
+ * completes, so the form tracks only WHETHER this person is enrolled. Re-enrolling needs a
1153
+ * fresh capture -- there is no stored image to re-send.
1154
+ */
1155
+ const facialEnrolled = ref(false);
1156
+
1157
+ function hasFacialEnrollment(user?: HidUser | null) {
1158
+ return Boolean(
1159
+ user?.metadata?.facialEnrolled ||
1160
+ user?.metadata?.facialData ||
1161
+ user?.metadata?.imageTimestamp
1162
+ );
1163
+ }
1164
+
1165
+ /**
1166
+ * The reader's own reference for the enrolled face, shown while editing.
1167
+ *
1168
+ * We hold neither the image nor the biometric template - the reader does - so
1169
+ * this value is the only handle on the enrollment there is. `getFacialData` is
1170
+ * the roster's Facial Data column, reused here so the dialog and the table
1171
+ * cannot disagree; it answers "N/A" when there is nothing, which is the case
1172
+ * this must not draw a row for.
1173
+ */
1174
+ const editFacialData = computed(() => {
1175
+ if (!selectedUser.value) return "";
1176
+ const value = String(getFacialData(selectedUser.value) ?? "").trim();
1177
+ return value && value !== "N/A" ? value : "";
1178
+ });
1179
+ const removePinRequested = ref(false);
1180
+ const passwordSet = ref(false);
1181
+ const showPassword = ref(false);
1182
+ const removePasswordRequested = ref(false);
1183
+ const snackbar = reactive({
1184
+ show: false,
1185
+ color: "success",
1186
+ message: "",
1187
+ });
1188
+
1189
+ type OccupiedUnit = { _id: string; name: string; people: number };
1190
+ type OccupiedLevel = { _id: string; name: string; units?: OccupiedUnit[] };
1191
+ type OccupiedBlock = {
1192
+ _id: string;
1193
+ name: string;
1194
+ block: number | null;
1195
+ levels?: OccupiedLevel[];
1196
+ };
1197
+ type UnitPerson = {
1198
+ _id: string;
1199
+ name?: string;
1200
+ unitName?: string;
1201
+ /**
1202
+ * The resident's app account, and the reason this field is read here.
1203
+ *
1204
+ * A HID identity is refused unless its subject is a valid permission subject
1205
+ * at the site, and for a resident that means a `site.people` row WITH a
1206
+ * linked user - `assertPermissionSubject` in `iservice365-core` filters on
1207
+ * exactly this field. The permission model has no way to represent anyone
1208
+ * else: `resolvePermissionUserBindings` binds reader access by user id and
1209
+ * drops every row without one, so a resident enrolled without an account
1210
+ * would end up on the reader holding no access rules at all.
1211
+ *
1212
+ * The picker this replaced was fed by that same server-side function, so it
1213
+ * could never offer an unenrollable resident. The unit cascade asks People
1214
+ * Management instead, which has no such filter - hence this one.
1215
+ */
1216
+ user?: string | null;
1217
+ };
1218
+
1219
+ /**
1220
+ * THE SUBJECT TYPE THIS SCREEN WAS LAST USED FOR.
1221
+ *
1222
+ * Module scope, not component scope, and deliberately: the dialog is rebuilt on
1223
+ * every open and the component itself is unmounted when the operator moves
1224
+ * between HID screens, so a local ref would forget the choice immediately.
1225
+ * Enrolling ten guards in a row should not mean picking "Member" ten times.
1226
+ *
1227
+ * Not persisted. Surviving a hard refresh is a smaller benefit than surviving
1228
+ * the dialog, and `useHidReaderSelection` already set the precedent for where
1229
+ * that line sits.
1230
+ */
1231
+ /* The dropdown's values, plus `service_provider` - which the dropdown does not
1232
+ offer but `openEdit` resolves from records that name a COMPANY. */
1233
+ type TEnrolmentSubjectValue = HidEnrolmentSubject | "service_provider";
1234
+
1235
+ const REMEMBERED_SUBJECT_CATEGORY = ref<HidEnrolmentSubject>("resident");
1236
+
1237
+ const form = reactive({
1238
+ reader: "",
1239
+ block: "",
1240
+ level: "",
1241
+ unit: "",
1242
+ name: "",
1243
+ hidUserId: "",
1244
+ registration: "",
1245
+ isAdministrator: false,
1246
+ accessPin: "",
1247
+ password: "",
1248
+ photoPreview: "",
1249
+ subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value as TEnrolmentSubjectValue,
1250
+ subjectId: "",
1251
+ /** A mirror of the chosen member's role. Never sent; display only. */
1252
+ subjectRole: "",
1253
+ });
1254
+
1255
+ const route = useRoute();
1256
+ const orgId = computed(() => String(route.params.org || ""));
1257
+
1258
+ // The cascade runs on People Management's own endpoints, the same pair the
1259
+ // resident forms use:
1260
+ // GET /api/people/site/:site/occupied-structure - blocks/levels/units that
1261
+ // actually have an occupant, so the picker cannot offer an empty unit
1262
+ // GET /api/people/unit/:unit - who is in the chosen one
1263
+ // Both are fetched on FIRST USE, never when the dialog opens.
1264
+ const { getOccupiedStructure, getPeopleByUnit } = usePeople();
1265
+
1266
+ // `resident,tenant` matches the occupancy endpoint's own default, so the units
1267
+ // it offers and the people this returns are the same set. Asking the two
1268
+ // halves different questions is how a unit ends up offered but empty.
1269
+ const OCCUPANT_TYPES = "resident,tenant";
1270
+
1271
+ const occupiedBlocks = ref<OccupiedBlock[]>([]);
1272
+ const occupancyLoaded = ref(false);
1273
+ const loadingOccupancy = ref(false);
1274
+ const unitPeople = ref<UnitPerson[]>([]);
1275
+ const loadingUnitPeople = ref(false);
1276
+
1277
+ // "Is this resident already enrolled on THIS reader?" - the reader keeps its own
1278
+ // user table, so the same person at a different gate is normal and does not
1279
+ // count. Held here rather than derived, because it is the answer to a request.
1280
+ //
1281
+ // The server refuses the duplicate as well (`createIdentity` in
1282
+ // `iservice365-core`), but by the time it answers, `saveUser` has already
1283
+ // created a user on the physical device and has to roll it back. Asking here
1284
+ // keeps that off the reader entirely.
1285
+ const residentConflict = ref<HidUser | null>(null);
1286
+ const checkingResident = ref(false);
1287
+
1288
+ // One spelling of a block, used by the picker and by the label stamped onto the
1289
+ // record. Two copies of this expression is how the dropdown and the Unit column
1290
+ // drift apart.
1291
+ function formatBlockTitle(block?: OccupiedBlock | null) {
1292
+ if (!block) return "";
1293
+ return block.name
1294
+ ? `Block ${block.block} (${block.name})`
1295
+ : `Block ${block.block ?? ""}`.trim();
1296
+ }
1297
+
1298
+ const blockOptions = computed(() =>
1299
+ occupiedBlocks.value.map((block) => ({
1300
+ title: formatBlockTitle(block),
1301
+ value: String(block._id ?? ""),
1302
+ }))
1303
+ );
1304
+
1305
+ const levelOptions = computed(() => {
1306
+ const block = occupiedBlocks.value.find(
1307
+ (item) => String(item._id ?? "") === form.block
1308
+ );
1309
+ return (block?.levels ?? []).map((level) => ({
1310
+ title: String(level.name ?? ""),
1311
+ value: String(level._id ?? ""),
1312
+ }));
1313
+ });
1314
+
1315
+ const unitOptions = computed(() => {
1316
+ const block = occupiedBlocks.value.find(
1317
+ (item) => String(item._id ?? "") === form.block
1318
+ );
1319
+ const level = (block?.levels ?? []).find(
1320
+ (item) => String(item._id ?? "") === form.level
1321
+ );
1322
+ return (level?.units ?? []).map((unit) => ({
1323
+ title: String(unit.name ?? ""),
1324
+ value: String(unit._id ?? ""),
1325
+ }));
1326
+ });
1327
+
1328
+ // Only the residents the server will actually accept - see `UnitPerson.user`.
1329
+ // Offering the rest produced "The linked HID identity subject is not active at
1330
+ // this site." at the very end of enrollment, after a user had already been
1331
+ // written to the reader.
1332
+ const enrollableUnitPeople = computed(() =>
1333
+ unitPeople.value.filter((person) => Boolean(person.user))
1334
+ );
1335
+
1336
+ const unenrollableUnitPeopleCount = computed(
1337
+ () => unitPeople.value.length - enrollableUnitPeople.value.length
1338
+ );
1339
+
1340
+ // `_id` here is `site.people._id`, which is exactly the `person` that
1341
+ // `createIdentity` maps a HID user to.
1342
+ const subjectOptions = computed(() =>
1343
+ enrollableUnitPeople.value.map((person) => ({
1344
+ title: String(person.name ?? "Resident"),
1345
+ value: String(person._id ?? ""),
1346
+ }))
1347
+ );
1348
+
1349
+ // Says why somebody the operator can see in People Management is missing here.
1350
+ // Without it the unit just looks empty and the screen looks broken.
1351
+ const unitResidentNote = computed(() => {
1352
+ if (
1353
+ !form.unit ||
1354
+ loadingUnitPeople.value ||
1355
+ !unenrollableUnitPeopleCount.value
1356
+ )
1357
+ return "";
1358
+ const count = unenrollableUnitPeopleCount.value;
1359
+ const subject = count === 1 ? "1 resident" : `${count} residents`;
1360
+ const verb = count === 1 ? "is" : "are";
1361
+ return `${subject} in this unit ${verb} not listed: HID enrollment needs a resident app account, and ${
1362
+ count === 1 ? "that person has" : "they have"
1363
+ } none yet.`;
1364
+ });
1365
+
1366
+ /* ── THE SUBJECT TYPE ──────────────────────────────────────────────────── */
1367
+
1368
+ const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
1369
+
1370
+ const isResidentSubject = computed(() => form.subjectCategory === "resident");
1371
+ /* Members and provider staff both pick ONE PERSON from a flat list, but from
1372
+ different lists, so they are separate branches rather than "not a resident". */
1373
+ const isMemberSubject = computed(
1374
+ () => form.subjectCategory === "property_management"
1375
+ );
1376
+ const isProviderSubject = computed(
1377
+ () => form.subjectCategory === "service_provider_staff"
1378
+ );
1379
+ /** Either of the two person pickers - what the Role mirror follows. */
1380
+ const isPersonSubject = computed(
1381
+ () => isMemberSubject.value || isProviderSubject.value
1382
+ );
1383
+
1384
+ /* The mirror's label follows the list it mirrors. A member's subtitle is their
1385
+ role at the site; a contractor's is the firm they work for, and calling that
1386
+ "Role" would read as a mistake. */
1387
+ const subjectMirrorLabel = computed(() =>
1388
+ isProviderSubject.value ? "Company" : "Role"
1389
+ );
1390
+ const subjectMirrorPlaceholder = computed(() =>
1391
+ isProviderSubject.value ? "Select a person above" : "Select a member above"
1392
+ );
1393
+
1394
+ /* What an EDIT shows in place of the control, since the type cannot change.
1395
+ Looked up in the util, not in the switch's own options: records carry
1396
+ `service_provider` too, and the options list would have called those
1397
+ "Resident". */
1398
+ const subjectCategoryLabel = computed(() =>
1399
+ labelForSubjectCategory(form.subjectCategory)
1400
+ );
1401
+
1402
+ /* ── STAFF CANDIDATES ─────────────────────────────────────────────────────
1403
+ *
1404
+ * Read from `GET /api/members` — the SAME endpoint, with the same filters, that
1405
+ * the Members menu reads. That is deliberate and was the fix for a real bug: the
1406
+ * first version used the HID permission-candidate endpoint, which applies NO
1407
+ * `type` filter, so it offered every active members row at the site including
1408
+ * service providers and residents. A picker that disagrees with the Members menu
1409
+ * is a picker the operator cannot trust.
1410
+ *
1411
+ * `type` is the running app, the same way `HidQrCodeConfiguration` reads it, so
1412
+ * the property app lists its own staff and the security app lists its own. The
1413
+ * members endpoint expands legacy spellings of a type itself (`appTypeQuery`).
1414
+ *
1415
+ * Service providers get their own option next; they are not silently folded in
1416
+ * here.
1417
+ */
1418
+ /*
1419
+ * TEN, to share the Members menu's cache key.
1420
+ *
1421
+ * `member.repo.ts` caches each page under a key that INCLUDES `limit`, so a
1422
+ * different limit is a different cache entry with its own 15-minute life. This
1423
+ * picker asked for 50 and got its own rarely-touched slot, which went stale and
1424
+ * showed six members where the Members menu showed nine - the same query, the
1425
+ * same instant, two answers. Asking for the menu's own page size means the two
1426
+ * read the same entry and cannot disagree.
1427
+ */
1428
+ const MEMBER_PAGE_LIMIT = 10;
1429
+
1430
+ type MemberCandidate = { subjectId: string; name: string; subtitle: string };
1431
+
1432
+ const memberCandidates = ref<MemberCandidate[]>([]);
1433
+ const loadingMembers = ref(false);
1434
+ const loadingMoreMembers = ref(false);
1435
+ const membersLoaded = ref(false);
1436
+ /** The last page successfully loaded; 0 before the first. */
1437
+ const memberPage = ref(0);
1438
+ /** Rows fetched so far, INCLUDING those hidden for having no account. */
1439
+ const memberRowsLoaded = ref(0);
1440
+ /** What the server says matches the query, across every page. */
1441
+ const memberTotalRows = ref(0);
1442
+ /* Active staff dropped for having no app account. Counted here rather than
1443
+ * asked of the server, because this endpoint returns the `user` field and so
1444
+ * the answer is already in hand - see `hiddenMemberNote`. */
1445
+ const hiddenMemberCount = ref(0);
1446
+
1447
+ const hasMoreMembers = computed(
1448
+ () => memberRowsLoaded.value < memberTotalRows.value
1449
+ );
1450
+
1451
+ /*
1452
+ * Bumped on every request, so a response that is no longer the current one is
1453
+ * dropped. Scroll-loading makes this matter: a page still in flight when the
1454
+ * dialog reopens, or when the type is switched away and back, would otherwise
1455
+ * append onto a list that has since been replaced. Same guard, and the same
1456
+ * reason, as `cardRequestSeq` in `EntryPassInformation.vue`.
1457
+ */
1458
+ let memberRequestSeq = 0;
1459
+
1460
+ const memberOptions = computed(() =>
1461
+ memberCandidates.value.map((candidate) => ({
1462
+ // Role in the title as well as the mirror below: two people can share a name,
1463
+ // and the popover is where they have to be told apart.
1464
+ title: candidate.subtitle
1465
+ ? `${candidate.name} — ${candidate.subtitle}`
1466
+ : candidate.name,
1467
+ value: candidate.subjectId,
1468
+ }))
1469
+ );
1470
+
1471
+ const memberPlaceholder = computed(() => {
1472
+ if (loadingMembers.value) return "Loading staff...";
1473
+ if (!membersLoaded.value) return "Select a member";
1474
+ if (memberOptions.value.length) return "Select a member";
1475
+ return hiddenMemberCount.value
1476
+ ? "No staff here can be enrolled yet"
1477
+ : "No staff at this site";
1478
+ });
1479
+
1480
+ /* The same courtesy the resident cascade pays: say why somebody the operator
1481
+ * can see in the Members menu is not in this list. */
1482
+ const hiddenMemberNote = computed(() => {
1483
+ // Only once everything is loaded. Half a list gives half a count, and
1484
+ // "3 members at this site" would then be a number about this PAGE wearing the
1485
+ // word "site" - the range note below carries the partial truth instead.
1486
+ if (loadingMembers.value || hasMoreMembers.value) return "";
1487
+ return hiddenSubjectNote(hiddenMemberCount.value, "member");
1488
+ });
1489
+
1490
+ /* What is loaded against what exists - and the warning that the picker's own
1491
+ search only ever sees what has been fetched. */
1492
+ const memberRangeNote = computed(() =>
1493
+ hasMoreMembers.value
1494
+ ? `Showing ${memberRowsLoaded.value} of ${memberTotalRows.value}. Search looks only at these.`
1495
+ : ""
1496
+ );
1497
+
1498
+ /** Back to "nothing loaded", so the next open starts from page one. */
1499
+ function resetMemberCandidates() {
1500
+ // Anything still in flight belongs to the list being thrown away.
1501
+ memberRequestSeq += 1;
1502
+ memberCandidates.value = [];
1503
+ hiddenMemberCount.value = 0;
1504
+ memberPage.value = 0;
1505
+ memberRowsLoaded.value = 0;
1506
+ memberTotalRows.value = 0;
1507
+ membersLoaded.value = false;
1508
+ }
1509
+
1510
+ /**
1511
+ * One page of this site's active staff.
1512
+ *
1513
+ * Paged rather than fetched whole: a page of ten is what the Members menu asks
1514
+ * for, and sharing that page size shares its cache entry. `append` is what
1515
+ * separates the first load from Load more - the first replaces, the rest add.
1516
+ *
1517
+ * NEVER THROWS. The list is left as it is and the placeholder says so, because
1518
+ * choosing "Member" must not break the dialog.
1519
+ */
1520
+ async function fetchMemberPage(page: number, append: boolean) {
1521
+ const memberType = String(runtimeConfig.public.APP ?? "").trim();
1522
+ // "App" is the nuxt config's own placeholder default. Without a real app there
1523
+ // is no member type to ask for, and a typeless query returns nothing anyway.
1524
+ if (!props.site || !props.org || !memberType || memberType === "App") {
1525
+ resetMemberCandidates();
1526
+ membersLoaded.value = true;
1527
+ return;
1528
+ }
1529
+
1530
+ const seq = (memberRequestSeq += 1);
1531
+ const busy = append ? loadingMoreMembers : loadingMembers;
1532
+ busy.value = true;
1533
+ try {
1534
+ const response = await getAllMembers({
1535
+ org: props.org,
1536
+ siteId: props.site,
1537
+ type: memberType,
1538
+ status: "active",
1539
+ page,
1540
+ limit: MEMBER_PAGE_LIMIT,
1541
+ });
1542
+ if (seq !== memberRequestSeq) return;
1543
+ const parsed = readMemberPage(response);
1544
+ const enrollable = parsed.items.filter(memberHasAccount);
1545
+ const candidates = enrollable.map(memberCandidateOf);
1546
+
1547
+ memberCandidates.value = append
1548
+ ? [...memberCandidates.value, ...candidates]
1549
+ : candidates;
1550
+ const hiddenOnPage = parsed.items.length - enrollable.length;
1551
+ hiddenMemberCount.value = append
1552
+ ? hiddenMemberCount.value + hiddenOnPage
1553
+ : hiddenOnPage;
1554
+ memberRowsLoaded.value = append
1555
+ ? memberRowsLoaded.value + parsed.items.length
1556
+ : parsed.items.length;
1557
+ memberTotalRows.value = parsed.total;
1558
+ memberPage.value = page;
1559
+ membersLoaded.value = true;
1560
+ } catch (error: unknown) {
1561
+ if (seq !== memberRequestSeq) return;
1562
+ console.error("Unable to load staff for HID enrollment:", error);
1563
+ // An "append" that failed keeps what is already on screen; a first load has
1564
+ // nothing to keep. Either way the dialog stays usable.
1565
+ if (!append) resetMemberCandidates();
1566
+ membersLoaded.value = true;
1567
+ } finally {
1568
+ // Always its OWN flag, superseded or not: a request that leaves its spinner
1569
+ // set strands the list behind a wait that will never end.
1570
+ busy.value = false;
1571
+ }
1572
+ }
1573
+
1574
+ /** The first page, replacing whatever was there. */
1575
+ function loadMemberCandidates() {
1576
+ return fetchMemberPage(1, false);
1577
+ }
1578
+
1579
+ /** The next page, added to the list. */
1580
+ function loadMoreMembers() {
1581
+ if (loadingMembers.value || loadingMoreMembers.value || !hasMoreMembers.value)
1582
+ return;
1583
+ return fetchMemberPage(memberPage.value + 1, true);
1584
+ }
1585
+
1586
+ /* ── SERVICE PROVIDER STAFF ───────────────────────────────────────────────
1587
+ *
1588
+ * ONE PERSON from a provider company, never the company.
1589
+ *
1590
+ * Read from `GET /api/service-providers/site-members` - the same call behind
1591
+ * Members > Service Providers, with the same `filterProviderMembers` rule - so
1592
+ * the picker lists exactly the people that screen lists. The server scopes it to
1593
+ * the site; these are the contractors who attend THIS site, not every employee
1594
+ * of every firm engaged with the organisation.
1595
+ *
1596
+ * Unpaged, deliberately, unlike the staff picker above: this endpoint returns
1597
+ * the site's people in one response (capped at 1000 by the repository) and has
1598
+ * no per-page cache entry to disagree with. The service filter and the search
1599
+ * box both run over the loaded list, so neither costs a request.
1600
+ */
1601
+ const providerPeople = ref<Record<string, unknown>[]>([]);
1602
+ const providerCompanyCount = ref(0);
1603
+ const loadingProviders = ref(false);
1604
+ const providersLoaded = ref(false);
1605
+ /**
1606
+ * All services is the default, the same as the Members screen - but it carries a
1607
+ * REAL value here, not "". `AppSelect`'s `hasSingleValue` is false for an empty
1608
+ * string, so an empty-valued option draws the placeholder in placeholder grey and
1609
+ * is never marked as chosen: the filter worked and the field looked blank. See
1610
+ * `serviceFilterOf`.
1611
+ */
1612
+ const providerService = ref<string>(HID_ALL_SERVICES);
1613
+
1614
+ /** What the filter actually receives: "" for All services. */
1615
+ const providerServiceFilter = computed(() => serviceFilterOf(providerService.value));
1616
+ let providerRequestSeq = 0;
1617
+
1618
+ /** Everyone who can actually be given a door: an app account, and active. */
1619
+ const providerEnrollable = computed(() =>
1620
+ providerEnrollableRows(providerPeople.value)
1621
+ );
1622
+
1623
+ /* Whether the endpoint told us who holds an app account. It only began
1624
+ returning `user` in core 3.130, and this layer can be deployed ahead of it. */
1625
+ const providerAccountsVisible = computed(() =>
1626
+ providerAccountsKnown(providerPeople.value)
1627
+ );
1628
+
1629
+ /** People the operator can see on the Members screen but cannot enrol here. */
1630
+ const hiddenProviderCount = computed(
1631
+ () => providerPeople.value.length - providerEnrollable.value.length
1632
+ );
1633
+
1634
+ const providerServiceOptions = computed(() => [
1635
+ { title: "All services", value: HID_ALL_SERVICES },
1636
+ // From the ENROLLABLE set, so choosing a service never lands on an empty list.
1637
+ ...serviceTypeOptions(providerEnrollable.value as Array<{ type?: string; typeLabel?: string }>),
1638
+ ]);
1639
+
1640
+ /** The filtered list - what both the picker and the bulk table draw. */
1641
+ const providerRows = computed(() =>
1642
+ filterProviderMembers(providerEnrollable.value as Array<Record<string, any>>, {
1643
+ type: providerServiceFilter.value,
1644
+ })
1645
+ );
1646
+
1647
+ const providerOptions = computed(() =>
1648
+ providerRows.value.map((row) => {
1649
+ const candidate = providerCandidateOf(row);
1650
+ return {
1651
+ // The firm in the title as well as the mirror: two contractors sharing a
1652
+ // first name is the normal case, and the popover is where they have to be
1653
+ // told apart.
1654
+ title: candidate.subtitle
1655
+ ? `${candidate.name} — ${candidate.subtitle}`
1656
+ : candidate.name,
1657
+ value: candidate.subjectId,
1658
+ };
1659
+ })
1660
+ );
1661
+
1662
+ const providerPlaceholder = computed(() => {
1663
+ if (loadingProviders.value) return "Loading people...";
1664
+ if (!providersLoaded.value) return "Select a person";
1665
+ if (providerOptions.value.length) return "Select a person";
1666
+ if (providerServiceFilter.value) return "Nobody in this service can be enrolled yet";
1667
+ if (hiddenProviderCount.value) return "Nobody here can be enrolled yet";
1668
+ return providerCompanyCount.value
1669
+ ? "No service provider people at this site"
1670
+ : "No service providers work at this site yet";
1671
+ });
1672
+
1673
+ /* Say why somebody on the Members screen is not in this list, rather than
1674
+ letting a missing colleague read as a broken dialog. */
1675
+ const hiddenProviderNote = computed(() => {
1676
+ if (loadingProviders.value || !hiddenProviderCount.value) return "";
1677
+ const count = hiddenProviderCount.value;
1678
+ const subject = count === 1 ? "1 person" : `${count} people`;
1679
+ const verb = count === 1 ? "is" : "are";
1680
+ const holder = count === 1 ? "that person is" : "they are";
1681
+ const needs = providerAccountsVisible.value
1682
+ ? "an active membership with an app account"
1683
+ : "an active membership at this site";
1684
+ return `${subject} from this site's service providers ${verb} not listed:`
1685
+ + ` HID enrollment needs ${needs}, and ${holder} without one.`;
1686
+ });
1687
+
1688
+ /** Back to "nothing loaded", so the next open starts clean. */
1689
+ function resetProviderCandidates() {
1690
+ providerRequestSeq += 1;
1691
+ providerPeople.value = [];
1692
+ providerCompanyCount.value = 0;
1693
+ providerService.value = HID_ALL_SERVICES;
1694
+ providersLoaded.value = false;
1695
+ }
1696
+
1697
+ /**
1698
+ * This site's service provider people.
1699
+ *
1700
+ * NEVER THROWS, on the same rule as the staff loader: choosing Service provider
1701
+ * must not break the dialog. The placeholder carries the failure.
1702
+ */
1703
+ async function loadProviderCandidates() {
1704
+ if (!props.site) {
1705
+ resetProviderCandidates();
1706
+ providersLoaded.value = true;
1707
+ return;
1708
+ }
1709
+ const seq = (providerRequestSeq += 1);
1710
+ loadingProviders.value = true;
1711
+ try {
1712
+ const data = await getSiteMembers(props.site);
1713
+ // A response that arrived after the dialog was reopened belongs to a list
1714
+ // that no longer exists - the same guard the staff pager uses.
1715
+ if (seq !== providerRequestSeq) return;
1716
+ providerPeople.value = Array.isArray(data?.items)
1717
+ ? (data.items as unknown as Record<string, unknown>[])
1718
+ : [];
1719
+ providerCompanyCount.value = Array.isArray(data?.companies)
1720
+ ? data.companies.length
1721
+ : 0;
1722
+ providersLoaded.value = true;
1723
+ } catch (error: unknown) {
1724
+ if (seq !== providerRequestSeq) return;
1725
+ console.error("Unable to load service provider people for HID enrollment:", error);
1726
+ providerPeople.value = [];
1727
+ providerCompanyCount.value = 0;
1728
+ providersLoaded.value = true;
1729
+ } finally {
1730
+ loadingProviders.value = false;
1731
+ }
1732
+ }
1733
+
1734
+ /** The chosen person, restated into Name and the Company mirror. */
1735
+ function onProviderChanged() {
1736
+ // Compared on the TRIMMED id, because that is what the option's value carries
1737
+ // (`providerCandidateOf` trims) - matching the raw field would silently find
1738
+ // nobody and blank the name the operator just picked.
1739
+ const row = providerRows.value.find(
1740
+ (item) => String(item._id ?? "").trim() === form.subjectId
1741
+ );
1742
+ const candidate = row ? providerCandidateOf(row) : null;
1743
+ form.name = candidate?.name ?? "";
1744
+ form.subjectRole = candidate?.subtitle ?? "";
1745
+ void checkSubjectEnrollment();
1746
+ }
1747
+
1748
+ /* Changing the service can take the chosen person out of the list. Leaving a
1749
+ selection that is no longer on screen is how a save ends up naming somebody
1750
+ the operator cannot see. */
1751
+ function onProviderServiceChanged() {
1752
+ if (!form.subjectId) return;
1753
+ const stillThere = providerRows.value.some(
1754
+ (item) => String(item._id ?? "").trim() === form.subjectId
1755
+ );
1756
+ if (stillThere) return;
1757
+ form.subjectId = "";
1758
+ form.name = "";
1759
+ form.subjectRole = "";
1760
+ residentConflict.value = null;
1761
+ }
1762
+
1763
+ /* ── ADD USERS IN BULK ────────────────────────────────────────────────────
1764
+ *
1765
+ * ACCESS, NOT CREDENTIALS. This grants a reader group to many people at once and
1766
+ * stops there - no face, no card, no PIN. Each person then adds their own
1767
+ * credential from their app, which attaches to the HID user this grant created
1768
+ * (`getProfileEnrollmentContext` reuses the existing profile identity), so
1769
+ * nobody has to be enrolled twice.
1770
+ *
1771
+ * It writes through `PUT /sites/:siteId/permissions`, the one call that writes
1772
+ * group membership, and the device users are created by the reconcile that call
1773
+ * triggers: `provisionPermissionUser` writes `{ name, registration }` and
1774
+ * nothing else. That is exactly "user record and group, credentials empty".
1775
+ *
1776
+ * THE TRAP, and the reason the saved set is fetched first: that PUT REPLACES
1777
+ * this reader's whole assignment set. Sending only the ticked contractors would
1778
+ * revoke every resident and every member on the door. The payload is always
1779
+ * built from the reader's existing assignments PLUS the selection.
1780
+ */
1781
+ const bulkDialog = ref(false);
1782
+ const bulkLoading = ref(false);
1783
+ const bulkSaving = ref(false);
1784
+ const bulkSearch = ref("");
1785
+ /** The reader's CURRENT assignments, across every category. The merge base. */
1786
+ const bulkSaved = ref<HidAssignmentState>(new Map());
1787
+ /** Who is being added now. Already-granted people are never in here. */
1788
+ const bulkSelected = ref<Set<string>>(new Set());
1789
+
1790
+ /* NAME THE DOOR. A bulk grant is reader-scoped and the reader was chosen on the
1791
+ screen behind two dialogs, so the confirmation says which one rather than
1792
+ leaving the operator to remember. */
1793
+ const bulkReaderLabel = computed(() => {
1794
+ const reader = selectedReader.value;
1795
+ const name = String(reader?.name ?? "").trim() || "this HID reader";
1796
+ const location = String(reader?.location ?? "").trim();
1797
+ return location ? `${name} · ${location}` : name;
1798
+ });
1799
+
1800
+ /** The same people the picker offers, narrowed by this dialog's own search. */
1801
+ const bulkRows = computed(() => {
1802
+ const rows = filterProviderMembers(
1803
+ providerRows.value as Array<Record<string, any>>,
1804
+ { search: bulkSearch.value }
1805
+ );
1806
+ return rows.map((row) => {
1807
+ const candidate = providerCandidateOf(row);
1808
+ const id = candidate.subjectId;
1809
+ return {
1810
+ subjectId: id,
1811
+ name: candidate.name,
1812
+ company: String(row.company ?? "").trim(),
1813
+ service: String(row.typeLabel ?? row.type ?? "").trim(),
1814
+ // Already on the door. Ticked and locked rather than hidden: hiding them
1815
+ // would make "select all" look like it had missed people, and unticking
1816
+ // one here would revoke access this dialog is not for revoking.
1817
+ granted: bulkSaved.value.has(id),
1818
+ };
1819
+ });
1820
+ });
1821
+
1822
+ const bulkSelectableRows = computed(() =>
1823
+ bulkRows.value.filter((row) => !row.granted)
1824
+ );
1825
+ const bulkSelectedCount = computed(() => bulkSelected.value.size);
1826
+ const bulkAllSelected = computed(
1827
+ () =>
1828
+ bulkSelectableRows.value.length > 0 &&
1829
+ bulkSelectableRows.value.every((row) => bulkSelected.value.has(row.subjectId))
1830
+ );
1831
+ const bulkGrantedCount = computed(
1832
+ () => bulkRows.value.filter((row) => row.granted).length
1833
+ );
1834
+
1835
+ const bulkEmptyText = computed(() => {
1836
+ if (bulkLoading.value) return "Loading...";
1837
+ if (bulkSearch.value.trim()) return "No one matches this search.";
1838
+ if (providerServiceFilter.value) return "No one in this service can be enrolled yet.";
1839
+ return "No service provider people can be enrolled at this site yet.";
1840
+ });
1841
+
1842
+ function isBulkSelected(subjectId: string) {
1843
+ return bulkSelected.value.has(subjectId);
1844
+ }
1845
+
1846
+ function toggleBulkRow(subjectId: string, selected: boolean) {
1847
+ const next = new Set(bulkSelected.value);
1848
+ if (selected) next.add(subjectId);
1849
+ else next.delete(subjectId);
1850
+ bulkSelected.value = next;
1851
+ }
1852
+
1853
+ /* Select-all acts on the ROWS IN FRONT OF THE OPERATOR - what the service filter
1854
+ and the search have left - never on the whole estate. A tick box cannot
1855
+ quietly grant a door to people who are not on screen. */
1856
+ function toggleBulkAll(selected: boolean) {
1857
+ const next = new Set(bulkSelected.value);
1858
+ for (const row of bulkSelectableRows.value) {
1859
+ if (selected) next.add(row.subjectId);
1860
+ else next.delete(row.subjectId);
1861
+ }
1862
+ bulkSelected.value = next;
1863
+ }
1864
+
1865
+ /**
1866
+ * Open the bulk dialog, after reading what the reader already holds.
1867
+ *
1868
+ * The saved set is fetched EVERY time rather than cached: it is the merge base
1869
+ * for a write that replaces the reader's assignments, and a stale one would
1870
+ * revoke whoever was granted in between.
1871
+ */
1872
+ async function openBulkAdd() {
1873
+ const readerId =
1874
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
1875
+ if (!readerId) {
1876
+ showToast("Select a HID reader before adding people in bulk.", "error");
1877
+ return;
1878
+ }
1879
+ /* The permissions endpoints REQUIRE an org - `schemaHidPermissionScopeQuery`
1880
+ has it as `required()`, unlike `createIdentity`, which falls back to the
1881
+ site's own. Both pages that mount this component pass one, so this is a
1882
+ guard against a 400 that would read as a server fault. */
1883
+ if (!props.org) {
1884
+ showToast(
1885
+ "This screen cannot grant access in bulk without an organization.",
1886
+ "error"
1887
+ );
1888
+ return;
1889
+ }
1890
+ bulkSearch.value = "";
1891
+ bulkSelected.value = new Set();
1892
+ bulkSaved.value = new Map();
1893
+ bulkDialog.value = true;
1894
+ bulkLoading.value = true;
1895
+ try {
1896
+ if (!providersLoaded.value) await loadProviderCandidates();
1897
+ const response = await getSitePermissions(props.site, props.org, readerId);
1898
+ bulkSaved.value = seedAssignments(response?.data?.assignments ?? []);
1899
+ } catch (error: unknown) {
1900
+ // Closed rather than left open on an empty merge base: saving from here
1901
+ // would send only the ticked people and revoke everybody else.
1902
+ bulkDialog.value = false;
1903
+ showToast(
1904
+ apiReason(error) || "Unable to read this reader's current access.",
1905
+ "error"
1906
+ );
1907
+ } finally {
1908
+ bulkLoading.value = false;
1909
+ }
1910
+ }
1911
+
1912
+ /** Grant the ticked people, keeping everyone the reader already admits. */
1913
+ async function saveBulkAdd() {
1914
+ if (bulkSaving.value || !bulkSelected.value.size) return;
1915
+ const readerId =
1916
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
1917
+ if (!readerId) {
1918
+ showToast("Select a HID reader before adding people in bulk.", "error");
1919
+ return;
1920
+ }
1921
+
1922
+ bulkSaving.value = true;
1923
+ try {
1924
+ // Seeded from what the server holds, so every category the reader already
1925
+ // admits survives this write.
1926
+ let draft: HidAssignmentState = new Map(bulkSaved.value);
1927
+ for (const subjectId of bulkSelected.value) {
1928
+ draft = setAssignment(
1929
+ draft,
1930
+ bulkSaved.value,
1931
+ subjectId,
1932
+ // A provider employee's grant is stored under the STAFF category: core's
1933
+ // `permissionSubjectOfIdentity` reads the `member` link and cannot see
1934
+ // this dropdown, and that category's subject list already includes these
1935
+ // people. See `permissionCategoryOf`.
1936
+ permissionCategoryOf("service_provider_staff") as THidPermissionCategory,
1937
+ true
1938
+ );
1939
+ }
1940
+ const added = bulkSelected.value.size;
1941
+ await updateSitePermissions(
1942
+ props.site,
1943
+ props.org,
1944
+ readerId,
1945
+ toAssignmentPayload(draft)
1946
+ );
1947
+ // Both dialogs close and the list is re-read: the reconcile has just created
1948
+ // a HID user for each person, so the roster behind this dialog is stale.
1949
+ bulkDialog.value = false;
1950
+ formDialog.value = false;
1951
+ await loadUsers();
1952
+ showToast(
1953
+ `${added} ${added === 1 ? "person" : "people"} can now use this reader. `
1954
+ + "They add their own face, QR or PIN from their app.",
1955
+ "success"
1956
+ );
1957
+ } catch (error: unknown) {
1958
+ showToast(
1959
+ apiReason(error) || "Unable to give these people access to this reader.",
1960
+ "error"
1961
+ );
1962
+ } finally {
1963
+ bulkSaving.value = false;
1964
+ }
1965
+ }
1966
+
1967
+ /**
1968
+ * Switching type clears the chosen subject, and nothing else.
1969
+ *
1970
+ * The id spaces do not overlap - a `site.people._id` submitted as a `member`
1971
+ * would be refused by the server, and worse, a stale one could name a real
1972
+ * member - so it is cleared rather than carried. Name and Role mirror the
1973
+ * choice, so they clear with it.
654
1974
  */
655
- const facialEnrolled = ref(false);
1975
+ async function onSubjectCategoryChanged() {
1976
+ REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory as HidEnrolmentSubject;
1977
+ form.subjectId = "";
1978
+ form.subjectRole = "";
1979
+ form.name = "";
1980
+ residentConflict.value = null;
656
1981
 
657
- function hasFacialEnrollment(user?: HidUser | null) {
658
- return Boolean(
659
- user?.metadata?.facialEnrolled ||
660
- user?.metadata?.facialData ||
661
- user?.metadata?.imageTimestamp
1982
+ // Each list is fetched on first use and then kept, so flipping between the
1983
+ // three options costs one request each rather than one per flip.
1984
+ if (isMemberSubject.value && !membersLoaded.value) {
1985
+ await loadMemberCandidates();
1986
+ }
1987
+ if (isProviderSubject.value && !providersLoaded.value) {
1988
+ await loadProviderCandidates();
1989
+ }
1990
+ }
1991
+
1992
+ /* The staff equivalent of `onResidentChanged`. */
1993
+ async function onMemberChanged() {
1994
+ const candidate = memberCandidates.value.find(
1995
+ (item) => String(item.subjectId) === String(form.subjectId)
662
1996
  );
1997
+ form.name = String(candidate?.name ?? "");
1998
+ form.subjectRole = String(candidate?.subtitle ?? "");
1999
+ await checkSubjectEnrollment();
663
2000
  }
664
- const removePinRequested = ref(false);
665
- const snackbar = reactive({
666
- show: false,
667
- color: "success",
668
- message: "",
2001
+
2002
+ const blockPlaceholder = computed(() => {
2003
+ if (loadingOccupancy.value) return "Loading...";
2004
+ if (occupancyLoaded.value && !blockOptions.value.length) {
2005
+ return "No occupied units at this site";
2006
+ }
2007
+ return "Select block";
669
2008
  });
670
2009
 
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: "",
2010
+ const unitResidentPlaceholder = computed(() => {
2011
+ if (!form.unit) return "Select a unit first";
2012
+ if (loadingUnitPeople.value) return "Loading residents...";
2013
+ if (enrollableUnitPeople.value.length) return "Select a resident";
2014
+ // The two empty cases are different problems with different fixes, so they
2015
+ // do not share a sentence: nobody lives here, versus nobody here can be
2016
+ // enrolled yet. `unitResidentNote` carries the detail for the second.
2017
+ return unitPeople.value.length
2018
+ ? "No resident here can be enrolled yet"
2019
+ : "No residents in this unit";
684
2020
  });
685
2021
 
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
- })));
2022
+ // Names the HID user that is in the way, so the operator can go and edit it
2023
+ // rather than being told only that something is wrong.
2024
+ const residentConflictMessage = computed(() => {
2025
+ const conflict = residentConflict.value;
2026
+ if (!conflict) return "";
2027
+ const label = [getName(conflict), formatHidUid(conflict.hidUserId)]
2028
+ .filter(Boolean)
2029
+ .join(" · ");
2030
+ return label
2031
+ ? `Already enrolled on this reader as ${label}. Edit that HID user instead of enrolling again.`
2032
+ : "Already enrolled on this reader. Edit that HID user instead of enrolling again.";
2033
+ });
699
2034
 
700
2035
  const cardForm = reactive({
701
2036
  cardType: "pacs" as "pacs" | "csn",
@@ -705,23 +2040,37 @@ const cardForm = reactive({
705
2040
  });
706
2041
 
707
2042
  const cardReader = computed(() => {
708
- const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
709
- return readers.value.find((reader) => String(reader._id) === readerId) || null;
2043
+ const readerId = String(
2044
+ cardUser.value?.reader || selectedReaderId.value || ""
2045
+ );
2046
+ return (
2047
+ readers.value.find((reader) => String(reader._id) === readerId) || null
2048
+ );
710
2049
  });
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");
2050
+ const cardProfileConfigured = computed(() =>
2051
+ ["pacs", "csn", "csn_for_mifare"].includes(
2052
+ String(cardReader.value?.cardReadingMode || "")
2053
+ )
2054
+ );
2055
+ const hybridCardProfile = computed(
2056
+ () => cardReader.value?.cardReadingMode === "csn_for_mifare"
2057
+ );
715
2058
  const hybridCardTypeOptions = [
716
2059
  { title: "MIFARE card (CSN)", value: "csn" },
717
2060
  { title: "Other card (PACS)", value: "pacs" },
718
2061
  ];
719
2062
  const cardProfileLabel = computed(() => {
720
2063
  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`;
2064
+ if (mode === "pacs")
2065
+ return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
2066
+ if (mode === "csn")
2067
+ return `CSN · ${(
2068
+ cardReader.value?.csnByteOrder || "msb"
2069
+ ).toUpperCase()} first`;
723
2070
  if (mode === "csn_for_mifare") {
724
- return `PACS for non-MIFARE cards · CSN for MIFARE (${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first)`;
2071
+ return `PACS for non-MIFARE cards · CSN for MIFARE (${(
2072
+ cardReader.value?.csnByteOrder || "msb"
2073
+ ).toUpperCase()} first)`;
725
2074
  }
726
2075
  return "Configure the card data mode on the HID Reader screen before assigning a card.";
727
2076
  });
@@ -738,6 +2087,8 @@ const userHeaders = [
738
2087
  { title: "Name", value: "name", sortable: false },
739
2088
  { title: "Facial Data", value: "facialData", sortable: false },
740
2089
  { title: "UID", value: "hidUserId", sortable: false },
2090
+ // Reads in the same order the dialog asks for them: UID then registration.
2091
+ { title: "Registration No.", value: "registration", sortable: false },
741
2092
  { title: "Status", value: "status", sortable: false },
742
2093
  { title: "", value: "action-table", sortable: false },
743
2094
  ];
@@ -749,8 +2100,12 @@ const cardHeaders = [
749
2100
  { title: "", value: "action-table", sortable: false },
750
2101
  ];
751
2102
 
752
- const tableTitle = computed(() => props.cardManagement ? "HID Cards" : "HID Users");
753
- const tableHeaders = computed(() => props.cardManagement ? cardHeaders : userHeaders);
2103
+ const tableTitle = computed(() =>
2104
+ props.cardManagement ? "HID Cards" : "HID Users"
2105
+ );
2106
+ const tableHeaders = computed(() =>
2107
+ props.cardManagement ? cardHeaders : userHeaders
2108
+ );
754
2109
 
755
2110
  /**
756
2111
  * The list is paged on the SERVER, so the shared pager only moves the cursor -
@@ -767,14 +2122,45 @@ function reloadUsersFromFirstPage() {
767
2122
  loadUsers();
768
2123
  }
769
2124
 
2125
+ /**
2126
+ * Typing is the search.
2127
+ *
2128
+ * The field used to reload on `@keyup.enter` and on nothing else, so a screen
2129
+ * that looks like every other search box did nothing at all unless you guessed
2130
+ * that Enter was required - no request left the browser.
2131
+ *
2132
+ * Debounced, because each reload is a login/load/logout round trip to a reader
2133
+ * over a VPN, not a database query. Enter still works and skips the wait.
2134
+ */
2135
+ const SEARCH_DEBOUNCE_MS = 400;
2136
+ let searchDebounce: ReturnType<typeof setTimeout> | undefined;
2137
+
2138
+ function onSearchInput() {
2139
+ clearTimeout(searchDebounce);
2140
+ searchDebounce = setTimeout(reloadUsersFromFirstPage, SEARCH_DEBOUNCE_MS);
2141
+ }
2142
+
2143
+ function onSearchSubmit() {
2144
+ clearTimeout(searchDebounce);
2145
+ reloadUsersFromFirstPage();
2146
+ }
2147
+
2148
+ watch(search, onSearchInput);
2149
+ onBeforeUnmount(() => clearTimeout(searchDebounce));
2150
+
770
2151
  const readerOptions = computed(() =>
771
2152
  readers.value.map((reader) => ({
772
- title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
2153
+ title: `${reader.name || reader.deviceId || reader._id} — ${
2154
+ reader.portalName || "Portal not configured"
2155
+ }`,
773
2156
  value: reader._id,
774
2157
  }))
775
2158
  );
776
- const selectedReader = computed(() =>
777
- readers.value.find((reader) => reader._id === (selectedReaderId.value || form.reader)) || null,
2159
+ const selectedReader = computed(
2160
+ () =>
2161
+ readers.value.find(
2162
+ (reader) => reader._id === (selectedReaderId.value || form.reader)
2163
+ ) || null
778
2164
  );
779
2165
 
780
2166
  const pageRange = computed(() => {
@@ -801,15 +2187,28 @@ async function loadReaders() {
801
2187
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
802
2188
  const responseRecord = toRecord(response);
803
2189
  const responseData = toRecord(responseRecord.data);
804
- const items = responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
2190
+ const items =
2191
+ responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
805
2192
  readers.value = (Array.isArray(items) ? items : [])
806
2193
  .map(toHidReader)
807
2194
  .filter((reader): reader is HidReader => reader !== null);
808
- if (!selectedReaderId.value && readers.value.length) {
809
- selectedReaderId.value = readers.value[0]._id;
810
- }
2195
+ /* Shared with every other HID screen — see `useHidReaderSelection`. The
2196
+ resolver refuses a remembered reader that is not this site's, so a site
2197
+ switch or a deleted reader falls back to the first rather than querying
2198
+ an id the site does not own. */
2199
+ selectedReaderId.value = resolveForSite(props.site, readers.value);
811
2200
  }
812
2201
 
2202
+ /**
2203
+ * Discards a load whose answer arrives after a newer one started.
2204
+ *
2205
+ * Each reload is a slow round trip to a reader over a VPN, and typing now fires
2206
+ * them, so "abc" can have three in flight at once. Without this the reply to
2207
+ * "a" can land last and repopulate the table with the wrong rows. Same monotonic
2208
+ * counter `HidAccessLogDashboard` uses.
2209
+ */
2210
+ const loadUsersSequence = ref(0);
2211
+
813
2212
  async function loadUsers() {
814
2213
  if (!selectedReaderId.value) {
815
2214
  users.value = [];
@@ -819,32 +2218,83 @@ async function loadUsers() {
819
2218
  return;
820
2219
  }
821
2220
 
2221
+ const sequence = loadUsersSequence.value + 1;
2222
+ loadUsersSequence.value = sequence;
822
2223
  loading.value = true;
823
2224
  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
- });
2225
+ // `userType: "user"` is REQUIRED here, not a nicety. The reader keeps
2226
+ // visitors in the same `users` table, marked `user_type_id = 1`, and the
2227
+ // API's default is `all`. Visitor rows are written by our own visitor-QR
2228
+ // issuance and are never removed - revoke only pushes `end_time` into the
2229
+ // past - so without this filter the enrollment roster counts every visitor
2230
+ // pass ever issued and drifts further from the reader's own Users screen
2231
+ // with each one. This screen enrolls and edits real people; visitors are
2232
+ // owned by the visitor module.
2233
+ let clientFiltersVisitors = false;
2234
+ let response: unknown;
2235
+ try {
2236
+ response = await getReaderUsers(selectedReaderId.value, {
2237
+ page: page.value,
2238
+ limit: limit.value,
2239
+ search: search.value.trim(),
2240
+ status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
2241
+ userType: "user",
2242
+ });
2243
+ } catch (error: unknown) {
2244
+ if (!isUnsupportedUserTypeFilter(error)) throw error;
2245
+
2246
+ // A deployed API can lag behind this shared layer. Keep the roster
2247
+ // correct on one that has not added `userType` yet, the same way
2248
+ // HidReaderUserRoster does, rather than failing the whole screen.
2249
+ clientFiltersVisitors = true;
2250
+ response = await getReaderUsers(selectedReaderId.value, {
2251
+ page: 1,
2252
+ limit: 100,
2253
+ search: search.value.trim(),
2254
+ status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
2255
+ });
2256
+ }
830
2257
  const responseRecord = toRecord(response);
831
2258
  const responseData = toRecord(responseRecord.data);
832
2259
  const responseItems = responseRecord.items ?? responseData.items ?? [];
833
- const pageItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
2260
+ const allItems = (Array.isArray(responseItems) ? responseItems : []).map(
2261
+ toHidUser
2262
+ );
2263
+ const matchingItems = clientFiltersVisitors
2264
+ ? allItems.filter((user) => !isVisitorUser(user))
2265
+ : allItems;
2266
+ const pageStart = (page.value - 1) * limit.value;
2267
+ const pageItems = clientFiltersVisitors
2268
+ ? matchingItems.slice(pageStart, pageStart + limit.value)
2269
+ : matchingItems;
2270
+ if (sequence !== loadUsersSequence.value) return;
834
2271
  administratorUserIds.value = new Set(
835
2272
  pageItems
836
2273
  .filter((user) => user.isAdministrator === true)
837
2274
  .map((user) => toHidNumericId(user.hidUserId))
838
- .filter((userId: number | undefined): userId is number => Boolean(userId)),
2275
+ .filter((userId: number | undefined): userId is number =>
2276
+ Boolean(userId)
2277
+ )
839
2278
  );
840
2279
  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 ?? "");
2280
+ total.value = clientFiltersVisitors
2281
+ ? matchingItems.length
2282
+ : Number(
2283
+ responseRecord.total ?? responseData.total ?? users.value.length
2284
+ );
2285
+ pages.value = clientFiltersVisitors
2286
+ ? Math.max(1, Math.ceil(total.value / limit.value))
2287
+ : Number(responseRecord.pages ?? responseData.pages ?? 1);
2288
+ // The server's `pageRange` counts the unfiltered page, so it cannot be
2289
+ // trusted once this screen has done the filtering itself.
2290
+ serverPageRange.value = clientFiltersVisitors
2291
+ ? ""
2292
+ : String(responseRecord.pageRange ?? responseData.pageRange ?? "");
844
2293
  if (props.cardManagement) {
845
2294
  await loadVisibleUserCards(users.value);
846
2295
  }
847
2296
  } catch (error) {
2297
+ if (sequence !== loadUsersSequence.value) return;
848
2298
  console.error("Unable to load HID reader users:", error);
849
2299
  showToast("Unable to load HID users from reader.", "error");
850
2300
  users.value = [];
@@ -855,11 +2305,12 @@ async function loadUsers() {
855
2305
  pages.value = 1;
856
2306
  serverPageRange.value = "";
857
2307
  } finally {
858
- loading.value = false;
2308
+ // Only the newest load owns the spinner; an older one finishing must not
2309
+ // clear it while that newer one is still running.
2310
+ if (sequence === loadUsersSequence.value) loading.value = false;
859
2311
  }
860
2312
  }
861
2313
 
862
-
863
2314
  function resetForm() {
864
2315
  form.reader = selectedReaderId.value || readers.value[0]?._id || "";
865
2316
  form.block = "";
@@ -870,6 +2321,10 @@ function resetForm() {
870
2321
  form.registration = "";
871
2322
  form.isAdministrator = false;
872
2323
  form.accessPin = "";
2324
+ form.password = "";
2325
+ passwordSet.value = false;
2326
+ showPassword.value = false;
2327
+ removePasswordRequested.value = false;
873
2328
  pinEnrolled.value = false;
874
2329
  showPin.value = false;
875
2330
  removePinRequested.value = false;
@@ -877,34 +2332,201 @@ function resetForm() {
877
2332
  selectedPhotoFile.value = null;
878
2333
  removePhotoRequested.value = false;
879
2334
  facialEnrolled.value = false;
880
- form.subjectCategory = "resident";
2335
+ // The type the operator last enrolled, not a hard "resident": somebody adding
2336
+ // a shift of guards should not re-pick Member for each one.
2337
+ form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
881
2338
  form.subjectId = "";
2339
+ form.subjectRole = "";
2340
+ residentConflict.value = null;
2341
+ checkingResident.value = false;
882
2342
  }
883
2343
 
884
- async function loadSubjectCandidates() {
885
- const readerId = form.reader || selectedReaderId.value;
886
- if (!readerId || !orgId.value) {
887
- permissionCandidates.value = [];
888
- return;
2344
+ /** Surface what the API actually said - a bare "unable to load" hides the
2345
+ * difference between a permission refusal, a bad id and a server fault. */
2346
+ function apiReason(error: unknown) {
2347
+ const record = toRecord(error);
2348
+ const data = toRecord(record.data);
2349
+ return (
2350
+ toText(data.message) ||
2351
+ toText(record.message) ||
2352
+ toText(record.statusMessage)
2353
+ );
2354
+ }
2355
+
2356
+ // Called when the Block select is first touched, not when the dialog opens.
2357
+ async function ensureOccupancy() {
2358
+ if (!props.site || occupancyLoaded.value || loadingOccupancy.value) return;
2359
+ loadingOccupancy.value = true;
2360
+ try {
2361
+ const response = toRecord(
2362
+ await getOccupiedStructure(props.site, {
2363
+ type: OCCUPANT_TYPES,
2364
+ status: "active",
2365
+ })
2366
+ );
2367
+ const data = toRecord(response.data);
2368
+ const blocks = response.blocks ?? data.blocks ?? [];
2369
+ occupiedBlocks.value = (
2370
+ Array.isArray(blocks) ? blocks : []
2371
+ ) as OccupiedBlock[];
2372
+ occupancyLoaded.value = true;
2373
+ } catch (error: unknown) {
2374
+ console.error("Unable to load occupied units:", error);
2375
+ occupiedBlocks.value = [];
2376
+ const reason = apiReason(error);
2377
+ showToast(
2378
+ reason
2379
+ ? `Unable to load units: ${reason}`
2380
+ : "Unable to load units for this site.",
2381
+ "error"
2382
+ );
2383
+ } finally {
2384
+ loadingOccupancy.value = false;
889
2385
  }
890
- loadingSubjects.value = true;
2386
+ }
2387
+
2388
+ async function loadUnitPeople() {
2389
+ unitPeople.value = [];
2390
+ if (!form.unit) return;
2391
+ loadingUnitPeople.value = true;
891
2392
  try {
892
- const response = await getPermissionCandidates(props.site, {
893
- orgId: orgId.value,
894
- readerId,
895
- category: form.subjectCategory,
896
- page: 1,
897
- limit: 500,
2393
+ const response = await getPeopleByUnit(form.unit, {
2394
+ status: "active",
2395
+ type: OCCUPANT_TYPES,
898
2396
  });
899
- permissionCandidates.value = response.items ?? response.data?.items ?? [];
2397
+ const record = toRecord(response);
2398
+ const rows = Array.isArray(response)
2399
+ ? response
2400
+ : record.data ?? record.items ?? [];
2401
+ unitPeople.value = (Array.isArray(rows) ? rows : []) as UnitPerson[];
2402
+ } catch (error: unknown) {
2403
+ console.error("Unable to load residents for the unit:", error);
2404
+ const reason = apiReason(error);
2405
+ showToast(
2406
+ reason
2407
+ ? `Unable to load residents: ${reason}`
2408
+ : "Unable to load residents for this unit.",
2409
+ "error"
2410
+ );
900
2411
  } finally {
901
- loadingSubjects.value = false;
2412
+ loadingUnitPeople.value = false;
902
2413
  }
903
2414
  }
904
2415
 
905
- async function onSubjectCategoryChanged() {
2416
+ function onBlockChanged() {
2417
+ form.level = "";
2418
+ form.unit = "";
2419
+ unitPeople.value = [];
2420
+ clearResidentSelection();
2421
+ }
2422
+
2423
+ // Wrapper `@click`/`@focusin` on the Block select: load once, on first touch.
2424
+ async function onBlockOpened() {
2425
+ await ensureOccupancy();
2426
+ }
2427
+
2428
+ function onLevelChanged() {
2429
+ form.unit = "";
2430
+ unitPeople.value = [];
2431
+ clearResidentSelection();
2432
+ }
2433
+
2434
+ async function onUnitChanged() {
2435
+ clearResidentSelection();
2436
+ await loadUnitPeople();
2437
+ }
2438
+
2439
+ function clearResidentSelection() {
906
2440
  form.subjectId = "";
907
- await loadSubjectCandidates();
2441
+ residentConflict.value = null;
2442
+ // Name only ever mirrors a chosen resident, so it clears with the choice.
2443
+ if (!selectedUser.value) form.name = "";
2444
+ }
2445
+
2446
+ // The auto-fill the whole cascade exists for.
2447
+ async function onResidentChanged() {
2448
+ const person = unitPeople.value.find(
2449
+ (item) => String(item._id) === String(form.subjectId)
2450
+ );
2451
+ form.name = String(person?.name ?? "");
2452
+ await checkSubjectEnrollment();
2453
+ }
2454
+
2455
+ /**
2456
+ * The HID user this resident already holds on the selected reader, or null.
2457
+ *
2458
+ * `subject`, not `person`. The same resident is stored under different ids
2459
+ * depending on which screen wrote the row - the permissions screen keys on
2460
+ * their user account, this screen on the record the picker offered - so a
2461
+ * filter on one field alone reports "not enrolled" for somebody who plainly is.
2462
+ * `subject` follows the record to its account and matches every link field,
2463
+ * which is exactly what the server does before it accepts an enrollment.
2464
+ *
2465
+ * Reader-scoped by the endpoint itself.
2466
+ */
2467
+ async function findResidentIdentity(readerId: string, personId: string) {
2468
+ const response = await getIdentities(readerId, {
2469
+ page: 1,
2470
+ // A resident should hold at most one, but ask for a few: on the edit screen
2471
+ // the first row back can be the very identity being edited, and skipping it
2472
+ // must not hide a real second one behind it.
2473
+ limit: 10,
2474
+ subject: personId,
2475
+ });
2476
+ const responseRecord = toRecord(response);
2477
+ const responseData = toRecord(responseRecord.data);
2478
+ const responseItems =
2479
+ responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
2480
+ const identities = (Array.isArray(responseItems) ? responseItems : []).map(
2481
+ toHidUser
2482
+ );
2483
+ const editingId = String(selectedUser.value?._id ?? "");
2484
+
2485
+ return (
2486
+ identities.find(
2487
+ (identity) => !editingId || String(identity._id ?? "") !== editingId
2488
+ ) ?? null
2489
+ );
2490
+ }
2491
+
2492
+ /**
2493
+ * Sets `residentConflict` for the currently selected resident.
2494
+ *
2495
+ * Fails OPEN: if the lookup itself errors the operator is not blocked, because
2496
+ * the server refuses the duplicate anyway. This is a courtesy check that keeps
2497
+ * a doomed enrollment off the physical reader, not the guard itself.
2498
+ */
2499
+ /**
2500
+ * Is this subject already enrolled on this reader?
2501
+ *
2502
+ * Category-agnostic, since staff can be enrolled too. `findResidentIdentity`
2503
+ * queries the server's `subject` parameter, which is matched against every link
2504
+ * field - `person`, `member`, `serviceProvider`, `user` - so a member needs no
2505
+ * separate lookup. The server refuses a duplicate either way; this only puts the
2506
+ * refusal in front of the operator before the device is touched.
2507
+ */
2508
+ async function checkSubjectEnrollment() {
2509
+ residentConflict.value = null;
2510
+ const readerId =
2511
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
2512
+ if (!readerId || !form.subjectId) return null;
2513
+
2514
+ checkingResident.value = true;
2515
+ try {
2516
+ residentConflict.value = await findResidentIdentity(
2517
+ readerId,
2518
+ form.subjectId
2519
+ );
2520
+ return residentConflict.value;
2521
+ } catch (error: unknown) {
2522
+ console.error(
2523
+ "Unable to check whether this resident is already enrolled:",
2524
+ error
2525
+ );
2526
+ return null;
2527
+ } finally {
2528
+ checkingResident.value = false;
2529
+ }
908
2530
  }
909
2531
 
910
2532
  async function openEnroll() {
@@ -916,8 +2538,16 @@ async function openEnroll() {
916
2538
  ]);
917
2539
  form.hidUserId = hidUserId;
918
2540
  form.registration = registration;
919
- await loadSubjectCandidates();
920
2541
  formDialog.value = true;
2542
+
2543
+ // `resetForm` restores the remembered type, so the dialog can open straight
2544
+ // onto Member with an empty list. Loaded after the dialog is up, not before:
2545
+ // the request must not delay it, and the picker carries its own loading
2546
+ // placeholder. Fresh each opening, because staff change between them.
2547
+ resetMemberCandidates();
2548
+ resetProviderCandidates();
2549
+ if (isMemberSubject.value) await loadMemberCandidates();
2550
+ if (isProviderSubject.value) await loadProviderCandidates();
921
2551
  }
922
2552
 
923
2553
  async function openEdit(user: HidUser) {
@@ -931,31 +2561,59 @@ async function openEdit(user: HidUser) {
931
2561
  form.registration = user.registration ?? "";
932
2562
  form.isAdministrator = hasAdministratorRule(user);
933
2563
  form.accessPin = "";
2564
+ form.password = "";
2565
+ showPassword.value = false;
2566
+ removePasswordRequested.value = false;
2567
+ passwordSet.value = Boolean(user.metadata?.passwordSet);
934
2568
  pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
935
2569
  removePinRequested.value = false;
936
2570
  form.photoPreview = "";
937
2571
  facialEnrolled.value = hasFacialEnrollment(user);
2572
+ /*
2573
+ * Read in the order `createIdentity` validates the links, with one extra
2574
+ * test. A `member` link is how BOTH a property-management member and a
2575
+ * service provider's own employee are stored - the person's membership at
2576
+ * this site - so the identity's `type` is what tells them apart. Without
2577
+ * that test a contractor's edit would be headed "Member".
2578
+ */
938
2579
  form.subjectCategory = user.person
939
2580
  ? "resident"
940
2581
  : user.serviceProvider
941
- ? "service_provider"
942
- : "property_management";
943
- form.subjectId = String(user.person || user.member || user.serviceProvider || "");
944
- await loadSubjectCandidates();
2582
+ ? "service_provider"
2583
+ : user.member && String(user.type ?? "") === "contractor"
2584
+ ? "service_provider_staff"
2585
+ : "property_management";
2586
+ form.subjectId = String(
2587
+ user.person || user.member || user.serviceProvider || ""
2588
+ );
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) {