@7365admin1/layer-common 4.90.1 → 4.90.2-staging.484

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 (55) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +57 -0
  3. package/assets/css/robot-hero.css +3 -0
  4. package/assets/css/screens.css +8 -4
  5. package/components/AccessCardDetailsDialog.vue +57 -4
  6. package/components/AccessCardPreviewDialog.vue +291 -50
  7. package/components/AppSelect.vue +143 -4
  8. package/components/BuildingUnitFormEdit.vue +6 -0
  9. package/components/BuildingUnitManagement.vue +298 -0
  10. package/components/ClientMain.vue +56 -14
  11. package/components/Dialog/UpdateMoreAction.vue +11 -1
  12. package/components/DocumentForm.vue +407 -112
  13. package/components/DocumentManagement.vue +213 -12
  14. package/components/Facility/BookingSetup.vue +63 -0
  15. package/components/HidAccessLogDashboard.vue +74 -17
  16. package/components/HidAccessPermissions.vue +424 -0
  17. package/components/HidIntercomManagement.vue +183 -14
  18. package/components/HidProfileQrCode.vue +332 -0
  19. package/components/HidQrCodeConfiguration.vue +17 -195
  20. package/components/HidReaderManagement.vue +398 -0
  21. package/components/HidReaderUserRoster.vue +44 -3
  22. package/components/HidUserEnrollment.vue +2239 -276
  23. package/components/InvitationClientForm.vue +19 -1
  24. package/components/MemberInformation.vue +4 -4
  25. package/components/PassInformation.vue +2 -2
  26. package/components/TableMain.vue +22 -7
  27. package/components/VisitorForm.vue +65 -19
  28. package/components/VisitorManagement.vue +267 -5
  29. package/composables/useDocument.ts +7 -0
  30. package/composables/useFacility.ts +11 -0
  31. package/composables/useHidAmico.ts +165 -0
  32. package/composables/useHidNavigation.ts +25 -1
  33. package/composables/useHidReaderSelection.ts +50 -0
  34. package/composables/useMember.ts +12 -0
  35. package/composables/useNFCPatrolReportFilters.ts +21 -3
  36. package/composables/useNFCPatrolRoute.ts +8 -1
  37. package/composables/usePeople.ts +19 -0
  38. package/composables/useServiceProvider.ts +45 -2
  39. package/composables/useSiteCategory.ts +45 -0
  40. package/package.json +1 -1
  41. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  42. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  43. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  44. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  45. package/types/document.d.ts +14 -0
  46. package/types/facility.d.ts +4 -0
  47. package/types/member.d.ts +4 -0
  48. package/types/people.d.ts +28 -1
  49. package/types/service-provider.d.ts +5 -0
  50. package/types/site.d.ts +7 -1
  51. package/utils/hid-enrolment-subject.ts +344 -0
  52. package/utils/hid-permission-assignments.ts +198 -0
  53. package/utils/hid-reader-selection.ts +52 -0
  54. package/utils/occupancy-role.ts +109 -0
  55. package/utils/service-type.ts +30 -0
package/CHANGELOG.md CHANGED
@@ -2016,11 +2016,11 @@
2016
2016
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
2017
2017
  own rule from two endpoints the console already calls, unprojected:
2018
2018
 
2019
- GET /api/members/user/:user/app/admin the Seven365 staff membership
2020
- GET /api/roles/id/:role that membership's role document
2019
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
2020
+ GET /api/roles/id/:role that membership's role document
2021
2021
 
2022
- owner = member.type === "admin" && role.type === "admin" && role.default === true
2023
- staff = member.type === "admin" && role.type === "admin"
2022
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
2023
+ staff = member.type === "admin" && role.type === "admin"
2024
2024
 
2025
2025
  `role.default` is the marker because it is the only property of a platform
2026
2026
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -775,6 +775,63 @@ select option {
775
775
  box-shadow: var(--shadow-menu);
776
776
  }
777
777
 
