@7365admin1/layer-common 4.73.1-staging.464 → 4.73.1-staging.465

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.
@@ -25,7 +25,11 @@
25
25
  the old table card, so it stays above the shared one. -->
26
26
  <v-alert
27
27
  v-if="selectedReader"
28
- :type="selectedReader.portalId && selectedReader.portalName ? 'info' : 'warning'"
28
+ :type="
29
+ selectedReader.portalId && selectedReader.portalName
30
+ ? 'info'
31
+ : 'warning'
32
+ "
29
33
  variant="tonal"
30
34
  density="compact"
31
35
  class="mb-4"
@@ -50,7 +54,9 @@
50
54
  @update:page="goToPage"
51
55
  >
52
56
  <template v-if="!props.cardManagement" #actions>
53
- <AppButton icon="mdi-account-plus" @click="openEnroll">Enroll</AppButton>
57
+ <AppButton icon="mdi-account-plus" @click="openEnroll"
58
+ >Enroll</AppButton
59
+ >
54
60
  </template>
55
61
 
56
62
  <!-- The design puts filters on their own full-width row under the
@@ -93,7 +99,9 @@
93
99
  </template>
94
100
 
95
101
  <template #[`item.hidUserId`]="{ item }">
96
- <span class="app-cell--num">{{ formatHidUid(item.hidUserId) || "N/A" }}</span>
102
+ <span class="app-cell--num">{{
103
+ formatHidUid(item.hidUserId) || "N/A"
104
+ }}</span>
97
105
  </template>
98
106
 
99
107
  <!-- The reader allocates this alongside the UID. A row the reader holds
@@ -119,7 +127,10 @@
119
127
  {{ formatPhysicalCard(card) }}
120
128
  </span>
121
129
  </div>
122
- <span v-else-if="getUserCardLoadError(item)" class="hid-card-load-error">
130
+ <span
131
+ v-else-if="getUserCardLoadError(item)"
132
+ class="hid-card-load-error"
133
+ >
123
134
  Unable to load
124
135
  <v-tooltip activator="parent" location="top">
125
136
  {{ getUserCardLoadError(item) }}
@@ -139,13 +150,20 @@
139
150
  </AppButton>
140
151
  <v-menu v-else>
141
152
  <template #activator="{ props: menuProps }">
142
- <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
153
+ <AppButton
154
+ v-bind="menuProps"
155
+ variant="row"
156
+ icon="mdi-dots-vertical"
157
+ />
143
158
  </template>
144
159
 
145
160
  <v-list density="compact" min-width="150">
146
161
  <v-list-item title="View" @click="openView(item)" />
147
162
  <v-list-item title="Edit" @click="openEdit(item)" />
148
- <v-list-item title="Manage HID Cards" @click="openCardManager(item)" />
163
+ <v-list-item
164
+ title="Manage HID Cards"
165
+ @click="openCardManager(item)"
166
+ />
149
167
  <v-list-item
150
168
  title="Delete"
151
169
  class="text-error"
@@ -161,11 +179,19 @@
161
179
  <template #no-data>
162
180
  <div class="table-card__empty">
163
181
  <v-icon
164
- :icon="props.cardManagement ? 'mdi-card-account-details-outline' : 'mdi-account-off-outline'"
182
+ :icon="
183
+ props.cardManagement
184
+ ? 'mdi-card-account-details-outline'
185
+ : 'mdi-account-off-outline'
186
+ "
165
187
  size="32"
166
188
  />
167
189
  <span>
168
- {{ props.cardManagement ? "No HID users available for card assignment." : "No HID users enrolled yet." }}
190
+ {{
191
+ props.cardManagement
192
+ ? "No HID users available for card assignment."
193
+ : "No HID users enrolled yet."
194
+ }}
169
195
  </span>
170
196
  </div>
171
197
  </template>
@@ -237,6 +263,25 @@
237
263
  />
238
264
  </div>
239
265
 
266
+ <!-- WHO IS BEING ENROLLED.
267
+ The write path has always carried three subject categories -
268
+ `subjectLink` below maps them onto `person`, `member` or
269
+ `serviceProvider` - but the form only ever offered residents.
270
+ Staff hold no unit, so choosing Member replaces the whole
271
+ block/level/unit cascade with a single picker.
272
+ Enrolment only: an identity's subject cannot change category
273
+ after the fact, so an edit STATES the type rather than offering
274
+ it. -->
275
+ <div class="field-label">Enrolling <span>*</span></div>
276
+ <AppSelect
277
+ v-if="!selectedUser"
278
+ v-model="form.subjectCategory"
279
+ :items="subjectCategoryOptions"
280
+ klass="mb-3"
281
+ @update:model-value="onSubjectCategoryChanged"
282
+ />
283
+ <p v-else class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
284
+
240
285
  <!-- Block / Level / Unit / Resident. Enrolment only.
241
286
  Enrollment is resident-only and the resident must already exist
242
287
  in iService365 - you pick a person, you do not type one. Name is
@@ -252,7 +297,7 @@
252
297
  asked for. A row that never had one keeps not having one; that
253
298
  is not a reason to make somebody fill in a location before they
254
299
  can change a PIN. -->
255
- <template v-if="!selectedUser">
300
+ <template v-if="!selectedUser && isResidentSubject">
256
301
  <div class="field-label">Block <span>*</span></div>
257
302
  <!-- The listener sits on a wrapper, NOT on AppSelect: AppSelect
258
303
  spreads `$attrs` over its v-menu activator AFTER the activator's
@@ -297,7 +342,7 @@
297
342
  with no unit has no candidates to show, and the dialog is then
298
343
  purely the reader's own credentials - photo, PIN, password -
299
344
  which is exactly the edit that must not be blocked. -->
300
- <template v-if="!selectedUser || form.unit">
345
+ <template v-if="isResidentSubject && (!selectedUser || form.unit)">
301
346
  <div class="field-label">Resident <span>*</span></div>
302
347
  <AppSelect
303
348
  v-model="form.subjectId"
@@ -308,30 +353,96 @@
308
353
  @update:model-value="onResidentChanged"
309
354
  />
310
355
  </template>
356
+
357
+ <!-- STAFF. One picker, because a member has no unit to narrow by.
358
+ The candidates are the permission subjects the Access Permissions
359
+ screen already reads - active members of this site or its
360
+ organisation, each carrying its role as a subtitle. Filtering is
361
+ AppSelect's own, over the loaded page, so typing costs no request.
362
+ A member with no app account is not in the list at all: reader
363
+ access is bound by user id, so there is nothing to bind. The note
364
+ below says how many were left out rather than letting a missing
365
+ colleague read as a bug. -->
366
+ <template v-if="!selectedUser && !isResidentSubject">
367
+ <div class="field-label">Member <span>*</span></div>
368
+ <AppSelect
369
+ v-model="form.subjectId"
370
+ :items="memberOptions"
371
+ searchable
372
+ search-placeholder="Search staff by name or role"
373
+ :placeholder="memberPlaceholder"
374
+ :has-more="hasMoreMembers"
375
+ :loading-more="loadingMoreMembers"
376
+ klass="mb-3"
377
+ @update:model-value="onMemberChanged"
378
+ @load-more="loadMoreMembers"
379
+ />
380
+ <!-- Ten at a time, because that page size shares the Members
381
+ menu's cache entry. The rest arrive as the list is scrolled, so
382
+ this only has to SAY where the list has got to - and warn that
383
+ the search box filters what is loaded, not the whole estate. -->
384
+ <p v-if="memberRangeNote" class="field-hint mb-3">
385
+ {{ memberRangeNote }}
386
+ </p>
387
+ <p v-if="hiddenMemberNote" class="field-hint mb-3">
388
+ {{ hiddenMemberNote }}
389
+ </p>
390
+ </template>
391
+
311
392
  <!-- The duplicate check. It is reader-scoped: this resident holding a
312
393
  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">
394
+ <p v-if="checkingResident" class="field-hint">
395
+ Checking enrollment...
396
+ </p>
397
+ <p
398
+ v-else-if="residentConflict"
399
+ class="field-hint field-hint--error mb-3"
400
+ >
315
401
  {{ residentConflictMessage }}
316
402
  </p>
317
- <p v-else-if="unitResidentNote" class="field-hint mb-3">
403
+ <p
404
+ v-else-if="isResidentSubject && unitResidentNote"
405
+ class="field-hint mb-3"
406
+ >
318
407
  {{ unitResidentNote }}
319
408
  </p>
320
409
 
321
410
  <!-- Read-only: it restates the chosen resident, it does not collect
322
411
  anything. `selectedUser` is the edit case, where the name is
323
412
  whatever the reader already holds. -->
