@7365admin1/layer-common 4.97.0 → 4.97.1-staging.501

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 (62) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +57 -0
  3. package/assets/css/screens.css +8 -4
  4. package/components/AccessCardDetailsDialog.vue +59 -6
  5. package/components/AccessCardPreviewDialog.vue +371 -53
  6. package/components/AppSelect.vue +143 -4
  7. package/components/BuildingUnitFormEdit.vue +6 -0
  8. package/components/BuildingUnitManagement.vue +298 -0
  9. package/components/Dialog/UpdateMoreAction.vue +11 -1
  10. package/components/DocumentForm.vue +407 -112
  11. package/components/DocumentManagement.vue +213 -12
  12. package/components/Facility/BookingSetup.vue +63 -0
  13. package/components/HidAccessLogDashboard.vue +74 -17
  14. package/components/HidAccessPermissions.vue +532 -0
  15. package/components/HidIntercomManagement.vue +183 -14
  16. package/components/HidProfileQrCode.vue +332 -0
  17. package/components/HidQrCodeConfiguration.vue +17 -195
  18. package/components/HidReaderManagement.vue +398 -0
  19. package/components/HidReaderUserRoster.vue +44 -3
  20. package/components/HidUserEnrollment.vue +2242 -279
  21. package/components/InvitationClientForm.vue +19 -1
  22. package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
  23. package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
  24. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
  25. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
  26. package/components/ServiceProviderMain.vue +48 -0
  27. package/components/SiteProviderChange.vue +371 -0
  28. package/components/TableMain.vue +22 -7
  29. package/components/VisitorForm.vue +12 -0
  30. package/components/VisitorManagement.vue +267 -5
  31. package/composables/useAccessManagement.ts +17 -1
  32. package/composables/useCustomerSite.ts +29 -0
  33. package/composables/useDocument.ts +7 -0
  34. package/composables/useFacility.ts +11 -0
  35. package/composables/useHidAmico.ts +165 -0
  36. package/composables/useHidNavigation.ts +25 -1
  37. package/composables/useHidReaderSelection.ts +50 -0
  38. package/composables/useMember.ts +12 -0
  39. package/composables/useNFCPatrolReport.ts +36 -32
  40. package/composables/useNFCPatrolReportFilters.ts +21 -3
  41. package/composables/useNFCPatrolRoute.ts +10 -3
  42. package/composables/usePeople.ts +19 -0
  43. package/composables/useServiceProvider.ts +45 -2
  44. package/composables/useSiteCategory.ts +45 -0
  45. package/package.json +1 -1
  46. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  47. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  48. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  49. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  50. package/types/document.d.ts +14 -0
  51. package/types/facility.d.ts +4 -0
  52. package/types/member.d.ts +4 -0
  53. package/types/nfc-patrol-report.ts +0 -2
  54. package/types/people.d.ts +28 -1
  55. package/types/service-provider.d.ts +5 -0
  56. package/types/site.d.ts +7 -1
  57. package/utils/hid-enrolment-subject.ts +366 -0
  58. package/utils/hid-permission-assignments.ts +259 -0
  59. package/utils/hid-reader-selection.ts +52 -0
  60. package/utils/nfc-patrol-time.ts +99 -0
  61. package/utils/occupancy-role.ts +109 -0
  62. package/utils/service-type.ts +30 -0
@@ -87,6 +87,8 @@
87
87
 
88
88
  <v-list density="compact" min-width="180">
89
89
  <v-list-item title="View" @click="openView(item)" />
90
+ <v-list-item title="Access Permissions" @click="openPermissions(item)" />
91
+ <v-list-item title="Identification Methods" @click="openIdentification(item)" />
90
92
  <v-list-item title="Sync HID Reader" @click="openSyncConfirm(item)" />
91
93
  <v-list-item title="Test Connection" @click="openTestConfirm(item)" />
92
94
  <v-list-item title="Edit" @click="openEdit(item)" />
@@ -144,6 +146,125 @@
144
146
  </v-card>
145
147
  </v-dialog>