778
+ /* The filter box of a `searchable` AppSelect. Sticky rather than scrolled
779
+ away with the options: the list it filters is long enough to need scrolling,
780
+ which is the only reason the box is there. */
781
+ .app-select__search {
782
+ position: sticky;
783
+ top: -6px;
784
+ z-index: 1;
785
+ display: flex;
786
+ align-items: center;
787
+ gap: 6px;
788
+ margin: -6px -6px 4px;
789
+ padding: 8px 10px;
790
+ border-bottom: 1px solid var(--border);
791
+ background: var(--card);
792
+ color: var(--muted);
793
+ }
794
+
795
+ .app-select__search-input {
796
+ flex: 1;
797
+ min-width: 0;
798
+ border: none;
799
+ outline: none;
800
+ background: none;
801
+ font-family: var(--font-sans);
802
+ font-size: var(--fs-cell);
803
+ font-weight: 500;
804
+ color: var(--text);
805
+ }
806
+
807
+ .app-select__search-input::placeholder {
808
+ color: var(--muted);
809
+ font-weight: 500;
810
+ }
811
+
812
+ .app-select__empty {
813
+ margin: 0;
814
+ padding: 10px 8px;
815
+ font-family: var(--font-sans);
816
+ font-size: var(--fs-cell);
817
+ color: var(--muted);
818
+ text-align: center;
819
+ }
820
+
821
+ /* The footer of a paging AppSelect. Same voice as the empty state - it is the
822
+ same kind of message about the list rather than an option in it - with a rule
823
+ above so it does not read as one more row to click. */
824
+ .app-select__loading {
825
+ margin: 6px 0 0;
826
+ padding: 9px 8px;
827
+ border-top: 1px solid var(--border);
828
+ font-family: var(--font-sans);
829
+ font-size: var(--fs-cell);
830
+ font-weight: 600;
831
+ color: var(--muted);
832
+ text-align: center;
833
+ }
834
+
778
835
  .app-select__option {
779
836
  display: flex;
780
837
  align-items: center;
@@ -324,6 +324,9 @@
324
324
  }
325
325
  /* The site fleet's strip: always six columns (README §2). */
326
326
  .hero-summary--six { grid-template-columns: repeat(6, minmax(0, 1fr)); }
