@7365admin1/layer-common 4.149.2 → 4.151.0

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 (42) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/components/EntryPassInformation.vue +13 -0
  3. package/components/HidAccessPermissions.vue +202 -40
  4. package/components/HidEnabledGate.vue +29 -10
  5. package/components/HidIntercomManagement.vue +26 -13
  6. package/components/HidProfileQrCode.vue +6 -2
  7. package/components/HidQrCodeConfiguration.vue +2 -2
  8. package/components/HidReaderForm.vue +239 -1
  9. package/components/HidReaderManagement.vue +26 -8
  10. package/components/HidReaderUserRoster.vue +3 -1
  11. package/components/HidServiceSettingsPanel.vue +2 -2
  12. package/components/HidUserEnrollment.vue +1662 -162
  13. package/components/OvernightParkingAvailability.vue +123 -11
  14. package/components/Robot/Detail.vue +23 -4
  15. package/components/SiteSettings.vue +5 -2
  16. package/components/VisitorForm.vue +39 -11
  17. package/composables/useCleaningPermission.ts +11 -0
  18. package/composables/useHidAccessPermission.ts +176 -81
  19. package/composables/useHidAmico.ts +37 -0
  20. package/composables/useHidNavigation.ts +44 -23
  21. package/composables/useLandscapingPermission.ts +11 -0
  22. package/composables/useMechanicalElectricalPermission.ts +5 -0
  23. package/composables/usePestControlPermission.ts +11 -0
  24. package/composables/usePoolMaintenancePermission.ts +11 -0
  25. package/composables/usePropertyPermission.ts +31 -7
  26. package/composables/useSecurityPermission.ts +33 -13
  27. package/composables/useServiceProvider.ts +9 -4
  28. package/constants/permissions.ts +73 -21
  29. package/package.json +1 -1
  30. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +3 -0
  31. package/types/overnight-parking.d.ts +14 -0
  32. package/types/site.d.ts +7 -1
  33. package/utils/hid-enrolment-subject.ts +226 -4
  34. package/utils/hid-permission-assignments.ts +75 -6
  35. package/utils/hid-qr-validity.ts +25 -0
  36. package/utils/hid-reader-audiences.ts +557 -0
  37. package/utils/hid-registration.ts +24 -0
  38. package/utils/module-routes.ts +15 -2
  39. package/utils/nav-sections.ts +1 -1
  40. package/utils/robot-credential-form.ts +4 -2
  41. package/utils/robot-display.ts +28 -6
  42. package/utils/robot-setup.ts +1 -0
@@ -53,7 +53,9 @@
53
53
  @refresh="loadUsers"
54
54
  @update:page="goToPage"
55
55
  >
56
- <template v-if="!props.cardManagement" #actions>
56
+ <!-- Enrolling is the users right (`hid-mgmt:manage-hid-users`). Hidden,
57
+ not disabled: the server refuses the call for a member without it. -->
58
+ <template v-if="!props.cardManagement && canManageHidUsers" #actions>
57
59
  <AppButton icon="mdi-account-plus" @click="openEnroll"
58
60
  >Enroll</AppButton
59
61
  >
@@ -141,14 +143,14 @@
141
143
 
142
144
  <template #[`item.action-table`]="{ item }">
143
145
  <AppButton
144
- v-if="props.cardManagement"
146
+ v-if="props.cardManagement && canManageHidUsers"
145
147
  variant="secondary"
146
148
  icon="mdi-card-account-details-outline"
147
149
  @click="openCardManager(item)"
148
150
  >
149
151
  Manage
150
152
  </AppButton>
151
- <v-menu v-else>
153
+ <v-menu v-else-if="!props.cardManagement">
152
154
  <template #activator="{ props: menuProps }">
153
155
  <AppButton
154
156
  v-bind="menuProps"
@@ -159,16 +161,18 @@
159
161
 
160
162
  <v-list density="compact" min-width="150">
161
163
  <v-list-item title="View" @click="openView(item)" />
162
- <v-list-item title="Edit" @click="openEdit(item)" />
163
- <v-list-item
164
- title="Manage HID Cards"
165
- @click="openCardManager(item)"
166
- />
167
- <v-list-item
168
- title="Delete"
169
- class="text-error"
170
- @click="openDelete(item)"
171
- />
164
+ <template v-if="canManageHidUsers">
165
+ <v-list-item title="Edit" @click="openEdit(item)" />
166
+ <v-list-item
167
+ title="Manage HID Cards"
168
+ @click="openCardManager(item)"
169
+ />
170
+ <v-list-item
171
+ title="Delete"
172
+ class="text-error"
173
+ @click="openDelete(item)"
174
+ />
175
+ </template>
172
176
  </v-list>
173
177
  </v-menu>
174
178
  </template>
@@ -274,13 +278,26 @@
274
278
  it. -->
275
279
  <div class="field-label">Enrolling <span>*</span></div>
276
280
  <AppSelect
277
- v-if="!selectedUser"
281
+ v-if="!selectedUser && subjectCategoryOptions.length"
278
282
  v-model="form.subjectCategory"
279
283
  :items="subjectCategoryOptions"
280
284
  klass="mb-3"
281
285
  @update:model-value="onSubjectCategoryChanged"
282
286
  />
283
- <p v-else class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
287
+ <p v-else-if="selectedUser" class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
288
+ <!-- WHY THERE IS NOTHING TO PICK. Drawn in place of the dropdown when
289
+ the reader serves nobody, and beneath it when only some types were
290
+ removed. Without it, a missing option reads as a screen that failed
291
+ to load rather than as a setting on the reader.
292
+ Nothing extra is needed to stop the save: with no type there is no
293
+ subject, and the save already refuses that. -->
294
+ <p
295
+ v-if="!selectedUser && subjectCategoryNote"
296
+ class="field-hint mb-3"
297
+ :class="{ 'field-hint--error': enrolmentSubjects.emptied }"
298
+ >
299
+ {{ subjectCategoryNote }}
300
+ </p>
284
301
 
285
302
  <!-- Block / Level / Unit / Resident. Enrolment only.
286
303
  Enrollment is resident-only and the resident must already exist
@@ -363,7 +380,7 @@
363
380
  access is bound by user id, so there is nothing to bind. The note
364
381
  below says how many were left out rather than letting a missing
365
382
  colleague read as a bug. -->
366
- <template v-if="!selectedUser && !isResidentSubject">
383
+ <template v-if="!selectedUser && isMemberSubject">
367
384
  <div class="field-label">Member <span>*</span></div>
368
385
  <AppSelect
369
386
  v-model="form.subjectId"
@@ -389,6 +406,59 @@
389
406
  </p>
390
407
  </template>
391
408
 
409
+ <!-- SERVICE PROVIDER STAFF. One person from a provider company, and
410
+ never the company: granting a company puts every employee of the
411
+ firm on the door, including the ones who do not attend this site.
412
+ The list, the service filter and the filter's "All services"
413
+ default are the Members > Service Providers screen's own, from the
414
+ same endpoint and through the same `filterProviderMembers` rule,
415
+ so the two screens cannot disagree about who works here.
416
+ Unpaged - the endpoint returns the site's people in one response -
417
+ so searching and filtering cost no request. -->
418
+ <template v-if="!selectedUser && isProviderSubject">
419
+ <div class="field-label">Service</div>
420
+ <AppSelect
421
+ v-model="providerService"
422
+ :items="providerServiceOptions"
423
+ klass="mb-3"
424
+ @update:model-value="onProviderServiceChanged"
425
+ />
426
+
427
+ <div class="field-label">Person <span>*</span></div>
428
+ <AppSelect
429
+ v-model="form.subjectId"
430
+ :items="providerOptions"
431
+ searchable
432
+ search-placeholder="Search by name, company or role"
433
+ :placeholder="providerPlaceholder"
434
+ klass="mb-3"
435
+ @update:model-value="onProviderChanged"
436
+ />
437
+
438
+ <!-- ACCESS ONLY, and the button says so. Shown here and nowhere
439
+ else: it is the one subject type that arrives a shift at a time,
440
+ and the people it adds fill in their own credentials from their
441
+ app afterwards. -->
442
+ <div class="hid-bulk-cta mb-3">
443
+ <AppButton
444
+ variant="ghost"
445
+ icon="mdi-account-multiple-plus-outline"
446
+ :disabled="loadingProviders"
447
+ @click="openBulkAdd"
448
+ >
449
+ Add Users in Bulk
450
+ </AppButton>
451
+ <span class="field-hint">
452
+ Gives access to many people at once. They add their own face, QR
453
+ or PIN from their app.
454
+ </span>
455
+ </div>
456
+
457
+ <p v-if="hiddenProviderNote" class="field-hint mb-3">
458
+ {{ hiddenProviderNote }}
459
+ </p>
460
+ </template>
461
+
392
462
  <!-- The duplicate check. It is reader-scoped: this resident holding a
393
463
  HID user on another gate is normal and says nothing here. -->
394
464
  <p v-if="checkingResident" class="field-hint">
@@ -412,17 +482,176 @@
412
482
  whatever the reader already holds. -->
413
483
  <!-- A mirror of the chosen member's role, on the same rule as Name:
414
484
  restated from the record, never collected. -->
415
- <template v-if="!selectedUser && !isResidentSubject">
416
- <div class="field-label">Role</div>
485
+ <template v-if="!selectedUser && isPersonSubject">
486
+ <div class="field-label">{{ subjectMirrorLabel }}</div>
417
487
  <AppField
418
488
  v-model="form.subjectRole"
419
- aria-label="Role"
420
- placeholder="Select a member above"
489
+ :aria-label="subjectMirrorLabel"
490
+ :placeholder="subjectMirrorPlaceholder"
421
491
  readonly
422
492
  klass="mb-3"
423
493
  />
424
494
  </template>
425
495
 