146
148
 
149
+ <!--
150
+ IDENTIFICATION METHODS - what this reader accepts at the door.
151
+
152
+ Per reader, not per site: the device's own `identifier` module is the
153
+ authority, and a site-wide copy could only ever describe one of them.
154
+
155
+ There is deliberately no Bluetooth control. The HID API has no Bluetooth
156
+ identification field; the old site-level toggle of that name referred to
157
+ the phone-as-a-card feature in the mobile app, which is a different thing.
158
+ -->
159
+ <v-dialog v-model="identificationDialog" max-width="430" persistent>
160
+ <v-card rounded="lg">
161
+ <v-card-title class="screen-card-title">Identification Methods</v-card-title>
162
+
163
+ <v-card-text v-if="identificationStage === 'edit'">
164
+ <p class="identification-lead">
165
+ Credentials {{ selectedReader?.name || "this HID Reader" }} will accept at the door.
166
+ </p>
167
+
168
+ <div class="identification-grid">
169
+ <div
170
+ v-for="method in identificationMethodRows"
171
+ :key="method.key"
172
+ class="identification-row"
173
+ >
174
+ <div class="identification-copy">
175
+ <span>{{ method.label }}</span>
176
+ <small v-if="!method.supported">Not supported by this reader</small>
177
+ </div>
178
+ <v-switch
179
+ v-model="identificationForm[method.key]"
180
+ class="app-switch"
181
+ hide-details
182
+ density="compact"
183
+ :disabled="!method.supported || identificationSaving"
184
+ />
185
+ </div>
186
+ </div>
187
+
188
+ <!--
189
+ One reader setting with two modes, not two switches:
190
+ `pin_identification_enabled` is 1 for PIN and 0 for ID-and-password.
191
+ A reader without the PIN capability can be given neither.
192
+ -->
193
+ <div class="identification-credential">
194
+ <div class="identification-copy">
195
+ <span>Credential entry</span>
196
+ <small v-if="!credentialModeSupported">Not supported by this reader</small>
197
+ </div>
198
+ <v-radio-group
199
+ v-model="identificationForm.credentialMode"
200
+ hide-details
201
+ density="compact"
202
+ :disabled="!credentialModeSupported || identificationSaving"
203
+ >
204
+ <v-radio label="PIN" value="pin" />
205
+ <v-radio label="ID and password" value="idPassword" />
206
+ </v-radio-group>
207
+ </div>
208
+
209
+ <p class="identification-applied">
210
+ Confirmed on the reader:
211
+ <strong>{{ formatDate(selectedReader?.identificationMethodsAppliedAt) }}</strong>
212
+ </p>
213
+
214
+ <!--
215
+ Its own action, not a save. A reader's settings can change without
216
+ us — a firmware update, its own touchscreen, a restore, a reset —
217
+ and saving only writes what is on screen. This reads the device
218
+ first and reports what had moved.
219
+ -->
220
+ <div class="dialog-actions-inline">
221
+ <AppButton
222
+ variant="ghost"
223
+ icon="mdi-sync"
224
+ :disabled="identificationReconciling || identificationSaving || !selectedReader?.identificationMethods"
225
+ @click="reapplyIdentificationMethods"
226
+ >
227
+ {{ identificationReconciling ? "Checking reader..." : "Re-apply settings" }}
228
+ </AppButton>
229
+ </div>
230
+ </v-card-text>
231
+
232
+ <v-card-text v-else class="reader-action-content">
233
+ <v-icon icon="mdi-shield-key-outline" class="dialog-mark" size="48" />
234
+ <h3>Change what opens this door?</h3>
235
+ <p>
236
+ You are about to change the credentials {{ selectedReader?.name || "this HID Reader" }}
237
+ accepts.
238
+ </p>
239
+ <p v-if="identificationBeingDisabled.length" class="identification-warning">
240
+ Turning off: <strong>{{ identificationBeingDisabled.join(", ") }}</strong>.
241
+ Anyone relying on that credential will stop getting in at this reader.
242
+ </p>
243
+ </v-card-text>
244
+
245
+ <v-card-actions>
246
+ <v-spacer />
247
+ <AppButton
248
+ variant="ghost"
249
+ :disabled="identificationSaving || identificationReconciling"
250
+ @click="identificationStage === 'confirm' ? (identificationStage = 'edit') : (identificationDialog = false)"
251
+ >
252
+ {{ identificationStage === "confirm" ? "Back" : "Cancel" }}
253
+ </AppButton>
254
+ <AppButton
255
+ v-if="identificationStage === 'edit'"
256
+ :disabled="!identificationChanged || identificationReconciling"
257
+ @click="identificationStage = 'confirm'"
258
+ >
259
+ Save
260
+ </AppButton>
261
+ <AppButton v-else :disabled="identificationSaving" @click="saveIdentificationMethods">
262
+ {{ identificationSaving ? "Applying..." : "Apply to reader" }}
263
+ </AppButton>
264
+ </v-card-actions>
265
+ </v-card>
266
+ </v-dialog>
267
+
147
268
  <v-dialog v-model="confirmDialog" max-width="390" persistent>
