@7365admin1/layer-common 4.72.0 → 4.73.1-staging.452

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 (54) hide show
  1. package/CHANGELOG.md +10 -4
  2. package/assets/css/primitives.css +43 -0
  3. package/components/AccessCardPreviewDialog.vue +286 -50
  4. package/components/AppSelect.vue +77 -3
  5. package/components/BuildingUnitFormEdit.vue +6 -0
  6. package/components/BuildingUnitManagement.vue +298 -0
  7. package/components/Dialog/UpdateMoreAction.vue +11 -1
  8. package/components/DocumentForm.vue +407 -112
  9. package/components/DocumentManagement.vue +213 -12
  10. package/components/Facility/BookingSetup.vue +63 -0
  11. package/components/HidAccessLogDashboard.vue +74 -17
  12. package/components/HidAccessPermissions.vue +424 -0
  13. package/components/HidIntercomManagement.vue +183 -14
  14. package/components/HidProfileQrCode.vue +332 -0
  15. package/components/HidQrCodeConfiguration.vue +17 -195
  16. package/components/HidReaderManagement.vue +398 -0
  17. package/components/HidReaderUserRoster.vue +44 -3
  18. package/components/HidUserEnrollment.vue +845 -155
  19. package/components/InventoryHistoryDialog.vue +205 -0
  20. package/components/InventoryMain.vue +23 -2
  21. package/components/InventoryMyStockTab.vue +63 -8
  22. package/components/InventoryPhotoThumbs.vue +67 -0
  23. package/components/InventoryReportsTab.vue +154 -28
  24. package/components/InventoryUsageDialog.vue +119 -28
  25. package/components/InvitationClientForm.vue +19 -1
  26. package/components/VisitorForm.vue +12 -0
  27. package/components/VisitorManagement.vue +267 -5
  28. package/composables/useDocument.ts +7 -0
  29. package/composables/useFacility.ts +11 -0
  30. package/composables/useHidAmico.ts +165 -0
  31. package/composables/useHidNavigation.ts +25 -1
  32. package/composables/useHidReaderSelection.ts +50 -0
  33. package/composables/useInventory.ts +11 -3
  34. package/composables/useMember.ts +12 -0
  35. package/composables/usePeople.ts +19 -0
  36. package/composables/useSecurityPermission.ts +20 -0
  37. package/composables/useServiceProvider.ts +33 -2
  38. package/composables/useSiteCategory.ts +45 -0
  39. package/package.json +1 -1
  40. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  41. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  42. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  43. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  44. package/types/document.d.ts +14 -0
  45. package/types/facility.d.ts +4 -0
  46. package/types/inventory.d.ts +18 -2
  47. package/types/member.d.ts +4 -0
  48. package/types/people.d.ts +28 -1
  49. package/types/service-provider.d.ts +5 -0
  50. package/utils/hid-permission-assignments.ts +190 -0
  51. package/utils/hid-reader-selection.ts +52 -0
  52. package/utils/inventory-usage.ts +94 -0
  53. package/utils/occupancy-role.ts +109 -0
  54. package/utils/service-type.ts +30 -0
@@ -67,8 +67,8 @@
67
67
  <AppField
68
68
  v-model="search"
69
69
  search
70
- placeholder="Search"
71
- @keyup.enter="reloadUsersFromFirstPage"
70
+ placeholder="Search name, UID or registration no."
71
+ @keyup.enter="onSearchSubmit"
72
72
  />
73
73
  <AppSelect
74
74
  v-if="!props.cardManagement"
@@ -96,6 +96,12 @@
96
96
  <span class="app-cell--num">{{ formatHidUid(item.hidUserId) || "N/A" }}</span>
97
97
  </template>
98
98
 
99
+ <!-- The reader allocates this alongside the UID. A row the reader holds
100
+ that we never enrolled has none, so it is not always present. -->
101
+ <template #[`item.registration`]="{ item }">
102
+ <span class="app-cell--num">{{ item.registration || "N/A" }}</span>
103
+ </template>
104
+
99
105
  <!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
100
106
  the same tones this screen's private `getMappingStatusClass` gave
101
107
  them, so the map is one list rather than a fourth private copy. -->
@@ -166,12 +172,12 @@
166
172
  </TableMain>
167
173
 
168
174
  <v-dialog v-model="formDialog" max-width="420" persistent>
169
- <v-card rounded="lg">
170
- <v-card-title class="small-title">
175
+ <v-card class="screen-modal">
176
+ <v-card-title>
171
177
  {{ selectedUser ? "Edit User Information" : "Enroll" }}
172
178
  </v-card-title>
173
179
 
174
- <v-card-text>
180
+ <v-card-text class="screen-modal__body">
175
181
  <div class="photo-wrap">
176
182
  <v-menu>
177
183
  <template #activator="{ props: menuProps }">
@@ -213,6 +219,15 @@
213
219
  />
214
220
  </v-list>
215
221
  </v-menu>
222
+ <!-- Sits under the button because it describes what the button
223
+ is reporting: the reader holds a face, and this is its
224
+ reference. Nothing to show while enrolling - the value does not
225
+ exist until the reader has accepted a photo. -->
226
+ <p v-if="editFacialData" class="photo-facial">
227
+ <span class="photo-facial__label">Facial Data</span>
228
+ <span class="photo-facial__value">{{ editFacialData }}</span>
229
+ </p>
230
+
216
231
  <input
217
232
  ref="photoInput"
218
233
  type="file"
@@ -222,29 +237,96 @@
222
237
  />
223
238
  </div>
224
239
 
225
- <div class="field-label">Name <span>*</span></div>
240
+ <!-- Block / Level / Unit / Resident. Enrolment only.
241
+ Enrollment is resident-only and the resident must already exist
242
+ in iService365 - you pick a person, you do not type one. Name is
243
+ therefore a mirror of that record, never an input, so the reader
244
+ can never end up holding a name the app does not know.
245
+ Every option is derived from the residents themselves, so a
246
+ block, level or unit only appears when somebody lives there.
247
+ The list loads on first touch of Block, not on dialog open.
248
+ Enrolment only, with no exception. Editing does not move
249
+ anybody: the record already carries its block, level and unit,
250
+ `openEdit` loads them into the form and `saveUser` writes them
251
+ back untouched, so the location survives an edit without being
252
+ asked for. A row that never had one keeps not having one; that
253
+ is not a reason to make somebody fill in a location before they
254
+ can change a PIN. -->
255
+ <template v-if="!selectedUser">
256
+ <div class="field-label">Block <span>*</span></div>
257
+ <!-- The listener sits on a wrapper, NOT on AppSelect: AppSelect
258
+ spreads `$attrs` over its v-menu activator AFTER the activator's
259
+ own bindings, so an `@click` here would replace the handler that
260
+ opens the dropdown. `focusin` covers keyboard users, and both
261
+ are idempotent - `ensureOccupancy()` fetches once.
262
+ AppSelect has no `loading` prop either; the placeholder carries
263
+ the loading and empty states instead. -->
264
+ <div @click="onBlockOpened" @focusin="onBlockOpened">
265
+ <AppSelect
266
+ v-model="form.block"
267
+ :items="blockOptions"
268
+ :placeholder="blockPlaceholder"
269
+ klass="mb-3"
270
+ @update:model-value="onBlockChanged"
271
+ />
272
+ </div>
273
+
274
+ <div class="field-label">Level <span>*</span></div>
275
+ <AppSelect
276
+ v-model="form.level"
277
+ :items="levelOptions"
278
+ :disabled="!form.block"
279
+ placeholder="Select level"
280
+ klass="mb-3"
281
+ @update:model-value="onLevelChanged"
282
+ />
283
+
284
+ <div class="field-label">Unit <span>*</span></div>
285
+ <AppSelect
286
+ v-model="form.unit"
287
+ :items="unitOptions"
288
+ :disabled="!form.level"
289
+ placeholder="Select unit"
290
+ klass="mb-3"
291
+ @update:model-value="onUnitChanged"
292
+ />
293
+ </template>
294
+
295
+ <!-- Drawn only where it can offer something: while enrolling, or
296
+ while editing a record whose stored unit gives it a list. A row
297
+ with no unit has no candidates to show, and the dialog is then
298
+ purely the reader's own credentials - photo, PIN, password -
299
+ which is exactly the edit that must not be blocked. -->
300
+ <template v-if="!selectedUser || form.unit">
301
+ <div class="field-label">Resident <span>*</span></div>
302
+ <AppSelect
303
+ v-model="form.subjectId"
304
+ :items="subjectOptions"
305
+ :disabled="!form.unit"
306
+ :placeholder="unitResidentPlaceholder"
307
+ klass="mb-3"
308
+ @update:model-value="onResidentChanged"
309
+ />
310
+ </template>
311
+ <!-- The duplicate check. It is reader-scoped: this resident holding a
312
+ HID user on another gate is normal and says nothing here. -->
313
+ <p v-if="checkingResident" class="field-hint">Checking enrollment...</p>
314
+ <p v-else-if="residentConflict" class="field-hint field-hint--error mb-3">
315
+ {{ residentConflictMessage }}
316
+ </p>
317
+ <p v-else-if="unitResidentNote" class="field-hint mb-3">
318
+ {{ unitResidentNote }}
319
+ </p>
320
+
321
+ <!-- Read-only: it restates the chosen resident, it does not collect
322
+ anything. `selectedUser` is the edit case, where the name is
323
+ whatever the reader already holds. -->
324
+ <div class="field-label">Name</div>
226
325
  <AppField