496
+ <!-- WHERE THIS PERSON WILL END UP.
497
+ Enrolling is no longer "pick a reader" - they land on every reader
498
+ at the site that serves their account type - so the dialog states
499
+ it rather than asking. The excluded readers are drawn too, with the
500
+ reason: an absent reader reads as a screen that failed to load,
501
+ while "does not serve residents" reads as a setting somebody chose.
502
+ Replaced while a save is running by the per-reader progress below,
503
+ which is the same list with each row's own state. -->
504
+ <template v-if="!selectedUser">
505
+ <div class="field-label">
506
+ {{ readerOutcomes.length ? "Enrolling on" : "Will be enrolled on" }}
507
+ </div>
508
+
509
+ <div v-if="readerOutcomes.length" class="hid-fanout mb-1">
510
+ <div
511
+ v-for="outcome in readerOutcomes"
512
+ :key="outcome.readerId"
513
+ class="hid-fanout__row"
514
+ :class="`hid-fanout__row--${outcome.step}`"
515
+ >
516
+ <v-icon
517
+ :icon="
518
+ outcome.step === 'done'
519
+ ? 'mdi-check-circle-outline'
520
+ : outcome.step === 'failed'
521
+ ? 'mdi-alert-circle-outline'
522
+ : outcome.step === 'working'
523
+ ? 'mdi-progress-upload'
524
+ : 'mdi-clock-outline'
525
+ "
526
+ size="17"
527
+ />
528
+ <div class="hid-fanout__body">
529
+ <strong>{{ outcome.name }}</strong>
530
+ <span v-if="outcome.message">{{ outcome.message }}</span>
531
+ </div>
532
+ <!-- Retry is per reader, because the failure was. Enabled only
533
+ when nothing else is running, so two writes cannot race at
534
+ one device. -->
535
+ <AppButton
536
+ v-if="outcome.step === 'failed'"
537
+ variant="row"
538
+ icon="mdi-refresh"
539
+ :disabled="fanOutRunning"
540
+ @click="retryReader(outcome)"
541
+ />
542
+ </div>
543
+ </div>
544
+
545
+ <div v-else class="hid-fanout mb-1">
546
+ <div
547
+ v-for="target in enrolmentTargets.targets"
548
+ :key="target.readerId"
549
+ class="hid-fanout__row hid-fanout__row--target"
550
+ >
551
+ <v-icon icon="mdi-check" size="17" />
552
+ <div class="hid-fanout__body">
553
+ <strong>{{ target.name }}</strong>
554
+ <span v-if="target.location">{{ target.location }}</span>
555
+ <span v-if="target.warning">{{ target.warning }}</span>
556
+ </div>
557
+ </div>
558
+ <div
559
+ v-for="skipped in enrolmentTargets.excluded"
560
+ :key="skipped.readerId"
561
+ class="hid-fanout__row hid-fanout__row--excluded"
562
+ >
563
+ <v-icon icon="mdi-minus" size="17" />
564
+ <div class="hid-fanout__body">
565
+ <strong>{{ skipped.name }}</strong>
566
+ <span>{{ skipped.reason }}</span>
567
+ </div>
568
+ </div>
569
+ </div>
570
+
571
+ <p v-if="fanOutProgress && fanOutRunning" class="field-hint mb-3">
572
+ {{ fanOutProgress }}
573
+ </p>
574
+ <p
575
+ v-else-if="fanOutResult"
576
+ class="field-hint mb-3"
577
+ :class="{ 'field-hint--error': fanOutResult.tone !== 'success' }"
578
+ >
579
+ {{ fanOutResult.message }}
580
+ </p>
581
+ <p v-else-if="!enrolmentTargets.targets.length" class="field-hint field-hint--error mb-3">
582
+ No reader at this site serves this kind of person yet.
583
+ </p>
584
+ <!-- Each reader numbers its own users, so the ID and registration
585
+ below describe the reader on screen - not the others. -->
586
+ <p v-else-if="enrolmentTargets.targets.length > 1" class="field-hint mb-3">
587
+ Each reader assigns its own ID and registration number.
588
+ </p>
589
+ </template>
590
+
591
+ <!-- EDITING SOMEBODY WHO IS ON SEVERAL READERS.
592
+ The same list, for the same reason, except the readers are the
593
+ ones that already hold this person rather than the ones that would
594
+ take them. Said before the save because of the PIN: a changed PIN
595
+ that reaches two readers of three leaves this person holding two
596
+ PINs, so the operator needs to know the change travels. -->
597
+ <template v-else-if="editPlaces.length > 1 || readerOutcomes.length">
598
+ <div class="field-label">
599
+ {{ readerOutcomes.length ? "Updating on" : "Also on" }}
600
+ </div>
601
+
602
+ <div class="hid-fanout mb-1">
603
+ <div
604
+ v-for="outcome in editRows"
605
+ :key="outcome.readerId"
606
+ class="hid-fanout__row"
607
+ :class="`hid-fanout__row--${outcome.step}`"
608
+ >
609
+ <v-icon
610
+ :icon="
611
+ outcome.step === 'done'
612
+ ? 'mdi-check-circle-outline'
613
+ : outcome.step === 'failed'
614
+ ? 'mdi-alert-circle-outline'
615
+ : outcome.step === 'working'
616
+ ? 'mdi-progress-upload'
617
+ : outcome.step === 'target'
618
+ ? 'mdi-check'
619
+ : 'mdi-clock-outline'
620
+ "
621
+ size="17"
622
+ />
623
+ <div class="hid-fanout__body">
624
+ <strong>{{ outcome.name }}</strong>
625
+ <span v-if="outcome.message">{{ outcome.message }}</span>
626
+ </div>
627
+ </div>
628
+ </div>
629
+
630
+ <p v-if="fanOutProgress && fanOutRunning" class="field-hint mb-3">
631
+ {{ fanOutProgress }}
632
+ </p>
633
+ <p
634
+ v-else-if="editResult"
635
+ class="field-hint mb-3"
636
+ :class="{ 'field-hint--error': editResult.tone !== 'success' }"
637
+ >
638
+ {{ editResult.message }}
639
+ </p>
640
+ <!-- Registration is the one field that does NOT travel: each reader
641
+ allocates its own, and one number written to all would collide. -->
642
+ <p v-else class="field-hint mb-3">
643
+ Name, photo, PIN and password are updated on every reader. The
644
+ registration number is each reader's own.
645
+ </p>
646
+ </template>
647
+
648
+ <p
649
+ v-else-if="editPlacesLoading"
650
+ class="field-hint mb-3"
651
+ >
652
+ Checking which readers hold this person...
653
+ </p>
654
+
426
655
  <div class="field-label">Name</div>
427
656
  <AppField
428
657
  v-model="form.name"
@@ -430,6 +659,8 @@
430
659
  :placeholder="
431
660
  isResidentSubject
432
661
  ? 'Select a resident above'
662
+ : isProviderSubject
663
+ ? 'Select a person above'
433
664
  : 'Select a member above'
434
665
  "
435
666
  readonly
@@ -567,6 +798,115 @@
567
798
  </v-card>
568
799
  </v-dialog>
569
800
 
801
+ <!-- ADD USERS IN BULK.
802
+ Over the enrolment form, not instead of it: the operator came here to
803
+ enrol, and this is the shortcut for the case where a whole shift arrives
804
+ at once. It grants ACCESS only - the people it adds are recognised by the
805
+ reader once they add a face, QR or PIN from their own app - which is why
806
+ it collects no photo, PIN or password.
807
+ The table is the same list as the picker behind it, under the same
808
+ service filter, with Company / Name / Service and nothing else: the
809
+ Members screen's other columns say nothing about whether to open a door
810
+ for somebody. -->
811
+ <v-dialog v-model="bulkDialog" max-width="640" persistent>
812
+ <v-card class="screen-modal">
813
+ <v-card-title>Add Users in Bulk</v-card-title>
814
+ <v-card-text class="screen-modal__body">
815
+ <p class="field-hint mb-3">
816
+ Gives these people access to
817
+ <strong>{{ bulkReaderLabel }}</strong>. They add their own face, QR
818
+ or PIN from their app - nothing is enrolled on the reader here.
819
+ </p>
820
+
821
+ <AppField
822
+ v-model="bulkSearch"
823
+ aria-label="Search people"
824
+ placeholder="Search by name, company or role"
825
+ klass="mb-3"
826
+ />
827
+
828
+ <v-progress-linear v-if="bulkLoading" indeterminate class="mb-3" />
829
+
830
+ <template v-else-if="bulkRows.length">
831
+ <v-table density="compact" class="hid-bulk-table mb-2">
832
+ <thead>
833
+ <tr>
834
+ <th class="hid-bulk-table__check">
835
+ <!-- Acts on the rows in front of you - what the service
836
+ filter and the search have left - never on the whole
837
+ estate. -->
838
+ <v-checkbox
839
+ :model-value="bulkAllSelected"
840
+ :disabled="!bulkSelectableRows.length"
841
+ hide-details
842
+ density="compact"
843
+ aria-label="Select everyone listed"
844
+ @update:model-value="toggleBulkAll"
845
+ />
846
+ </th>
847
+ <th>Company</th>
848
+ <th>Name</th>
849
+ <th>Service</th>
850
+ </tr>
851
+ </thead>
852
+ <tbody>
853
+ <tr v-for="row in bulkRows" :key="row.subjectId">
854
+ <td class="hid-bulk-table__check">
855
+ <!-- Already on the door: ticked and locked. Unticking here
856
+ would revoke access, and this dialog does not revoke. -->
857
+ <v-checkbox
858
+ :model-value="row.granted || isBulkSelected(row.subjectId)"
859
+ :disabled="row.granted"
860
+ hide-details
861
+ density="compact"
862
+ :aria-label="`Select ${row.name}`"
863
+ @update:model-value="toggleBulkRow(row.subjectId, $event)"
864
+ />
865
+ </td>
866
+ <td>{{ row.company || "—" }}</td>
867
+ <td>
868
+ {{ row.name }}
869
+ <span v-if="row.granted" class="hid-bulk-granted">
870
+ Already has access
871
+ </span>
872
+ </td>
873
+ <td>{{ row.service || "—" }}</td>
874
+ </tr>
875
+ </tbody>
876
+ </v-table>
877
+ <p class="field-hint">
878
+ {{ bulkSelectedCount }} selected<template v-if="bulkGrantedCount">
879
+ · {{ bulkGrantedCount }} already have access</template
880
+ >
881
+ </p>
882
+ </template>
883
+
884
+ <v-alert v-else type="info" variant="tonal" density="compact">
885
+ {{ bulkEmptyText }}
886
+ </v-alert>
887
+ </v-card-text>
888
+ <v-card-actions class="screen-modal__footer">
889
+ <v-btn
890
+ class="text-none screen-btn-ghost"
891
+ variant="flat"
892
+ :disabled="bulkSaving"
893
+ @click="bulkDialog = false"
894
+ >
895
+ Cancel
896
+ </v-btn>
897
+ <v-btn
898
+ class="text-none screen-btn-primary"
899
+ variant="flat"
900
+ :loading="bulkSaving"
901
+ :disabled="!bulkSelectedCount"
902
+ @click="saveBulkAdd"
903
+ >
904
+ Give access
905
+ </v-btn>
906
+ </v-card-actions>
907
+ </v-card>
908
+ </v-dialog>
909
+
570
910
  <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
571
911
 
572
912
  <v-dialog v-model="viewDialog" max-width="430">
@@ -759,20 +1099,76 @@
759
1099
  </v-card>
760
1100
  </v-dialog>
761
1101
 
762
- <v-dialog v-model="deleteDialog" max-width="360" persistent>
1102
+ <v-dialog v-model="deleteDialog" max-width="420" persistent>
763
1103
  <v-card class="screen-modal">
764
1104
  <v-card-title>Delete</v-card-title>
765
1105
  <v-card-text class="screen-modal__body text-body-2">
766
1106
  Are you sure you want to permanently delete this HID user?
1107
+
1108
+ <div v-if="deletePlacesLoading" class="text-caption text-medium-emphasis mt-2">
1109
+ Checking which readers hold this person...
1110
+ </div>
1111
+ <div v-else class="text-caption text-medium-emphasis mt-2">
1112
+ {{ deleteReach }}
1113
+ </div>
1114
+
1115
+ <!-- Only a choice when there is one to make. On one reader the radios
1116
+ would ask the operator to confirm the only possible answer. -->
1117
+ <v-radio-group
1118
+ v-if="deleteScopeOptions.length > 1"
1119
+ v-model="deleteScope"
1120
+ class="mt-2"
1121
+ density="compact"
1122
+ hide-details
1123
+ :disabled="deleting"
1124
+ >
1125
+ <v-radio
1126
+ v-for="choice in deleteScopeOptions"
1127
+ :key="choice.value"
1128
+ :label="choice.label"
1129
+ :value="choice.value"
1130
+ density="compact"
1131
+ />
1132
+ </v-radio-group>
1133
+
1134
+ <!-- Per-reader, so a reader that refused is named rather than hidden
1135
+ behind a single failure message. -->
1136
+ <div v-if="deleteOutcomes.length > 1" class="mt-3">
1137
+ <div
1138
+ v-for="outcome in deleteOutcomes"
1139
+ :key="outcome.readerId"
1140
+ class="d-flex align-center justify-space-between text-caption"
1141
+ >
1142
+ <span>{{ outcome.name }}</span>
1143
+ <span
1144
+ :class="outcome.step === 'failed'
1145
+ ? 'text-error'
1146
+ : 'text-medium-emphasis'"
1147
+ >
1148
+ {{ outcome.step === "done" ? "Deleted"
1149
+ : outcome.step === "failed" ? (outcome.message || "Failed")
1150
+ : outcome.step === "working" ? "Deleting..." : "Waiting" }}
1151
+ </span>
1152
+ </div>
1153
+ </div>
1154
+
1155
+ <div
1156
+ v-if="deleteSummary && deleteSummary.tone !== 'success'"
1157
+ class="text-caption text-error mt-2"
1158
+ >
1159
+ {{ deleteSummary.message }}
1160
+ </div>
1161
+
767
1162
  <div class="text-caption text-medium-emphasis mt-2">
768
- This action will remove the user from the HID reader and mark the
769
- identity as deleted.
1163
+ This removes the user from the reader and marks the identity as
1164
+ deleted, which also takes away the access that let them through.
770
1165
  </div>
771
1166
  </v-card-text>
772
1167
  <v-card-actions class="screen-modal__footer">
773
1168
  <v-btn
774
1169
  class="text-none screen-btn-ghost"
775
1170
  variant="flat"
1171
+ :disabled="deleting"
776
1172
  @click="deleteDialog = false"
777
1173
  >
778
1174
  Cancel
@@ -780,10 +1176,12 @@
780
1176
  <v-btn