413
+ <!-- A mirror of the chosen member's role, on the same rule as Name:
414
+ restated from the record, never collected. -->
415
+ <template v-if="!selectedUser && !isResidentSubject">
416
+ <div class="field-label">Role</div>
417
+ <AppField
418
+ v-model="form.subjectRole"
419
+ aria-label="Role"
420
+ placeholder="Select a member above"
421
+ readonly
422
+ klass="mb-3"
423
+ />
424
+ </template>
425
+
324
426
  <div class="field-label">Name</div>
325
427
  <AppField
326
428
  v-model="form.name"
327
429
  aria-label="Name"
328
- placeholder="Select a resident above"
430
+ :placeholder="
431
+ isResidentSubject
432
+ ? 'Select a resident above'
433
+ : 'Select a member above'
434
+ "
329
435
  readonly
330
436
  klass="mb-3"
331
437
  />
332
438
 
333
439
  <div class="field-label">ID</div>
334
- <AppField v-model="form.hidUserId" aria-label="ID" readonly klass="mb-3" />
440
+ <AppField
441
+ v-model="form.hidUserId"
442
+ aria-label="ID"
443
+ readonly
444
+ klass="mb-3"
445
+ />
335
446
 
336
447
  <div class="field-label">Registration No. <span>*</span></div>
337
448
  <AppField
@@ -372,7 +483,11 @@
372
483
  <!-- `persistent-hint` was Vuetify's; the design's settings module
373
484
  draws an explanatory line as 12-13px/600 in `--muted`. -->
374
485
  <p class="field-hint">
375
- {{ selectedUser && pinEnrolled ? "Leave blank to keep the current PIN." : "PIN works as an alternative to face or QR." }}
486
+ {{
487
+ selectedUser && pinEnrolled
488
+ ? "Leave blank to keep the current PIN."
489
+ : "PIN works as an alternative to face or QR."
490
+ }}
376
491
  </p>
377
492
  <v-checkbox
378
493
  v-if="selectedUser && pinEnrolled"
@@ -389,7 +504,11 @@
389
504
  <AppField
390
505
  v-model="form.password"
391
506
  aria-label="Password"
392
- :placeholder="selectedUser && passwordSet ? 'Enter a new password' : 'Enter password'"
507
+ :placeholder="
508
+ selectedUser && passwordSet
509
+ ? 'Enter a new password'
510
+ : 'Enter password'
511
+ "
393
512
  autocomplete="new-password"
394
513
  :type="showPassword ? 'text' : 'password'"
395
514
  >
@@ -400,12 +519,19 @@
400
519
  :aria-label="showPassword ? 'Hide password' : 'Show password'"
401
520
  @click="showPassword = !showPassword"
402
521
  >
403
- <v-icon :icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
522
+ <v-icon
523
+ :icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
524
+ size="18"
525
+ />
404
526
  </button>
405
527
  </template>
406
528
  </AppField>
407
529
  <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." }}
530
+ {{
531
+ selectedUser && passwordSet
532
+ ? "A password is set. Leave blank to keep it."
533
+ : "Used with the user ID on the reader keypad."
534
+ }}
409
535
  </p>
410
536
  <v-checkbox
411
537
  v-if="selectedUser && passwordSet"
@@ -464,13 +590,19 @@
464
590
  selectedUser ? getMappingStatus(selectedUser) : "N/A"
465
591
  }}</strong>
466
592
  <span>PIN</span
467
- ><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
593
+ ><strong>{{
594
+ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A"
595
+ }}</strong>
468
596
  <span>Password</span
469
- ><strong>{{ selectedUser?.metadata?.passwordSet ? "Configured" : "N/A" }}</strong>
597
+ ><strong>{{
598
+ selectedUser?.metadata?.passwordSet ? "Configured" : "N/A"
599
+ }}</strong>
470
600
  </div>
471
601
  </v-card-text>
472
602
  <v-card-actions class="screen-modal__footer">
473
- <AppButton variant="ghost" @click="viewDialog = false">Close</AppButton>
603
+ <AppButton variant="ghost" @click="viewDialog = false"
604
+ >Close</AppButton
605
+ >
474
606
  </v-card-actions>
475
607
  </v-card>
476
608
  </v-dialog>
@@ -486,7 +618,11 @@
486
618
 
487
619
  <v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
488
620
  <div v-else-if="physicalCards.length" class="hid-card-list mb-4">
489
- <div v-for="card in physicalCards" :key="card.id" class="hid-card-row">
621
+ <div
622
+ v-for="card in physicalCards"
623
+ :key="card.id"
624
+ class="hid-card-row"
625
+ >
490
626
  <v-icon icon="mdi-card-account-details-outline" size="22" />
491
627
  <div class="hid-card-row__content">
492
628
  <strong>{{ formatPhysicalCard(card) }}</strong>
@@ -500,7 +636,13 @@
500
636
  />
501
637
  </div>
502
638
  </div>
503
- <v-alert v-else type="info" variant="tonal" density="compact" class="mb-4">
639
+ <v-alert
640
+ v-else
641
+ type="info"
642
+ variant="tonal"
643
+ density="compact"
644
+ class="mb-4"
645
+ >
504
646
  No physical HID card is assigned to this user.
505
647
  </v-alert>
506
648
 
@@ -514,7 +656,8 @@
514
656
  {{ cardProfileLabel }}
515
657
  </v-alert>
516
658
  <p class="field-hint hid-card-type-hint">
517
- PACS uses Facility Code + Card Number. CSN uses the raw serial number read from the physical card.
659
+ PACS uses Facility Code + Card Number. CSN uses the raw serial
660
+ number read from the physical card.
518
661
  </p>
519
662
  <template v-if="hybridCardProfile">
520
663
  <div class="field-label mt-3">Presented Card Type</div>
@@ -525,11 +668,13 @@
525
668
  class="mb-3"
526
669
  />
527
670
  <p class="field-hint hid-card-type-hint">
528
- Choose MIFARE CSN for a MIFARE card. Choose PACS for any other card in this reader mode.
671
+ Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
672
+ card in this reader mode.
529
673
  </p>
530
674
  </template>
531
675
  <p class="field-hint hid-card-access-hint">
532
- The card uses this HID user's existing portal and access rules; assigning a card does not grant new access.
676
+ The card uses this HID user's existing portal and access rules;
677
+ assigning a card does not grant new access.
533
678
  </p>
534
679
 
535
680
  <div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
@@ -566,7 +711,8 @@
566
711
  density="compact"
567
712
  class="mt-4"
568
713
  >
569
- Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
714
+ Present the physical card to the selected Amico reader. The request
715
+ will time out after 30 seconds.
570
716
  </v-alert>
571
717
 
572
718
  <!-- Reading the card at the reader is the primary way in: it is the
@@ -602,7 +748,11 @@
602
748
  <v-card-actions class="screen-modal__footer">
603
749
  <!-- Closing mid-read would leave the reader listening with nothing
604
750
  to receive the card it reads. -->
605
- <AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
751
+ <AppButton
752
+ variant="ghost"
753
+ :disabled="enrollingCard"
754
+ @click="cardDialog = false"
755
+ >
606
756
  Close
607
757
  </AppButton>
608
758
  </v-card-actions>
@@ -646,6 +796,18 @@
646
796
  </template>
647
797
 
648
798
  <script setup lang="ts">