148
269
  <v-card rounded="lg" class="reader-action-dialog">
149
270
  <v-card-title class="screen-card-title">{{ confirmTitle }}</v-card-title>
@@ -241,6 +362,18 @@
241
362
  </v-card-actions>
242
363
  </v-card>
243
364
  </v-dialog>
365
+
366
+ <!-- Who may actually pass this reader. Enrolment makes a person
367
+ RECOGNISABLE; this is what puts them in the group the access rule is
368
+ attached to, and without it a recognised person is refused at the
369
+ door. Reloads the list on save so the reader's own counts stay true. -->
370
+ <HidAccessPermissions
371
+ v-model="permissionsDialog"
372
+ :site="props.site"
373
+ :org="props.org"
374
+ :reader="selectedReader"
375
+ @saved="loadReaders"
376
+ />
244
377
  </section>
245
378
  </template>
246
379
 
@@ -284,6 +417,21 @@ type HidRecord = {
284
417
  cardReadingMode?: "unconfigured" | "pacs" | "csn" | "csn_for_mifare";
285
418
  csnByteOrder?: "msb" | "lsb";
286
419
  pacsFormat?: "raw" | "wiegand";
420
+ capabilities?: {
421
+ card?: boolean;
422
+ facial?: boolean;
423
+ qr?: boolean;
424
+ pin?: boolean;
425
+ intercom?: boolean;
426
+ };
427
+ identificationMethods?: {
428
+ facial?: boolean;
429
+ card?: boolean;
430
+ qrCode?: boolean;
431
+ credentialMode?: "pin" | "idPassword";
432
+ };
433
+ /** When the READER confirmed the methods, not when they were saved here. */
434
+ identificationMethodsAppliedAt?: string;
287
435
  };
288
436
 
289
437
  type HidReaderFormPayload = {
@@ -322,6 +470,13 @@ const props = defineProps({
322
470
  type: String,
323
471
  required: true,
324
472
  },
473
+ // The permission endpoints are org-scoped (`assertOrganizationSiteAccess`),
474
+ // and this component previously had no reason to know the org. Passed from
475
+ // the page, as `HidQrCodeConfiguration` already does.
476
+ org: {
477
+ type: String,
478
+ required: true,
479
+ },
325
480
  });
326
481
 
