@7365admin1/layer-common 4.82.0 → 4.82.1-staging.469

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