799
+ // Shared with the Access Permissions screen: `paginate` never echoes the page it
800
+ // was asked for, and `items` sits at the root on some of these endpoints and
801
+ // under `data` on others. This reads both, so the mistake is made once.
802
+ import {
803
+ HID_ENROLMENT_SUBJECTS,
804
+ hiddenSubjectNote,
805
+ memberCandidateOf,
806
+ memberHasAccount,
807
+ readMemberPage,
808
+ subjectCategoryLabel as labelForSubjectCategory,
809
+ } from "../utils/hid-enrolment-subject";
810
+
649
811
  const props = defineProps({
650
812
  site: {
651
813
  type: String,
@@ -686,6 +848,8 @@ const {
686
848
  updateIdentity,
687
849
  deleteIdentity,
688
850
  } = useHidAmico();
851
+ const { getAll: getAllMembers } = useMember();
852
+ const runtimeConfig = useRuntimeConfig();
689
853
 
690
854
  type HidMetadata = Record<string, unknown> & {
691
855
  name?: string;
@@ -799,8 +963,8 @@ const facialEnrolled = ref(false);
799
963
  function hasFacialEnrollment(user?: HidUser | null) {
800
964
  return Boolean(
801
965
  user?.metadata?.facialEnrolled ||
802
- user?.metadata?.facialData ||
803
- user?.metadata?.imageTimestamp
966
+ user?.metadata?.facialData ||
967
+ user?.metadata?.imageTimestamp
804
968
  );
805
969
  }
806
970
 
@@ -858,6 +1022,20 @@ type UnitPerson = {
858
1022
  user?: string | null;
859
1023
  };
860
1024
 
1025
+ /**
1026
+ * THE SUBJECT TYPE THIS SCREEN WAS LAST USED FOR.
1027
+ *
1028
+ * Module scope, not component scope, and deliberately: the dialog is rebuilt on
1029
+ * every open and the component itself is unmounted when the operator moves
1030
+ * between HID screens, so a local ref would forget the choice immediately.
1031
+ * Enrolling ten guards in a row should not mean picking "Member" ten times.
1032
+ *
1033
+ * Not persisted. Surviving a hard refresh is a smaller benefit than surviving
1034
+ * the dialog, and `useHidReaderSelection` already set the precedent for where
1035
+ * that line sits.
1036
+ */
1037
+ const REMEMBERED_SUBJECT_CATEGORY = ref<THidPermissionCategory>("resident");
1038
+
861
1039
  const form = reactive({
862
1040
  reader: "",
863
1041
  block: "",
@@ -870,8 +1048,10 @@ const form = reactive({
870
1048
  accessPin: "",
871
1049
  password: "",
872
1050
  photoPreview: "",
873
- subjectCategory: "resident" as THidPermissionCategory,
1051
+ subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
874
1052
  subjectId: "",
1053
+ /** A mirror of the chosen member's role. Never sent; display only. */
1054
+ subjectRole: "",
875
1055
  });
876
1056
 
877
1057
  const route = useRoute();
@@ -917,13 +1097,17 @@ function formatBlockTitle(block?: OccupiedBlock | null) {
917
1097
  : `Block ${block.block ?? ""}`.trim();
918
1098
  }
919
1099
 
920
- const blockOptions = computed(() => occupiedBlocks.value.map((block) => ({
921
- title: formatBlockTitle(block),
922
- value: String(block._id ?? ""),
923
- })));
1100
+ const blockOptions = computed(() =>
1101
+ occupiedBlocks.value.map((block) => ({
1102
+ title: formatBlockTitle(block),
1103
+ value: String(block._id ?? ""),
1104
+ }))
1105
+ );
924
1106
 
925
1107
  const levelOptions = computed(() => {
926
- const block = occupiedBlocks.value.find((item) => String(item._id ?? "") === form.block);
1108
+ const block = occupiedBlocks.value.find(
1109
+ (item) => String(item._id ?? "") === form.block
1110
+ );
927
1111
  return (block?.levels ?? []).map((level) => ({
928
1112
  title: String(level.name ?? ""),
929
1113
  value: String(level._id ?? ""),
@@ -931,8 +1115,12 @@ const levelOptions = computed(() => {
931
1115
  });
932
1116
 
933
1117
  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);
1118
+ const block = occupiedBlocks.value.find(
1119
+ (item) => String(item._id ?? "") === form.block
1120
+ );
1121
+ const level = (block?.levels ?? []).find(
1122
+ (item) => String(item._id ?? "") === form.level
1123
+ );
936
1124
  return (level?.units ?? []).map((unit) => ({
937
1125
  title: String(unit.name ?? ""),
938
1126
  value: String(unit._id ?? ""),
@@ -944,30 +1132,267 @@ const unitOptions = computed(() => {
944
1132
  // this site." at the very end of enrollment, after a user had already been
945
1133
  // written to the reader.
946
1134
  const enrollableUnitPeople = computed(() =>
947
- unitPeople.value.filter((person) => Boolean(person.user)),
1135
+ unitPeople.value.filter((person) => Boolean(person.user))
948
1136
  );
949
1137
 
950
1138
  const unenrollableUnitPeopleCount = computed(
951
- () => unitPeople.value.length - enrollableUnitPeople.value.length,
1139
+ () => unitPeople.value.length - enrollableUnitPeople.value.length
952
1140
  );
953
1141
 
954
1142
  // `_id` here is `site.people._id`, which is exactly the `person` that
955
1143
  // `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
- })));
1144
+ const subjectOptions = computed(() =>
1145
+ enrollableUnitPeople.value.map((person) => ({
1146
+ title: String(person.name ?? "Resident"),
1147
+ value: String(person._id ?? ""),
1148
+ }))
1149
+ );
960
1150
 
961
1151
  // Says why somebody the operator can see in People Management is missing here.
962
1152
  // Without it the unit just looks empty and the screen looks broken.
963
1153
  const unitResidentNote = computed(() => {
964
- if (!form.unit || loadingUnitPeople.value || !unenrollableUnitPeopleCount.value) return "";
1154
+ if (
1155
+ !form.unit ||
1156
+ loadingUnitPeople.value ||
1157
+ !unenrollableUnitPeopleCount.value
1158
+ )
1159
+ return "";
965
1160
  const count = unenrollableUnitPeopleCount.value;
966
1161
  const subject = count === 1 ? "1 resident" : `${count} residents`;
967
1162
  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.`;
1163
+ return `${subject} in this unit ${verb} not listed: HID enrollment needs a resident app account, and ${
1164
+ count === 1 ? "that person has" : "they have"
1165
+ } none yet.`;
1166
+ });
1167
+
1168
+ /* ── THE SUBJECT TYPE ──────────────────────────────────────────────────── */
1169
+
1170
+ const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
1171
+
1172
+ const isResidentSubject = computed(() => form.subjectCategory === "resident");
1173
+
1174
+ /* What an EDIT shows in place of the control, since the type cannot change.
1175
+ Looked up in the util, not in the switch's own options: records carry
1176
+ `service_provider` too, and the options list would have called those
1177
+ "Resident". */
1178
+ const subjectCategoryLabel = computed(() =>
1179
+ labelForSubjectCategory(form.subjectCategory)
1180
+ );
1181
+
1182
+ /* ── STAFF CANDIDATES ─────────────────────────────────────────────────────
1183
+ *
1184
+ * Read from `GET /api/members` — the SAME endpoint, with the same filters, that
1185
+ * the Members menu reads. That is deliberate and was the fix for a real bug: the
1186
+ * first version used the HID permission-candidate endpoint, which applies NO
1187
+ * `type` filter, so it offered every active members row at the site including
1188
+ * service providers and residents. A picker that disagrees with the Members menu
1189
+ * is a picker the operator cannot trust.
1190
+ *
1191
+ * `type` is the running app, the same way `HidQrCodeConfiguration` reads it, so
1192
+ * the property app lists its own staff and the security app lists its own. The
1193
+ * members endpoint expands legacy spellings of a type itself (`appTypeQuery`).
1194
+ *
1195
+ * Service providers get their own option next; they are not silently folded in
1196
+ * here.
1197
+ */
1198
+ /*
1199
+ * TEN, to share the Members menu's cache key.
1200
+ *
1201
+ * `member.repo.ts` caches each page under a key that INCLUDES `limit`, so a
1202
+ * different limit is a different cache entry with its own 15-minute life. This
1203
+ * picker asked for 50 and got its own rarely-touched slot, which went stale and
1204
+ * showed six members where the Members menu showed nine - the same query, the
1205
+ * same instant, two answers. Asking for the menu's own page size means the two
1206
+ * read the same entry and cannot disagree.
1207
+ */
1208
+ const MEMBER_PAGE_LIMIT = 10;
1209
+
1210
+ type MemberCandidate = { subjectId: string; name: string; subtitle: string };
1211
+
1212
+ const memberCandidates = ref<MemberCandidate[]>([]);
1213
+ const loadingMembers = ref(false);
1214
+ const loadingMoreMembers = ref(false);
1215
+ const membersLoaded = ref(false);
1216
+ /** The last page successfully loaded; 0 before the first. */
1217
+ const memberPage = ref(0);
1218
+ /** Rows fetched so far, INCLUDING those hidden for having no account. */
1219
+ const memberRowsLoaded = ref(0);
1220
+ /** What the server says matches the query, across every page. */
1221
+ const memberTotalRows = ref(0);
1222
+ /* Active staff dropped for having no app account. Counted here rather than
1223
+ * asked of the server, because this endpoint returns the `user` field and so
1224
+ * the answer is already in hand - see `hiddenMemberNote`. */
1225
+ const hiddenMemberCount = ref(0);
1226
+
1227
+ const hasMoreMembers = computed(
1228
+ () => memberRowsLoaded.value < memberTotalRows.value
1229
+ );
1230
+
1231
+ /*
1232
+ * Bumped on every request, so a response that is no longer the current one is
1233
+ * dropped. Scroll-loading makes this matter: a page still in flight when the
1234
+ * dialog reopens, or when the type is switched away and back, would otherwise
1235
+ * append onto a list that has since been replaced. Same guard, and the same
1236
+ * reason, as `cardRequestSeq` in `EntryPassInformation.vue`.
1237
+ */
1238
+ let memberRequestSeq = 0;
1239
+
1240
+ const memberOptions = computed(() =>
1241
+ memberCandidates.value.map((candidate) => ({
1242
+ // Role in the title as well as the mirror below: two people can share a name,
1243
+ // and the popover is where they have to be told apart.
1244
+ title: candidate.subtitle
1245
+ ? `${candidate.name} — ${candidate.subtitle}`
1246
+ : candidate.name,
1247
+ value: candidate.subjectId,
1248
+ }))
1249
+ );
1250
+
1251
+ const memberPlaceholder = computed(() => {
1252
+ if (loadingMembers.value) return "Loading staff...";
1253
+ if (!membersLoaded.value) return "Select a member";
1254
+ if (memberOptions.value.length) return "Select a member";
1255
+ return hiddenMemberCount.value
1256
+ ? "No staff here can be enrolled yet"
1257
+ : "No staff at this site";
1258
+ });
1259
+
1260
+ /* The same courtesy the resident cascade pays: say why somebody the operator
1261
+ * can see in the Members menu is not in this list. */
1262
+ const hiddenMemberNote = computed(() => {
1263
+ // Only once everything is loaded. Half a list gives half a count, and
1264
+ // "3 members at this site" would then be a number about this PAGE wearing the
1265
+ // word "site" - the range note below carries the partial truth instead.
1266
+ if (loadingMembers.value || hasMoreMembers.value) return "";
1267
+ return hiddenSubjectNote(hiddenMemberCount.value, "member");
969
1268
  });
970
1269
 
1270
+ /* What is loaded against what exists - and the warning that the picker's own
1271
+ search only ever sees what has been fetched. */
1272
+ const memberRangeNote = computed(() =>
1273
+ hasMoreMembers.value
1274
+ ? `Showing ${memberRowsLoaded.value} of ${memberTotalRows.value}. Search looks only at these.`
1275
+ : ""
1276
+ );
1277
+
1278
+ /** Back to "nothing loaded", so the next open starts from page one. */
1279
+ function resetMemberCandidates() {
1280
+ // Anything still in flight belongs to the list being thrown away.
1281
+ memberRequestSeq += 1;
1282
+ memberCandidates.value = [];
1283
+ hiddenMemberCount.value = 0;
1284
+ memberPage.value = 0;
1285
+ memberRowsLoaded.value = 0;
1286
+ memberTotalRows.value = 0;
1287
+ membersLoaded.value = false;
1288
+ }
1289
+
1290
+ /**
1291
+ * One page of this site's active staff.
1292
+ *
1293
+ * Paged rather than fetched whole: a page of ten is what the Members menu asks
1294
+ * for, and sharing that page size shares its cache entry. `append` is what
1295
+ * separates the first load from Load more - the first replaces, the rest add.
1296
+ *
1297
+ * NEVER THROWS. The list is left as it is and the placeholder says so, because
1298
+ * choosing "Member" must not break the dialog.
1299
+ */
1300
+ async function fetchMemberPage(page: number, append: boolean) {
1301
+ const memberType = String(runtimeConfig.public.APP ?? "").trim();
1302
+ // "App" is the nuxt config's own placeholder default. Without a real app there
1303
+ // is no member type to ask for, and a typeless query returns nothing anyway.
1304
+ if (!props.site || !props.org || !memberType || memberType === "App") {
1305
+ resetMemberCandidates();
1306
+ membersLoaded.value = true;
1307
+ return;
1308
+ }
1309
+
1310
+ const seq = (memberRequestSeq += 1);
1311
+ const busy = append ? loadingMoreMembers : loadingMembers;
1312
+ busy.value = true;
1313
+ try {
1314
+ const response = await getAllMembers({
1315
+ org: props.org,
1316
+ siteId: props.site,
1317
+ type: memberType,
1318
+ status: "active",
1319
+ page,
1320
+ limit: MEMBER_PAGE_LIMIT,
1321
+ });
1322
+ if (seq !== memberRequestSeq) return;
1323
+ const parsed = readMemberPage(response);
1324
+ const enrollable = parsed.items.filter(memberHasAccount);
1325
+ const candidates = enrollable.map(memberCandidateOf);
1326
+
1327
+ memberCandidates.value = append
1328
+ ? [...memberCandidates.value, ...candidates]
1329
+ : candidates;
1330
+ const hiddenOnPage = parsed.items.length - enrollable.length;
1331
+ hiddenMemberCount.value = append
1332
+ ? hiddenMemberCount.value + hiddenOnPage
1333
+ : hiddenOnPage;
1334
+ memberRowsLoaded.value = append
1335
+ ? memberRowsLoaded.value + parsed.items.length
1336
+ : parsed.items.length;
1337
+ memberTotalRows.value = parsed.total;
1338
+ memberPage.value = page;
1339
+ membersLoaded.value = true;
1340
+ } catch (error: unknown) {
1341
+ if (seq !== memberRequestSeq) return;
1342
+ console.error("Unable to load staff for HID enrollment:", error);
1343
+ // An "append" that failed keeps what is already on screen; a first load has
1344
+ // nothing to keep. Either way the dialog stays usable.
1345
+ if (!append) resetMemberCandidates();
1346
+ membersLoaded.value = true;
1347
+ } finally {
1348
+ // Always its OWN flag, superseded or not: a request that leaves its spinner
1349
+ // set strands the list behind a wait that will never end.
1350
+ busy.value = false;
1351
+ }
1352
+ }
1353
+
1354
+ /** The first page, replacing whatever was there. */
1355
+ function loadMemberCandidates() {
1356
+ return fetchMemberPage(1, false);
1357
+ }
1358
+
1359
+ /** The next page, added to the list. */
1360
+ function loadMoreMembers() {
1361
+ if (loadingMembers.value || loadingMoreMembers.value || !hasMoreMembers.value)
1362
+ return;
1363
+ return fetchMemberPage(memberPage.value + 1, true);
1364
+ }
1365
+
1366
+ /**
1367
+ * Switching type clears the chosen subject, and nothing else.
1368
+ *
1369
+ * The id spaces do not overlap - a `site.people._id` submitted as a `member`
1370
+ * would be refused by the server, and worse, a stale one could name a real
1371
+ * member - so it is cleared rather than carried. Name and Role mirror the
1372
+ * choice, so they clear with it.
1373
+ */
1374
+ async function onSubjectCategoryChanged() {
1375
+ REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory;
1376
+ form.subjectId = "";
1377
+ form.subjectRole = "";
1378
+ form.name = "";
1379
+ residentConflict.value = null;
1380
+
1381
+ if (!isResidentSubject.value && !membersLoaded.value) {
1382
+ await loadMemberCandidates();
1383
+ }
1384
+ }
1385
+
1386
+ /* The staff equivalent of `onResidentChanged`. */
1387
+ async function onMemberChanged() {
1388
+ const candidate = memberCandidates.value.find(
1389
+ (item) => String(item.subjectId) === String(form.subjectId)
1390
+ );
1391
+ form.name = String(candidate?.name ?? "");
1392
+ form.subjectRole = String(candidate?.subtitle ?? "");
1393
+ await checkSubjectEnrollment();
1394
+ }
1395
+
971
1396
  const blockPlaceholder = computed(() => {
972
1397
  if (loadingOccupancy.value) return "Loading...";
973
1398
  if (occupancyLoaded.value && !blockOptions.value.length) {
@@ -1009,23 +1434,37 @@ const cardForm = reactive({
1009
1434
  });
1010
1435
 
1011
1436
  const cardReader = computed(() => {
1012
- const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
1013
- return readers.value.find((reader) => String(reader._id) === readerId) || null;
1437
+ const readerId = String(
1438
+ cardUser.value?.reader || selectedReaderId.value || ""
1439
+ );
1440
+ return (
1441
+ readers.value.find((reader) => String(reader._id) === readerId) || null
1442
+ );
1014
1443
  });
1015
- const cardProfileConfigured = computed(() => ["pacs", "csn", "csn_for_mifare"].includes(
1016
- String(cardReader.value?.cardReadingMode || ""),
1017
- ));
1018
- const hybridCardProfile = computed(() => cardReader.value?.cardReadingMode === "csn_for_mifare");
1444
+ const cardProfileConfigured = computed(() =>
1445
+ ["pacs", "csn", "csn_for_mifare"].includes(
1446
+ String(cardReader.value?.cardReadingMode || "")
1447
+ )
1448
+ );
1449
+ const hybridCardProfile = computed(
1450
+ () => cardReader.value?.cardReadingMode === "csn_for_mifare"
1451
+ );
1019
1452
  const hybridCardTypeOptions = [
1020
1453
  { title: "MIFARE card (CSN)", value: "csn" },
1021
1454
  { title: "Other card (PACS)", value: "pacs" },
1022
1455
  ];
1023
1456
  const cardProfileLabel = computed(() => {
1024
1457
  const mode = String(cardReader.value?.cardReadingMode || "unconfigured");
1025
- if (mode === "pacs") return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
1026
- if (mode === "csn") return `CSN · ${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first`;
1458
+ if (mode === "pacs")
1459
+ return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
1460
+ if (mode === "csn")
1461
+ return `CSN · ${(
1462
+ cardReader.value?.csnByteOrder || "msb"
1463
+ ).toUpperCase()} first`;
1027
1464
  if (mode === "csn_for_mifare") {
1028
- return `PACS for non-MIFARE cards · CSN for MIFARE (${(cardReader.value?.csnByteOrder || "msb").toUpperCase()} first)`;
1465
+ return `PACS for non-MIFARE cards · CSN for MIFARE (${(
1466
+ cardReader.value?.csnByteOrder || "msb"
1467
+ ).toUpperCase()} first)`;
1029
1468
  }
1030
1469
  return "Configure the card data mode on the HID Reader screen before assigning a card.";
1031
1470
  });
@@ -1055,8 +1494,12 @@ const cardHeaders = [
1055
1494
  { title: "", value: "action-table", sortable: false },
1056
1495
  ];
1057
1496
 
1058
- const tableTitle = computed(() => props.cardManagement ? "HID Cards" : "HID Users");
1059
- const tableHeaders = computed(() => props.cardManagement ? cardHeaders : userHeaders);
1497
+ const tableTitle = computed(() =>
1498
+ props.cardManagement ? "HID Cards" : "HID Users"
1499
+ );
1500
+ const tableHeaders = computed(() =>
1501
+ props.cardManagement ? cardHeaders : userHeaders
1502
+ );
1060
1503
 
1061
1504
  /**
1062
1505
  * The list is paged on the SERVER, so the shared pager only moves the cursor -
@@ -1101,12 +1544,17 @@ onBeforeUnmount(() => clearTimeout(searchDebounce));
1101
1544
 
1102
1545
  const readerOptions = computed(() =>
1103
1546
  readers.value.map((reader) => ({
1104
- title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
1547
+ title: `${reader.name || reader.deviceId || reader._id} — ${
1548
+ reader.portalName || "Portal not configured"
1549
+ }`,
1105
1550
  value: reader._id,
1106
1551
  }))
1107
1552
  );
1108
- const selectedReader = computed(() =>
1109
- readers.value.find((reader) => reader._id === (selectedReaderId.value || form.reader)) || null,
1553
+ const selectedReader = computed(
1554
+ () =>
1555
+ readers.value.find(
1556
+ (reader) => reader._id === (selectedReaderId.value || form.reader)
1557
+ ) || null
1110
1558
  );
1111
1559
 
1112
1560
  const pageRange = computed(() => {
@@ -1133,7 +1581,8 @@ async function loadReaders() {
1133
1581
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
1134
1582
  const responseRecord = toRecord(response);
1135
1583
  const responseData = toRecord(responseRecord.data);
1136
- const items = responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
1584
+ const items =
1585
+ responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
1137
1586
  readers.value = (Array.isArray(items) ? items : [])
1138
1587
  .map(toHidReader)
1139
1588
  .filter((reader): reader is HidReader => reader !== null);
@@ -1202,7 +1651,9 @@ async function loadUsers() {
1202
1651
  const responseRecord = toRecord(response);
1203
1652
  const responseData = toRecord(responseRecord.data);
1204
1653
  const responseItems = responseRecord.items ?? responseData.items ?? [];
1205
- const allItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
1654
+ const allItems = (Array.isArray(responseItems) ? responseItems : []).map(
1655
+ toHidUser
1656
+ );
1206
1657
  const matchingItems = clientFiltersVisitors
1207
1658
  ? allItems.filter((user) => !isVisitorUser(user))
1208
1659
  : allItems;
@@ -1215,12 +1666,16 @@ async function loadUsers() {
1215
1666
  pageItems
1216
1667
  .filter((user) => user.isAdministrator === true)
1217
1668
  .map((user) => toHidNumericId(user.hidUserId))
1218
- .filter((userId: number | undefined): userId is number => Boolean(userId)),
1669
+ .filter((userId: number | undefined): userId is number =>
1670
+ Boolean(userId)
1671
+ )
1219
1672
  );
1220
1673
  users.value = pageItems;
1221
1674
  total.value = clientFiltersVisitors
1222
1675
  ? matchingItems.length
1223
- : Number(responseRecord.total ?? responseData.total ?? users.value.length);
1676
+ : Number(
1677
+ responseRecord.total ?? responseData.total ?? users.value.length
1678
+ );
1224
1679
  pages.value = clientFiltersVisitors
1225
1680
  ? Math.max(1, Math.ceil(total.value / limit.value))
1226
1681
  : Number(responseRecord.pages ?? responseData.pages ?? 1);
@@ -1250,7 +1705,6 @@ async function loadUsers() {
1250
1705
  }
1251
1706
  }
1252
1707
 
1253
-
1254
1708
  function resetForm() {
1255
1709
  form.reader = selectedReaderId.value || readers.value[0]?._id || "";
1256
1710
  form.block = "";
@@ -1272,19 +1726,25 @@ function resetForm() {
1272
1726
  selectedPhotoFile.value = null;
1273
1727
  removePhotoRequested.value = false;
1274
1728
  facialEnrolled.value = false;
1275
- form.subjectCategory = "resident";
1729
+ // The type the operator last enrolled, not a hard "resident": somebody adding
1730
+ // a shift of guards should not re-pick Member for each one.
1731
+ form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
1276
1732
  form.subjectId = "";
1733
+ form.subjectRole = "";
1277
1734
  residentConflict.value = null;
1278
1735
  checkingResident.value = false;
1279
1736
  }
1280
1737
 
1281
-
1282
1738
  /** Surface what the API actually said - a bare "unable to load" hides the
1283
1739
  * difference between a permission refusal, a bad id and a server fault. */
1284
1740
  function apiReason(error: unknown) {
1285
1741
  const record = toRecord(error);
1286
1742
  const data = toRecord(record.data);
1287
- return toText(data.message) || toText(record.message) || toText(record.statusMessage);
1743
+ return (
1744
+ toText(data.message) ||
1745
+ toText(record.message) ||
1746
+ toText(record.statusMessage)
1747
+ );
1288
1748
  }
1289
1749
 
1290
1750
  // Called when the Block select is first touched, not when the dialog opens.
@@ -1292,21 +1752,27 @@ async function ensureOccupancy() {
1292
1752
  if (!props.site || occupancyLoaded.value || loadingOccupancy.value) return;
1293
1753
  loadingOccupancy.value = true;
1294
1754
  try {
1295
- const response = toRecord(await getOccupiedStructure(props.site, {
1296
- type: OCCUPANT_TYPES,
1297
- status: "active",
1298
- }));
1755
+ const response = toRecord(
1756
+ await getOccupiedStructure(props.site, {
1757
+ type: OCCUPANT_TYPES,
1758
+ status: "active",
1759
+ })
1760
+ );
1299
1761
  const data = toRecord(response.data);
1300
1762
  const blocks = response.blocks ?? data.blocks ?? [];
1301
- occupiedBlocks.value = (Array.isArray(blocks) ? blocks : []) as OccupiedBlock[];
1763
+ occupiedBlocks.value = (
1764
+ Array.isArray(blocks) ? blocks : []
1765
+ ) as OccupiedBlock[];
1302
1766
  occupancyLoaded.value = true;
1303
1767
  } catch (error: unknown) {
1304
1768
  console.error("Unable to load occupied units:", error);
1305
1769
  occupiedBlocks.value = [];
1306
1770
  const reason = apiReason(error);
1307
1771
  showToast(
1308
- reason ? `Unable to load units: ${reason}` : "Unable to load units for this site.",
1309
- "error",
1772
+ reason
1773
+ ? `Unable to load units: ${reason}`
1774
+ : "Unable to load units for this site.",
1775
+ "error"
1310
1776
  );
1311
1777
  } finally {
1312
1778
  loadingOccupancy.value = false;
@@ -1331,8 +1797,10 @@ async function loadUnitPeople() {
1331
1797
  console.error("Unable to load residents for the unit:", error);
1332
1798
  const reason = apiReason(error);
1333
1799
  showToast(
1334
- reason ? `Unable to load residents: ${reason}` : "Unable to load residents for this unit.",
1335
- "error",
1800
+ reason
1801
+ ? `Unable to load residents: ${reason}`
1802
+ : "Unable to load residents for this unit.",
1803
+ "error"
1336
1804
  );
1337
1805
  } finally {
1338
1806
  loadingUnitPeople.value = false;
@@ -1372,10 +1840,10 @@ function clearResidentSelection() {
1372
1840
  // The auto-fill the whole cascade exists for.
1373
1841
  async function onResidentChanged() {
1374
1842
  const person = unitPeople.value.find(
1375
- (item) => String(item._id) === String(form.subjectId),
1843
+ (item) => String(item._id) === String(form.subjectId)
1376
1844
  );
1377
1845
  form.name = String(person?.name ?? "");
1378
- await checkResidentEnrollment();
1846
+ await checkSubjectEnrollment();
1379
1847
  }
1380
1848
 
1381
1849
  /**
@@ -1403,11 +1871,15 @@ async function findResidentIdentity(readerId: string, personId: string) {
1403
1871
  const responseData = toRecord(responseRecord.data);
1404
1872
  const responseItems =
1405
1873
  responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
1406
- const identities = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
1874
+ const identities = (Array.isArray(responseItems) ? responseItems : []).map(
1875
+ toHidUser
1876
+ );
1407
1877
  const editingId = String(selectedUser.value?._id ?? "");
1408
1878
 
1409
1879
  return (
1410
- identities.find((identity) => !editingId || String(identity._id ?? "") !== editingId) ?? null
1880
+ identities.find(
1881
+ (identity) => !editingId || String(identity._id ?? "") !== editingId
1882
+ ) ?? null
1411
1883
  );
1412
1884
  }
1413
1885
 
@@ -1418,17 +1890,33 @@ async function findResidentIdentity(readerId: string, personId: string) {
1418
1890
  * the server refuses the duplicate anyway. This is a courtesy check that keeps
1419
1891
  * a doomed enrollment off the physical reader, not the guard itself.
1420
1892
  */
1421
- async function checkResidentEnrollment() {
1893
+ /**
1894
+ * Is this subject already enrolled on this reader?
1895
+ *
1896
+ * Category-agnostic, since staff can be enrolled too. `findResidentIdentity`
1897
+ * queries the server's `subject` parameter, which is matched against every link
1898
+ * field - `person`, `member`, `serviceProvider`, `user` - so a member needs no
1899
+ * separate lookup. The server refuses a duplicate either way; this only puts the
1900
+ * refusal in front of the operator before the device is touched.
1901
+ */
1902
+ async function checkSubjectEnrollment() {
1422
1903
  residentConflict.value = null;
1423
- const readerId = selectedReaderId.value || form.reader || readers.value[0]?._id;
1424
- if (!readerId || !form.subjectId || form.subjectCategory !== "resident") return null;
1904
+ const readerId =
1905
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
1906
+ if (!readerId || !form.subjectId) return null;
1425
1907
 
1426
1908
  checkingResident.value = true;
1427
1909
  try {
1428
- residentConflict.value = await findResidentIdentity(readerId, form.subjectId);
1910
+ residentConflict.value = await findResidentIdentity(
1911
+ readerId,
1912
+ form.subjectId
1913
+ );
1429
1914
  return residentConflict.value;
1430
1915
  } catch (error: unknown) {
1431
- console.error("Unable to check whether this resident is already enrolled:", error);
1916
+ console.error(
1917
+ "Unable to check whether this resident is already enrolled:",
1918
+ error
1919
+ );
1432
1920
  return null;
1433
1921
  } finally {
1434
1922
  checkingResident.value = false;
@@ -1445,6 +1933,13 @@ async function openEnroll() {
1445
1933
  form.hidUserId = hidUserId;
1446
1934
  form.registration = registration;
1447
1935
  formDialog.value = true;
1936
+
1937
+ // `resetForm` restores the remembered type, so the dialog can open straight
1938
+ // onto Member with an empty list. Loaded after the dialog is up, not before:
1939
+ // the request must not delay it, and the picker carries its own loading
1940
+ // placeholder. Fresh each opening, because staff change between them.
1941
+ resetMemberCandidates();
1942
+ if (!isResidentSubject.value) await loadMemberCandidates();
1448
1943
  }
1449
1944
 
1450
1945
  async function openEdit(user: HidUser) {
@@ -1469,9 +1964,11 @@ async function openEdit(user: HidUser) {
1469
1964
  form.subjectCategory = user.person
1470
1965
  ? "resident"
1471
1966
  : user.serviceProvider
1472
- ? "service_provider"
1473
- : "property_management";
1474
- form.subjectId = String(user.person || user.member || user.serviceProvider || "");
1967
+ ? "service_provider"
1968
+ : "property_management";
1969
+ form.subjectId = String(
1970
+ user.person || user.member || user.serviceProvider || ""
1971
+ );
1475
1972
  // The resident this user already holds is not a conflict with itself. A real
1476
1973
  // one only appears if the operator reassigns the row to somebody else.
1477
1974
  residentConflict.value = null;
@@ -1500,7 +1997,6 @@ async function openEdit(user: HidUser) {
1500
1997
  } catch {
1501
1998
  // Preserve the last known status if the reader is temporarily offline.
1502
1999
  }
1503
-
1504
2000
  }
1505
2001
 
1506
2002
  function openView(user: HidUser) {
@@ -1583,24 +2079,35 @@ async function saveUser() {
1583
2079
  const missing = !readerId
1584
2080
  ? "a reader"
1585
2081
  : !selectedUser.value && !form.subjectId
2082
+ ? isResidentSubject.value
1586
2083
  ? "a resident"
1587
- : !form.name
1588
- ? "a name"
1589
- : !form.hidUserId
1590
- ? "an ID"
1591
- : !form.registration
1592
- ? "a registration number"
1593
- : "";
2084
+ : "a member"
2085
+ : !form.name
2086
+ ? "a name"
2087
+ : !form.hidUserId
2088
+ ? "an ID"
2089
+ : !form.registration
2090
+ ? "a registration number"
2091
+ : "";
1594
2092
  if (missing) {
1595
- showToast(`This HID user needs ${missing} before it can be saved.`, "error");
2093
+ showToast(
2094
+ `This HID user needs ${missing} before it can be saved.`,
2095
+ "error"
2096
+ );
1596
2097
  return;
1597
2098
  }
1598
2099
  if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
1599
2100
  showToast("PIN must contain numbers only.", "error");
1600
2101
  return;
1601
2102
  }
1602
- if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
1603
- showToast("Reconnect this HID reader and select a portal before enrolling facial recognition.", "error");
2103
+ if (
2104
+ selectedPhotoFile.value &&
2105
+ (!selectedReader.value?.portalId || !selectedReader.value?.portalName)
2106
+ ) {
2107
+ showToast(
2108
+ "Reconnect this HID reader and select a portal before enrolling facial recognition.",
2109
+ "error"
2110
+ );
1604
2111
  return;
1605
2112
  }
1606
2113
 
@@ -1608,7 +2115,7 @@ async function saveUser() {
1608
2115
  // somebody else enrolls the same resident, and the reader is only ever
1609
2116
  // written to below this line. A duplicate caught here costs one request; the
1610
2117
  // same duplicate caught by the server costs a created HID user and a rollback.
1611
- const alreadyEnrolled = await checkResidentEnrollment();
2118
+ const alreadyEnrolled = await checkSubjectEnrollment();
1612
2119
  if (alreadyEnrolled) {
1613
2120
  showToast(residentConflictMessage.value, "error");
1614
2121
  return;
@@ -1634,20 +2141,24 @@ async function saveUser() {
1634
2141
  // subject the record already has.
1635
2142
  const subjectLink = form.subjectId
1636
2143
  ? {
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"
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"
1643
2154
  ? "contractor"
1644
2155
  : "staff") as
1645
- | "resident"
1646
- | "staff"
1647
- | "contractor"
1648
- | "visitor"
1649
- | "unknown",
1650
- }
2156
+ | "resident"
2157
+ | "staff"
2158
+ | "contractor"
2159
+ | "visitor"
2160
+ | "unknown",
2161
+ }
1651
2162
  : {};
1652
2163
 
1653
2164
  const payload = {
@@ -1716,7 +2227,10 @@ async function saveUser() {
1716
2227
  where: { users: { id: newlyCreatedHidUserId } },
1717
2228
  });
1718
2229
  } catch (rollbackError) {
1719
- console.error("Unable to roll back the partially enrolled HID user:", rollbackError);
2230
+ console.error(
2231
+ "Unable to roll back the partially enrolled HID user:",
2232
+ rollbackError
2233
+ );
1720
2234
  }
1721
2235
  }
1722
2236
  console.error("Unable to save HID user:", error);
@@ -1829,7 +2343,7 @@ async function deleteUser() {
1829
2343
  );
1830
2344
  await deleteUserPin(
1831
2345
  selectedReaderId.value,
1832
- toHidNumericId(selectedUser.value.hidUserId),
2346
+ toHidNumericId(selectedUser.value.hidUserId)
1833
2347
  );
1834
2348
  await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
1835
2349
  if (selectedUser.value._id) {
@@ -1848,7 +2362,7 @@ async function deleteUser() {
1848
2362
 
1849
2363
  async function saveIdentityOnReader(
1850
2364
  readerId: string,
1851
- payload: HidIdentityPayload,
2365
+ payload: HidIdentityPayload
1852
2366
  ) {
1853
2367
  const existingIdentity = await findExistingIdentityOnReader(
1854
2368
  readerId,
@@ -1860,7 +2374,11 @@ async function saveIdentityOnReader(
1860
2374
  }
1861
2375
 
1862
2376
  try {
1863
- await createIdentity(readerId, { ...payload, site: props.site, ...(props.org ? { orgId: props.org } : {}) });
2377
+ await createIdentity(readerId, {
2378
+ ...payload,
2379
+ site: props.site,
2380
+ ...(props.org ? { orgId: props.org } : {}),
2381
+ });
1864
2382
  } catch (error: unknown) {
1865
2383
  const message = getHidErrorMessage(error);
1866
2384
  if (!message.toLowerCase().includes("already exists")) throw error;
@@ -1876,7 +2394,7 @@ async function saveIdentityOnReader(
1876
2394
 
1877
2395
  async function findExistingIdentityOnReader(
1878
2396
  readerId: string,
1879
- payload: HidIdentityPayload,
2397
+ payload: HidIdentityPayload
1880
2398
  ) {
1881
2399
  const response = await getIdentities(readerId, {
1882
2400
  page: 1,
@@ -1885,8 +2403,11 @@ async function findExistingIdentityOnReader(
1885
2403
  });
1886
2404
  const responseRecord = toRecord(response);
1887
2405
  const responseData = toRecord(responseRecord.data);
1888
- const responseItems = responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
1889
- const identities = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
2406
+ const responseItems =
2407
+ responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
2408
+ const identities = (Array.isArray(responseItems) ? responseItems : []).map(
2409
+ toHidUser
2410
+ );
1890
2411
  const hidUserId = String(payload.hidUserId || "");
1891
2412
  const registration = String(payload.registration || "");
1892
2413
  const cardNo = String(payload.cardNo || "");
@@ -1903,7 +2424,7 @@ async function findExistingIdentityOnReader(
1903
2424
 
1904
2425
  async function createHidUserOnReader(
1905
2426
  readerId: string,
1906
- user: { id?: number; name: string; registration: string },
2427
+ user: { id?: number; name: string; registration: string }
1907
2428
  ) {
1908
2429
  const existing = await getHidUserFromReader(readerId, user.id);
1909
2430
  if (existing) {
@@ -1937,7 +2458,7 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
1937
2458
 
1938
2459
  async function updateHidUserOnReader(
1939
2460
  readerId: string,
1940
- user: { id?: number; name: string; registration: string },
2461
+ user: { id?: number; name: string; registration: string }
1941
2462
  ) {
1942
2463
  const { id, ...values } = user;
1943
2464
  await runObjectOperation(readerId, {
@@ -1952,10 +2473,7 @@ async function updateHidUserOnReader(
1952
2473
  });
1953
2474
  }
1954
2475
 
1955
- async function deleteHidUserFromReader(
1956
- readerId: string,
1957
- user: HidUser,
1958
- ) {
2476
+ async function deleteHidUserFromReader(readerId: string, user: HidUser) {
1959
2477
  const id = toHidNumericId(user.hidUserId);
1960
2478
  if (!readerId || !id) return;
1961
2479
 
@@ -2043,7 +2561,9 @@ async function getHidUserRole(readerId: string, userId: number) {
2043
2561
  return Array.isArray(roles) ? roles[0] : undefined;
2044
2562
  }
2045
2563
 
2046
- function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registration">) {
2564
+ function toHidUserObject(
2565
+ identity: Pick<HidUser, "hidUserId" | "name" | "registration">
2566
+ ) {
2047
2567
  return {
2048
2568
  id: toHidNumericId(identity.hidUserId),
2049
2569
  name:
@@ -2065,8 +2585,10 @@ function isUnsupportedUserTypeFilter(error: unknown) {
2065
2585
  const data = toRecord(record.data);
2066
2586
  const message = toText(data.message) || toText(record.message);
2067
2587
  const normalized = message.toLowerCase();
2068
- return normalized.includes("usertype")
2069
- && (normalized.includes("not allowed") || normalized.includes("unknown"));
2588
+ return (
2589
+ normalized.includes("usertype") &&
2590
+ (normalized.includes("not allowed") || normalized.includes("unknown"))
2591
+ );
2070
2592
  }
2071
2593
 
2072
2594
  function toHidNumericId(value: unknown) {
@@ -2097,16 +2619,19 @@ async function loadVisibleUserCards(items: HidUser[]) {
2097
2619
  const response = await getUserCards(selectedReaderId.value, hidUserId);
2098
2620
  const responseData = toRecord(toRecord(response).data);
2099
2621
  const cards = responseData.cards;
2100
- return [String(hidUserId), (Array.isArray(cards) ? cards : []).map(toHidCard)] as const;
2622
+ return [
2623
+ String(hidUserId),
2624
+ (Array.isArray(cards) ? cards : []).map(toHidCard),
2625
+ ] as const;
2101
2626
  } catch (error: unknown) {
2102
2627
  cardErrors[String(hidUserId)] = getHidErrorMessage(error);
2103
2628
  return [String(hidUserId), []] as const;
2104
2629
  }
2105
- }),
2630
+ })
2106
2631
  );
2107
2632
 
2108
2633
  physicalCardsByUserId.value = Object.fromEntries(
2109
- entries.filter(([hidUserId]) => Boolean(hidUserId)),
2634
+ entries.filter(([hidUserId]) => Boolean(hidUserId))
2110
2635
  );
2111
2636
  physicalCardErrorsByUserId.value = cardErrors;
2112
2637
  }
@@ -2118,12 +2643,16 @@ function getUserPhysicalCards(user: HidUser) {
2118
2643
 
2119
2644
  function getUserCardLoadError(user: HidUser) {
2120
2645
  const hidUserId = toHidNumericId(user.hidUserId);
2121
- return hidUserId ? physicalCardErrorsByUserId.value[String(hidUserId)] || "" : "";
2646
+ return hidUserId
2647
+ ? physicalCardErrorsByUserId.value[String(hidUserId)] || ""
2648
+ : "";
2122
2649
  }
2123
2650
 
2124
2651
  async function openCardManager(user: HidUser) {
2125
2652
  cardUser.value = user;
2126
- const reader = readers.value.find((item) => String(item._id) === String(user.reader || selectedReaderId.value));
2653
+ const reader = readers.value.find(
2654
+ (item) => String(item._id) === String(user.reader || selectedReaderId.value)
2655
+ );
2127
2656
  cardForm.cardType = reader?.cardReadingMode === "pacs" ? "pacs" : "csn";
2128
2657
  cardForm.facilityCode = "";
2129
2658
  cardForm.cardNumber = "";
@@ -2134,7 +2663,9 @@ async function openCardManager(user: HidUser) {
2134
2663
  }
2135
2664
 
2136
2665
  function getCardReaderAndUser() {
2137
- const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
2666
+ const readerId = String(
2667
+ cardUser.value?.reader || selectedReaderId.value || ""
2668
+ );
2138
2669
  const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
2139
2670
  return { readerId, hidUserId };
2140
2671
  }
@@ -2157,7 +2688,11 @@ async function loadPhysicalCards() {
2157
2688
  }
2158
2689
  }
2159
2690
 
2160
- function parsePhysicalCardInteger(value: string, label: string, maximum: bigint) {
2691
+ function parsePhysicalCardInteger(
2692
+ value: string,
2693
+ label: string,
2694
+ maximum: bigint
2695
+ ) {
2161
2696
  if (!/^\d+$/.test(value.trim())) {
2162
2697
  showToast(`${label} must contain numbers only.`, "error");
2163
2698
  return null;
@@ -2178,14 +2713,27 @@ async function assignPhysicalCard() {
2178
2713
  | { cardType: "pacs"; facilityCode: string; cardNumber: string }
2179
2714
  | { cardType: "csn"; cardValue: string };
2180
2715
  if (cardForm.cardType === "pacs") {
2181
- const facilityCode = parsePhysicalCardInteger(cardForm.facilityCode, "Facility Code", 4294967295n);
2182
- const cardNumber = parsePhysicalCardInteger(cardForm.cardNumber, "Card Number", 4294967295n);
2716
+ const facilityCode = parsePhysicalCardInteger(
2717
+ cardForm.facilityCode,
2718
+ "Facility Code",
2719
+ 4294967295n
2720
+ );
2721
+ const cardNumber = parsePhysicalCardInteger(
2722
+ cardForm.cardNumber,
2723
+ "Card Number",
2724
+ 4294967295n
2725
+ );
2183
2726
  if (facilityCode === null || cardNumber === null) return;
2184
2727
  payload = { cardType: "pacs", facilityCode, cardNumber };
2185
2728
  } else {
2186
- const cardValue = parsePhysicalCardInteger(cardForm.cardValue, "CSN", 18446744073709551615n);
2729
+ const cardValue = parsePhysicalCardInteger(
2730
+ cardForm.cardValue,
2731
+ "CSN",
2732
+ 18446744073709551615n
2733
+ );
2187
2734
  if (cardValue === null || BigInt(cardValue) <= 0n) {
2188
- if (cardValue === "0") showToast("CSN must be greater than zero.", "error");
2735
+ if (cardValue === "0")
2736
+ showToast("CSN must be greater than zero.", "error");
2189
2737
  return;
2190
2738
  }
2191
2739
  payload = { cardType: "csn", cardValue };
@@ -2284,7 +2832,8 @@ async function cancelPhysicalCardEnrollment() {
2284
2832
  }
2285
2833
 
2286
2834
  async function removePhysicalCard(card: HidCard) {
2287
- if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`)) return;
2835
+ if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
2836
+ return;
2288
2837
  const { readerId, hidUserId } = getCardReaderAndUser();
2289
2838
  if (!readerId || !hidUserId || !card?.id) return;
2290
2839
 
@@ -2310,7 +2859,12 @@ function formatPhysicalCard(card: HidCard) {
2310
2859
  }
2311
2860
 
2312
2861
  function getPhysicalCardSubtitle(card: HidCard) {
2313
- const source = card?.source === "reader" ? "Read at reader" : card?.source === "manual" ? "Manual" : "On device";
2862
+ const source =
2863
+ card?.source === "reader"
2864
+ ? "Read at reader"
2865
+ : card?.source === "manual"
2866
+ ? "Manual"
2867
+ : "On device";
2314
2868
  return `${source} - Raw value ${card?.value || "N/A"}`;
2315
2869
  }
2316
2870
 
@@ -2318,9 +2872,15 @@ function buildUnitLabel() {
2318
2872
  // `form.block/level/unit` hold ObjectIds, so the label has to resolve them
2319
2873
  // back to names through the occupancy tree. Joining the raw values would
2320
2874
  // 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);
2875
+ const block = occupiedBlocks.value.find(
2876
+ (item) => String(item._id ?? "") === form.block
2877
+ );
2878
+ const level = (block?.levels ?? []).find(
2879
+ (item) => String(item._id ?? "") === form.level
2880
+ );
2881
+ const unit = (level?.units ?? []).find(
2882
+ (item) => String(item._id ?? "") === form.unit
2883
+ );
2324
2884
  const parts = [
2325
2885
  formatBlockTitle(block),
2326
2886
  String(level?.name ?? ""),
@@ -2328,7 +2888,8 @@ function buildUnitLabel() {
2328
2888
  ].filter(Boolean);
2329
2889
  // Editing a record enrolled before the cascade existed: keep its stored
2330
2890
  // label rather than blanking it.
2331
- if (!parts.length) return String(selectedUser.value?.metadata?.unitLabel ?? "");
2891
+ if (!parts.length)
2892
+ return String(selectedUser.value?.metadata?.unitLabel ?? "");
2332
2893
  return parts.join(" / ");
2333
2894
  }
2334
2895
 
@@ -2416,27 +2977,28 @@ function normalizeHidUsers(response: unknown): HidUser[] {
2416
2977
  const responseRecord = toRecord(response);
2417
2978
  const responseData = toRecord(responseRecord.data);
2418
2979
  const nestedData = toRecord(responseData.data);
2419
- const items = responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
2980
+ const items =
2981
+ responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
2420
2982
 
2421
2983
  return Array.isArray(items)
2422
2984
  ? items.map((value) => {
2423
2985
  const user = toRecord(value);
2424
2986
  return {
2425
- _rowKey: `reader-${user.id}`,
2426
- hidUserId: String(user.id || ""),
2427
- rawHidUserId: toText(user.id),
2428
- registration: toText(user.registration),
2429
- userTypeId: toText(user.user_type_id),
2430
- cardNo: toText(user.card_value || user.cardNo),
2431
- name: toText(user.name),
2432
- metadata: {
2987
+ _rowKey: `reader-${user.id}`,
2988
+ hidUserId: String(user.id || ""),
2989
+ rawHidUserId: toText(user.id),
2990
+ registration: toText(user.registration),
2991
+ userTypeId: toText(user.user_type_id),
2992
+ cardNo: toText(user.card_value || user.cardNo),
2433
2993
  name: toText(user.name),
2434
- imageTimestamp: user.image_timestamp
2435
- ? String(user.image_timestamp)
2436
- : "",
2437
- lastAccessAt: fromUnixSeconds(user.last_access),
2438
- hidReaderSource: true,
2439
- },
2994
+ metadata: {
2995
+ name: toText(user.name),
2996
+ imageTimestamp: user.image_timestamp
2997
+ ? String(user.image_timestamp)
2998
+ : "",
2999
+ lastAccessAt: fromUnixSeconds(user.last_access),
3000
+ hidReaderSource: true,
3001
+ },
2440
3002
  };
2441
3003
  })
2442
3004
  : [];
@@ -2444,7 +3006,7 @@ function normalizeHidUsers(response: unknown): HidUser[] {
2444
3006
 
2445
3007
  function mergeReaderUsersWithIdentities(
2446
3008
  readerUsers: HidUser[],
2447
- identities: HidUser[],
3009
+ identities: HidUser[]
2448
3010
  ) {
2449
3011
  const identityById = new Map<string, HidUser>();
2450
3012
  identities.forEach((identity) => {
@@ -2487,7 +3049,13 @@ function getMappingStatus(user: HidUser) {
2487
3049
  }
2488
3050
 
2489
3051
  function hasMappedRecord(user: HidUser) {
2490
- return Boolean(user?.person || user?.user || user?.member || user?.serviceProvider || user?.visitor);
3052
+ return Boolean(
3053
+ user?.person ||
3054
+ user?.user ||
3055
+ user?.member ||
3056
+ user?.serviceProvider ||
3057
+ user?.visitor
3058
+ );
2491
3059
  }
2492
3060
 
2493
3061
  function formatDate(value?: string) {
@@ -2519,18 +3087,26 @@ function getHidErrorMessage(error: unknown) {
2519
3087
  const cause = toRecord(errorRecord.cause);
2520
3088
  const causeResponse = toRecord(cause.response);
2521
3089
  const data = toRecord(
2522
- errorRecord.data || response._data || response.data || cause.data || causeResponse._data,
3090
+ errorRecord.data ||
3091
+ response._data ||
3092
+ response.data ||
3093
+ cause.data ||
3094
+ causeResponse._data
2523
3095
  );
2524
3096
 
2525
3097
  return toText(
2526
- data.message || data.error || data.statusMessage || errorRecord.statusMessage || errorRecord.message,
2527
- "Unable to save HID user.",
3098
+ data.message ||
3099
+ data.error ||
3100
+ data.statusMessage ||
3101
+ errorRecord.statusMessage ||
3102
+ errorRecord.message,
3103
+ "Unable to save HID user."
2528
3104
  );
2529
3105
  }
2530
3106
 
2531
3107
  function toRecord(value: unknown): Record<string, unknown> {
2532
3108
  return typeof value === "object" && value !== null && !Array.isArray(value)
2533
- ? value as Record<string, unknown>
3109
+ ? (value as Record<string, unknown>)
2534
3110
  : {};
2535
3111
  }
2536
3112
 
@@ -2576,8 +3152,16 @@ function toHidCard(value: unknown): HidCard {
2576
3152
  return {
2577
3153
  ...card,
2578
3154
  id: toText(card.id) || undefined,
2579
- cardType: card.cardType === "pacs" || card.cardType === "csn" ? card.cardType : undefined,
2580
- source: card.source === "reader" || card.source === "manual" || card.source === "device" ? card.source : undefined,
3155
+ cardType:
3156
+ card.cardType === "pacs" || card.cardType === "csn"
3157
+ ? card.cardType
3158
+ : undefined,
3159
+ source:
3160
+ card.source === "reader" ||
3161
+ card.source === "manual" ||
3162
+ card.source === "device"
3163
+ ? card.source
3164
+ : undefined,
2581
3165
  };
2582
3166
  }
2583
3167
 
@@ -2712,7 +3296,6 @@ watch(selectedReaderId, (value) => {
2712
3296
  box-shadow: var(--shadow-menu);
2713
3297
  }
2714
3298
 
2715
-
2716
3299
  .admin-rule-indicator {
2717
3300
  display: inline-flex;
2718
3301
  vertical-align: middle;