781
1177
  class="text-none screen-btn-primary"
782
1178
  variant="flat"
783
- :loading="deleting"
1179
+ :loading="deleting || deletePlacesLoading"
784
1180
  @click="deleteUser"
785
1181
  >
786
- Delete
1182
+ {{ deleteSummary && deleteSummary.tone === "partial"
1183
+ ? "Try again"
1184
+ : "Delete" }}
787
1185
  </v-btn>
788
1186
  </v-card-actions>
789
1187
  </v-card>
@@ -804,9 +1202,51 @@ import {
804
1202
  hiddenSubjectNote,
805
1203
  memberCandidateOf,
806
1204
  memberHasAccount,
1205
+ HID_ALL_SERVICES,
1206
+ enrolmentSubjectOfCategory,
1207
+ permissionCategoryOf,
1208
+ providerAccountsKnown,
1209
+ providerCandidateOf,
1210
+ providerEnrollableRows,
807
1211
  readMemberPage,
1212
+ serviceFilterOf,
808
1213
  subjectCategoryLabel as labelForSubjectCategory,
1214
+ subjectLinkOf,
1215
+ type HidEnrolmentSubject,
809
1216
  } from "../utils/hid-enrolment-subject";
1217
+ import { nextRegistration } from "../utils/hid-registration";
1218
+ /* Mirrors core's audience rules — core is what enforces them; this only decides
1219
+ what is worth offering and where an enrolment lands. */
1220
+ import {
1221
+ availableEnrolmentSubjects,
1222
+ deleteFanOutSummary,
1223
+ deleteReachText,
1224
+ deleteScopeChoices,
1225
+ editFanOutSummary,
1226
+ fanOutProgressText,
1227
+ fanOutSummary,
1228
+ partitionEnrolmentTargets,
1229
+ type THidReaderOutcome,
1230
+ type THidReaderStep,
1231
+ } from "../utils/hid-reader-audiences";
1232
+ /* The SAME filter the Members > Service Providers screen applies, not a second
1233
+ copy of the rule: a picker that disagrees with the screen the operator was
1234
+ just looking at is a picker they cannot trust. Core applies it to the Excel
1235
+ export too (`filterSiteProviderMembers`). */
1236
+ import {
1237
+ filterProviderMembers,
1238
+ serviceTypeOptions,
1239
+ } from "../utils/site-provider-members";
1240
+ /* Bulk access is granted through the permissions document, and the PUT REPLACES
1241
+ this reader's whole assignment set. These three are what keep a bulk add from
1242
+ revoking everybody who was already on the door - see the file's own header. */
1243
+ import {
1244
+ seedAssignments,
1245
+ setAssignment,
1246
+ subjectOfIdentity,
1247
+ toAssignmentPayload,
1248
+ type HidAssignmentState,
1249
+ } from "../utils/hid-permission-assignments";
810
1250
 
