@7365admin1/layer-common 4.97.1-staging.502 → 4.98.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.
@@ -68,9 +68,9 @@
68
68
  <!-- Visitor / Resident Credentials -->
69
69
  <template v-if="details.userCred">
70
70
  <v-divider />
71
- <!-- <div class="card-details__section">
71
+ <div class="card-details__section">
72
72
  {{ details.userType ?? "Visitor Information" }}
73
- </div> -->
73
+ </div>
74
74
 
75
75
  <div v-if="details.userCred.email">
76
76
  <div class="card-details__label">Email</div>
@@ -50,28 +50,18 @@
50
50
 
51
51
  <!-- Person Selector -->
52
52
  <InputLabel title="Person" required class="mb-1" />
53
- <!--
54
- An autocomplete rather than a select: a unit's Management and provider
55
- lists are short, but a site's can run to dozens, and scrolling for a
56
- name is slow. `custom-filter` widens the match beyond the title,
57
- because the thing an operator remembers is often the email or the role
58
- - and a member's name IS frequently their email address.
59
- -->
60
- <v-autocomplete
53
+ <v-select
61
54
  v-model="assignPerson"
62
- v-model:search="personSearch"
63
55
  :items="assignPeopleItems"
64
- :custom-filter="filterPerson"
65
56
  density="compact"
66
57
  item-title="name"
67
58
  item-value="assigneeId"
68
59
  hide-details="auto"
69
- placeholder="Search or select person..."
60
+ placeholder="Select person..."
70
61
  persistent-placeholder
71
62
  :loading="peopleLoading"
72
63
  :disabled="peopleLoading"
73
64
  return-object
74
- :hide-no-data="false"
75
65
  :no-data-text="assignNoDataText"
76
66
  >
77
67
  <!--
@@ -108,7 +98,7 @@
108
98
  </div>
109
99
  </div>
110
100
  </template>
111
- </v-autocomplete>
101
+ </v-select>
112
102
 
113
103
  <!--
114
104
  A card is assigned by writing the person's USER id onto it, so a row
@@ -465,20 +455,6 @@ const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
465
455
  const { getAll: getMembers } = useMember();
466
456
  const { getSiteMembers } = useServiceProvider();
467
457
 
468
- /**
469
- * The member type of whichever app this layer is running in
470
- * ("property_management_agency", "security_agency", ...), which `/api/members`
471
- * requires and rejects empty.
472
- *
473
- * Read HERE, during setup, not inside the loader that uses it:
474
- * `useRuntimeConfig` needs the Nuxt instance, and that is only guaranteed
475
- * synchronously in setup. Called from an async function resumed after an
476
- * await — which is exactly what the loader is — it throws "nuxt instance
477
- * unavailable", the loader's catch turns that into an empty list, and the
478
- * Management group silently shows nothing with no request ever sent.
479
- */
480
- const APP = String(useRuntimeConfig().public.APP ?? "");
481
-
482
458
  const historyDialog = ref(false);
483
459
  const showAssignForm = ref(false);
484
460
  const assignPerson = ref<Record<string, any> | null>(null);
@@ -495,22 +471,10 @@ const assignPerson = ref<Record<string, any> | null>(null);
495
471
  */
496
472
  type AssignCategory = "residents" | "management" | "providers";
497
473
 