327
+ /* Phones (owner 2026-10-02): six cells in one row cut every label ("TOTA",
328
+ "ONL"); three per row keeps each label whole. */
329
+ @media (max-width: 599.98px) { .hero-summary--six { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
327
330
  .hero-cell {
328
331
  --hero-cell-color: var(--hero-text);
329
332
  position: relative;
@@ -809,10 +809,14 @@
809
809
  color: var(--muted);
810
810
  }
811
811
 
812
- /* The in-card variant: 34px, 10px radius, as drawn. */
813
- .table-card__search .v-field {
814
- border-radius: var(--r-inner);
815
- font-size: var(--fs-cell);
812
+ /* The title-row search beside the primary button. `--search` alone is
813
+ `flex: 1`, written for a filter row; in the actions group that collapses it,
814
+ so it gets a fixed width here and never more than the row on a phone. */
815
+ .table-card__search.app-field--search {
816
+ flex: 0 0 auto;
817
+ width: 260px;
818
+ max-width: 100%;
819
+ min-width: 0;
816
820
  }
817
821
 
818
822
  .table-card__primary {
@@ -47,14 +47,22 @@
47
47
  <div>
48
48
  <div class="card-details__label">User</div>
49
49
  <div class="card-details__value">
50
- {{ details.userCred?.name ?? details.user?.name ?? "N/A" }}
51
- <template v-if="details.user?.email">({{ details.user.email }})</template>
50
+ <template v-if="holderState === 'named'">
51
+ {{ holderName }}
52
+ <template v-if="details.user?.email">({{ details.user.email }})</template>
53
+ </template>
54
+ <!-- Assigned, but the server did not send the name back. Saying
55
+ so beats "N/A", which reads as "nobody holds this card". -->
56
+ <template v-else-if="holderState === 'assigned'">
57
+ <span class="text-medium-emphasis">Assigned — name unavailable</span>
58
+ </template>
59
+ <template v-else>Unassigned</template>
52
60
  </div>
53
61
  </div>
54
62
 
55
- <div v-if="details.userType">
63
+ <div v-if="holderType">
56
64
  <div class="card-details__label">User Type</div>
57
- <div class="card-details__value">{{ details.userType }}</div>
65
+ <div class="card-details__value">{{ holderType }}</div>
58
66
  </div>
59
67
 
60
68
  <!-- Visitor / Resident Credentials -->
@@ -216,6 +224,51 @@ const sortedHistory = computed(() =>
216
224
  )
217
225
  );
218
226
 
227
+ /**
228
+ * WHO HOLDS THIS CARD.
229
+ *
230
+ * `userCred` is written by the visitor-pass flow and carries the person's
231
+ * details on the card itself. `user` is the resolved account - the card only
232
+ * stores `userId`, so the server has to join it.
233
+ *
234
+ * `card-details` does not do that join today (the lookup is commented out in
235
+ * core's `getCardDetailsRepo`), so this is empty for every card assigned
236
+ * through Assign to Person and the field falls through to `holderState`.
237
+ * Nothing here needs to change when the join is restored.
238
+ */
239
+ const holderName = computed(
240
+ () => details.value?.userCred?.name ?? details.value?.user?.name ?? "",
241
+ );
242
+
243
+ /**
244
+ * A card with a `userId` IS assigned, whether or not the name came back.
245
+ *
246
+ * Printing "N/A" for both cases is the actual defect here: it reads as "nobody
247
+ * holds this card", which is the opposite of the truth for an assigned one and
248
+ * could have someone hand out a card that is already in use.
249
+ */
250
+ const holderState = computed<"named" | "assigned" | "unassigned">(() => {
251
+ if (holderName.value) return "named";
252
+ return details.value?.userId ? "assigned" : "unassigned";
253
+ });
254
+
255
+ /**
256
+ * WHICH KIND OF PERSON HOLDS THIS CARD — Visitor/Resident, Management or
257
+ * Service Provider, matching the groups Assign to Person offers.
258
+ *
259
+ * `holderType` is what the server should send once it records the category the
260
+ * card was assigned under. `userType` is NOT that: it is the card's own pool,
261
+ * and `getCardDetailsRepo` filters on `EAccessCardUserTypes.DEFAULT`, so every
262
+ * card this dialog can load has the literal value "Visitor/Resident" whoever
263
+ * actually holds it. Preferring `holderType` means a card given to Management
264
+ * stops describing itself as a resident's the day the field arrives.
265
+ *
266
+ * Falls back to `userType` so nothing disappears in the meantime.
267
+ */
268
+ const holderType = computed(
269
+ () => details.value?.holderType ?? details.value?.userType ?? "",
270
+ );
271
+
219
272
  watch(
220
273
  () => props.modelValue,
221
274
  async (val) => {
@@ -18,50 +18,98 @@
18
18
 
19
19
  <v-card-text class="pa-4">
20
20
  <div class="text-caption text-fontgray mb-4">
21
- Card: <strong class="text-body-2">{{ selectedCardInUnit?.cardNo }}</strong>
21
+ <!-- The card number is the one fact this dialog is about, so it is
22
+ not greyed out with the "Card:" label around it. `text-high-emphasis`
23
+ rather than a literal black: every app has a light/dark toggle
24
+ (plugins/vuetify.ts), and #000 here would be black text on a dark
25
+ panel. It resolves to black in the light theme. -->
26
+ Card:
27
+ <strong class="text-body-2 font-weight-bold text-high-emphasis">{{
28
+ selectedCardInUnit?.cardNo
29
+ }}</strong>
22
30
  </div>
23
31
 
24
- <!-- Person Type Filter -->
25
- <!-- <div class="text-caption font-weight-bold mb-1 text-uppercase text-grey-darken-1">Person Type</div>
26
- <v-btn-toggle
27
- v-model="assignPersonType"
28
- color="primary"
32
+ <!--
33
+ WHO the card is being given to. The dialog only ever offered the
34
+ unit's residents and tenants; Management and Service Providers are
35
+ the other two groups of people who hold cards on a site. A select
36
+ rather than a button toggle: three labels this long overflow a 420px
37
+ dialog, and this stays legible at phone width.
38
+ -->
39
+ <InputLabel title="Assign to" required class="mb-1" />
40
+ <v-select
41
+ v-model="assignCategory"
42
+ :items="assignCategories"
29
43
  density="compact"
30
- variant="outlined"
31
- rounded="pill"
44
+ item-title="label"
45
+ item-value="value"
46
+ hide-details
32
47
  class="mb-4"
33
- >
34
- <v-btn value="" size="small" class="text-none px-3">All</v-btn>
35
- <v-btn value="owner" size="small" class="text-none px-3">Unit Owner</v-btn>
36
- <v-btn value="resident" size="small" class="text-none px-3">Resident</v-btn>
37
- <v-btn value="tenant" size="small" class="text-none px-3">Tenant</v-btn>
38
- </v-btn-toggle> -->
48
+ @update:model-value="onAssignCategoryChange"
49
+ />
39
50
 
40
51
  <!-- Person Selector -->
41
52
  <InputLabel title="Person" required class="mb-1" />
42
53
  <v-select
43
54
  v-model="assignPerson"
44
- :items="filteredPeopleItems"
55
+ :items="assignPeopleItems"
45
56
  density="compact"
46
57
  item-title="name"
47
- item-value="_id"
58
+ item-value="assigneeId"
48
59
  hide-details="auto"
49
60
  placeholder="Select person..."
50
61
  persistent-placeholder
51
62
  :loading="peopleLoading"
63
+ :disabled="peopleLoading"
52
64
  return-object
53
- no-data-text="No residents or tenants found for this unit"
65
+ :no-data-text="assignNoDataText"
54
66
  >
67
+ <!--
68
+ Three lines: who they are, their email, and what they are here -
69
+ the member's role, or the provider's line of business. `title` is
70
+ bound rather than left to `itemProps` so the subtitle slot can add
71
+ the third line under it.
72
+ -->
55
73
  <template #item="{ props: itemProps, item }">
56
- <v-list-item v-bind="itemProps" :subtitle="personTypeLabel(item.raw)" />
74
+ <v-list-item v-bind="itemProps" :title="item.raw.name">
75
+ <template v-if="item.raw.subtitle || item.raw.meta" #subtitle>
76
+ <div v-if="item.raw.subtitle" class="text-truncate">
77
+ {{ item.raw.subtitle }}
78
+ </div>
79
+ <div v-if="item.raw.meta" class="text-caption text-medium-emphasis">
80
+ {{ item.raw.meta }}
81
+ </div>
82
+ </template>
83
+ </v-list-item>
57
84
  </template>
85
+ <!--
86
+ The same two lines once a person is chosen. A member's name is
87
+ often their email, so a selection showing the name alone would drop
88
+ the one thing that tells two of them apart - their role.
89
+ -->
58
90
  <template #selection="{ item }">
59
- <span>{{ item.raw.name }}</span>
60
- <v-chip size="x-small" class="ml-2" variant="tonal">
61
- {{ personTypeLabel(item.raw) }}
62
- </v-chip>
91
+ <div class="assign-selection">
92
+ <div class="text-truncate">{{ item.raw.name }}</div>
93
+ <div
94
+ v-if="item.raw.subtitle || item.raw.meta"
95
+ class="text-caption text-medium-emphasis text-truncate"
96
+ >
97
+ {{ [item.raw.subtitle, item.raw.meta].filter(Boolean).join(" · ") }}
98
+ </div>
99
+ </div>
63
100
  </template>
64
101
  </v-select>
102
+
103
+ <!--
104
+ A card is assigned by writing the person's USER id onto it, so a row
105
+ the list cannot give one for must not be sent - the server stores
106
+ whatever id it is handed without checking, which would silently bind
107
+ the card to nobody. Data-driven rather than per-group, so this
108
+ disappears by itself once the endpoint carries the id.
109
+ -->
110
+ <p v-if="assignPerson && !assignPerson.assigneeId" class="text-warning text-caption mt-2">
111
+ {{ assignUnassignableText }}
112
+ </p>
65
113
  <p v-if="assignError" class="text-error text-caption mt-2">{{ assignError }}</p>
66
114
  </v-card-text>
67
115
 
@@ -76,7 +124,7 @@
76
124
  <v-btn
77
125
  variant="flat"
78
126
  class="text-none screen-btn-primary"
79
- :disabled="!assignPerson || assignLoading"
127
+ :disabled="!assignPerson?.assigneeId || assignLoading"
80
128
  :loading="assignLoading"
81
129
  @click="confirmAssign"
82
130
  >
@@ -404,12 +452,54 @@ const emit = defineEmits<{
404
452
 
405
453
  const { getResidents, assignUser } = useAccessManagement();
406
454
  const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
455
+ const { getAll: getMembers } = useMember();
456
+ const { getSiteMembers } = useServiceProvider();
407
457
 
408
458
  const historyDialog = ref(false);
409
459
  const showAssignForm = ref(false);
410
- const assignPersonType = ref<string>("");
411
460
  const assignPerson = ref<Record<string, any> | null>(null);
412
- const peopleItems = ref<Record<string, any>[]>([]);
461
+
462
+ /**
463
+ * WHO A CARD CAN BE GIVEN TO.
464
+ *
465
+ * The dialog only ever listed the unit's residents and tenants. Management
466
+ * (the site's own members) and Service Providers hold cards on a site too, so
467
+ * the operator picks the group first and then the person.
468
+ *
469
+ * `value` is only ever read here; nothing is sent to the server, which takes a
470
+ * flat list of user ids and does not know about these groups.
471
+ */
472
+ type AssignCategory = "residents" | "management" | "providers";
473
+
474
+ const assignCategories: { value: AssignCategory; label: string }[] = [
475
+ { value: "residents", label: "Resident / Tenant" },
476
+ { value: "management", label: "Management" },
477
+ { value: "providers", label: "Service Provider" },
478
+ ];
479
+
480
+ const assignCategory = ref<AssignCategory>("residents");
481
+
482
+ /**
483
+ * One row shape for all three groups, so the picker does not care which it is
484
+ * showing.
485
+ *
486
+ * `assigneeId` is the person's USER id - the id `assignUser` sends and the
487
+ * server writes onto the card. Residents already come from the users
488
+ * collection, and a member row carries its `user`. A row that cannot supply
489
+ * one keeps `assigneeId: ""`, which is what disables Assign for it rather than
490
+ * sending an id that binds the card to nobody.
491
+ */
492
+ type AssignRow = {
493
+ assigneeId: string;
494
+ name: string;
495
+ /** Second line - the email, or what kind of resident this is. */
496
+ subtitle: string;
497
+ /** Third line, under the subtitle - the member's role or the provider's service. */
498
+ meta?: string;
499
+ raw: Record<string, any>;
500
+ };
501
+
502
+ const assignPeopleItems = ref<AssignRow[]>([]);
413
503
  const peopleLoading = ref(false);
414
504
  const assignLoading = ref(false);
415
505
  const assignError = ref("");
@@ -452,12 +542,6 @@ const isSelectedCardDeletable = computed(() => {
452
542
  ].some((c) => c._id === id);
453
543
  });
454
544
 
455
- const filteredPeopleItems = computed(() => {
456
- if (!assignPersonType.value) return peopleItems.value;
457
- if (assignPersonType.value === "owner") return peopleItems.value.filter((p) => p.isOwner);
458
- return peopleItems.value.filter((p) => p.type === assignPersonType.value && !p.isOwner);
459
- });
460
-
461
545
  const personTypeLabel = (person: Record<string, any>) => {
462
546
  if (person.isOwner) return "Unit Owner";
463
547
  if (person.type === "resident") return "Resident";
@@ -465,34 +549,173 @@ const personTypeLabel = (person: Record<string, any>) => {
465
549
  return person.type ?? "";
466
550
  };
467
551
 
552
+ const assignNoDataText = computed(() => {
553
+ if (peopleLoading.value) return "Loading...";
554
+ if (assignCategory.value === "management") return "No management members assigned to this unit";
555
+ if (assignCategory.value === "providers") return "No service provider members assigned to this unit";
556
+ return "No residents or tenants found for this unit";
557
+ });
558
+
559
+ const assignUnassignableText = computed(
560
+ () => "This person has no account on record, so a card cannot be assigned to them.",
561
+ );
562
+
563
+ /** Residents and tenants of THIS UNIT - the list the dialog has always shown. */
564
+ async function loadResidentRows(): Promise<AssignRow[]> {
565
+ if (!props.unit?._id || !props.orgId || !props.siteId) return [];
566
+ const res = await getResidents({
567
+ orgId: props.orgId,
568
+ siteId: props.siteId,
569
+ unitId: props.unit._id,
570
+ });
571
+ const rows = res?.data ?? [];
572
+ return rows.map((person: Record<string, any>) => ({
573
+ // Already the users collection's own `_id` - see core's residents repo.
574
+ assigneeId: String(person._id ?? ""),
575
+ name: person.name ?? "",
576
+ subtitle: person.email ?? "",
577
+ meta: personTypeLabel(person),
578
+ raw: person,
579
+ }));
580
+ }
581
+
582
+ /**
583
+ * Management - the site's own members ATTACHED TO THIS UNIT.
584
+ *
585
+ * `unit` comes from core #2515 (Unit Management tab), which gave members a
586
+ * `unit`/`unitName` and taught `GET /api/members` to filter on it. Narrowing
587
+ * to the unit matches what the card is: it belongs to a unit, so the people
588
+ * offered are the ones that unit has.
589
+ *
590
+ * `type` is the member type of whichever app this layer is running in
591
+ * (`property_management_agency`, `security_agency`, ...) - the endpoint
592
+ * requires it and rejects an empty string, and `limit` is capped at 50 there.
593
+ */
594
+ async function loadManagementRows(): Promise<AssignRow[]> {
595
+ if (!props.orgId || !props.siteId || !props.unit?._id) return [];
596
+ const res: any = await getMembers({
597
+ org: props.orgId,
598
+ siteId: props.siteId,
599
+ unit: props.unit._id,
600
+ type: String(useRuntimeConfig().public.APP ?? ""),
601
+ status: "active",
602
+ page: 1,
603
+ limit: 50,
604
+ });
605
+ const rows: Record<string, any>[] = res?.items ?? res?.data ?? [];
606
+ return rows.map((member) => ({
607
+ // `user` is the account; `_id` is the membership record and is NOT it.
608
+ assigneeId: String(member.user ?? ""),
609
+ name: member.name ?? member.email ?? "",
610
+ // One line under the name: "Access Mgmt Only · towefa4573@aghism.com".
611
+ // The role first, because that is what distinguishes two members of the
612
+ // same unit from each other. Either half is dropped if the row lacks it,
613
+ // so the separator never dangles.
614
+ subtitle: [member.roleName, member.email].filter(Boolean).join(" · "),
615
+ raw: member,
616
+ }));
617
+ }
618
+
619
+ /**
620
+ * Service Providers - the provider PEOPLE attached to this unit.
621
+ *
622
+ * `GET /api/service-providers/site-members?siteId&unit=<id>`. A card is held by
623
+ * a person, so this lists provider staff rather than the companies
624
+ * `GET /api/service-providers` returns, and the `unit` filter keeps it to the
625
+ * people that unit actually has - the same scoping Management uses.
626
+ *
627
+ * The response also carries a `companies` summary; only `items` is people.
628
+ */
629
+ async function loadProviderRows(): Promise<AssignRow[]> {
630
+ if (!props.siteId || !props.unit?._id) return [];
631
+ const res = await getSiteMembers(props.siteId, props.unit._id);
632
+ const rows = res?.items ?? [];
633
+ return rows.map((member: Record<string, any>) => ({
634
+ /*
635
+ * `user` when the endpoint returns it, otherwise the MEMBERSHIP `_id`.
636
+ *
637
+ * `site-members` does not carry `user` today (the repository projects it,
638
+ * the service layer drops it), and the product decision is to assign on
639
+ * `_id` rather than wait. Worth knowing what that means: the card's
640
+ * `userId` then holds a `service-provider-members` id, so the card list's
641
+ * `userId -> users` lookup finds nothing and the holder column reads blank
642
+ * for these cards. The door access itself is unaffected - the ACM command
643
+ * is built from the CARD's id, not the assignee's.
644
+ *
645
+ * `user` is preferred so that the day it starts arriving, these cards
646
+ * begin resolving to real people with no change here.
647
+ */
648
+ assigneeId: String(member.user ?? member._id ?? ""),
649
+ name: member.name || member.email || "",
650
+ subtitle: member.email ?? "",
651
+ /*
652
+ * The service the company provides, plus this person's role - two people
653
+ * from the same company differ only by role ("VMS Access" vs "Security
654
+ * Access"). `typeLabel` is computed by the endpoint; the local converter is
655
+ * the fallback for a row that predates it.
656
+ */
657
+ meta:
658
+ [member.typeLabel || serviceTypeLabel(member.type), member.role]
659
+ .filter(Boolean)
660
+ .join(" · "),
661
+ raw: member,
662
+ }));
663
+ }
664
+
665
+ /** Load the chosen group, dropping a response that a later switch superseded. */
666
+ let assignLoadSeq = 0;
667
+ async function loadAssignPeople() {
668
+ const seq = ++assignLoadSeq;
669
+ const category = assignCategory.value;
670
+ peopleLoading.value = true;
671
+ assignPeopleItems.value = [];
672
+ try {
673
+ const rows =
674
+ category === "management"
675
+ ? await loadManagementRows()
676
+ : category === "providers"
677
+ ? await loadProviderRows()
678
+ : await loadResidentRows();
679
+ if (seq !== assignLoadSeq) return;
680
+ assignPeopleItems.value = rows;
681
+ } catch {
682
+ if (seq !== assignLoadSeq) return;
683
+ assignPeopleItems.value = [];
684
+ } finally {
685
+ if (seq === assignLoadSeq) peopleLoading.value = false;
686
+ }
687
+ }
688
+
689
+ function onAssignCategoryChange() {
690
+ assignPerson.value = null;
691
+ assignError.value = "";
692
+ loadAssignPeople();
693
+ }
694
+
468
695
  async function openAssignForm() {
469
696
  showAssignForm.value = true;
470
697
  assignPerson.value = null;
471
- assignPersonType.value = "";
698
+ assignCategory.value = "residents";
472
699
  assignError.value = "";
473
- peopleItems.value = [];
700
+ assignPeopleItems.value = [];
474
701
  encryptedAcmUrl.value = "";
475
702
 
476
703
  if (!props.unit?._id || !props.orgId || !props.siteId) return;
477
- peopleLoading.value = true;
478
- try {
479
- const [residentsRes, acmUrl] = await Promise.all([
480
- getResidents({ orgId: props.orgId, siteId: props.siteId, unitId: props.unit._id }),
481
- getEncryptedAcmUrl(props.siteId),
482
- ]);
483
- peopleItems.value = residentsRes?.data ?? [];
484
- encryptedAcmUrl.value = acmUrl ?? "";
485
- } catch {
486
- peopleItems.value = [];
487
- } finally {
488
- peopleLoading.value = false;
489
- }
704
+
705
+ // The ACM URL is needed whichever group the card goes to, so it is fetched
706
+ // once here rather than with each list.
707
+ getEncryptedAcmUrl(props.siteId)
708
+ .then((url) => { encryptedAcmUrl.value = url ?? ""; })
709
+ .catch(() => { encryptedAcmUrl.value = ""; });
710
+
711
+ await loadAssignPeople();
490
712
  }
491
713
 
492
714
  function cancelAssign() {
493
715
  showAssignForm.value = false;
494
716
  assignPerson.value = null;
495
- assignPersonType.value = "";
717
+ assignCategory.value = "residents";
718
+ assignPeopleItems.value = [];
496
719
  assignError.value = "";
497
720
  }
498
721
 
@@ -507,12 +730,16 @@ const selectedCardType = computed(() => {
507
730
  });
508
731
 
509
732
  async function confirmAssign() {
510
- if (!assignPerson.value || !props.unit?._id) return;
733
+ // `assigneeId` rather than `_id`: the rows now come from three different
734
+ // lists and only this field is the user account the card is written to. The
735
+ // Assign button is already disabled without one; this is the guard behind it.
736
+ const assigneeId = assignPerson.value?.assigneeId;
737
+ if (!assigneeId || !props.unit?._id) return;
511
738
  assignLoading.value = true;
512
739
  assignError.value = "";
513
740
  try {
514
741
  await assignUser({
515
- assignees: [assignPerson.value._id],
742
+ assignees: [assigneeId],
516
743
  unit: props.unit._id,
517
744
  type: selectedCardType.value,
518
745
  acm_url: encryptedAcmUrl.value,
@@ -520,7 +747,9 @@ async function confirmAssign() {
520
747
  });
521
748
  emit("assign-to-person", {
522
749
  card: props.selectedCardInUnit,
523
- person: assignPerson.value,
750
+ // The original record, not the picker's row, so listeners see the same
751
+ // shape they always have.
752
+ person: assignPerson.value?.raw ?? assignPerson.value,
524
753
  });
525
754
  showToast("Card assigned successfully.", "success");
526
755
  cancelAssign();
@@ -544,3 +773,15 @@ function toggleCard(card: Record<string, any>) {
544
773
  );
545
774
  }
546
775
  </script>
776
+
777
+ <style scoped>
778
+ /* The chosen person, two lines inside the field. `min-width: 0` is what lets
779
+ `text-truncate` work at all here - the selection sits in a flex row, and a
780
+ flex child will not shrink below its content without it, so a long email
781
+ would push the field wide instead of ellipsing. */
782
+ .assign-selection {
783
+ min-width: 0;
784
+ line-height: 1.25;
785
+ padding-block: 2px;
786
+ }
787
+ </style>