327
482
  const {
@@ -333,6 +488,8 @@ const {
333
488
  configureReaderIntegration,
334
489
  setReaderMonitor,
335
490
  setReaderOperatingMode,
491
+ setReaderIdentificationMethods,
492
+ reconcileReaderIdentificationMethods,
336
493
  getReaderUsers,
337
494
  } = useHidAmico();
338
495
 
@@ -347,6 +504,20 @@ const viewDialog = ref(false);
347
504
  const confirmDialog = ref(false);
348
505
  const resultDialog = ref(false);
349
506
  const selectedReader = ref<HidRecord | null>(null);
507
+ const permissionsDialog = ref(false);
508
+ const identificationDialog = ref(false);
509
+ /* Two stages in one dialog, like the sync flow: the controls, then the
510
+ confirmation. Switching a credential off decides what stops opening a door,
511
+ so it is never one click away. */
512
+ const identificationStage = ref<"edit" | "confirm">("edit");
513
+ const identificationSaving = ref(false);
514
+ const identificationReconciling = ref(false);
515
+ const identificationForm = ref<{
516
+ facial: boolean;
517
+ card: boolean;
518
+ qrCode: boolean;
519
+ credentialMode: "pin" | "idPassword";
520
+ }>({ facial: false, card: false, qrCode: false, credentialMode: "idPassword" });
350
521
  const pendingAction = ref<"test" | "sync" | "delete" | null>(null);
351
522
  const actionStage = ref<"confirm" | "review" | "loading">("confirm");
352
523
  const resultSuccess = ref(true);
@@ -484,6 +655,182 @@ function openView(reader: HidRecord) {
484
655
  viewDialog.value = true;
485
656
  }
486
657
 
658
+ function openPermissions(reader: HidRecord) {
659
+ selectedReader.value = reader;
660
+ permissionsDialog.value = true;
661
+ }
662
+
663
+ /*
664
+ * The reader record is the source of truth, including for what is already on.
665
+ * A missing `identificationMethods` means nothing has been applied through this
666
+ * screen yet; the switches start off rather than guessing at the device.
667
+ */
668
+ function openIdentification(reader: HidRecord) {
669
+ selectedReader.value = reader;
670
+ const current = reader.identificationMethods || {};
671
+ identificationForm.value = {
672
+ facial: current.facial === true,
673
+ card: current.card === true,
674
+ qrCode: current.qrCode === true,
675
+ credentialMode: current.credentialMode === "pin" ? "pin" : "idPassword",
676
+ };
677
+ identificationStage.value = "edit";
678
+ identificationDialog.value = true;
679
+ }
680
+
681
+ /*
682
+ * A reader saved before capabilities were recorded has none. Those are treated
683
+ * as capable rather than having every control disabled on an older record —
684
+ * the backend gate makes the same allowance, and it is the one that decides.
685
+ */
686
+ function readerSupports(key: "facial" | "card" | "qr" | "pin") {
687
+ const capabilities = selectedReader.value?.capabilities;
688
+ if (!capabilities) return true;
689
+ return capabilities[key] === true;
690
+ }
691
+
692
+ const identificationMethodRows = computed(() => [
693
+ { key: "facial" as const, label: "Facial", supported: readerSupports("facial") },
694
+ { key: "card" as const, label: "Card", supported: readerSupports("card") },
695
+ { key: "qrCode" as const, label: "QR code", supported: readerSupports("qr") },
696
+ ]);
697
+
698
+ /* Both modes ride the reader's single PIN switch, so the pair stands or falls
699
+ together on that one capability. */
700
+ const credentialModeSupported = computed(() => readerSupports("pin"));
701
+
702
+ const identificationChanged = computed(() => {
703
+ const current = selectedReader.value?.identificationMethods || {};
704
+ const form = identificationForm.value;
705
+ return (
706
+ form.facial !== (current.facial === true)
707
+ || form.card !== (current.card === true)
708
+ || form.qrCode !== (current.qrCode === true)
709
+ || form.credentialMode !== (current.credentialMode === "pin" ? "pin" : "idPassword")
710
+ );
711
+ });
712
+
713
+ /* Named in the confirmation, because switching a credential OFF is the change
714
+ that locks somebody out. */
715
+ const identificationBeingDisabled = computed(() => {
716
+ const current = selectedReader.value?.identificationMethods || {};
717
+ const form = identificationForm.value;
718
+ const disabled: string[] = [];
719
+ if (current.facial === true && !form.facial) disabled.push("Facial");
720
+ if (current.card === true && !form.card) disabled.push("Card");
721
+ if (current.qrCode === true && !form.qrCode) disabled.push("QR code");
722
+ if (current.credentialMode === "pin" && form.credentialMode !== "pin") disabled.push("PIN");
723
+ return disabled;
724
+ });
725
+
726
+ /*
727
+ * Reload after BOTH outcomes.
728
+ *
729
+ * The confirmed time and the stored methods must come from the server, which
730
+ * only writes them once the reader has echoed the values back. Assuming them
731
+ * here would let the screen claim a door accepts something it refused. On
732
+ * failure the reload matters just as much: the API names the credential the
733
+ * reader would not take, and the controls must go back to what the device
734
+ * actually holds rather than keeping the rejected choice on screen.
735
+ */
736
+ /*
737
+ * What the device is actually holding, put back to what we hold for it.
738
+ *
739
+ * Worth a button of its own because drift has causes nobody here controls — a
740
+ * firmware update, somebody using the reader's own touchscreen, a restore, a
741
+ * reset. Saving the dialog only writes what is on screen; this reads the
742
+ * device first and reports what had moved.
743
+ */
744
+ async function reapplyIdentificationMethods() {
745
+ const readerId = String(selectedReader.value?._id || "");
746
+ if (!readerId) return;
747
+
748
+ const readerName = String(selectedReader.value?.name || "HID Reader");
749
+ identificationReconciling.value = true;
750
+ try {
751
+ const response = await reconcileReaderIdentificationMethods(readerId);
752
+ const result = (response?.data || response || {}) as HidRecord;
753
+ identificationDialog.value = false;
754
+ await loadReaders();
755
+ selectedReader.value = readers.value.find((reader) => String(reader._id) === readerId) || null;
756
+
757
+ const status = String(result.status || "");
758
+ const drifted = Array.isArray(result.drifted) ? result.drifted.map(String) : [];
759
+ if (status === "drift_corrected") {
760
+ showResult(
761
+ true,
762
+ "Settings Re-applied",
763
+ `${readerName} had drifted and has been put back. Corrected: ${drifted.map(identificationFieldLabel).join(", ")}.`,
764
+ { readerName },
765
+ );
766
+ } else if (status === "in_sync") {
767
+ showResult(true, "Settings Already Correct", `${readerName} matches what iService365 holds for it.`, { readerName });
768
+ } else if (status === "skipped" && result.reason === "duplicate_reader") {
769
+ // Not a failure to retry: the device is registered twice, and until a
770
+ // person removes one record both would fight over its configuration.
771
+ showResult(
772
+ false,
773
+ "Duplicate HID Reader",
774
+ `${readerName} shares a device with another registered reader. Remove or deactivate one of them before re-applying settings.`,
775
+ { readerName },
776
+ );
777
+ } else if (status === "skipped") {
778
+ showResult(
779
+ false,
780
+ "Nothing to Re-apply",
781
+ `No identification methods have been set for ${readerName} yet.`,
782
+ { readerName },
783
+ );
784
+ } else {
785
+ showResult(false, "Could Not Re-apply Settings", String(result.message || "The HID reader could not be reached."), { readerName });
786
+ }
787
+ } catch (error: unknown) {
788
+ console.error("Unable to reconcile HID identification methods:", error);
789
+ identificationDialog.value = false;
790
+ await loadReaders();
791
+ showResult(false, "Could Not Re-apply Settings", getFriendlyHidError(error), { readerName });
792
+ } finally {
793
+ identificationReconciling.value = false;
794
+ }
795
+ }
796
+
797
+ /** The reader's own field names, as the person in front of the door knows them. */
798
+ function identificationFieldLabel(field: string) {
799
+ const labels: Record<string, string> = {
800
+ face_identification_enabled: "Facial",
801
+ card_identification_enabled: "Card",
802
+ qrcode_identification_enabled: "QR code",
803
+ pin_identification_enabled: "Credential entry",
804
+ multi_factor_authentication: "Multi-factor",
805
+ };
806
+ return labels[field] || field;
807
+ }
808
+
809
+ async function saveIdentificationMethods() {
810
+ const readerId = String(selectedReader.value?._id || "");
811
+ if (!readerId) return;
812
+
813
+ const readerName = String(selectedReader.value?.name || "HID Reader");
814
+ identificationSaving.value = true;
815
+ try {
816
+ await setReaderIdentificationMethods(readerId, { ...identificationForm.value });
817
+ identificationDialog.value = false;
818
+ await loadReaders();
819
+ selectedReader.value = readers.value.find((reader) => String(reader._id) === readerId) || null;
820
+ showResult(true, "Identification Methods Updated", `${readerName} now accepts the credentials you selected.`, {
821
+ readerName,
822
+ });
823
+ } catch (error: unknown) {
824
+ console.error("Unable to set HID identification methods:", error);
825
+ identificationDialog.value = false;
826
+ await loadReaders();
827
+ showResult(false, "Identification Methods Not Applied", getFriendlyHidError(error), { readerName });
828
+ } finally {
829
+ identificationSaving.value = false;
830
+ identificationStage.value = "edit";
831
+ }
832
+ }
833
+
487
834
  function openTestConfirm(reader?: HidRecord | null) {
488
835
  if (reader) selectedReader.value = reader;
489
836
  pendingAction.value = "test";
@@ -919,6 +1266,57 @@ function formatCallback(reader?: HidRecord | null) {
919
1266
  margin-top: 20px;
920
1267
  }
921
1268
 
1269
+ .identification-lead {
1270
+ margin: 0 0 14px;
1271
+ color: var(--muted);
1272
+ font-size: 13px;
1273
+ }
1274
+
1275
+ .identification-grid {
1276
+ display: grid;
1277
+ gap: 4px;
1278
+ }
1279
+
1280
+ .identification-row,
1281
+ .identification-credential {
1282
+ display: flex;
1283
+ align-items: center;
1284
+ justify-content: space-between;
1285
+ gap: 16px;
1286
+ }
1287
+
1288
+ .identification-credential {
1289
+ align-items: flex-start;
1290
+ margin-top: 12px;
1291
+ padding-top: 12px;
1292
+ border-top: 1px solid var(--border);
1293
+ }
1294
+
1295
+ .identification-copy {
1296
+ display: grid;
1297
+ gap: 2px;
1298
+ font-size: 13px;
1299
+ color: var(--text);
1300
+ }
1301
+
1302
+ /* The reason a control is dead, next to the control rather than in a tooltip
1303
+ nobody opens. */
1304
+ .identification-copy small {
1305
+ color: var(--muted);
1306
+ font-size: 11px;
1307
+ }
1308
+
1309
+ .identification-applied {
1310
+ margin: 16px 0 0;
1311
+ color: var(--muted);
1312
+ font-size: 12px;
1313
+ }
1314
+
1315
+ .identification-warning {
1316
+ color: var(--text);
1317
+ font-size: 13px;
1318
+ }
1319
+
922
1320
  .reader-action-dialog {
923
1321
  overflow: hidden;
924
1322
  }
@@ -27,7 +27,7 @@
27
27
  v-model="search"
28
28
  search
29
29
  placeholder="Search Amico user, UID or registration"
30
- @keyup.enter="reloadFromFirstPage"
30
+ @keyup.enter="onSearchSubmit"
31
31
  />
32
32
  <AppSelect
33
33
  v-model="userType"
@@ -208,6 +208,7 @@ const {
208
208
  } = useHidAmico();
209
209
  const readers = ref<HidReader[]>([]);
210
210
  const users = ref<HidReaderUser[]>([]);
211
+ const { selectHidReader, resolveForSite } = useHidReaderSelection();
211
212
  const selectedReaderId = ref("");
212
213
  const search = ref("");
213
214
  const userType = ref<"all" | "user" | "visitor">("all");
@@ -270,7 +271,11 @@ async function loadReaders() {
270
271
  readers.value = (Array.isArray(items) ? items : [])
271
272
  .map(toReader)
272
273
  .filter((reader): reader is HidReader => reader !== null);
273
- if (!selectedReaderId.value && readers.value.length) selectedReaderId.value = readers.value[0]._id;
274
+ /* Shared with every other HID screen — see `useHidReaderSelection`. The
275
+ resolver refuses a remembered reader that is not this site's, so a site
276
+ switch or a deleted reader falls back to the first rather than querying
277
+ an id the site does not own. */
278
+ selectedReaderId.value = resolveForSite(props.site, readers.value);
274
279
  } catch (error: unknown) {
275
280
  readers.value = [];
276
281
  showToast(getErrorMessage(error), "error");
@@ -284,6 +289,8 @@ async function loadUsers() {
284
289
  return;
285
290
  }
286
291
 
292
+ const sequence = loadUsersSequence.value + 1;
293
+ loadUsersSequence.value = sequence;
287
294
  loading.value = true;
288
295
  try {
289
296
  let clientFiltersUserType = false;
@@ -319,6 +326,7 @@ async function loadUsers() {
319
326
  const pageItems = clientFiltersUserType
320
327
  ? matchingItems.slice(pageStart, pageStart + 10)
321
328
  : matchingItems;
329
+ if (sequence !== loadUsersSequence.value) return;
322
330
  users.value = pageItems.map(toReaderUser);
323
331
  total.value = clientFiltersUserType
324
332
  ? matchingItems.length
@@ -330,13 +338,15 @@ async function loadUsers() {
330
338
  ? ""
331
339
  : toText(response.pageRange ?? data.pageRange);
332
340
  } catch (error: unknown) {
341
+ if (sequence !== loadUsersSequence.value) return;
333
342
  users.value = [];
334
343
  total.value = 0;
335
344
  pages.value = 1;
336
345
  serverPageRange.value = "";
337
346
  showToast(getErrorMessage(error), "error");
338
347
  } finally {
339
- loading.value = false;
348
+ // Only the newest load owns the spinner.
349
+ if (sequence === loadUsersSequence.value) loading.value = false;
340
350
  }
341
351
  }
342
352
 
@@ -350,6 +360,31 @@ function reloadFromFirstPage() {
350
360
  loadUsers();
351
361
  }
352
362
 
363
+ /**
364
+ * Typing is the search. It used to reload on `@keyup.enter` and on nothing
365
+ * else, so unless you guessed Enter was required the screen sent no request at
366
+ * all. Debounced, because each reload is a login/load/logout round trip to a
367
+ * reader over a VPN; Enter still works and skips the wait.
368
+ */
369
+ const SEARCH_DEBOUNCE_MS = 400;
370
+ let searchDebounce: ReturnType<typeof setTimeout> | undefined;
371
+
372
+ function onSearchInput() {
373
+ clearTimeout(searchDebounce);
374
+ searchDebounce = setTimeout(reloadFromFirstPage, SEARCH_DEBOUNCE_MS);
375
+ }
376
+
377
+ function onSearchSubmit() {
378
+ clearTimeout(searchDebounce);
379
+ reloadFromFirstPage();
380
+ }
381
+
382
+ watch(search, onSearchInput);
383
+ onBeforeUnmount(() => clearTimeout(searchDebounce));
384
+
385
+ /** Discards a load whose answer arrives after a newer one started. */
386
+ const loadUsersSequence = ref(0);
387
+
353
388
  function hasFacialData(user: HidReaderUser) {
354
389
  return user.metadata?.facialEnrolled === true || Boolean(toText(user.metadata?.facialData));
355
390
  }
@@ -511,6 +546,12 @@ function toNumber(value: unknown, fallback: number) {
511
546
  const number = Number(value);
512
547
  return Number.isFinite(number) ? number : fallback;
513
548
  }
549
+
550
+ /* Publish every change, including the seed above: a screen that had to fall
551
+ back to the first reader should leave the others agreeing with it. */
552
+ watch(selectedReaderId, (value) => {
553
+ if (value) selectHidReader(props.site, value);
554
+ });
514
555
  </script>
515
556
 
516
557
  <style scoped>