498
- const assignCategories: {
499
- value: AssignCategory;
500
- label: string;
501
- /**
502
- * What the server stores as the card's `holderType` (core
503
- * `EAccessCardHolderTypes`). Sent with the assignment, because `assignees`
504
- * alone is ambiguous: it is a `users` id for a resident and a membership id
505
- * for a provider, and only the group says which to resolve against. These
506
- * strings are a server contract - the labels above are free to change, these
507
- * are not.
508
- */
509
- holderType: "Visitor/Resident" | "Management" | "Service Provider";
510
- }[] = [
511
- { value: "residents", label: "Resident / Tenant", holderType: "Visitor/Resident" },
512
- { value: "management", label: "Management", holderType: "Management" },
513
- { value: "providers", label: "Service Provider", holderType: "Service Provider" },
474
+ const assignCategories: { value: AssignCategory; label: string }[] = [
475
+ { value: "residents", label: "Resident / Tenant" },
476
+ { value: "management", label: "Management" },
477
+ { value: "providers", label: "Service Provider" },
514
478
  ];
515
479
 
516
480
  const assignCategory = ref<AssignCategory>("residents");
@@ -536,30 +500,6 @@ type AssignRow = {
536
500
  };
537
501
 
538
502
  const assignPeopleItems = ref<AssignRow[]>([]);
539
-
540
- /** What is typed in the person picker. Cleared whenever the group changes. */
541
- const personSearch = ref("");
542
-
543
- /**
544
- * Match a person on everything the row shows, not just the name.
545
- *
546
- * Vuetify filters on the title alone by default, which is too narrow here: a
547
- * Management member's name is often their email address, and two provider
548
- * people from one company are told apart only by their role. Both of those sit
549
- * in the second line, so a name-only match would hide the row an operator is
550
- * actually searching for.
551
- *
552
- * The first argument is the title Vuetify matched against; it is ignored in
553
- * favour of the row itself, which carries all three fields.
554
- */
555
- function filterPerson(_title: string, query: string, item: any): boolean {
556
- const q = String(query ?? "").trim().toLowerCase();
557
- if (!q) return true;
558
- const row = item?.raw ?? {};
559
- return [row.name, row.subtitle, row.meta].some((field) =>
560
- String(field ?? "").toLowerCase().includes(q),
561
- );
562
- }
563
503
  const peopleLoading = ref(false);
564
504
  const assignLoading = ref(false);
565
505
  const assignError = ref("");
@@ -657,7 +597,7 @@ async function loadManagementRows(): Promise<AssignRow[]> {
657
597
  org: props.orgId,
658
598
  siteId: props.siteId,
659
599
  unit: props.unit._id,
660
- type: APP,
600
+ type: String(useRuntimeConfig().public.APP ?? ""),
661
601
  status: "active",
662
602
  page: 1,
663
603
  limit: 50,
@@ -738,19 +678,9 @@ async function loadAssignPeople() {
738
678
  : await loadResidentRows();
739
679
  if (seq !== assignLoadSeq) return;
740
680
  assignPeopleItems.value = rows;
741
- } catch (e: any) {
681
+ } catch {
742
682
  if (seq !== assignLoadSeq) return;
743
683
  assignPeopleItems.value = [];
744
- /*
745
- * Say that the list failed rather than showing an empty one.
746
- *
747
- * This swallowed everything, so a group whose request 400'd, or never got
748
- * sent at all, looked exactly like a group with nobody in it - there was
749
- * nothing on screen to tell the two apart, and no way to tell from the UI
750
- * that anything had gone wrong.
751
- */
752
- assignError.value =
753
- e?.data?.message ?? e?.message ?? "Could not load the list. Please try again.";
754
684
  } finally {
755
685
  if (seq === assignLoadSeq) peopleLoading.value = false;
756
686
  }
@@ -758,9 +688,6 @@ async function loadAssignPeople() {
758
688
 
759
689
  function onAssignCategoryChange() {
760
690
  assignPerson.value = null;
761
- // A term typed for the previous group means nothing in the next one, and
762
- // leaving it would show an empty list over a list that has people in it.
763
- personSearch.value = "";
764
691
  assignError.value = "";
765
692
  loadAssignPeople();
766
693
  }
@@ -768,7 +695,6 @@ function onAssignCategoryChange() {
768
695
  async function openAssignForm() {
769
696
  showAssignForm.value = true;
770
697
  assignPerson.value = null;
771
- personSearch.value = "";
772
698
  assignCategory.value = "residents";
773
699
  assignError.value = "";
774
700
  assignPeopleItems.value = [];
@@ -788,7 +714,6 @@ async function openAssignForm() {
788
714
  function cancelAssign() {
789
715
  showAssignForm.value = false;
790
716
  assignPerson.value = null;
791
- personSearch.value = "";
792
717
  assignCategory.value = "residents";
793
718
  assignPeopleItems.value = [];
794
719
  assignError.value = "";
@@ -819,8 +744,6 @@ async function confirmAssign() {
819
744
  type: selectedCardType.value,
820
745
  acm_url: encryptedAcmUrl.value,
821
746
  id: props.selectedCardInUnit?._id,
822
- holderType: assignCategories.find((c) => c.value === assignCategory.value)
823
- ?.holderType,
824
747
  });
825
748
  emit("assign-to-person", {
826
749
  card: props.selectedCardInUnit,
@@ -26,9 +26,9 @@
26
26
  <template v-else>
27
27
  <div class="hid-access-permissions__intro">
28
28
  <p class="text-body-2 text-medium-emphasis mb-0">
29
- Everybody enrolled on this reader, by type. Enrolling already grants
30
- access — switch somebody off to close the door to them without removing
31
- their face, card or PIN.
29
+ Residents enrolled on this reader. Enrolling already grants access — switch
30
+ somebody off to close the door to them without removing their face, card
31
+ or PIN.
32
32
  </p>
33
33
  </div>
34
34
 
@@ -45,36 +45,12 @@
45
45
  @refresh="loadEnrolled"
46
46
  @update:page="goToPage"
47
47
  >
48
- <!-- WHICH OF THE READER'S PEOPLE TO SHOW.
49
- The reader holds residents, members and contractors; each tab is
50
- one `type`, filtered and paged by the server. The strip is
51
- `TableMain`'s own slot and the shared `.app-tab` markup, the same
52
- as every other tabbed table here - not a `v-tabs`, whose selected
53
- state is drawn from Vuetify's `on-surface` rather than the
54
- design's accent underline.
55
- The count beside each label is the whole reader's, from the
56
- draft, so it follows an unsaved tick. -->
57
- <template #tabs>
58
- <button
59
- v-for="tab in TABS"
60
- :key="tab.value"
61
- type="button"
62
- class="app-tab"
63
- :class="{ 'app-tab--active': activeTab === tab.value }"
64
- :aria-pressed="activeTab === tab.value"
65
- @click="selectTab(tab.value)"
66
- >
67
- {{ tab.label }}
68
- <span class="hid-access-permissions__tab-count">{{ tabCount(tab) }}</span>
69
- </button>
70
- </template>
71
-
72
48
  <template #extension>
73
49
  <div class="app-filter-row">
74
50
  <AppField
75
51
  v-model="search"
76
52
  search
77
- :placeholder="searchPlaceholder"
53
+ placeholder="Search name, unit or registration"
78
54
  @keyup.enter="searchFromFirstPage"
79
55
  />
80
56
  </div>
@@ -145,7 +121,6 @@ import {
145
121
  readCandidatePage,
146
122
  seedAssignments,
147
123
  setAssignment,
148
- subjectOfIdentity,
149
124
  toAssignmentPayload,
150
125
  type HidAssignmentState,
151
126
  } from "../utils/hid-permission-assignments";
@@ -181,52 +156,11 @@ const emit = defineEmits<{
181
156
 
182
157
  const { getSitePermissions, getIdentities, updateSitePermissions } = useHidAmico();
183
158
 
184
- /**
185
- * WHO EACH TAB LISTS, AND WHAT A GRANT MADE FROM IT IS FILED UNDER.
186
- *
187
- * `type` is the identity's own, filtered by the SERVER (`listIdentities` does an
188
- * exact match on it), and it is what the enrolment writes and what
189
- * `permissionIdentityType` re-confirms on every reconcile. `category` is the
190
- * permission category a NEW grant from this tab belongs to.
191
- *
192
- * The two are a pair rather than one derived from the other, because the tab is
193
- * the only thing on this screen that knows which of them the operator means: a
194
- * contractor and a property-management member are both a `member` link, and
195
- * guessing between them from the record is exactly what went wrong before.
196
- */
197
- const TABS = [
198
- { value: "resident", label: "Resident", noun: "Resident", type: "resident", category: "resident" },
199
- { value: "member", label: "Member", noun: "Member", type: "staff", category: "property_management" },
200
- {
201
- value: "provider",
202
- label: "Service provider",
203
- noun: "Person",
204
- type: "contractor",
205
- category: "service_provider_member",
206
- },
207
- ] as const satisfies ReadonlyArray<{
208
- value: string;
209
- label: string;
210
- noun: string;
211
- type: string;
212
- category: THidPermissionCategory;
213
- }>;
214
-
215
- const activeTab = ref<(typeof TABS)[number]["value"]>("resident");
216
- const currentTab = computed(
217
- () => TABS.find((tab) => tab.value === activeTab.value) ?? TABS[0],
218
- );
219
-
220
- /** Granted counts per category, for the tab strip. One call, all three. */
221
- const counts = ref<Record<string, number>>({});
222
- const tabCount = (tab: (typeof TABS)[number]) => counts.value[tab.category] ?? 0;
223
-
224
- /* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`.
225
- The first column follows the tab; "Door access" is the same action for all. */
226
- const headers = computed(() => [
227
- { title: currentTab.value.noun, value: "name", sortable: false },
159
+ /* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`. */
160
+ const headers = [
161
+ { title: "Resident", value: "name", sortable: false },
228
162
  { title: "Door access", value: "selected", sortable: false, width: 190 },
229
- ]);
163
+ ];
230
164
 
231
165
  const search = ref("");
232
166
  const page = ref(1);
@@ -253,13 +187,6 @@ const readerLabel = computed(() => {
253
187
  return readerLocation.value ? `${name} · ${readerLocation.value}` : name;
254
188
  });
255
189
 
256
- /* "unit" only means something for a resident. */
257
- const searchPlaceholder = computed(() =>
258
- activeTab.value === "resident"
259
- ? "Search name, unit or registration"
260
- : "Search name or registration",
261
- );
262
-
263
190
  const assignedTotal = computed(() => draft.value.size);
264
191
  const dirty = computed(() => hasChanges(savedState.value, draft.value));
265
192
  /* The draft is the truth once the dialog is open, so `selected` is recomputed
@@ -272,7 +199,6 @@ watch(
272
199
  if (!isOpen || !readerId.value) return;
273
200
  search.value = "";
274
201
  page.value = 1;
275
- activeTab.value = "resident";
276
202
  void loadAll();
277
203
  },
278
204
  { immediate: true },
@@ -295,13 +221,6 @@ async function loadAssignments() {
295
221
  const assignments = response?.data?.assignments ?? [];
296
222
  savedState.value = seedAssignments(assignments);
297
223
  draft.value = seedAssignments(assignments);
298
- /*
299
- * Counted from the assignments themselves rather than the server's `counts`
300
- * block, so the strip follows the DRAFT and a tick updates the number it
301
- * sits beside. `countByCategory` is the same helper the candidate screens
302
- * use.
303
- */
304
- counts.value = countByCategory(draft.value);
305
224
  } catch (error) {
306
225
  showToast(getErrorMessage(error, "Unable to load this reader's permissions."), "error");
307
226
  }
@@ -311,6 +230,27 @@ function toRecord(value: unknown): Record<string, unknown> {
311
230
  return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
312
231
  }
313
232
 
233
+ /**
234
+ * The subject an identity names, in the order `createIdentity` validates them.
235
+ *
236
+ * A visitor returns null and is left out of the list: visitor access comes from
237
+ * `user_access_rules`, not from a group, so there is nothing here to switch
238
+ * off. An identity linked to nobody — a device user the reader holds that was
239
+ * never tied to a person — is dropped for the same reason.
240
+ */
241
+ function subjectOf(identity: Record<string, unknown>) {
242
+ if (identity.person) {
243
+ return { subjectId: String(identity.person), category: "resident" as const };
244
+ }
245
+ if (identity.member) {
246
+ return { subjectId: String(identity.member), category: "property_management" as const };
247
+ }
248
+ if (identity.serviceProvider) {
249
+ return { subjectId: String(identity.serviceProvider), category: "service_provider" as const };
250
+ }
251
+ return null;
252
+ }
253
+
314
254
  /**
315
255
  * Why a row cannot be switched, in words the reader of the screen can act on.
316
256
  *
@@ -354,10 +294,10 @@ async function loadEnrolled() {
354
294
  page: page.value,
355
295
  limit: 10,
356
296
  search: search.value.trim(),
357
- // THIS TAB's people, and filtered by the SERVER rather than here. The
358
- // pager's count comes back from the same query, so narrowing the list on
359
- // this side would print a total that never matched the rows under it.
360
- type: currentTab.value.type,
297
+ // Residents only, and filtered by the SERVER rather than here. The pager's
298
+ // count comes back from the same query, so narrowing the list on this side
299
+ // would print a total that never matched the rows under it.
300
+ type: "resident",
361
301
  });
362
302
  const result = readCandidatePage(response);
363
303
  /*
@@ -369,13 +309,11 @@ async function loadEnrolled() {
369
309
  */
370
310
  enrolled.value = result.items.map((row) => {
371
311
  const identity = toRecord(row);
372
- const subject = subjectOfIdentity(identity);
312
+ const subject = subjectOf(identity);
373
313
  const metadata = toRecord(identity.metadata);
374
314
  return {
375
315
  subjectId: subject?.subjectId ?? "",
376
- // The record's own category where it has one; otherwise the tab's,
377
- // which is the only thing that knows what the operator means.
378
- category: subject?.category ?? currentTab.value.category,
316
+ category: subject?.category ?? "resident",
379
317
  // The row's own key. `subjectId` is empty for the rows below, and two
380
318
  // empty keys would collapse into one row in the table.
381
319
  rowKey: String(identity._id ?? identity.hidUserId ?? Math.random()),
@@ -404,24 +342,6 @@ async function loadEnrolled() {
404
342
  }
405
343
  }
406
344
 
407
- /**
408
- * Switching tabs refetches, because the filter is the SERVER's.
409
- *
410
- * The draft is untouched: it is the whole reader's, and the save sends all of it
411
- * whatever tab is in front of you. Clearing it here - or building the payload
412
- * from the loaded rows - is what would revoke everybody on the other two tabs.
413
- *
414
- * Search is cleared with the tab. It is a server-side search within one type, so
415
- * carrying it across would land on an empty list and look like the tab was empty.
416
- */
417
- function selectTab(value: (typeof TABS)[number]["value"]) {
418
- if (activeTab.value === value) return;
419
- activeTab.value = value;
420
- search.value = "";
421
- page.value = 1;
422
- void loadEnrolled();
423
- }
424
-
425
345
  function goToPage(value: number) {
426
346
  page.value = Number(value) || 1;
427
347
  void loadEnrolled();
@@ -432,34 +352,15 @@ function searchFromFirstPage() {
432
352
  void loadEnrolled();
433
353
  }
434
354
 
435
- /**
436
- * Grant or revoke one person.
437
- *
438
- * The category comes from the row where the server gave it one, and otherwise
439
- * from the TAB - never from re-reading the identity's links on this side. A
440
- * contractor and a property-management member are both a `member` link, and the
441
- * stale local copy of that reading is what used to file contractors as staff: the
442
- * reconcile then derived `staff` from that category and rewrote the person's
443
- * type, moving them into the Member tab.
444
- *
445
- * An ALREADY GRANTED person keeps the category their assignment holds, so merely
446
- * visiting a tab and toggling somebody off and on cannot re-file them.
447
- */
448
355
  function toggle(item: THidPermissionCandidate, selected: boolean) {
449
- const subjectId = String(item.subjectId ?? "");
450
- if (!subjectId) return;
451
- const category = savedState.value.get(subjectId)?.category
452
- ?? item.category
453
- ?? currentTab.value.category;
356
+ if (!String(item.subjectId ?? "")) return;
454
357
  draft.value = setAssignment(
455
358
  draft.value,
456
359
  savedState.value,
457
- subjectId,
458
- category,
360
+ String(item.subjectId ?? ""),
361
+ item.category,
459
362
  selected,
460
363
  );
461
- // The strip sits beside the rows, so it follows the tick rather than the save.
462
- counts.value = countByCategory(draft.value);
463
364
  }
464
365
 
465
366
  async function save() {
@@ -500,15 +401,6 @@ function getErrorMessage(error: unknown, fallback: string) {
500
401
  </script>
501
402
 
502
403
  <style scoped lang="scss">
503
- /* The granted count beside a tab label. Quieter than the label and set in
504
- tabular figures so the three tabs do not shift width as numbers change. */
505
- .hid-access-permissions__tab-count {
506
- margin-left: 6px;
507
- color: var(--muted);
508
- font-weight: 600;
509
- font-variant-numeric: tabular-nums;
510
- }
511
-
512
404
  .hid-access-permissions {
513
405
  &__title { padding-block: 16px; }
514
406