811
1251
  const props = defineProps({
812
1252
  site: {
@@ -826,6 +1266,10 @@ const props = defineProps({
826
1266
  },
827
1267
  });
828
1268
 
1269
+ // Enrol, edit, delete, cards and PINs are all the users right. The screen
1270
+ // itself is behind `HidEnabledGate` (the view right).
1271
+ const { canManageHidUsers } = useHidAccessPermission();
1272
+
829
1273
  const {
830
1274
  getReaders,
831
1275
  getIdentities,
@@ -847,8 +1291,14 @@ const {
847
1291
  createIdentity,
848
1292
  updateIdentity,
849
1293
  deleteIdentity,
1294
+ pushStoredFace,
1295
+ getSitePermissions,
1296
+ updateSitePermissions,
850
1297
  } = useHidAmico();
851
1298
  const { getAll: getAllMembers } = useMember();
1299
+ /* Members > Service Providers reads this same call, so the enrolment picker
1300
+ offers exactly the people that screen lists. Site-scoped by the server. */
1301
+ const { getSiteMembers } = useServiceProvider();
852
1302
  const runtimeConfig = useRuntimeConfig();
853
1303
 
854
1304
  type HidMetadata = Record<string, unknown> & {
@@ -928,7 +1378,36 @@ const saving = ref(false);
928
1378
  const deleting = ref(false);
929
1379
  const formDialog = ref(false);
930
1380
  const viewDialog = ref(false);
1381
+ /** One reader that holds this person, and the ids that reader knows them by. */
1382
+ type TPersonPlace = {
1383
+ readerId: string;
1384
+ name: string;
1385
+ identityId: string;
1386
+ hidUserId: string;
1387
+ };
1388
+
931
1389
  const deleteDialog = ref(false);
1390
+ /* Where this person actually is, resolved when the dialog opens so the operator
1391
+ chooses the scope knowing the reach rather than after the fact. */
1392
+ const deletePlaces = ref<TPersonPlace[]>([]);
1393
+ const deletePlacesLoading = ref(false);
1394
+ const deleteScope = ref<"here" | "all">("here");
1395
+ /* Kept apart from the enrolment fan-out's outcomes on purpose: that list drives
1396
+ a Retry that RE-ENROLS, which is the wrong action for a reader a delete could
1397
+ not reach. */
1398
+ const deleteOutcomes = ref<THidReaderOutcome[]>([]);
1399
+ const deleteReach = computed(
1400
+ () => deleteReachText(deletePlaces.value, selectedReaderId.value),
1401
+ );
1402
+ const deleteScopeOptions = computed(() => deleteScopeChoices(
1403
+ deletePlaces.value.length,
1404
+ selectedReader.value?.name || "this reader",
1405
+ ));
1406
+ const deleteSummary = computed(() =>
1407
+ deleteOutcomes.value.length && !deleting.value
1408
+ ? deleteFanOutSummary(deleteOutcomes.value)
1409
+ : null
1410
+ );
932
1411
  const cardDialog = ref(false);
933
1412
  const selectedUser = ref<HidUser | null>(null);
934
1413
  const cardUser = ref<HidUser | null>(null);
@@ -1034,7 +1513,11 @@ type UnitPerson = {
1034
1513
  * the dialog, and `useHidReaderSelection` already set the precedent for where
1035
1514
  * that line sits.
1036
1515
  */
1037
- const REMEMBERED_SUBJECT_CATEGORY = ref<THidPermissionCategory>("resident");
1516
+ /* The dropdown's values, plus `service_provider` - which the dropdown does not
1517
+ offer but `openEdit` resolves from records that name a COMPANY. */
1518
+ type TEnrolmentSubjectValue = HidEnrolmentSubject | "service_provider";
1519
+
1520
+ const REMEMBERED_SUBJECT_CATEGORY = ref<HidEnrolmentSubject>("resident");
1038
1521
 
1039
1522
  const form = reactive({
1040
1523
  reader: "",
@@ -1048,7 +1531,7 @@ const form = reactive({
1048
1531
  accessPin: "",
1049
1532
  password: "",
1050
1533
  photoPreview: "",
1051
- subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
1534
+ subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value as TEnrolmentSubjectValue,
1052
1535
  subjectId: "",
1053
1536
  /** A mirror of the chosen member's role. Never sent; display only. */
1054
1537
  subjectRole: "",
@@ -1167,9 +1650,137 @@ const unitResidentNote = computed(() => {
1167
1650
 
1168
1651
  /* ── THE SUBJECT TYPE ──────────────────────────────────────────────────── */
1169
1652
 
1170
- const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
1653
+ /**
1654
+ * ONLY THE TYPES THE TARGETED READER WILL ACCEPT.
1655
+ *
1656
+ * Offering one it refuses is not merely untidy: the server rejects the grant on
1657
+ * SAVE, after a person has been chosen and a face captured - and the capture is
1658
+ * the expensive step at a reader.
1659
+ *
1660
+ * A SET of readers rather than one, so the fan-out enrolment that lands a person
1661
+ * on every matching reader asks the same question without this being rewritten.
1662
+ * Today the set holds whichever reader the screen has selected.
1663
+ */
1664
+ const enrolmentSubjects = computed(() => availableEnrolmentSubjects(
1665
+ [selectedReader.value as Record<string, unknown> | null],
1666
+ HID_ENROLMENT_SUBJECTS,
1667
+ (option) => permissionCategoryOf(option.value),
1668
+ ));
1669
+ const subjectCategoryOptions = computed(() => enrolmentSubjects.value.options);
1670
+
1671
+ /* ── WHICH READERS THIS ENROLMENT LANDS ON ─────────────────────────────────
1672
+ *
1673
+ * Enrolling is no longer "pick a reader". A person belongs on every reader at
1674
+ * the site that serves their account type, so the dialog states where they will
1675
+ * end up and why anything is left out, rather than asking.
1676
+ *
1677
+ * The excluded list is drawn too. An absent reader with no reason reads as a
1678
+ * screen that failed to load; a reader with "does not serve residents" beside it
1679
+ * reads as a setting somebody chose.
1680
+ */
1681
+ const enrolmentTargets = computed(() => partitionEnrolmentTargets(
1682
+ readers.value as Array<Record<string, unknown>>,
1683
+ permissionCategoryOf(form.subjectCategory),
1684
+ ));
1685
+
1686
+ /**
1687
+ * How each reader is getting on, in the order they are being written.
1688
+ *
1689
+ * Held for the whole save so the list can be read after it finishes: a reader
1690
+ * that failed keeps its row and its Retry, and the person stays enrolled on the
1691
+ * ones that worked. Cleared when the dialog opens, never when a save ends.
1692
+ */
1693
+ const readerOutcomes = ref<THidReaderOutcome[]>([]);
1694
+ const fanOutRunning = ref(false);
1695
+ const fanOutProgress = computed(() => fanOutProgressText(readerOutcomes.value));
1696
+ const fanOutResult = computed(() =>
1697
+ readerOutcomes.value.length && !fanOutRunning.value
1698
+ ? fanOutSummary(readerOutcomes.value)
1699
+ : null
1700
+ );
1701
+ const failedReaders = computed(() =>
1702
+ readerOutcomes.value.filter((outcome) => outcome.step === "failed")
1703
+ );
1704
+ /* Where an edit will land, resolved when the form opens so the dialog can say
1705
+ it BEFORE the operator changes a PIN - "this also changes the gym" is only
1706
+ useful in advance. */
1707
+ const editPlaces = ref<TPersonPlace[]>([]);
1708
+ const editPlacesLoading = ref(false);
1709
+ /* Before a save, the readers that hold this person; during and after it, those
1710
+ same readers with each one's own state. One list either way, so the rows do
1711
+ not move when the save starts. */
1712
+ type TEditRow = {
1713
+ readerId: string;
1714
+ name: string;
1715
+ /* "target" is this list's own state and not a fan-out step: a reader that
1716
+ holds this person but has not been asked to do anything yet. */
1717
+ step: THidReaderStep | "target";
1718
+ message?: string;
1719
+ };
1720
+ const editRows = computed<TEditRow[]>(() => (
1721
+ readerOutcomes.value.length
1722
+ ? readerOutcomes.value
1723
+ : editPlaces.value.map((place) => ({
1724
+ readerId: place.readerId,
1725
+ name: place.name,
1726
+ step: "target" as const,
1727
+ }))
1728
+ ));
1729
+ /* The edit's own summary: it has to speak about the PIN, which an enrolment's
1730
+ does not - a PIN that reached some readers and not others is worse than one
1731
+ that reached none. */
1732
+ const editResult = computed(() =>
1733
+ readerOutcomes.value.length && !fanOutRunning.value
1734
+ ? editFanOutSummary(readerOutcomes.value, Boolean(form.accessPin))
1735
+ : null
1736
+ );
1737
+
1738
+ function setReaderStep(
1739
+ readerId: string,
1740
+ step: THidReaderStep,
1741
+ extra: { message?: string; retryable?: boolean } = {},
1742
+ ) {
1743
+ readerOutcomes.value = readerOutcomes.value.map((outcome) => (
1744
+ outcome.readerId === readerId ? { ...outcome, step, ...extra } : outcome
1745
+ ));
1746
+ }
1747
+
1748
+ /* Why a type is missing, and - when none is left - why there is nothing to pick.
1749
+ An absent option with no explanation reads as a broken screen. */
1750
+ const subjectCategoryNote = computed(() => {
1751
+ if (enrolmentSubjects.value.emptied) {
1752
+ return "This reader has no types switched on. Switch Resident, Member or"
1753
+ + " Service provider on in the reader's settings before enrolling anybody here.";
1754
+ }
1755
+ if (enrolmentSubjects.value.narrowed) {
1756
+ return "Only the kinds of people this reader serves are listed.";
1757
+ }
1758
+ return "";
1759
+ });
1171
1760
 
1172
1761
  const isResidentSubject = computed(() => form.subjectCategory === "resident");
1762
+ /* Members and provider staff both pick ONE PERSON from a flat list, but from
1763
+ different lists, so they are separate branches rather than "not a resident". */
1764
+ const isMemberSubject = computed(
1765
+ () => form.subjectCategory === "property_management"
1766
+ );
1767
+ const isProviderSubject = computed(
1768
+ () => form.subjectCategory === "service_provider_staff"
1769
+ );
1770
+ /** Either of the two person pickers - what the Role mirror follows. */
1771
+ const isPersonSubject = computed(
1772
+ () => isMemberSubject.value || isProviderSubject.value
1773
+ );
1774
+
1775
+ /* The mirror's label follows the list it mirrors. A member's subtitle is their
1776
+ role at the site; a contractor's is the firm they work for, and calling that
1777
+ "Role" would read as a mistake. */
1778
+ const subjectMirrorLabel = computed(() =>
1779
+ isProviderSubject.value ? "Company" : "Role"
1780
+ );
1781
+ const subjectMirrorPlaceholder = computed(() =>
1782
+ isProviderSubject.value ? "Select a person above" : "Select a member above"
1783
+ );
1173
1784
 
1174
1785
  /* What an EDIT shows in place of the control, since the type cannot change.
1175
1786
  Looked up in the util, not in the switch's own options: records carry
@@ -1363,6 +1974,387 @@ function loadMoreMembers() {
1363
1974
  return fetchMemberPage(memberPage.value + 1, true);
1364
1975
  }
1365
1976
 
1977
+ /* ── SERVICE PROVIDER STAFF ───────────────────────────────────────────────
1978
+ *
1979
+ * ONE PERSON from a provider company, never the company.
1980
+ *
1981
+ * Read from `GET /api/service-providers/site-members` - the same call behind
1982
+ * Members > Service Providers, with the same `filterProviderMembers` rule - so
1983
+ * the picker lists exactly the people that screen lists. The server scopes it to
1984
+ * the site; these are the contractors who attend THIS site, not every employee
1985
+ * of every firm engaged with the organisation.
1986
+ *
1987
+ * Unpaged, deliberately, unlike the staff picker above: this endpoint returns
1988
+ * the site's people in one response (capped at 1000 by the repository) and has
1989
+ * no per-page cache entry to disagree with. The service filter and the search
1990
+ * box both run over the loaded list, so neither costs a request.
1991
+ */
1992
+ const providerPeople = ref<Record<string, unknown>[]>([]);
1993
+ const providerCompanyCount = ref(0);
1994
+ const loadingProviders = ref(false);
1995
+ const providersLoaded = ref(false);
1996
+ /**
1997
+ * All services is the default, the same as the Members screen - but it carries a
1998
+ * REAL value here, not "". `AppSelect`'s `hasSingleValue` is false for an empty
1999
+ * string, so an empty-valued option draws the placeholder in placeholder grey and
2000
+ * is never marked as chosen: the filter worked and the field looked blank. See
2001
+ * `serviceFilterOf`.
2002
+ */
2003
+ const providerService = ref<string>(HID_ALL_SERVICES);
2004
+
2005
+ /** What the filter actually receives: "" for All services. */
2006
+ const providerServiceFilter = computed(() => serviceFilterOf(providerService.value));
2007
+ let providerRequestSeq = 0;
2008
+
2009
+ /** Everyone who can actually be given a door: an app account, and active. */
2010
+ const providerEnrollable = computed(() =>
2011
+ providerEnrollableRows(providerPeople.value)
2012
+ );
2013
+
2014
+ /* Whether the endpoint told us who holds an app account. It only began
2015
+ returning `user` in core 3.130, and this layer can be deployed ahead of it. */
2016
+ const providerAccountsVisible = computed(() =>
2017
+ providerAccountsKnown(providerPeople.value)
2018
+ );
2019
+
2020
+ /** People the operator can see on the Members screen but cannot enrol here. */
2021
+ const hiddenProviderCount = computed(
2022
+ () => providerPeople.value.length - providerEnrollable.value.length
2023
+ );
2024
+
2025
+ const providerServiceOptions = computed(() => [
2026
+ { title: "All services", value: HID_ALL_SERVICES },
2027
+ // From the ENROLLABLE set, so choosing a service never lands on an empty list.
2028
+ ...serviceTypeOptions(providerEnrollable.value as Array<{ type?: string; typeLabel?: string }>),
2029
+ ]);
2030
+
2031
+ /** The filtered list - what both the picker and the bulk table draw. */
2032
+ const providerRows = computed(() =>
2033
+ filterProviderMembers(providerEnrollable.value as Array<Record<string, any>>, {
2034
+ type: providerServiceFilter.value,
2035
+ })
2036
+ );
2037
+
2038
+ const providerOptions = computed(() =>
2039
+ providerRows.value.map((row) => {
2040
+ const candidate = providerCandidateOf(row);
2041
+ return {
2042
+ // The firm in the title as well as the mirror: two contractors sharing a
2043
+ // first name is the normal case, and the popover is where they have to be
2044
+ // told apart.
2045
+ title: candidate.subtitle
2046
+ ? `${candidate.name} — ${candidate.subtitle}`
2047
+ : candidate.name,
2048
+ value: candidate.subjectId,
2049
+ };
2050
+ })
2051
+ );
2052
+
2053
+ const providerPlaceholder = computed(() => {
2054
+ if (loadingProviders.value) return "Loading people...";
2055
+ if (!providersLoaded.value) return "Select a person";
2056
+ if (providerOptions.value.length) return "Select a person";
2057
+ if (providerServiceFilter.value) return "Nobody in this service can be enrolled yet";
2058
+ if (hiddenProviderCount.value) return "Nobody here can be enrolled yet";
2059
+ return providerCompanyCount.value
2060
+ ? "No service provider people at this site"
2061
+ : "No service providers work at this site yet";
2062
+ });
2063
+
2064
+ /* Say why somebody on the Members screen is not in this list, rather than
2065
+ letting a missing colleague read as a broken dialog. */
2066
+ const hiddenProviderNote = computed(() => {
2067
+ if (loadingProviders.value || !hiddenProviderCount.value) return "";
2068
+ const count = hiddenProviderCount.value;
2069
+ const subject = count === 1 ? "1 person" : `${count} people`;
2070
+ const verb = count === 1 ? "is" : "are";
2071
+ const holder = count === 1 ? "that person is" : "they are";
2072
+ const needs = providerAccountsVisible.value
2073
+ ? "an active membership with an app account"
2074
+ : "an active membership at this site";
2075
+ return `${subject} from this site's service providers ${verb} not listed:`
2076
+ + ` HID enrollment needs ${needs}, and ${holder} without one.`;
2077
+ });
2078
+
2079
+ /** Back to "nothing loaded", so the next open starts clean. */
2080
+ function resetProviderCandidates() {
2081
+ providerRequestSeq += 1;
2082
+ providerPeople.value = [];
2083
+ providerCompanyCount.value = 0;
2084
+ providerService.value = HID_ALL_SERVICES;
2085
+ providersLoaded.value = false;
2086
+ }
2087
+
2088
+ /**
2089
+ * This site's service provider people.
2090
+ *
2091
+ * NEVER THROWS, on the same rule as the staff loader: choosing Service provider
2092
+ * must not break the dialog. The placeholder carries the failure.
2093
+ */
2094
+ async function loadProviderCandidates() {
2095
+ if (!props.site) {
2096
+ resetProviderCandidates();
2097
+ providersLoaded.value = true;
2098
+ return;
2099
+ }
2100
+ const seq = (providerRequestSeq += 1);
2101
+ loadingProviders.value = true;
2102
+ try {
2103
+ const data = await getSiteMembers(props.site);
2104
+ // A response that arrived after the dialog was reopened belongs to a list
2105
+ // that no longer exists - the same guard the staff pager uses.
2106
+ if (seq !== providerRequestSeq) return;
2107
+ providerPeople.value = Array.isArray(data?.items)
2108
+ ? (data.items as unknown as Record<string, unknown>[])
2109
+ : [];
2110
+ providerCompanyCount.value = Array.isArray(data?.companies)
2111
+ ? data.companies.length
2112
+ : 0;
2113
+ providersLoaded.value = true;
2114
+ } catch (error: unknown) {
2115
+ if (seq !== providerRequestSeq) return;
2116
+ console.error("Unable to load service provider people for HID enrollment:", error);
2117
+ providerPeople.value = [];
2118
+ providerCompanyCount.value = 0;
2119
+ providersLoaded.value = true;
2120
+ } finally {
2121
+ loadingProviders.value = false;
2122
+ }
2123
+ }
2124
+
2125
+ /** The chosen person, restated into Name and the Company mirror. */
2126
+ function onProviderChanged() {
2127
+ // Compared on the TRIMMED id, because that is what the option's value carries
2128
+ // (`providerCandidateOf` trims) - matching the raw field would silently find
2129
+ // nobody and blank the name the operator just picked.
2130
+ const row = providerRows.value.find(
2131
+ (item) => String(item._id ?? "").trim() === form.subjectId
2132
+ );
2133
+ const candidate = row ? providerCandidateOf(row) : null;
2134
+ form.name = candidate?.name ?? "";
2135
+ form.subjectRole = candidate?.subtitle ?? "";
2136
+ void checkSubjectEnrollment();
2137
+ }
2138
+
2139
+ /* Changing the service can take the chosen person out of the list. Leaving a
2140
+ selection that is no longer on screen is how a save ends up naming somebody
2141
+ the operator cannot see. */
2142
+ function onProviderServiceChanged() {
2143
+ if (!form.subjectId) return;
2144
+ const stillThere = providerRows.value.some(
2145
+ (item) => String(item._id ?? "").trim() === form.subjectId
2146
+ );
2147
+ if (stillThere) return;
2148
+ form.subjectId = "";
2149
+ form.name = "";
2150
+ form.subjectRole = "";
2151
+ residentConflict.value = null;
2152
+ }
2153
+
2154
+ /* ── ADD USERS IN BULK ────────────────────────────────────────────────────
2155
+ *
2156
+ * ACCESS, NOT CREDENTIALS. This grants a reader group to many people at once and
2157
+ * stops there - no face, no card, no PIN. Each person then adds their own
2158
+ * credential from their app, which attaches to the HID user this grant created
2159
+ * (`getProfileEnrollmentContext` reuses the existing profile identity), so
2160
+ * nobody has to be enrolled twice.
2161
+ *
2162
+ * It writes through `PUT /sites/:siteId/permissions`, the one call that writes
2163
+ * group membership, and the device users are created by the reconcile that call
2164
+ * triggers: `provisionPermissionUser` writes `{ name, registration }` and
2165
+ * nothing else. That is exactly "user record and group, credentials empty".
2166
+ *
2167
+ * THE TRAP, and the reason the saved set is fetched first: that PUT REPLACES
2168
+ * this reader's whole assignment set. Sending only the ticked contractors would
2169
+ * revoke every resident and every member on the door. The payload is always
2170
+ * built from the reader's existing assignments PLUS the selection.
2171
+ */
2172
+ const bulkDialog = ref(false);
2173
+ const bulkLoading = ref(false);
2174
+ const bulkSaving = ref(false);
2175
+ const bulkSearch = ref("");
2176
+ /** The reader's CURRENT assignments, across every category. The merge base. */
2177
+ const bulkSaved = ref<HidAssignmentState>(new Map());
2178
+ /** Who is being added now. Already-granted people are never in here. */
2179
+ const bulkSelected = ref<Set<string>>(new Set());
2180
+
2181
+ /* NAME THE DOOR. A bulk grant is reader-scoped and the reader was chosen on the
2182
+ screen behind two dialogs, so the confirmation says which one rather than
2183
+ leaving the operator to remember. */
2184
+ const bulkReaderLabel = computed(() => {
2185
+ const reader = selectedReader.value;
2186
+ const name = String(reader?.name ?? "").trim() || "this HID reader";
2187
+ const location = String(reader?.location ?? "").trim();
2188
+ return location ? `${name} · ${location}` : name;
2189
+ });
2190
+
2191
+ /** The same people the picker offers, narrowed by this dialog's own search. */
2192
+ const bulkRows = computed(() => {
2193
+ const rows = filterProviderMembers(
2194
+ providerRows.value as Array<Record<string, any>>,
2195
+ { search: bulkSearch.value }
2196
+ );
2197
+ return rows.map((row) => {
2198
+ const candidate = providerCandidateOf(row);
2199
+ const id = candidate.subjectId;
2200
+ return {
2201
+ subjectId: id,
2202
+ name: candidate.name,
2203
+ company: String(row.company ?? "").trim(),
2204
+ service: String(row.typeLabel ?? row.type ?? "").trim(),
2205
+ // Already on the door. Ticked and locked rather than hidden: hiding them
2206
+ // would make "select all" look like it had missed people, and unticking
2207
+ // one here would revoke access this dialog is not for revoking.
2208
+ granted: bulkSaved.value.has(id),
2209
+ };
2210
+ });
2211
+ });
2212
+
2213
+ const bulkSelectableRows = computed(() =>
2214
+ bulkRows.value.filter((row) => !row.granted)
2215
+ );
2216
+ const bulkSelectedCount = computed(() => bulkSelected.value.size);
2217
+ const bulkAllSelected = computed(
2218
+ () =>
2219
+ bulkSelectableRows.value.length > 0 &&
2220
+ bulkSelectableRows.value.every((row) => bulkSelected.value.has(row.subjectId))
2221
+ );
2222
+ const bulkGrantedCount = computed(
2223
+ () => bulkRows.value.filter((row) => row.granted).length
2224
+ );
2225
+
2226
+ const bulkEmptyText = computed(() => {
2227
+ if (bulkLoading.value) return "Loading...";
2228
+ if (bulkSearch.value.trim()) return "No one matches this search.";
2229
+ if (providerServiceFilter.value) return "No one in this service can be enrolled yet.";
2230
+ return "No service provider people can be enrolled at this site yet.";
2231
+ });
2232
+
2233
+ function isBulkSelected(subjectId: string) {
2234
+ return bulkSelected.value.has(subjectId);
2235
+ }
2236
+
2237
+ function toggleBulkRow(subjectId: string, selected: boolean) {
2238
+ const next = new Set(bulkSelected.value);
2239
+ if (selected) next.add(subjectId);
2240
+ else next.delete(subjectId);
2241
+ bulkSelected.value = next;
2242
+ }
2243
+
2244
+ /* Select-all acts on the ROWS IN FRONT OF THE OPERATOR - what the service filter
2245
+ and the search have left - never on the whole estate. A tick box cannot
2246
+ quietly grant a door to people who are not on screen. */
2247
+ function toggleBulkAll(selected: boolean) {
2248
+ const next = new Set(bulkSelected.value);
2249
+ for (const row of bulkSelectableRows.value) {
2250
+ if (selected) next.add(row.subjectId);
2251
+ else next.delete(row.subjectId);
2252
+ }
2253
+ bulkSelected.value = next;
2254
+ }
2255
+
2256
+ /**
2257
+ * Open the bulk dialog, after reading what the reader already holds.
2258
+ *
2259
+ * The saved set is fetched EVERY time rather than cached: it is the merge base
2260
+ * for a write that replaces the reader's assignments, and a stale one would
2261
+ * revoke whoever was granted in between.
2262
+ */
2263
+ async function openBulkAdd() {
2264
+ const readerId =
2265
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
2266
+ if (!readerId) {
2267
+ showToast("Select a HID reader before adding people in bulk.", "error");
2268
+ return;
2269
+ }
2270
+ /* The permissions endpoints REQUIRE an org - `schemaHidPermissionScopeQuery`
2271
+ has it as `required()`, unlike `createIdentity`, which falls back to the
2272
+ site's own. Both pages that mount this component pass one, so this is a
2273
+ guard against a 400 that would read as a server fault. */
2274
+ if (!props.org) {
2275
+ showToast(
2276
+ "This screen cannot grant access in bulk without an organization.",
2277
+ "error"
2278
+ );
2279
+ return;
2280
+ }
2281
+ bulkSearch.value = "";
2282
+ bulkSelected.value = new Set();
2283
+ bulkSaved.value = new Map();
2284
+ bulkDialog.value = true;
2285
+ bulkLoading.value = true;
2286
+ try {
2287
+ if (!providersLoaded.value) await loadProviderCandidates();
2288
+ const response = await getSitePermissions(props.site, props.org, readerId);
2289
+ bulkSaved.value = seedAssignments(response?.data?.assignments ?? []);
2290
+ } catch (error: unknown) {
2291
+ // Closed rather than left open on an empty merge base: saving from here
2292
+ // would send only the ticked people and revoke everybody else.
2293
+ bulkDialog.value = false;
2294
+ showToast(
2295
+ apiReason(error) || "Unable to read this reader's current access.",
2296
+ "error"
2297
+ );
2298
+ } finally {
2299
+ bulkLoading.value = false;
2300
+ }
2301
+ }
2302
+
2303
+ /** Grant the ticked people, keeping everyone the reader already admits. */
2304
+ async function saveBulkAdd() {
2305
+ if (bulkSaving.value || !bulkSelected.value.size) return;
2306
+ const readerId =
2307
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
2308
+ if (!readerId) {
2309
+ showToast("Select a HID reader before adding people in bulk.", "error");
2310
+ return;
2311
+ }
2312
+
2313
+ bulkSaving.value = true;
2314
+ try {
2315
+ // Seeded from what the server holds, so every category the reader already
2316
+ // admits survives this write.
2317
+ let draft: HidAssignmentState = new Map(bulkSaved.value);
2318
+ for (const subjectId of bulkSelected.value) {
2319
+ draft = setAssignment(
2320
+ draft,
2321
+ bulkSaved.value,
2322
+ subjectId,
2323
+ // A provider employee's grant is stored under the STAFF category: core's
2324
+ // `permissionSubjectOfIdentity` reads the `member` link and cannot see
2325
+ // this dropdown, and that category's subject list already includes these
2326
+ // people. See `permissionCategoryOf`.
2327
+ permissionCategoryOf("service_provider_staff") as THidPermissionCategory,
2328
+ true
2329
+ );
2330
+ }
2331
+ const added = bulkSelected.value.size;
2332
+ await updateSitePermissions(
2333
+ props.site,
2334
+ props.org,
2335
+ readerId,
2336
+ toAssignmentPayload(draft)
2337
+ );
2338
+ // Both dialogs close and the list is re-read: the reconcile has just created
2339
+ // a HID user for each person, so the roster behind this dialog is stale.
2340
+ bulkDialog.value = false;
2341
+ formDialog.value = false;
2342
+ await loadUsers();
2343
+ showToast(
2344
+ `${added} ${added === 1 ? "person" : "people"} can now use this reader. `
2345
+ + "They add their own face, QR or PIN from their app.",
2346
+ "success"
2347
+ );
2348
+ } catch (error: unknown) {
2349
+ showToast(
2350
+ apiReason(error) || "Unable to give these people access to this reader.",
2351
+ "error"
2352
+ );
2353
+ } finally {
2354
+ bulkSaving.value = false;
2355
+ }
2356
+ }
2357
+
1366
2358
  /**
1367
2359
  * Switching type clears the chosen subject, and nothing else.
1368
2360
  *
@@ -1372,15 +2364,20 @@ function loadMoreMembers() {
1372
2364
  * choice, so they clear with it.
1373
2365
  */
1374
2366
  async function onSubjectCategoryChanged() {
1375
- REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory;
2367
+ REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory as HidEnrolmentSubject;
1376
2368
  form.subjectId = "";
1377
2369
  form.subjectRole = "";
1378
2370
  form.name = "";
1379
2371
  residentConflict.value = null;
1380
2372
 
1381
- if (!isResidentSubject.value && !membersLoaded.value) {
2373
+ // Each list is fetched on first use and then kept, so flipping between the
2374
+ // three options costs one request each rather than one per flip.
2375
+ if (isMemberSubject.value && !membersLoaded.value) {
1382
2376
  await loadMemberCandidates();
1383
2377
  }
2378
+ if (isProviderSubject.value && !providersLoaded.value) {
2379
+ await loadProviderCandidates();
2380
+ }
1384
2381
  }
1385
2382
 
1386
2383
  /* The staff equivalent of `onResidentChanged`. */
@@ -1576,7 +2573,14 @@ async function init() {
1576
2573
  await loadUsers();
1577
2574
  }
1578
2575
 
1579
- async function loadReaders() {
2576
+ /**
2577
+ * `resolveSelection` is off when the enrolment dialog refreshes this.
2578
+ *
2579
+ * The tail of this function re-resolves the SHARED reader selection, which is
2580
+ * right on first load and wrong from a dialog: opening Enroll should not be able
2581
+ * to move the reader the operator picked on the screen behind it.
2582
+ */
2583
+ async function loadReaders(options: { resolveSelection?: boolean } = {}) {
1580
2584
  if (!props.site) return;
1581
2585
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
1582
2586
  const responseRecord = toRecord(response);
@@ -1590,7 +2594,9 @@ async function loadReaders() {
1590
2594
  resolver refuses a remembered reader that is not this site's, so a site
1591
2595
  switch or a deleted reader falls back to the first rather than querying
1592
2596
  an id the site does not own. */
1593
- selectedReaderId.value = resolveForSite(props.site, readers.value);
2597
+ if (options.resolveSelection !== false) {
2598
+ selectedReaderId.value = resolveForSite(props.site, readers.value);
2599
+ }
1594
2600
  }
1595
2601
 
1596
2602
  /**
@@ -1726,9 +2732,19 @@ function resetForm() {
1726
2732
  selectedPhotoFile.value = null;
1727
2733
  removePhotoRequested.value = false;
1728
2734
  facialEnrolled.value = false;
1729
- // The type the operator last enrolled, not a hard "resident": somebody adding
1730
- // a shift of guards should not re-pick Member for each one.
1731
- form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
2735
+ /*
2736
+ * The type the operator last enrolled, not a hard "resident": somebody adding
2737
+ * a shift of guards should not re-pick Member for each one.
2738
+ *
2739
+ * UNLESS this reader does not serve it. `AppSelect` draws blank when its value
2740
+ * is not among its items - the same way "All services" drew blank when its
2741
+ * value was "" - so the remembered type falls back to the first one on offer.
2742
+ */
2743
+ const offered = subjectCategoryOptions.value;
2744
+ const remembered = REMEMBERED_SUBJECT_CATEGORY.value;
2745
+ form.subjectCategory = offered.some((option) => option.value === remembered)
2746
+ ? remembered
2747
+ : (offered[0]?.value ?? remembered);
1732
2748
  form.subjectId = "";
1733
2749
  form.subjectRole = "";
1734
2750
  residentConflict.value = null;
@@ -1925,11 +2941,25 @@ async function checkSubjectEnrollment() {
1925
2941
 
1926
2942
  async function openEnroll() {
1927
2943
  selectedUser.value = null;
1928
- resetForm();
2944
+ /*
2945
+ * READERS FIRST, and before `resetForm`.
2946
+ *
2947
+ * The audiences decide which types are offered and which one the form opens
2948
+ * on, and they are edited on a different screen - so the copy loaded when this
2949
+ * screen mounted can be stale by the time somebody clicks Enroll. Without this
2950
+ * the dropdown offers a type the reader no longer serves until the page is
2951
+ * reloaded.
2952
+ *
2953
+ * Folded into the same wait as the id allocations so it costs no extra delay,
2954
+ * and with the shared selection left alone: opening Enroll must not move the
2955
+ * reader chosen on the screen behind it.
2956
+ */
1929
2957
  const [hidUserId, registration] = await Promise.all([
1930
- getNextUid(),
1931
- getNextRegistration(),
2958
+ getNextUid(selectedReaderId.value),
2959
+ getNextRegistration(selectedReaderId.value),
2960
+ loadReaders({ resolveSelection: false }),
1932
2961
  ]);
2962
+ resetForm();
1933
2963
  form.hidUserId = hidUserId;
1934
2964
  form.registration = registration;
1935
2965
  formDialog.value = true;
@@ -1938,8 +2968,11 @@ async function openEnroll() {
1938
2968
  // onto Member with an empty list. Loaded after the dialog is up, not before:
1939
2969
  // the request must not delay it, and the picker carries its own loading
1940
2970
  // placeholder. Fresh each opening, because staff change between them.
2971
+ readerOutcomes.value = [];
1941
2972
  resetMemberCandidates();
1942
- if (!isResidentSubject.value) await loadMemberCandidates();
2973
+ resetProviderCandidates();
2974
+ if (isMemberSubject.value) await loadMemberCandidates();
2975
+ if (isProviderSubject.value) await loadProviderCandidates();
1943
2976
  }
1944
2977
 
1945
2978
  async function openEdit(user: HidUser) {
@@ -1961,14 +2994,21 @@ async function openEdit(user: HidUser) {
1961
2994
  removePinRequested.value = false;
1962
2995
  form.photoPreview = "";
1963
2996
  facialEnrolled.value = hasFacialEnrollment(user);
1964
- form.subjectCategory = user.person
1965
- ? "resident"
1966
- : user.serviceProvider
1967
- ? "service_provider"
1968
- : "property_management";
1969
- form.subjectId = String(
1970
- user.person || user.member || user.serviceProvider || ""
1971
- );
2997
+ /*
2998
+ * ONE reading of the links, shared with the Access Permissions screen.
2999
+ * `subjectOfIdentity` knows that a `member` link is how BOTH a
3000
+ * property-management member and a service provider's own employee are stored,
3001
+ * and uses the identity's `type` to tell them apart - without which a
3002
+ * contractor's edit would be headed "Member".
3003
+ *
3004
+ * This was the same ternary written out inline. The other copy of it went
3005
+ * stale, so neither is written out any more.
3006
+ */
3007
+ const editedSubject = subjectOfIdentity(user as Record<string, unknown>);
3008
+ form.subjectCategory = enrolmentSubjectOfCategory(
3009
+ editedSubject?.category
3010
+ ) as TEnrolmentSubjectValue;
3011
+ form.subjectId = editedSubject?.subjectId ?? "";
1972
3012
  // The resident this user already holds is not a conflict with itself. A real
1973
3013
  // one only appears if the operator reassigns the row to somebody else.
1974
3014
  residentConflict.value = null;
@@ -1984,8 +3024,22 @@ async function openEdit(user: HidUser) {
1984
3024
  if (form.unit) await loadUnitPeople();
1985
3025
  selectedPhotoFile.value = null;
1986
3026
  removePhotoRequested.value = false;
3027
+ readerOutcomes.value = [];
3028
+ editPlaces.value = [];
1987
3029
  formDialog.value = true;
1988
3030
 
3031
+ // After the dialog is up, like the candidate lists: it is several requests and
3032
+ // the form must not wait on them. A failure leaves the list empty and `saveUser`
3033
+ // resolves it again rather than skipping readers.
3034
+ editPlacesLoading.value = true;
3035
+ try {
3036
+ editPlaces.value = await findPersonReaders(user);
3037
+ } catch (error: unknown) {
3038
+ console.error("Unable to work out which readers hold this person:", error);
3039
+ } finally {
3040
+ editPlacesLoading.value = false;
3041
+ }
3042
+
1989
3043
  try {
1990
3044
  const hidUserId = toHidNumericId(user.hidUserId);
1991
3045
  const [pinStatus, passwordStatus] = await Promise.all([
@@ -2004,9 +3058,26 @@ function openView(user: HidUser) {
2004
3058
  viewDialog.value = true;
2005
3059
  }
2006
3060
 
2007
- function openDelete(user: HidUser) {
3061
+ async function openDelete(user: HidUser) {
2008
3062
  selectedUser.value = user;
3063
+ // Always reopened narrow. A scope carried over from the last delete would
3064
+ // widen a delete the operator did not widen.
3065
+ deleteScope.value = "here";
3066
+ deleteOutcomes.value = [];
3067
+ deletePlaces.value = [];
2009
3068
  deleteDialog.value = true;
3069
+
3070
+ deletePlacesLoading.value = true;
3071
+ try {
3072
+ deletePlaces.value = await findPersonReaders(user);
3073
+ } catch (error: unknown) {
3074
+ // The reach is what the scope choice is FOR, so without it the dialog
3075
+ // offers only this reader rather than guessing at "all".
3076
+ console.error("Unable to work out which readers hold this person:", error);
3077
+ deletePlaces.value = [];
3078
+ } finally {
3079
+ deletePlacesLoading.value = false;
3080
+ }
2010
3081
  }
2011
3082
 
2012
3083
  function triggerPhotoInput() {
@@ -2081,6 +3152,8 @@ async function saveUser() {
2081
3152
  : !selectedUser.value && !form.subjectId
2082
3153
  ? isResidentSubject.value
2083
3154
  ? "a resident"
3155
+ : isProviderSubject.value
3156
+ ? "a person"
2084
3157
  : "a member"
2085
3158
  : !form.name
2086
3159
  ? "a name"
@@ -2122,7 +3195,6 @@ async function saveUser() {
2122
3195
  }
2123
3196
 
2124
3197
  saving.value = true;
2125
- let newlyCreatedHidUserId: number | undefined;
2126
3198
  try {
2127
3199
  const hidUser = toHidUserObject({
2128
3200
  hidUserId: form.hidUserId,
@@ -2139,27 +3211,7 @@ async function saveUser() {
2139
3211
  // an empty string as a link being removed and then refuses the write for
2140
3212
  // having no subject at all. Omitting the keys is what makes it keep the
2141
3213
  // 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
- : {};
3214
+ const subjectLink = subjectLinkOf(form.subjectCategory, form.subjectId);
2163
3215
 
2164
3216
  const payload = {
2165
3217
  hidUserId: String(hidUser.id),
@@ -2187,61 +3239,359 @@ async function saveUser() {
2187
3239
  };
2188
3240
 
2189
3241
  if (selectedUser.value) {
2190
- await updateHidUserOnReader(readerId, hidUser);
2191
- const facialResult = await syncFacialImage(readerId, hidUser.id);
2192
- applyFacialResult(payload.metadata, facialResult);
2193
- payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
2194
- payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
2195
- if (selectedUser.value._id) {
2196
- await updateIdentity(selectedUser.value._id, payload);
2197
- } else if (form.subjectId) {
2198
- await saveIdentityOnReader(readerId, payload);
3242
+ /*
3243
+ * AN EDIT REACHES EVERY READER THIS PERSON IS ON.
3244
+ *
3245
+ * After a fan-out they are on several, and a change applied to one leaves
3246
+ * the others wrong in a way nothing on screen would show: a PIN changed at
3247
+ * the lobby and not at the gym means the person has two PINs and no way to
3248
+ * know which door wants which.
3249
+ *
3250
+ * Each reader knows them by its OWN HID user id, so the id comes from that
3251
+ * reader's identity rather than from the row on screen.
3252
+ *
3253
+ * REGISTRATION IS NOT FANNED OUT. Each reader allocates its own from its
3254
+ * own numbering, and one number written to all of them would collide with
3255
+ * whatever else holds it there.
3256
+ */
3257
+ const places = editPlaces.value.length
3258
+ ? editPlaces.value
3259
+ : await findPersonReaders(selectedUser.value);
3260
+ readerOutcomes.value = places.map((place) => ({
3261
+ readerId: place.readerId,
3262
+ name: place.name,
3263
+ step: "waiting" as const,
3264
+ }));
3265
+ fanOutRunning.value = true;
3266
+ for (const place of places) {
3267
+ setReaderStep(place.readerId, "working");
3268
+ try {
3269
+ const readerUser = toHidUserObject({
3270
+ hidUserId: place.hidUserId,
3271
+ name: form.name,
3272
+ // This reader's own, left exactly as it is.
3273
+ registration: "",
3274
+ });
3275
+ if (!readerUser.id) throw new Error("This reader has no usable HID user ID for this person.");
3276
+ await updateHidUserOnReader(place.readerId, {
3277
+ ...readerUser,
3278
+ registration: "",
3279
+ });
3280
+ const perReader = { ...payload, metadata: { ...payload.metadata } };
3281
+ applyFacialResult(
3282
+ perReader.metadata,
3283
+ await syncFacialImage(place.readerId, readerUser.id),
3284
+ );
3285
+ perReader.metadata.pinEnrolled = await syncAccessPin(place.readerId, readerUser.id);
3286
+ perReader.metadata.passwordSet = await syncPassword(place.readerId, readerUser.id);
3287
+ if (place.identityId) {
3288
+ await updateIdentity(place.identityId, {
3289
+ ...perReader,
3290
+ hidUserId: place.hidUserId,
3291
+ // Untouched: see above.
3292
+ registration: undefined as unknown as string,
3293
+ });
3294
+ }
3295
+ setReaderStep(place.readerId, "done");
3296
+ } catch (error: unknown) {
3297
+ setReaderStep(place.readerId, "failed", {
3298
+ message: getHidErrorMessage(error),
3299
+ retryable: false,
3300
+ });
3301
+ console.error(`Unable to update this person on reader ${place.readerId}:`, error);
3302
+ }
2199
3303
  }
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.
3304
+ fanOutRunning.value = false;
3305
+
3306
+ const summary = editFanOutSummary(readerOutcomes.value, Boolean(form.accessPin));
3307
+ await loadUsers();
3308
+ if (summary.tone === "success") formDialog.value = false;
3309
+ showToast(summary.message, summary.tone === "success" ? "success" : "error");
3310
+ return;
2203
3311
  } else {
2204
- const created = await createHidUserOnReader(readerId, hidUser);
2205
- if (created) newlyCreatedHidUserId = hidUser.id;
2206
- const facialResult = await syncFacialImage(readerId, hidUser.id);
2207
- applyFacialResult(payload.metadata, facialResult);
2208
- payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
2209
- payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
2210
- await saveIdentityOnReader(readerId, payload);
2211
- selectedReaderId.value = readerId;
3312
+ /*
3313
+ * EVERY READER THAT SERVES THIS PERSON, one at a time.
3314
+ *
3315
+ * Sequential rather than parallel on purpose: each reader is an embedded
3316
+ * server, several of them are reached over the same VPN, and the operator
3317
+ * is watching a list that only means anything if the rows settle in order.
3318
+ * It is also what keeps a failure attributable to one reader.
3319
+ *
3320
+ * The loop does not stop at the first failure. A reader being asleep is the
3321
+ * ordinary case, and abandoning the rest would deny somebody three doors
3322
+ * because of a fourth.
3323
+ */
3324
+ const targets = enrolmentTargets.value.targets;
3325
+ if (!targets.length) {
3326
+ showToast(
3327
+ "No reader at this site serves this kind of person. Switch an account type"
3328
+ + " on for a reader before enrolling anybody.",
3329
+ "error",
3330
+ );
3331
+ return;
3332
+ }
3333
+
3334
+ readerOutcomes.value = targets.map((target) => ({
3335
+ readerId: target.readerId,
3336
+ name: target.name,
3337
+ step: "waiting" as const,
3338
+ }));
3339
+ fanOutRunning.value = true;
3340
+ for (const target of targets) {
3341
+ setReaderStep(target.readerId, "working");
3342
+ try {
3343
+ await enrolOnReader(target.readerId);
3344
+ setReaderStep(target.readerId, "done");
3345
+ } catch (error: unknown) {
3346
+ setReaderStep(target.readerId, "failed", {
3347
+ message: getHidErrorMessage(error),
3348
+ // Retry needs no photo in hand once one is on file - that is what
3349
+ // the stored image bought. Without it, a retry can only work while
3350
+ // the capture is still in this dialog.
3351
+ retryable: true,
3352
+ });
3353
+ console.error(`Unable to enroll on reader ${target.readerId}:`, error);
3354
+ }
3355
+ }
3356
+ fanOutRunning.value = false;
3357
+
3358
+ const summary = fanOutSummary(readerOutcomes.value);
3359
+ await loadUsers();
3360
+ /*
3361
+ * THE DIALOG STAYS OPEN WHEN ANYTHING FAILED, because the Retry lives in
3362
+ * it. Closing would leave the operator with a toast naming readers they
3363
+ * can no longer act on.
3364
+ */
3365
+ if (summary.tone === "success") formDialog.value = false;
3366
+ showToast(
3367
+ summary.message,
3368
+ summary.tone === "success" ? "success" : "error",
3369
+ );
3370
+ return;
2212
3371
  }
2213
3372
 
2214
3373
  formDialog.value = false;
2215
3374
  await loadUsers();
2216
- showToast(
2217
- selectedUser.value?._id
2218
- ? "HID user updated on reader."
2219
- : "HID user enrolled on reader."
2220
- );
3375
+ showToast("HID user updated on reader.");
3376
+ } catch (error: unknown) {
3377
+ // No rollback here any more: `enrolOnReader` undoes ITS OWN reader, which is
3378
+ // the only place a HID user is created. A second copy of that logic here
3379
+ // could only ever fire for a reader this function no longer touches.
3380
+ console.error("Unable to save HID user:", error);
3381
+ showToast(getHidErrorMessage(error), "error");
3382
+ } finally {
3383
+ saving.value = false;
3384
+ fanOutRunning.value = false;
3385
+ // Discard the captured image on every path. On a part-way failure this is what stops
3386
+ // the photo lingering in the open form after a retry or a cancel.
3387
+ form.photoPreview = "";
3388
+ selectedPhotoFile.value = null;
3389
+ removePhotoRequested.value = false;
3390
+ }
3391
+ }
3392
+
3393
+ /**
3394
+ * ENROL THIS PERSON ON ONE READER.
3395
+ *
3396
+ * Lifted out of `saveUser` so the fan-out loop and the per-reader Retry run the
3397
+ * SAME sequence. Two paths that each "enrol on a reader" would drift, and the
3398
+ * one that drifts is the retry - the path nobody exercises until something has
3399
+ * already gone wrong.
3400
+ *
3401
+ * Every reader allocates its own UID and registration number, from the device
3402
+ * that will hold them. Reading one reader's numbering and writing it to another
3403
+ * is how two people end up sharing a UID.
3404
+ *
3405
+ * Rolls back ITS OWN reader only. A reader that failed should not undo the three
3406
+ * that worked - the person genuinely has access on those, and the whole point of
3407
+ * a per-reader result is that a partial enrolment is a real, recoverable state.
3408
+ */
3409
+ async function enrolOnReader(readerId: string) {
3410
+ const uid = await getNextUid(readerId);
3411
+ const registration = await getNextRegistration(readerId);
3412
+ const hidUser = toHidUserObject({
3413
+ hidUserId: uid,
3414
+ name: form.name,
3415
+ registration,
3416
+ });
3417
+ if (!hidUser.id) {
3418
+ throw new Error("This reader did not return a usable HID user ID.");
3419
+ }
3420
+
3421
+ let created = false;
3422
+ try {
3423
+ created = Boolean(await createHidUserOnReader(readerId, hidUser));
3424
+ const payload = {
3425
+ hidUserId: String(hidUser.id),
3426
+ registration,
3427
+ ...subjectLinkOf(form.subjectCategory, form.subjectId),
3428
+ status: "active" as const,
3429
+ metadata: {
3430
+ name: form.name,
3431
+ block: form.block,
3432
+ level: form.level,
3433
+ unit: form.unit,
3434
+ unitLabel: buildUnitLabel(),
3435
+ facialEnrolled: false,
3436
+ facialEnrolledAt: "",
3437
+ facialData: "",
3438
+ imageTimestamp: "",
3439
+ facialScores: {},
3440
+ pinEnrolled: false,
3441
+ passwordSet: false,
3442
+ hidPhysicalCards: [],
3443
+ },
3444
+ };
3445
+ applyFacialResult(payload.metadata, await syncFacialImage(readerId, hidUser.id));
3446
+ payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
3447
+ payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
3448
+ await saveIdentityOnReader(readerId, payload);
2221
3449
  } catch (error: unknown) {
2222
- if (newlyCreatedHidUserId) {
3450
+ if (created) {
2223
3451
  try {
2224
3452
  await runObjectOperation(readerId, {
2225
3453
  operation: "destroy",
2226
3454
  object: "users",
2227
- where: { users: { id: newlyCreatedHidUserId } },
3455
+ where: { users: { id: hidUser.id } },
2228
3456
  });
2229
3457
  } catch (rollbackError) {
2230
3458
  console.error(
2231
- "Unable to roll back the partially enrolled HID user:",
2232
- rollbackError
3459
+ `Unable to roll back the partially enrolled HID user on reader ${readerId}:`,
3460
+ rollbackError,
2233
3461
  );
2234
3462
  }
2235
3463
  }
2236
- console.error("Unable to save HID user:", error);
3464
+ throw error;
3465
+ }
3466
+ }
3467
+
3468
+ /**
3469
+ * TRY ONE READER AGAIN.
3470
+ *
3471
+ * Runs the same `enrolOnReader` the fan-out ran, so the retry cannot drift from
3472
+ * the thing it is retrying - and the drift would be invisible until somebody
3473
+ * needed it.
3474
+ *
3475
+ * When the capture is no longer in the dialog - reopened, or a photo discarded
3476
+ * after the first attempt - the face comes from the copy on file instead, which
3477
+ * is the whole reason the image is stored. Without that, retrying a reader that
3478
+ * was asleep meant bringing the person back.
3479
+ */
3480
+ /**
3481
+ * This person's iService account id, read off a reader that already has them.
3482
+ *
3483
+ * The form holds a SUBJECT - a resident record, a membership - and the account
3484
+ * behind it is resolved server-side, so the only way to learn it on this side is
3485
+ * to read back an identity that has been written. Returns "" when no reader has
3486
+ * one yet, which is also the case where there is no stored photo to push.
3487
+ */
3488
+ async function enrolledAccountId() {
3489
+ const done = readerOutcomes.value.find((outcome) => outcome.step === "done");
3490
+ if (!done || !form.subjectId) return "";
3491
+ try {
3492
+ const identity = await findResidentIdentity(done.readerId, form.subjectId);
3493
+ return String(identity?.user ?? "");
3494
+ } catch (error: unknown) {
3495
+ console.error("Unable to resolve the account behind this enrolment:", error);
3496
+ return "";
3497
+ }
3498
+ }
3499
+
3500
+ /**
3501
+ * EVERY READER THAT HOLDS THIS PERSON.
3502
+ *
3503
+ * Needed by both editing and deleting, because after a fan-out a person is on
3504
+ * several readers and each one knows them by a DIFFERENT HID user id. Acting on
3505
+ * the id from the reader on screen would write to the wrong user on every other
3506
+ * reader - or to nobody, which is worse because it looks like it worked.
3507
+ *
3508
+ * Matched on the ACCOUNT, not the subject record. `listIdentities` checks
3509
+ * `person`, `member`, `serviceProvider` and `user`, and the account is the one
3510
+ * field every row carries however it was written - an enrolment, a bulk grant or
3511
+ * a reconcile. A row written by the reconcile may hold no subject at all.
3512
+ *
3513
+ * Falls back to the reader on screen when there is no account to match: a device
3514
+ * user we have never linked exists on that reader and nowhere else we can prove.
3515
+ */
3516
+ async function findPersonReaders(user: HidUser): Promise<TPersonPlace[]> {
3517
+ const here = {
3518
+ readerId: selectedReaderId.value,
3519
+ name: selectedReader.value?.name || "this reader",
3520
+ identityId: await findIdentityIdForUser(selectedReaderId.value, user),
3521
+ hidUserId: String(user.hidUserId ?? ""),
3522
+ };
3523
+
3524
+ const current = await findExistingIdentityOnReader(selectedReaderId.value, {
3525
+ hidUserId: String(user.hidUserId ?? ""),
3526
+ registration: String(user.registration ?? ""),
3527
+ cardNo: String(user.cardNo ?? ""),
3528
+ metadata: {},
3529
+ }).catch(() => null);
3530
+ const accountId = String(current?.user ?? "").trim();
3531
+ if (!accountId) return [here];
3532
+
3533
+ const found: TPersonPlace[] = [];
3534
+ for (const reader of readers.value) {
3535
+ const readerId = String(reader._id ?? "");
3536
+ if (!readerId) continue;
3537
+ try {
3538
+ const response = await getIdentities(readerId, {
3539
+ page: 1,
3540
+ // A person should hold one per reader; a few are asked for so a stray
3541
+ // duplicate is seen rather than hidden behind the first row.
3542
+ limit: 5,
3543
+ subject: accountId,
3544
+ });
3545
+ const items = readCandidatePage(response).items.map(toHidUser);
3546
+ for (const identity of items) {
3547
+ if (!identity._id) continue;
3548
+ found.push({
3549
+ readerId,
3550
+ name: String(reader.name ?? "") || "HID Reader",
3551
+ identityId: identity._id,
3552
+ hidUserId: String(identity.hidUserId ?? ""),
3553
+ });
3554
+ }
3555
+ } catch (error: unknown) {
3556
+ // One unreachable reader must not hide the others. It is reported as a
3557
+ // failure when acted on, not silently dropped from the count.
3558
+ console.error(`Unable to check reader ${readerId} for this person:`, error);
3559
+ }
3560
+ }
3561
+ return found.length ? found : [here];
3562
+ }
3563
+
3564
+ async function retryReader(outcome: THidReaderOutcome) {
3565
+ if (fanOutRunning.value) return;
3566
+ fanOutRunning.value = true;
3567
+ setReaderStep(outcome.readerId, "working", { message: "" });
3568
+ try {
3569
+ await enrolOnReader(outcome.readerId);
3570
+ /*
3571
+ * NO PHOTO IN HAND, so use the one on file.
3572
+ *
3573
+ * `enrolOnReader` sends the capture when the dialog still holds it. Reopened
3574
+ * after a failure, it does not - and that is exactly when the stored image
3575
+ * earns its keep. The account id is read off a reader that already worked,
3576
+ * because it is the server that resolves a subject to an account and the
3577
+ * form only ever knew the subject.
3578
+ */
3579
+ if (!selectedPhotoFile.value) {
3580
+ const accountId = await enrolledAccountId();
3581
+ if (accountId) await pushStoredFace(outcome.readerId, accountId);
3582
+ }
3583
+ setReaderStep(outcome.readerId, "done", { message: "" });
3584
+ await loadUsers();
3585
+ showToast(`Enrolled on ${outcome.name}.`);
3586
+ } catch (error: unknown) {
3587
+ setReaderStep(outcome.readerId, "failed", {
3588
+ message: getHidErrorMessage(error),
3589
+ retryable: true,
3590
+ });
3591
+ console.error(`Retry failed on reader ${outcome.readerId}:`, error);
2237
3592
  showToast(getHidErrorMessage(error), "error");
2238
3593
  } finally {
2239
- saving.value = false;
2240
- // Discard the captured image on every path. On a part-way failure this is what stops
2241
- // the photo lingering in the open form after a retry or a cancel.
2242
- form.photoPreview = "";
2243
- selectedPhotoFile.value = null;
2244
- removePhotoRequested.value = false;
3594
+ fanOutRunning.value = false;
2245
3595
  }
2246
3596
  }
2247
3597
 
@@ -2373,41 +3723,84 @@ async function findIdentityIdForUser(readerId: string, user: HidUser) {
2373
3723
  }
2374
3724
  }
2375
3725
 
3726
+ /**
3727
+ * Remove this person from ONE reader: the admin role, the PIN, the device user,
3728
+ * then our own row.
3729
+ *
3730
+ * Order matters. The identity id is resolved by the caller before anything is
3731
+ * destroyed, because it is read from our own records and a failure there costs
3732
+ * nothing - whereas a device user destroyed with no row left to clear is the
3733
+ * orphan that `findIdentityIdForUser` exists to prevent.
3734
+ */
3735
+ async function deleteOnReader(place: TPersonPlace) {
3736
+ const hidUserId = toHidNumericId(place.hidUserId);
3737
+ await syncHidUserRole(place.readerId, hidUserId, false);
3738
+ await deleteUserPin(place.readerId, hidUserId);
3739
+ await deleteHidUserFromReader(place.readerId, place.hidUserId);
3740
+ if (place.identityId) {
3741
+ // Soft-deletes the row AND removes the access grant that let this person
3742
+ // through, which is what stops the next reconcile rebuilding them.
3743
+ await deleteIdentity(place.identityId);
3744
+ }
3745
+ }
3746
+
2376
3747
  async function deleteUser() {
2377
3748
  if (!selectedUser.value) return;
2378
3749
 
2379
- deleting.value = true;
2380
- try {
2381
- // Resolved BEFORE the device user is destroyed: this reads our own records,
2382
- // and doing it first means a failure here costs nothing.
2383
- const identityId = await findIdentityIdForUser(
3750
+ const here: TPersonPlace = {
3751
+ readerId: selectedReaderId.value,
3752
+ name: selectedReader.value?.name || "this reader",
3753
+ identityId: await findIdentityIdForUser(
2384
3754
  selectedReaderId.value,
2385
3755
  selectedUser.value
2386
- );
2387
- await syncHidUserRole(
2388
- selectedReaderId.value,
2389
- toHidNumericId(selectedUser.value.hidUserId),
2390
- false
2391
- );
2392
- await deleteUserPin(
2393
- selectedReaderId.value,
2394
- toHidNumericId(selectedUser.value.hidUserId)
2395
- );
2396
- await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
2397
- if (identityId) {
2398
- // Soft-deletes the row AND removes the access grant that let this person
2399
- // through, which is what stops the next reconcile rebuilding them.
2400
- await deleteIdentity(identityId);
3756
+ ),
3757
+ hidUserId: String(selectedUser.value.hidUserId ?? ""),
3758
+ };
3759
+ const places = deleteScope.value === "all" && deletePlaces.value.length
3760
+ ? deletePlaces.value
3761
+ : [deletePlaces.value.find((p) => p.readerId === here.readerId) ?? here];
3762
+
3763
+ deleting.value = true;
3764
+ deleteOutcomes.value = places.map((place) => ({
3765
+ readerId: place.readerId,
3766
+ name: place.name,
3767
+ step: "waiting" as const,
3768
+ }));
3769
+ try {
3770
+ for (const place of places) {
3771
+ setDeleteStep(place.readerId, "working");
3772
+ try {
3773
+ await deleteOnReader(place);
3774
+ setDeleteStep(place.readerId, "done");
3775
+ } catch (error: unknown) {
3776
+ // One reader refusing must not leave the others untouched - the point of
3777
+ // "all readers" is that the person loses the doors that CAN be reached.
3778
+ setDeleteStep(place.readerId, "failed", {
3779
+ message: getHidErrorMessage(error),
3780
+ });
3781
+ console.error(`Unable to delete this person from reader ${place.readerId}:`, error);
3782
+ }
2401
3783
  }
2402
- deleteDialog.value = false;
2403
- await loadUsers();
2404
- showToast("HID user deleted from reader.");
2405
- } catch (error: unknown) {
2406
- console.error("Unable to delete HID user:", error);
2407
- showToast(getHidErrorMessage(error), "error");
2408
3784
  } finally {
2409
3785
  deleting.value = false;
2410
3786
  }
3787
+
3788
+ const summary = deleteFanOutSummary(deleteOutcomes.value);
3789
+ await loadUsers();
3790
+ // A reader still holding this person keeps the dialog open, with its name on
3791
+ // screen, so the operator can see which door is still live and try again.
3792
+ if (summary.tone === "success") deleteDialog.value = false;
3793
+ showToast(summary.message, summary.tone === "success" ? "success" : "error");
3794
+ }
3795
+
3796
+ function setDeleteStep(
3797
+ readerId: string,
3798
+ step: THidReaderStep,
3799
+ extra: { message?: string } = {},
3800
+ ) {
3801
+ deleteOutcomes.value = deleteOutcomes.value.map((outcome) => (
3802
+ outcome.readerId === readerId ? { ...outcome, step, ...extra } : outcome
3803
+ ));
2411
3804
  }
2412
3805
 
2413
3806
  async function saveIdentityOnReader(
@@ -2523,8 +3916,8 @@ async function updateHidUserOnReader(
2523
3916
  });
2524
3917
  }
2525
3918
 
2526
- async function deleteHidUserFromReader(readerId: string, user: HidUser) {
2527
- const id = toHidNumericId(user.hidUserId);
3919
+ async function deleteHidUserFromReader(readerId: string, hidUserId: unknown) {
3920
+ const id = toHidNumericId(hidUserId);
2528
3921
  if (!readerId || !id) return;
2529
3922
 
2530
3923
  await runObjectOperation(readerId, {
@@ -2965,9 +4358,16 @@ function getFacialData(user: HidUser) {
2965
4358
  );
2966
4359
  }
2967
4360
 
2968
- async function getNextUid() {
2969
- const readerIds = await getExistingHidReaderIds();
2970
- const maxNumber = users.value.reduce((max, user) => {
4361
+ async function getNextUid(readerId: string) {
4362
+ const readerIds = await getExistingHidReaderIds(readerId);
4363
+ /*
4364
+ * `users.value` is the ROSTER ON SCREEN, which belongs to the selected reader.
4365
+ * It is still consulted for the one reader the operator is looking at - it
4366
+ * catches a number the device has but has not reported - and it is simply a
4367
+ * second opinion for the others, never the only one: `readerIds` above comes
4368
+ * from the reader being allocated for.
4369
+ */
4370
+ const maxNumber = (readerId === selectedReaderId.value ? users.value : []).reduce((max, user) => {
2971
4371
  const match = String(user.hidUserId ?? "").match(/^UID(\d+)$/i);
2972
4372
  return Math.max(
2973
4373
  max,
@@ -2978,39 +4378,48 @@ async function getNextUid() {
2978
4378
  return `UID${String(nextNumber).padStart(6, "0")}`;
2979
4379
  }
2980
4380
 
2981
- async function getNextRegistration() {
2982
- const existingUsers = await getExistingHidReaderUsers();
2983
- const registrations = existingUsers
2984
- .map((user) => String(user.registration || "").trim())
2985
- .filter(Boolean);
2986
- const numericRegistrations = registrations
2987
- .map((registration) => {
2988
- const match = registration.match(/^(\d+)$/);
2989
- return match
2990
- ? { value: Number(match[1]), width: match[1].length }
2991
- : undefined;
2992
- })
2993
- .filter((item): item is { value: number; width: number } => Boolean(item));
2994
- const maxRegistration = numericRegistrations.reduce(
2995
- (max, item) => Math.max(max, item.value),
2996
- 0
2997
- );
2998
- const width = Math.max(6, ...numericRegistrations.map((item) => item.width));
2999
-
3000
- return String(maxRegistration + 1).padStart(width, "0");
4381
+ /**
4382
+ * The next registration number ON THIS READER.
4383
+ *
4384
+ * Every reader numbers its own users (see `enrolOnReader`), so the list has to
4385
+ * come from the reader being allocated for. This used to read it with no
4386
+ * reader at all, which `getExistingHidReaderUsers` answers with an empty list,
4387
+ * so every enrolment proposed 000001 and the second person on a reader was
4388
+ * refused with "HID Amico identity already exists for this reader"
4389
+ * (2026-10-20, Landscape Management on HID 1 after Pest Management took
4390
+ * 000001). The roster on screen is the second opinion for the selected reader,
4391
+ * exactly as `getNextUid` consults it: it catches a number the device holds
4392
+ * but has not reported.
4393
+ */
4394
+ async function getNextRegistration(readerId: string) {
4395
+ const existingUsers = await getExistingHidReaderUsers(readerId, ["id", "registration"]);
4396
+ const onScreen = readerId === selectedReaderId.value ? users.value : [];
4397
+ return nextRegistration([
4398
+ ...existingUsers.map((user) => user.registration),
4399
+ ...onScreen.map((user) => user.registration),
4400
+ ]);
3001
4401
  }
3002
4402
 
3003
- async function getExistingHidReaderIds() {
3004
- const users = await getExistingHidReaderUsers(["id"]);
4403
+ async function getExistingHidReaderIds(readerId: string) {
4404
+ const users = await getExistingHidReaderUsers(readerId, ["id"]);
3005
4405
  return users
3006
4406
  .map((user) => toHidNumericId(user.hidUserId) || 0)
3007
4407
  .filter((value) => Number.isInteger(value) && value > 0);
3008
4408
  }
3009
4409
 
3010
- async function getExistingHidReaderUsers(fields?: string[]) {
3011
- if (!selectedReaderId.value) return [];
4410
+ /**
4411
+ * A reader's own `users` rows.
4412
+ *
4413
+ * `readerId` is a parameter rather than the screen's selection, because every
4414
+ * reader numbers its own users: enrolling one person across three readers means
4415
+ * three different UIDs and three different registration numbers, each allocated
4416
+ * from the device that will hold it. Reading one reader's numbering and writing
4417
+ * it to another is how two people end up sharing a UID.
4418
+ */
4419
+ async function getExistingHidReaderUsers(readerId: string, fields?: string[]) {
4420
+ if (!readerId) return [];
3012
4421
  try {
3013
- const response = await runObjectOperation(selectedReaderId.value, {
4422
+ const response = await runObjectOperation(readerId, {
3014
4423
  operation: "load",
3015
4424
  object: "users",
3016
4425
  ...(fields ? { fields } : {}),
@@ -3283,6 +4692,97 @@ watch(selectedReaderId, (value) => {
3283
4692
  color: var(--err);
3284
4693
  }
3285
4694
 
4695
+ /* The bulk button and the sentence that says what it does. Stacked rather than
4696
+ side by side: the sentence is the part that stops somebody expecting this to
4697
+ enrol faces, so it must not be squeezed into a margin at narrow widths. */
4698
+ /* The reader list, and the same list as a progress strip once a save starts.
4699
+ Rows rather than chips: each one carries a reason or a failure, and those are
4700
+ sentences. */
4701
+ .hid-fanout {
4702
+ display: flex;
4703
+ flex-direction: column;
4704
+ gap: 4px;
4705
+ }
4706
+
4707
+ .hid-fanout__row {
4708
+ display: flex;
4709
+ align-items: flex-start;
4710
+ gap: 8px;
4711
+ padding: 6px 8px;
4712
+ border-radius: 6px;
4713
+ background: var(--surface2, rgba(127, 127, 127, 0.06));
4714
+ }
4715
+
4716
+ .hid-fanout__body {
4717
+ display: flex;
4718
+ flex-direction: column;
4719
+ min-width: 0;
4720
+ }
4721
+
4722
+ .hid-fanout__body strong {
4723
+ font-size: 13px;
4724
+ font-weight: 700;
4725
+ }
4726
+
4727
+ /* Reasons and failures wrap rather than clip: a truncated explanation is worse
4728
+ than none, because it reads as a bug rather than as a setting. */
4729
+ .hid-fanout__body span {
4730
+ color: var(--muted);
4731
+ font-size: 12px;
4732
+ line-height: 1.4;
4733
+ white-space: normal;
4734
+ }
4735
+
4736
+ .hid-fanout__row--excluded {
4737
+ opacity: 0.72;
4738
+ }
4739
+
4740
+ .hid-fanout__row--failed {
4741
+ color: var(--err);
4742
+ }
4743
+
4744
+ .hid-fanout__row--done {
4745
+ color: var(--ok, var(--accent-text));
4746
+ }
4747
+
4748
+ .hid-bulk-cta {
4749
+ display: flex;
4750
+ flex-direction: column;
4751
+ align-items: flex-start;
4752
+ gap: 4px;
4753
+ }
4754
+
4755
+ .hid-bulk-cta .field-hint {
4756
+ margin: 0;
4757
+ }
4758
+
4759
+ /* Checkbox column only as wide as the control, so the three columns that carry
4760
+ meaning keep the width. */
4761
+ .hid-bulk-table__check {
4762
+ width: 44px;
4763
+ padding-right: 0 !important;
4764
+ }
4765
+
4766
+ .hid-bulk-table :deep(th) {
4767
+ font-size: 12px;
4768
+ font-weight: 700;
4769
+ color: var(--muted);
4770
+ white-space: nowrap;
4771
+ }
4772
+
4773
+ .hid-bulk-table :deep(td) {
4774
+ font-size: 13px;
4775
+ }
4776
+
4777
+ /* Why this row cannot be unticked, on the row itself. A disabled checkbox with
4778
+ no reason beside it reads as a broken control. */
4779
+ .hid-bulk-granted {
4780
+ display: block;
4781
+ color: var(--muted);
4782
+ font-size: 11px;
4783
+ font-weight: 600;
4784
+ }
4785
+
3286
4786
  .photo-wrap {
3287
4787
  display: grid;
3288
4788
  place-items: center;