227
326
  v-model="form.name"
228
327
  aria-label="Name"
229
- placeholder="Enter name"
230
- klass="mb-3"
231
- />
232
-
233
- <div class="field-label">Link To <span>*</span></div>
234
- <AppSelect
235
- v-model="form.subjectCategory"
236
- :items="subjectCategoryOptions"
237
- placeholder="Select person type"
238
- klass="mb-3"
239
- @update:model-value="onSubjectCategoryChanged"
240
- />
241
-
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"
328
+ placeholder="Select a resident above"
329
+ readonly
248
330
  klass="mb-3"
249
331
  />
250
332
 
@@ -256,11 +338,11 @@
256
338
  v-model="form.registration"
257
339
  aria-label="Registration No."
258
340
  placeholder="Enter registration no."
259
- :readonly="!selectedUser"
341
+ readonly
260
342
  klass="mb-3"
261
343
  />
262
344
 
263
- <div class="field-label">Access PIN (optional)</div>
345
+ <div class="field-label">PIN (optional)</div>
264
346
  <!--
265
347
  The handoff draws no password field anywhere (the sign-in screens
266
348
  are the owner's own), so the reveal toggle is this product's, not
@@ -270,8 +352,8 @@
270
352
  -->
271
353
  <AppField
272
354
  v-model="form.accessPin"
273
- aria-label="Access PIN"
274
- placeholder="Enter numeric access PIN"
355
+ aria-label="PIN"
356
+ placeholder="Enter pin"
275
357
  inputmode="numeric"
276
358
  autocomplete="new-password"
277
359
  :type="showPin ? 'text' : 'password'"
@@ -280,7 +362,7 @@
280
362
  <button
281
363
  type="button"
282
364
  class="app-field__trailing"
283
- :aria-label="showPin ? 'Hide access PIN' : 'Show access PIN'"
365
+ :aria-label="showPin ? 'Hide PIN' : 'Show PIN'"
284
366
  @click="showPin = !showPin"
285
367
  >
286
368
  <v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
@@ -295,28 +377,62 @@
295
377
  <v-checkbox
296
378
  v-if="selectedUser && pinEnrolled"
297
379
  v-model="removePinRequested"
298
- label="Remove current access PIN"
380
+ label="Remove current PIN"
381
+ density="compact"
382
+ hide-details
383
+ />
384
+
385
+ <!-- Password: the reader stores only a hash plus its salt, so there
386
+ is nothing to read back and the field is always empty on open.
387
+ Same reveal toggle as the PIN above. -->
388
+ <div class="field-label mt-3">Password (optional)</div>
389
+ <AppField
390
+ v-model="form.password"
391
+ aria-label="Password"
392
+ :placeholder="selectedUser && passwordSet ? 'Enter a new password' : 'Enter password'"
393
+ autocomplete="new-password"
394
+ :type="showPassword ? 'text' : 'password'"
395
+ >
396
+ <template #trailing>
397
+ <button
398
+ type="button"
399
+ class="app-field__trailing"
400
+ :aria-label="showPassword ? 'Hide password' : 'Show password'"
401
+ @click="showPassword = !showPassword"
402
+ >
403
+ <v-icon :icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
404
+ </button>
405
+ </template>
406
+ </AppField>
407
+ <p class="field-hint">
408
+ {{ selectedUser && passwordSet ? "A password is set. Leave blank to keep it." : "Used with the user ID on the reader keypad." }}
409
+ </p>
410
+ <v-checkbox
411
+ v-if="selectedUser && passwordSet"
412
+ v-model="removePasswordRequested"
413
+ label="Remove current password"
299
414
  density="compact"
300
415
  hide-details
301
416
  />
302
417
  </v-card-text>
303
418
 
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">
419
+ <!-- The shared modal footer: `screen-btn-ghost` + `screen-btn-primary`
420
+ on `v-btn`, the same pair every other module's dialog uses
421
+ (`assets/css/screens.css`). `v-btn` stays because the submit needs
422
+ `:loading`, which `AppButton` does not carry. -->
423
+ <v-card-actions class="screen-modal__footer">
308
424
  <v-btn
309
- class="text-none action-cancel"
425
+ class="text-none screen-btn-ghost"
310
426
  variant="flat"
311
- height="44"
312
427
  @click="formDialog = false"
313
- >Cancel</v-btn
314
428
  >
429
+ Cancel
430
+ </v-btn>
315
431
  <v-btn
316
- class="text-none action-submit"
432
+ class="text-none screen-btn-primary"
317
433
  variant="flat"
318
- height="44"
319
434
  :loading="saving"
435
+ :disabled="Boolean(residentConflict) || checkingResident"
320
436
  @click="saveUser"
321
437
  >
322
438
  Save
@@ -328,9 +444,9 @@
328
444
  <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
329
445
 
330
446
  <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>
447
+ <v-card class="screen-modal">
448
+ <v-card-title>User Information</v-card-title>
449
+ <v-card-text class="screen-modal__body">
334
450
  <div class="detail-grid">
335
451
  <span>Name</span><strong>{{ getName(selectedUser) }}</strong>
336
452
  <span>User ID</span
@@ -347,21 +463,22 @@
347
463
  ><strong>{{
348
464
  selectedUser ? getMappingStatus(selectedUser) : "N/A"
349
465
  }}</strong>
350
- <span>Access PIN</span
466
+ <span>PIN</span
351
467
  ><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
468
+ <span>Password</span
469
+ ><strong>{{ selectedUser?.metadata?.passwordSet ? "Configured" : "N/A" }}</strong>
352
470
  </div>
353
471
  </v-card-text>
354
- <v-card-actions>
355
- <v-spacer />
472
+ <v-card-actions class="screen-modal__footer">
356
473
  <AppButton variant="ghost" @click="viewDialog = false">Close</AppButton>
357
474
  </v-card-actions>
358
475
  </v-card>
359
476
  </v-dialog>
360
477
 
361
478
  <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>
479
+ <v-card class="screen-modal">
480
+ <v-card-title>Physical HID Cards</v-card-title>
481
+ <v-card-text class="screen-modal__body">
365
482
  <p class="hid-card-user">
366
483
  {{ getName(cardUser) }}
367
484
  <span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
@@ -452,6 +569,11 @@
452
569
  Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
453
570
  </v-alert>
454
571
 
572
+ <!-- Reading the card at the reader is the primary way in: it is the
573
+ only one that does not require knowing the facility code and card
574
+ number up front, which are printed on nothing the operator holds.
575
+ Assigning by hand stays for the cards whose values are already
576
+ known. -->
455
577
  <div class="hid-card-actions mt-4">
456
578
  <AppButton
457
579
  variant="secondary"
@@ -470,14 +592,16 @@
470
592
  <AppButton
471
593
  v-if="enrollingCard"
472
594
  variant="ghost"
595
+ :disabled="cancellingCard"
473
596
  @click="cancelPhysicalCardEnrollment"
474
597
  >
475
- Cancel Reading
598
+ {{ cancellingCard ? "Cancelling…" : "Cancel Reading" }}
476
599
  </AppButton>
477
600
  </div>
478
601
  </v-card-text>
479
- <v-card-actions>
480
- <v-spacer />
602
+ <v-card-actions class="screen-modal__footer">
603
+ <!-- Closing mid-read would leave the reader listening with nothing
604
+ to receive the card it reads. -->
481
605
  <AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
482
606
  Close
483
607
  </AppButton>
@@ -486,31 +610,31 @@
486
610
  </v-dialog>
487
611
 
488
612
  <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">
613
+ <v-card class="screen-modal">
614
+ <v-card-title>Delete</v-card-title>
615
+ <v-card-text class="screen-modal__body text-body-2">
492
616
  Are you sure you want to permanently delete this HID user?
493
617
  <div class="text-caption text-medium-emphasis mt-2">
494
618
  This action will remove the user from the HID reader and mark the
495
619
  identity as deleted.
496
620
  </div>
497
621
  </v-card-text>
498
- <v-card-actions class="pa-0">
622
+ <v-card-actions class="screen-modal__footer">
499
623
  <v-btn
500
- class="text-none action-cancel"
624
+ class="text-none screen-btn-ghost"
501
625
  variant="flat"
502
- height="44"
503
626
  @click="deleteDialog = false"
504
- >Cancel</v-btn
505
627
  >
628
+ Cancel
629
+ </v-btn>
506
630
  <v-btn
507
- class="text-none action-submit"
631
+ class="text-none screen-btn-primary"
508
632
  variant="flat"
509
- height="44"
510
633
  :loading="deleting"
511
634
  @click="deleteUser"
512
- >Delete</v-btn
513
635
  >
636
+ Delete
637
+ </v-btn>
514
638
  </v-card-actions>
515
639
  </v-card>
516
640
  </v-dialog>
@@ -527,6 +651,13 @@ const props = defineProps({
527
651
  type: String,
528
652
  required: true,
529
653
  },
654
+ // Which organisation the access this enrolment grants belongs to. Saving now
655
+ // assigns the person to the reader, and the assignment is stored per
656
+ // organisation - see `schemaCreateHidAmicoIdentity`.
657
+ org: {
658
+ type: String,
659
+ default: "",
660
+ },
530
661
  cardManagement: {
531
662
  type: Boolean,
532
663
  default: false,
@@ -547,11 +678,13 @@ const {
547
678
  getUserPinStatus,
548
679
  setUserPin,
549
680
  deleteUserPin,
681
+ getUserPasswordStatus,
682
+ setUserPassword,
683
+ deleteUserPassword,
550
684
  runObjectOperation,
551
685
  createIdentity,
552
686
  updateIdentity,
553
687
  deleteIdentity,
554
- getPermissionCandidates,
555
688
  } = useHidAmico();
556
689
 
557
690
  type HidMetadata = Record<string, unknown> & {
@@ -566,6 +699,7 @@ type HidMetadata = Record<string, unknown> & {
566
699
  lastAccessAt?: string;
567
700
  facialScores?: Record<string, unknown>;
568
701
  pinEnrolled?: boolean;
702
+ passwordSet?: boolean;
569
703
  hidPhysicalCards?: HidCard[];
570
704
  };
571
705
 
@@ -616,6 +750,7 @@ type HidIdentityPayload = Record<string, unknown> & {
616
750
  const readers = ref<HidReader[]>([]);
617
751
  const users = ref<HidUser[]>([]);
618
752
  const administratorUserIds = ref<Set<number>>(new Set());
753
+ const { selectHidReader, resolveForSite } = useHidReaderSelection();
619
754
  const selectedReaderId = ref("");
620
755
  const search = ref("");
621
756
  const status = ref("");
@@ -639,7 +774,14 @@ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
639
774
  const loadingCards = ref(false);
640
775
  const savingCard = ref(false);
641
776
  const enrollingCard = ref(false);
777
+ /*
778
+ * Cancelling makes the in-flight enrol REJECT, and that rejection is expected
779
+ * rather than a failure. Without this flag the cancel raises an error toast on
780
+ * top of the one confirming it, which reads as the cancel having gone wrong.
781
+ */
642
782
  const cardEnrollmentCancelled = ref(false);
783
+ /** The cancel's own round trip to the reader — a second or two, not the enrol's. */
784
+ const cancellingCard = ref(false);
643
785
  const deletingCardId = ref("");
644
786
  const showPin = ref(false);
645
787
  const pinEnrolled = ref(false);
@@ -661,13 +803,61 @@ function hasFacialEnrollment(user?: HidUser | null) {
661
803
  user?.metadata?.imageTimestamp
662
804
  );
663
805
  }
806
+
807
+ /**
808
+ * The reader's own reference for the enrolled face, shown while editing.
809
+ *
810
+ * We hold neither the image nor the biometric template - the reader does - so
811
+ * this value is the only handle on the enrollment there is. `getFacialData` is
812
+ * the roster's Facial Data column, reused here so the dialog and the table
813
+ * cannot disagree; it answers "N/A" when there is nothing, which is the case
814
+ * this must not draw a row for.
815
+ */
816
+ const editFacialData = computed(() => {
817
+ if (!selectedUser.value) return "";
818
+ const value = String(getFacialData(selectedUser.value) ?? "").trim();
819
+ return value && value !== "N/A" ? value : "";
820
+ });
664
821
  const removePinRequested = ref(false);
822
+ const passwordSet = ref(false);
823
+ const showPassword = ref(false);
824
+ const removePasswordRequested = ref(false);
665
825
  const snackbar = reactive({
666
826
  show: false,
667
827
  color: "success",
668
828
  message: "",
669
829
  });
670
830
 
831
+ type OccupiedUnit = { _id: string; name: string; people: number };
832
+ type OccupiedLevel = { _id: string; name: string; units?: OccupiedUnit[] };
833
+ type OccupiedBlock = {
834
+ _id: string;
835
+ name: string;
836
+ block: number | null;
837
+ levels?: OccupiedLevel[];
838
+ };
839
+ type UnitPerson = {
840
+ _id: string;
841
+ name?: string;
842
+ unitName?: string;
843
+ /**
844
+ * The resident's app account, and the reason this field is read here.
845
+ *
846
+ * A HID identity is refused unless its subject is a valid permission subject
847
+ * at the site, and for a resident that means a `site.people` row WITH a
848
+ * linked user - `assertPermissionSubject` in `iservice365-core` filters on
849
+ * exactly this field. The permission model has no way to represent anyone
850
+ * else: `resolvePermissionUserBindings` binds reader access by user id and
851
+ * drops every row without one, so a resident enrolled without an account
852
+ * would end up on the reader holding no access rules at all.
853
+ *
854
+ * The picker this replaced was fed by that same server-side function, so it
855
+ * could never offer an unenrollable resident. The unit cascade asks People
856
+ * Management instead, which has no such filter - hence this one.
857
+ */
858
+ user?: string | null;
859
+ };
860
+
671
861
  const form = reactive({
672
862
  reader: "",
673
863
  block: "",
@@ -678,6 +868,7 @@ const form = reactive({
678
868
  registration: "",
679
869
  isAdministrator: false,
680
870
  accessPin: "",
871
+ password: "",
681
872
  photoPreview: "",
682
873
  subjectCategory: "resident" as THidPermissionCategory,
683
874
  subjectId: "",
@@ -685,18 +876,131 @@ const form = reactive({
685
876
 
686
877
  const route = useRoute();
687
878
  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,
879
+
880
+ // The cascade runs on People Management's own endpoints, the same pair the
881
+ // resident forms use:
882
+ // GET /api/people/site/:site/occupied-structure - blocks/levels/units that
883
+ // actually have an occupant, so the picker cannot offer an empty unit
884
+ // GET /api/people/unit/:unit - who is in the chosen one
885
+ // Both are fetched on FIRST USE, never when the dialog opens.
886
+ const { getOccupiedStructure, getPeopleByUnit } = usePeople();
887
+
888
+ // `resident,tenant` matches the occupancy endpoint's own default, so the units
889
+ // it offers and the people this returns are the same set. Asking the two
890
+ // halves different questions is how a unit ends up offered but empty.
891
+ const OCCUPANT_TYPES = "resident,tenant";
892
+
893
+ const occupiedBlocks = ref<OccupiedBlock[]>([]);
894
+ const occupancyLoaded = ref(false);
895
+ const loadingOccupancy = ref(false);
896
+ const unitPeople = ref<UnitPerson[]>([]);
897
+ const loadingUnitPeople = ref(false);
898
+
899
+ // "Is this resident already enrolled on THIS reader?" - the reader keeps its own
900
+ // user table, so the same person at a different gate is normal and does not
901
+ // count. Held here rather than derived, because it is the answer to a request.
902
+ //
903
+ // The server refuses the duplicate as well (`createIdentity` in
904
+ // `iservice365-core`), but by the time it answers, `saveUser` has already
905
+ // created a user on the physical device and has to roll it back. Asking here
906
+ // keeps that off the reader entirely.
907
+ const residentConflict = ref<HidUser | null>(null);
908
+ const checkingResident = ref(false);
909
+
910
+ // One spelling of a block, used by the picker and by the label stamped onto the
911
+ // record. Two copies of this expression is how the dropdown and the Unit column
912
+ // drift apart.
913
+ function formatBlockTitle(block?: OccupiedBlock | null) {
914
+ if (!block) return "";
915
+ return block.name
916
+ ? `Block ${block.block} (${block.name})`
917
+ : `Block ${block.block ?? ""}`.trim();
918
+ }
919
+
920
+ const blockOptions = computed(() => occupiedBlocks.value.map((block) => ({
921
+ title: formatBlockTitle(block),
922
+ value: String(block._id ?? ""),
698
923
  })));
699
924
 
925
+ const levelOptions = computed(() => {
926
+ const block = occupiedBlocks.value.find((item) => String(item._id ?? "") === form.block);
927
+ return (block?.levels ?? []).map((level) => ({
928
+ title: String(level.name ?? ""),
929
+ value: String(level._id ?? ""),
930
+ }));
931
+ });
932
+
933
+ const unitOptions = computed(() => {
934
+ const block = occupiedBlocks.value.find((item) => String(item._id ?? "") === form.block);
935
+ const level = (block?.levels ?? []).find((item) => String(item._id ?? "") === form.level);
936
+ return (level?.units ?? []).map((unit) => ({
937
+ title: String(unit.name ?? ""),
938
+ value: String(unit._id ?? ""),
939
+ }));
940
+ });
941
+
942
+ // Only the residents the server will actually accept - see `UnitPerson.user`.
943
+ // Offering the rest produced "The linked HID identity subject is not active at
944
+ // this site." at the very end of enrollment, after a user had already been
945
+ // written to the reader.
946
+ const enrollableUnitPeople = computed(() =>
947
+ unitPeople.value.filter((person) => Boolean(person.user)),
948
+ );
949
+
950
+ const unenrollableUnitPeopleCount = computed(
951
+ () => unitPeople.value.length - enrollableUnitPeople.value.length,
952
+ );
953
+
954
+ // `_id` here is `site.people._id`, which is exactly the `person` that
955
+ // `createIdentity` maps a HID user to.
956
+ const subjectOptions = computed(() => enrollableUnitPeople.value.map((person) => ({
957
+ title: String(person.name ?? "Resident"),
958
+ value: String(person._id ?? ""),
959
+ })));
960
+
961
+ // Says why somebody the operator can see in People Management is missing here.
962
+ // Without it the unit just looks empty and the screen looks broken.
963
+ const unitResidentNote = computed(() => {
964
+ if (!form.unit || loadingUnitPeople.value || !unenrollableUnitPeopleCount.value) return "";
965
+ const count = unenrollableUnitPeopleCount.value;
966
+ const subject = count === 1 ? "1 resident" : `${count} residents`;
967
+ const verb = count === 1 ? "is" : "are";
968
+ return `${subject} in this unit ${verb} not listed: HID enrollment needs a resident app account, and ${count === 1 ? "that person has" : "they have"} none yet.`;
969
+ });
970
+
971
+ const blockPlaceholder = computed(() => {
972
+ if (loadingOccupancy.value) return "Loading...";
973
+ if (occupancyLoaded.value && !blockOptions.value.length) {
974
+ return "No occupied units at this site";
975
+ }
976
+ return "Select block";
977
+ });
978
+
979
+ const unitResidentPlaceholder = computed(() => {
980
+ if (!form.unit) return "Select a unit first";
981
+ if (loadingUnitPeople.value) return "Loading residents...";
982
+ if (enrollableUnitPeople.value.length) return "Select a resident";
983
+ // The two empty cases are different problems with different fixes, so they
984
+ // do not share a sentence: nobody lives here, versus nobody here can be
985
+ // enrolled yet. `unitResidentNote` carries the detail for the second.
986
+ return unitPeople.value.length
987
+ ? "No resident here can be enrolled yet"
988
+ : "No residents in this unit";
989
+ });
990
+
991
+ // Names the HID user that is in the way, so the operator can go and edit it
992
+ // rather than being told only that something is wrong.
993
+ const residentConflictMessage = computed(() => {
994
+ const conflict = residentConflict.value;
995
+ if (!conflict) return "";
996
+ const label = [getName(conflict), formatHidUid(conflict.hidUserId)]
997
+ .filter(Boolean)
998
+ .join(" · ");
999
+ return label
1000
+ ? `Already enrolled on this reader as ${label}. Edit that HID user instead of enrolling again.`
1001
+ : "Already enrolled on this reader. Edit that HID user instead of enrolling again.";
1002
+ });
1003
+
700
1004
  const cardForm = reactive({
701
1005
  cardType: "pacs" as "pacs" | "csn",
702
1006
  facilityCode: "",
@@ -738,6 +1042,8 @@ const userHeaders = [
738
1042
  { title: "Name", value: "name", sortable: false },
739
1043
  { title: "Facial Data", value: "facialData", sortable: false },
740
1044
  { title: "UID", value: "hidUserId", sortable: false },
1045
+ // Reads in the same order the dialog asks for them: UID then registration.
1046
+ { title: "Registration No.", value: "registration", sortable: false },
741
1047
  { title: "Status", value: "status", sortable: false },
742
1048
  { title: "", value: "action-table", sortable: false },
743
1049
  ];
@@ -767,6 +1073,32 @@ function reloadUsersFromFirstPage() {
767
1073
  loadUsers();
768
1074
  }
769
1075
 
1076
+ /**
1077
+ * Typing is the search.
1078
+ *
1079
+ * The field used to reload on `@keyup.enter` and on nothing else, so a screen
1080
+ * that looks like every other search box did nothing at all unless you guessed
1081
+ * that Enter was required - no request left the browser.
1082
+ *
1083
+ * Debounced, because each reload is a login/load/logout round trip to a reader
1084
+ * over a VPN, not a database query. Enter still works and skips the wait.
1085
+ */
1086
+ const SEARCH_DEBOUNCE_MS = 400;
1087
+ let searchDebounce: ReturnType<typeof setTimeout> | undefined;
1088
+
1089
+ function onSearchInput() {
1090
+ clearTimeout(searchDebounce);
1091
+ searchDebounce = setTimeout(reloadUsersFromFirstPage, SEARCH_DEBOUNCE_MS);
1092
+ }
1093
+
1094
+ function onSearchSubmit() {
1095
+ clearTimeout(searchDebounce);
1096
+ reloadUsersFromFirstPage();
1097
+ }
1098
+
1099
+ watch(search, onSearchInput);
1100
+ onBeforeUnmount(() => clearTimeout(searchDebounce));
1101
+
770
1102
  const readerOptions = computed(() =>
771
1103
  readers.value.map((reader) => ({
772
1104
  title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
@@ -805,11 +1137,23 @@ async function loadReaders() {
805
1137
  readers.value = (Array.isArray(items) ? items : [])
806
1138
  .map(toHidReader)
807
1139
  .filter((reader): reader is HidReader => reader !== null);
808
- if (!selectedReaderId.value && readers.value.length) {
809
- selectedReaderId.value = readers.value[0]._id;
810
- }
1140
+ /* Shared with every other HID screen — see `useHidReaderSelection`. The
1141
+ resolver refuses a remembered reader that is not this site's, so a site
1142
+ switch or a deleted reader falls back to the first rather than querying
1143
+ an id the site does not own. */
1144
+ selectedReaderId.value = resolveForSite(props.site, readers.value);
811
1145
  }
812
1146
 
1147
+ /**
1148
+ * Discards a load whose answer arrives after a newer one started.
1149
+ *
1150
+ * Each reload is a slow round trip to a reader over a VPN, and typing now fires
1151
+ * them, so "abc" can have three in flight at once. Without this the reply to
1152
+ * "a" can land last and repopulate the table with the wrong rows. Same monotonic
1153
+ * counter `HidAccessLogDashboard` uses.
1154
+ */
1155
+ const loadUsersSequence = ref(0);
1156
+
813
1157
  async function loadUsers() {
814
1158
  if (!selectedReaderId.value) {
815
1159
  users.value = [];
@@ -819,18 +1163,54 @@ async function loadUsers() {
819
1163
  return;
820
1164
  }
821
1165
 
1166
+ const sequence = loadUsersSequence.value + 1;
1167
+ loadUsersSequence.value = sequence;
822
1168
  loading.value = true;
823
1169
  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
- });
1170
+ // `userType: "user"` is REQUIRED here, not a nicety. The reader keeps
1171
+ // visitors in the same `users` table, marked `user_type_id = 1`, and the
1172
+ // API's default is `all`. Visitor rows are written by our own visitor-QR
1173
+ // issuance and are never removed - revoke only pushes `end_time` into the
1174
+ // past - so without this filter the enrollment roster counts every visitor
1175
+ // pass ever issued and drifts further from the reader's own Users screen
1176
+ // with each one. This screen enrolls and edits real people; visitors are
1177
+ // owned by the visitor module.
1178
+ let clientFiltersVisitors = false;
1179
+ let response: unknown;
1180
+ try {
1181
+ response = await getReaderUsers(selectedReaderId.value, {
1182
+ page: page.value,
1183
+ limit: limit.value,
1184
+ search: search.value.trim(),
1185
+ status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
1186
+ userType: "user",
1187
+ });
1188
+ } catch (error: unknown) {
1189
+ if (!isUnsupportedUserTypeFilter(error)) throw error;
1190
+
1191
+ // A deployed API can lag behind this shared layer. Keep the roster
1192
+ // correct on one that has not added `userType` yet, the same way
1193
+ // HidReaderUserRoster does, rather than failing the whole screen.
1194
+ clientFiltersVisitors = true;
1195
+ response = await getReaderUsers(selectedReaderId.value, {
1196
+ page: 1,
1197
+ limit: 100,
1198
+ search: search.value.trim(),
1199
+ status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
1200
+ });
1201
+ }
830
1202
  const responseRecord = toRecord(response);
831
1203
  const responseData = toRecord(responseRecord.data);
832
1204
  const responseItems = responseRecord.items ?? responseData.items ?? [];
833
- const pageItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
1205
+ const allItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
1206
+ const matchingItems = clientFiltersVisitors
1207
+ ? allItems.filter((user) => !isVisitorUser(user))
1208
+ : allItems;
1209
+ const pageStart = (page.value - 1) * limit.value;
1210
+ const pageItems = clientFiltersVisitors
1211
+ ? matchingItems.slice(pageStart, pageStart + limit.value)
1212
+ : matchingItems;
1213
+ if (sequence !== loadUsersSequence.value) return;
834
1214
  administratorUserIds.value = new Set(
835
1215
  pageItems
836
1216
  .filter((user) => user.isAdministrator === true)
@@ -838,13 +1218,22 @@ async function loadUsers() {
838
1218
  .filter((userId: number | undefined): userId is number => Boolean(userId)),
839
1219
  );
840
1220
  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 ?? "");
1221
+ total.value = clientFiltersVisitors
1222
+ ? matchingItems.length
1223
+ : Number(responseRecord.total ?? responseData.total ?? users.value.length);
1224
+ pages.value = clientFiltersVisitors
1225
+ ? Math.max(1, Math.ceil(total.value / limit.value))
1226
+ : Number(responseRecord.pages ?? responseData.pages ?? 1);
1227
+ // The server's `pageRange` counts the unfiltered page, so it cannot be
1228
+ // trusted once this screen has done the filtering itself.
1229
+ serverPageRange.value = clientFiltersVisitors
1230
+ ? ""
1231
+ : String(responseRecord.pageRange ?? responseData.pageRange ?? "");
844
1232
  if (props.cardManagement) {
845
1233
  await loadVisibleUserCards(users.value);
846
1234
  }
847
1235
  } catch (error) {
1236
+ if (sequence !== loadUsersSequence.value) return;
848
1237
  console.error("Unable to load HID reader users:", error);
849
1238
  showToast("Unable to load HID users from reader.", "error");
850
1239
  users.value = [];
@@ -855,7 +1244,9 @@ async function loadUsers() {
855
1244
  pages.value = 1;
856
1245
  serverPageRange.value = "";
857
1246
  } finally {
858
- loading.value = false;
1247
+ // Only the newest load owns the spinner; an older one finishing must not
1248
+ // clear it while that newer one is still running.
1249
+ if (sequence === loadUsersSequence.value) loading.value = false;
859
1250
  }
860
1251
  }
861
1252
 
@@ -870,6 +1261,10 @@ function resetForm() {
870
1261
  form.registration = "";
871
1262
  form.isAdministrator = false;
872
1263
  form.accessPin = "";
1264
+ form.password = "";
1265
+ passwordSet.value = false;
1266
+ showPassword.value = false;
1267
+ removePasswordRequested.value = false;
873
1268
  pinEnrolled.value = false;
874
1269
  showPin.value = false;
875
1270
  removePinRequested.value = false;
@@ -879,32 +1274,165 @@ function resetForm() {
879
1274
  facialEnrolled.value = false;
880
1275
  form.subjectCategory = "resident";
881
1276
  form.subjectId = "";
1277
+ residentConflict.value = null;
1278
+ checkingResident.value = false;
882
1279
  }
883
1280
 
884
- async function loadSubjectCandidates() {
885
- const readerId = form.reader || selectedReaderId.value;
886
- if (!readerId || !orgId.value) {
887
- permissionCandidates.value = [];
888
- return;
1281
+
1282
+ /** Surface what the API actually said - a bare "unable to load" hides the
1283
+ * difference between a permission refusal, a bad id and a server fault. */
1284
+ function apiReason(error: unknown) {
1285
+ const record = toRecord(error);
1286
+ const data = toRecord(record.data);
1287
+ return toText(data.message) || toText(record.message) || toText(record.statusMessage);
1288
+ }
1289
+
1290
+ // Called when the Block select is first touched, not when the dialog opens.
1291
+ async function ensureOccupancy() {
1292
+ if (!props.site || occupancyLoaded.value || loadingOccupancy.value) return;
1293
+ loadingOccupancy.value = true;
1294
+ try {
1295
+ const response = toRecord(await getOccupiedStructure(props.site, {
1296
+ type: OCCUPANT_TYPES,
1297
+ status: "active",
1298
+ }));
1299
+ const data = toRecord(response.data);
1300
+ const blocks = response.blocks ?? data.blocks ?? [];
1301
+ occupiedBlocks.value = (Array.isArray(blocks) ? blocks : []) as OccupiedBlock[];
1302
+ occupancyLoaded.value = true;
1303
+ } catch (error: unknown) {
1304
+ console.error("Unable to load occupied units:", error);
1305
+ occupiedBlocks.value = [];
1306
+ const reason = apiReason(error);
1307
+ showToast(
1308
+ reason ? `Unable to load units: ${reason}` : "Unable to load units for this site.",
1309
+ "error",
1310
+ );
1311
+ } finally {
1312
+ loadingOccupancy.value = false;
889
1313
  }
890
- loadingSubjects.value = true;
1314
+ }
1315
+
1316
+ async function loadUnitPeople() {
1317
+ unitPeople.value = [];
1318
+ if (!form.unit) return;
1319
+ loadingUnitPeople.value = true;
891
1320
  try {
892
- const response = await getPermissionCandidates(props.site, {
893
- orgId: orgId.value,
894
- readerId,
895
- category: form.subjectCategory,
896
- page: 1,
897
- limit: 500,
1321
+ const response = await getPeopleByUnit(form.unit, {
1322
+ status: "active",
1323
+ type: OCCUPANT_TYPES,
898
1324
  });
899
- permissionCandidates.value = response.items ?? response.data?.items ?? [];
1325
+ const record = toRecord(response);
1326
+ const rows = Array.isArray(response)
1327
+ ? response
1328
+ : record.data ?? record.items ?? [];
1329
+ unitPeople.value = (Array.isArray(rows) ? rows : []) as UnitPerson[];
1330
+ } catch (error: unknown) {
1331
+ console.error("Unable to load residents for the unit:", error);
1332
+ const reason = apiReason(error);
1333
+ showToast(
1334
+ reason ? `Unable to load residents: ${reason}` : "Unable to load residents for this unit.",
1335
+ "error",
1336
+ );
900
1337
  } finally {
901
- loadingSubjects.value = false;
1338
+ loadingUnitPeople.value = false;
902
1339
  }
903
1340
  }
904
1341
 
905
- async function onSubjectCategoryChanged() {
1342
+ function onBlockChanged() {
1343
+ form.level = "";
1344
+ form.unit = "";
1345
+ unitPeople.value = [];
1346
+ clearResidentSelection();
1347
+ }
1348
+
1349
+ // Wrapper `@click`/`@focusin` on the Block select: load once, on first touch.
1350
+ async function onBlockOpened() {
1351
+ await ensureOccupancy();
1352
+ }
1353
+
1354
+ function onLevelChanged() {
1355
+ form.unit = "";
1356
+ unitPeople.value = [];
1357
+ clearResidentSelection();
1358
+ }
1359
+
1360
+ async function onUnitChanged() {
1361
+ clearResidentSelection();
1362
+ await loadUnitPeople();
1363
+ }
1364
+
1365
+ function clearResidentSelection() {
906
1366
  form.subjectId = "";
907
- await loadSubjectCandidates();
1367
+ residentConflict.value = null;
1368
+ // Name only ever mirrors a chosen resident, so it clears with the choice.
1369
+ if (!selectedUser.value) form.name = "";
1370
+ }
1371
+
1372
+ // The auto-fill the whole cascade exists for.
1373
+ async function onResidentChanged() {
1374
+ const person = unitPeople.value.find(
1375
+ (item) => String(item._id) === String(form.subjectId),
1376
+ );
1377
+ form.name = String(person?.name ?? "");
1378
+ await checkResidentEnrollment();
1379
+ }
1380
+
1381
+ /**
1382
+ * The HID user this resident already holds on the selected reader, or null.
1383
+ *
1384
+ * `subject`, not `person`. The same resident is stored under different ids
1385
+ * depending on which screen wrote the row - the permissions screen keys on
1386
+ * their user account, this screen on the record the picker offered - so a
1387
+ * filter on one field alone reports "not enrolled" for somebody who plainly is.
1388
+ * `subject` follows the record to its account and matches every link field,
1389
+ * which is exactly what the server does before it accepts an enrollment.
1390
+ *
1391
+ * Reader-scoped by the endpoint itself.
1392
+ */
1393
+ async function findResidentIdentity(readerId: string, personId: string) {
1394
+ const response = await getIdentities(readerId, {
1395
+ page: 1,
1396
+ // A resident should hold at most one, but ask for a few: on the edit screen
1397
+ // the first row back can be the very identity being edited, and skipping it
1398
+ // must not hide a real second one behind it.
1399
+ limit: 10,
1400
+ subject: personId,
1401
+ });
1402
+ const responseRecord = toRecord(response);
1403
+ const responseData = toRecord(responseRecord.data);
1404
+ const responseItems =
1405
+ responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
1406
+ const identities = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
1407
+ const editingId = String(selectedUser.value?._id ?? "");
1408
+
1409
+ return (
1410
+ identities.find((identity) => !editingId || String(identity._id ?? "") !== editingId) ?? null
1411
+ );
1412
+ }
1413
+
1414
+ /**
1415
+ * Sets `residentConflict` for the currently selected resident.
1416
+ *
1417
+ * Fails OPEN: if the lookup itself errors the operator is not blocked, because
1418
+ * the server refuses the duplicate anyway. This is a courtesy check that keeps
1419
+ * a doomed enrollment off the physical reader, not the guard itself.
1420
+ */
1421
+ async function checkResidentEnrollment() {
1422
+ residentConflict.value = null;
1423
+ const readerId = selectedReaderId.value || form.reader || readers.value[0]?._id;
1424
+ if (!readerId || !form.subjectId || form.subjectCategory !== "resident") return null;
1425
+
1426
+ checkingResident.value = true;
1427
+ try {
1428
+ residentConflict.value = await findResidentIdentity(readerId, form.subjectId);
1429
+ return residentConflict.value;
1430
+ } catch (error: unknown) {
1431
+ console.error("Unable to check whether this resident is already enrolled:", error);
1432
+ return null;
1433
+ } finally {
1434
+ checkingResident.value = false;
1435
+ }
908
1436
  }
909
1437
 
910
1438
  async function openEnroll() {
@@ -916,7 +1444,6 @@ async function openEnroll() {
916
1444
  ]);
917
1445
  form.hidUserId = hidUserId;
918
1446
  form.registration = registration;
919
- await loadSubjectCandidates();
920
1447
  formDialog.value = true;
921
1448
  }
922
1449
 
@@ -931,6 +1458,10 @@ async function openEdit(user: HidUser) {
931
1458
  form.registration = user.registration ?? "";
932
1459
  form.isAdministrator = hasAdministratorRule(user);
933
1460
  form.accessPin = "";
1461
+ form.password = "";
1462
+ showPassword.value = false;
1463
+ removePasswordRequested.value = false;
1464
+ passwordSet.value = Boolean(user.metadata?.passwordSet);
934
1465
  pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
935
1466
  removePinRequested.value = false;
936
1467
  form.photoPreview = "";
@@ -941,17 +1472,31 @@ async function openEdit(user: HidUser) {
941
1472
  ? "service_provider"
942
1473
  : "property_management";
943
1474
  form.subjectId = String(user.person || user.member || user.serviceProvider || "");
944
- await loadSubjectCandidates();
1475
+ // The resident this user already holds is not a conflict with itself. A real
1476
+ // one only appears if the operator reassigns the row to somebody else.
1477
+ residentConflict.value = null;
1478
+ // The location is not drawn when editing, but it is still carried: these
1479
+ // three form fields are what `saveUser` writes back and what `buildUnitLabel`
1480
+ // resolves into the Unit column, so a save must not blank them.
1481
+ //
1482
+ // `ensureOccupancy` is what lets `buildUnitLabel` turn them back into names.
1483
+ // The unit's residents are loaded too, because the Resident select IS drawn
1484
+ // here and its options come from that list - without it the select shows the
1485
+ // stored id instead of a name.
1486
+ await ensureOccupancy();
1487
+ if (form.unit) await loadUnitPeople();
945
1488
  selectedPhotoFile.value = null;
946
1489
  removePhotoRequested.value = false;
947
1490
  formDialog.value = true;
948
1491
 
949
1492
  try {
950
- const status = await getUserPinStatus(
951
- form.reader,
952
- toHidNumericId(user.hidUserId),
953
- );
954
- pinEnrolled.value = Boolean(status?.data?.pinEnrolled);
1493
+ const hidUserId = toHidNumericId(user.hidUserId);
1494
+ const [pinStatus, passwordStatus] = await Promise.all([
1495
+ getUserPinStatus(form.reader, hidUserId),
1496
+ getUserPasswordStatus(form.reader, hidUserId),
1497
+ ]);
1498
+ pinEnrolled.value = Boolean(pinStatus?.data?.pinEnrolled);
1499
+ passwordSet.value = Boolean(passwordStatus?.data?.passwordSet);
955
1500
  } catch {
956
1501
  // Preserve the last known status if the reader is temporarily offline.
957
1502
  }
@@ -1030,12 +1575,28 @@ function onPhotoChange(event: Event) {
1030
1575
  async function saveUser() {
1031
1576
  const readerId =
1032
1577
  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");
1578
+ // A resident is required to ENROL, because an identity has to be linked to
1579
+ // somebody. It is not required to edit: the reader holds users we never
1580
+ // enrolled, and changing a photo, a PIN or a password on one of those is a
1581
+ // reader-side operation that has nothing to say about who they are. Demanding
1582
+ // a resident there refuses a save for a field the dialog does not even draw.
1583
+ const missing = !readerId
1584
+ ? "a reader"
1585
+ : !selectedUser.value && !form.subjectId
1586
+ ? "a resident"
1587
+ : !form.name
1588
+ ? "a name"
1589
+ : !form.hidUserId
1590
+ ? "an ID"
1591
+ : !form.registration
1592
+ ? "a registration number"
1593
+ : "";
1594
+ if (missing) {
1595
+ showToast(`This HID user needs ${missing} before it can be saved.`, "error");
1035
1596
  return;
1036
1597
  }
1037
1598
  if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
1038
- showToast("Access PIN must contain numbers only.", "error");
1599
+ showToast("PIN must contain numbers only.", "error");
1039
1600
  return;
1040
1601
  }
1041
1602
  if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
@@ -1043,6 +1604,16 @@ async function saveUser() {
1043
1604
  return;
1044
1605
  }
1045
1606
 
1607
+ // Re-asked here, not trusted from the select: the dialog can sit open while
1608
+ // somebody else enrolls the same resident, and the reader is only ever
1609
+ // written to below this line. A duplicate caught here costs one request; the
1610
+ // same duplicate caught by the server costs a created HID user and a rollback.
1611
+ const alreadyEnrolled = await checkResidentEnrollment();
1612
+ if (alreadyEnrolled) {
1613
+ showToast(residentConflictMessage.value, "error");
1614
+ return;
1615
+ }
1616
+
1046
1617
  saving.value = true;
1047
1618
  let newlyCreatedHidUserId: number | undefined;
1048
1619
  try {
@@ -1056,22 +1627,33 @@ async function saveUser() {
1056
1627
  return;
1057
1628
  }
1058
1629
 
1630
+ // The link is sent only when the form actually holds one. Sending
1631
+ // `person: ""` is not "leave it alone", it is "clear it": the server reads
1632
+ // an empty string as a link being removed and then refuses the write for
1633
+ // having no subject at all. Omitting the keys is what makes it keep the
1634
+ // subject the record already has.
1635
+ const subjectLink = form.subjectId
1636
+ ? {
1637
+ person: form.subjectCategory === "resident" ? form.subjectId : "",
1638
+ member: form.subjectCategory === "property_management" ? form.subjectId : "",
1639
+ serviceProvider: form.subjectCategory === "service_provider" ? form.subjectId : "",
1640
+ type: (form.subjectCategory === "resident"
1641
+ ? "resident"
1642
+ : form.subjectCategory === "service_provider"
1643
+ ? "contractor"
1644
+ : "staff") as
1645
+ | "resident"
1646
+ | "staff"
1647
+ | "contractor"
1648
+ | "visitor"
1649
+ | "unknown",
1650
+ }
1651
+ : {};
1652
+
1059
1653
  const payload = {
1060
1654
  hidUserId: String(hidUser.id),
1061
1655
  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",
1656
+ ...subjectLink,
1075
1657
  status: "active" as const,
1076
1658
  metadata: {
1077
1659
  name: form.name,
@@ -1088,6 +1670,7 @@ async function saveUser() {
1088
1670
  imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
1089
1671
  facialScores: selectedUser.value?.metadata?.facialScores || {},
1090
1672
  pinEnrolled: pinEnrolled.value,
1673
+ passwordSet: passwordSet.value,
1091
1674
  hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
1092
1675
  },
1093
1676
  };
@@ -1097,17 +1680,22 @@ async function saveUser() {
1097
1680
  const facialResult = await syncFacialImage(readerId, hidUser.id);
1098
1681
  applyFacialResult(payload.metadata, facialResult);
1099
1682
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
1683
+ payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
1100
1684
  if (selectedUser.value._id) {
1101
1685
  await updateIdentity(selectedUser.value._id, payload);
1102
- } else {
1686
+ } else if (form.subjectId) {
1103
1687
  await saveIdentityOnReader(readerId, payload);
1104
1688
  }
1689
+ // else: a user the reader holds and we have never linked. The photo, PIN
1690
+ // and password above are already on the device; creating an identity for
1691
+ // it would need a subject we deliberately no longer ask for here.
1105
1692
  } else {
1106
1693
  const created = await createHidUserOnReader(readerId, hidUser);
1107
1694
  if (created) newlyCreatedHidUserId = hidUser.id;
1108
1695
  const facialResult = await syncFacialImage(readerId, hidUser.id);
1109
1696
  applyFacialResult(payload.metadata, facialResult);
1110
1697
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
1698
+ payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
1111
1699
  await saveIdentityOnReader(readerId, payload);
1112
1700
  selectedReaderId.value = readerId;
1113
1701
  }
@@ -1143,6 +1731,25 @@ async function saveUser() {
1143
1731
  }
1144
1732
  }
1145
1733
 
1734
+ async function syncPassword(readerId: string, hidUserId: number) {
1735
+ // Mirrors syncAccessPin. Removal wins over a typed value, and a blank field
1736
+ // means "leave whatever is on the reader alone" - the hash cannot be read
1737
+ // back, so blank can never mean "clear it".
1738
+ if (removePasswordRequested.value) {
1739
+ await deleteUserPassword(readerId, hidUserId);
1740
+ passwordSet.value = false;
1741
+ form.password = "";
1742
+ return false;
1743
+ }
1744
+ if (form.password) {
1745
+ await setUserPassword(readerId, hidUserId, form.password);
1746
+ form.password = "";
1747
+ passwordSet.value = true;
1748
+ return true;
1749
+ }
1750
+ return passwordSet.value;
1751
+ }
1752
+
1146
1753
  async function syncAccessPin(readerId: string, hidUserId: number) {
1147
1754
  if (removePinRequested.value) {
1148
1755
  await deleteUserPin(readerId, hidUserId);
@@ -1253,7 +1860,7 @@ async function saveIdentityOnReader(
1253
1860
  }
1254
1861
 
1255
1862
  try {
1256
- await createIdentity(readerId, { ...payload, site: props.site });
1863
+ await createIdentity(readerId, { ...payload, site: props.site, ...(props.org ? { orgId: props.org } : {}) });
1257
1864
  } catch (error: unknown) {
1258
1865
  const message = getHidErrorMessage(error);
1259
1866
  if (!message.toLowerCase().includes("already exists")) throw error;
@@ -1447,6 +2054,21 @@ function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registr
1447
2054
  };
1448
2055
  }
1449
2056
 
2057
+ function isVisitorUser(user: HidUser) {
2058
+ // The reader marks visitors with `user_type_id = 1`; a real user leaves the
2059
+ // field null. `metadata.hidUserTypeId` is what our API forwards it as.
2060
+ return Number(user.metadata?.hidUserTypeId ?? user.user_type_id) === 1;
2061
+ }
2062
+
2063
+ function isUnsupportedUserTypeFilter(error: unknown) {
2064
+ const record = toRecord(error);
2065
+ const data = toRecord(record.data);
2066
+ const message = toText(data.message) || toText(record.message);
2067
+ const normalized = message.toLowerCase();
2068
+ return normalized.includes("usertype")
2069
+ && (normalized.includes("not allowed") || normalized.includes("unknown"));
2070
+ }
2071
+
1450
2072
  function toHidNumericId(value: unknown) {
1451
2073
  const raw = String(value ?? "").trim();
1452
2074
  if (!raw) return undefined;
@@ -1585,6 +2207,14 @@ async function assignPhysicalCard() {
1585
2207
  }
1586
2208
  }
1587
2209
 
2210
+ /**
2211
+ * Read a physical card by presenting it to the reader.
2212
+ *
2213
+ * The way most cards get assigned, because it is the only one that does not
2214
+ * require knowing the facility code and card number in advance - neither is
2215
+ * printed on the card, and an operator holding a blank fob has no way to look
2216
+ * them up. The reader listens for 30 seconds and reports what it read.
2217
+ */
1588
2218
  async function enrollPhysicalCard() {
1589
2219
  const { readerId, hidUserId } = getCardReaderAndUser();
1590
2220
  if (!readerId || !hidUserId) return;
@@ -1597,6 +2227,8 @@ async function enrollPhysicalCard() {
1597
2227
  await loadUsers();
1598
2228
  showToast("Physical HID card read and enrolled on the reader.");
1599
2229
  } catch (error: unknown) {
2230
+ // Cancelling rejects the request above. That is the operator getting what
2231
+ // they asked for, so it must not also be reported as a failure.
1600
2232
  if (!cardEnrollmentCancelled.value) {
1601
2233
  showToast(getHidErrorMessage(error), "error");
1602
2234
  }
@@ -1605,15 +2237,49 @@ async function enrollPhysicalCard() {
1605
2237
  }
1606
2238
  }
1607
2239
 
2240
+ /**
2241
+ * Stop the reader listening.
2242
+ *
2243
+ * Told to the DEVICE, not just the screen: abandoning the request on this side
2244
+ * would leave the reader waiting out its 30 seconds and enrolling whatever card
2245
+ * was presented in the meantime.
2246
+ */
2247
+ /**
2248
+ * Stop the reader listening, and hand the dialog straight back.
2249
+ *
2250
+ * Told to the DEVICE, not just the screen: abandoning the request on this side
2251
+ * would leave the reader waiting out its remaining seconds and enrolling
2252
+ * whatever card was presented in the meantime.
2253
+ *
2254
+ * The part worth understanding is what the ENROL request does next. The server
2255
+ * cannot tell a cancelled read from a failed one - the reader simply reports no
2256
+ * card - so the enrol that is still in flight ends as a 400, seconds after the
2257
+ * cancel. Waiting for it was the bug: Cancel appeared to do nothing until that
2258
+ * rejection finally arrived.
2259
+ *
2260
+ * So the dialog is released as soon as the DEVICE confirms it has stopped, and
2261
+ * the doomed enrol is left to land on its own. `cardEnrollmentCancelled` is set
2262
+ * before anything is awaited, which is what keeps its 400 silent.
2263
+ */
1608
2264
  async function cancelPhysicalCardEnrollment() {
1609
2265
  const { readerId } = getCardReaderAndUser();
1610
2266
  if (!readerId) return;
2267
+ // Set first, awaited second: the enrol can reject at any point from here.
1611
2268
  cardEnrollmentCancelled.value = true;
2269
+ cancellingCard.value = true;
1612
2270
  try {
1613
2271
  await cancelUserCardEnrollment(readerId);
2272
+ // Only now, because the enrollment lock is released by the call above.
2273
+ // Re-enabling Read at Reader any earlier invites a second attempt that
2274
+ // the reader refuses with "already running".
2275
+ enrollingCard.value = false;
1614
2276
  showToast("Physical HID card reading cancelled.");
1615
2277
  } catch (error: unknown) {
2278
+ // The cancel itself failing is real and worth saying: the reader may still
2279
+ // be listening, so the dialog stays in its reading state.
1616
2280
  showToast(getHidErrorMessage(error), "error");
2281
+ } finally {
2282
+ cancellingCard.value = false;
1617
2283
  }
1618
2284
  }
1619
2285
 
@@ -1649,8 +2315,21 @@ function getPhysicalCardSubtitle(card: HidCard) {
1649
2315
  }
1650
2316
 
1651
2317
  function buildUnitLabel() {
1652
- const parts = [form.block, form.level, form.unit].filter(Boolean);
1653
- return parts.length ? `BLK ${parts.join("/")}` : "";
2318
+ // `form.block/level/unit` hold ObjectIds, so the label has to resolve them
2319
+ // back to names through the occupancy tree. Joining the raw values would
2320
+ // stamp "665f.../665f.../665f..." onto the record and into the Unit column.
2321
+ const block = occupiedBlocks.value.find((item) => String(item._id ?? "") === form.block);
2322
+ const level = (block?.levels ?? []).find((item) => String(item._id ?? "") === form.level);
2323
+ const unit = (level?.units ?? []).find((item) => String(item._id ?? "") === form.unit);
2324
+ const parts = [
2325
+ formatBlockTitle(block),
2326
+ String(level?.name ?? ""),
2327
+ String(unit?.name ?? ""),
2328
+ ].filter(Boolean);
2329
+ // Editing a record enrolled before the cascade existed: keep its stored
2330
+ // label rather than blanking it.
2331
+ if (!parts.length) return String(selectedUser.value?.metadata?.unitLabel ?? "");
2332
+ return parts.join(" / ");
1654
2333
  }
1655
2334
 
1656
2335
  function getName(user?: HidUser | null) {
@@ -1901,6 +2580,12 @@ function toHidCard(value: unknown): HidCard {
1901
2580
  source: card.source === "reader" || card.source === "manual" || card.source === "device" ? card.source : undefined,
1902
2581
  };
1903
2582
  }
2583
+
2584
+ /* Publish every change, including the seed above: a screen that had to fall
2585
+ back to the first reader should leave the others agreeing with it. */
2586
+ watch(selectedReaderId, (value) => {
2587
+ if (value) selectHidReader(props.site, value);
2588
+ });
1904
2589
  </script>
1905
2590
 
1906
2591
  <style scoped>
@@ -1937,12 +2622,6 @@ function toHidCard(value: unknown): HidCard {
1937
2622
  max-width: 320px;
1938
2623
  }
1939
2624
 
1940
- .small-title {
1941
- font-size: 14px;
1942
- font-weight: 700;
1943
- color: var(--text);
1944
- }
1945
-
1946
2625
  /* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
1947
2626
  .field-label {
1948
2627
  color: var(--text2);
@@ -1964,12 +2643,42 @@ function toHidCard(value: unknown): HidCard {
1964
2643
  line-height: 1.45;
1965
2644
  }
1966
2645
 
2646
+ /* Same line, saying the field is refused rather than explained. `--err` is the
2647
+ token the required-field asterisk already uses. */
2648
+ .field-hint--error {
2649
+ color: var(--err);
2650
+ }
2651
+
1967
2652
  .photo-wrap {
1968
2653
  display: grid;
1969
2654
  place-items: center;
1970
2655
  margin-bottom: 14px;
1971
2656
  }
1972
2657
 
2658
+ .photo-facial {
2659
+ margin: 10px 0 0;
2660
+ text-align: center;
2661
+ }
2662
+
2663
+ /* The same small muted word `.field-label` uses, so the caption reads as a
2664
+ field label rather than as a second heading. */
2665
+ .photo-facial__label {
2666
+ display: block;
2667
+ color: var(--muted);
2668
+ font-size: 12px;
2669
+ font-weight: 600;
2670
+ }
2671
+
2672
+ .photo-facial__value {
2673
+ display: block;
2674
+ margin-top: 2px;
2675
+ font-size: 13.5px;
2676
+ font-weight: 500;
2677
+ /* The reader's reference is a long unbroken number; let it wrap rather than
2678
+ widen the dialog. */
2679
+ word-break: break-all;
2680
+ }
2681
+
1973
2682
  .photo-button {
1974
2683
  width: 120px;
1975
2684
  height: 120px;
@@ -2143,25 +2852,6 @@ function toHidCard(value: unknown): HidCard {
2143
2852
  color: var(--muted);
2144
2853
  }
2145
2854
 
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
2855
  @media (max-width: 960px) {
2166
2856
  .hid-user-filters :deep(.app-field),
2167
2857
  .hid-user-filters :deep(.app-select) {