@7365admin1/layer-common 4.82.1-staging.476 → 4.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/assets/css/primitives.css +0 -57
  3. package/assets/css/screens.css +4 -8
  4. package/components/AccessCardDetailsDialog.vue +4 -57
  5. package/components/AccessCardPreviewDialog.vue +50 -291
  6. package/components/AppSelect.vue +4 -143
  7. package/components/AttendanceDetailsDialog.vue +1 -48
  8. package/components/AttendanceMain.vue +10 -104
  9. package/components/AttendanceSettingsDialog.vue +1 -53
  10. package/components/BuildingUnitFormEdit.vue +0 -6
  11. package/components/BulletinBoardView.vue +1 -1
  12. package/components/Dialog/UpdateMoreAction.vue +1 -11
  13. package/components/DocumentForm.vue +112 -407
  14. package/components/DocumentManagement.vue +12 -213
  15. package/components/Facility/BookingSetup.vue +0 -63
  16. package/components/HidAccessLogDashboard.vue +17 -74
  17. package/components/HidIntercomManagement.vue +14 -183
  18. package/components/HidQrCodeConfiguration.vue +195 -17
  19. package/components/HidReaderManagement.vue +0 -398
  20. package/components/HidReaderUserRoster.vue +3 -44
  21. package/components/HidUserEnrollment.vue +250 -1573
  22. package/components/InventoryMain.vue +2 -11
  23. package/components/InvitationClientForm.vue +1 -19
  24. package/components/MyAttendanceMain.vue +2 -60
  25. package/components/NotificationBell.vue +2 -14
  26. package/components/TableMain.vue +7 -22
  27. package/components/VisitorForm.vue +0 -12
  28. package/components/VisitorManagement.vue +5 -267
  29. package/composables/useAttendance.ts +0 -53
  30. package/composables/useAttendancePermission.ts +0 -25
  31. package/composables/useCleaningPermission.ts +0 -5
  32. package/composables/useDocument.ts +0 -7
  33. package/composables/useFacility.ts +0 -11
  34. package/composables/useHidAmico.ts +0 -165
  35. package/composables/useHidNavigation.ts +1 -25
  36. package/composables/useLandscapingPermission.ts +0 -5
  37. package/composables/useMember.ts +0 -12
  38. package/composables/usePeople.ts +0 -19
  39. package/composables/usePestControlPermission.ts +0 -5
  40. package/composables/usePoolMaintenancePermission.ts +0 -5
  41. package/composables/useSecurityPermission.ts +0 -5
  42. package/composables/useServiceProvider.ts +2 -40
  43. package/constants/permissions.ts +0 -1
  44. package/nuxt.config.ts +0 -18
  45. package/package.json +2 -4
  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-readers/index.vue +1 -1
  49. package/types/attendance.d.ts +1 -13
  50. package/types/document.d.ts +0 -14
  51. package/types/facility.d.ts +0 -4
  52. package/types/member.d.ts +0 -4
  53. package/types/people.d.ts +1 -28
  54. package/types/service-provider.d.ts +0 -5
  55. package/utils/notification-target.ts +4 -43
  56. package/components/AttendanceProxyDialog.vue +0 -391
  57. package/components/BuildingUnitManagement.vue +0 -298
  58. package/components/HidAccessPermissions.vue +0 -424
  59. package/components/HidProfileQrCode.vue +0 -332
  60. package/composables/useHidReaderSelection.ts +0 -50
  61. package/composables/useSiteActions.ts +0 -29
  62. package/composables/useSiteCategory.ts +0 -45
  63. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  64. package/server/plugins/no-powered-by.ts +0 -7
  65. package/utils/attendance-proxy.ts +0 -110
  66. package/utils/hid-enrolment-subject.ts +0 -144
  67. package/utils/hid-permission-assignments.ts +0 -190
  68. package/utils/hid-reader-selection.ts +0 -52
  69. package/utils/occupancy-role.ts +0 -109
  70. package/utils/sanitize-html.ts +0 -16
  71. package/utils/security-headers.ts +0 -52
  72. package/utils/service-type.ts +0 -30
  73. package/utils/site-actions.ts +0 -18
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.84.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 57abba1: Suspended access: when the API answers ACCESS_SUSPENDED (every membership of the person is suspended), the app signs out and the sign-in page shows the server's message. Role names are unique per site, so role pickers and the roles table show "Admin · St. Regis Residences" (`roleLabel`), and the Members role chip uses the same "Role · Site" form.
8
+
9
+ ## 4.83.0
10
+
11
+ ### Minor Changes
12
+
13
+ - b69f1d9: "Open as Seven365 staff": persistent staff-view banner with Exit in every app's top bar, and staff in an open view work at that client with full setup rights without a membership.
14
+
3
15
  ## 4.82.0
4
16
 
5
17
  ### Minor Changes
@@ -1962,11 +1974,11 @@
1962
1974
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1963
1975
  own rule from two endpoints the console already calls, unprojected:
1964
1976
 
1965
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1966
- GET /api/roles/id/:role that membership's role document
1977
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1978
+ GET /api/roles/id/:role that membership's role document
1967
1979
 
1968
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1969
- staff = member.type === "admin" && role.type === "admin"
1980
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1981
+ staff = member.type === "admin" && role.type === "admin"
1970
1982
 
1971
1983
  `role.default` is the marker because it is the only property of a platform
1972
1984
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -775,63 +775,6 @@ 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
-
835
778
  .app-select__option {
836
779
  display: flex;
837
780
  align-items: center;
@@ -809,14 +809,10 @@
809
809
  color: var(--muted);
810
810
  }
811
811
 
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;
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);
820
816
  }
821
817
 
822
818
  .table-card__primary {
@@ -47,22 +47,14 @@
47
47
  <div>
48
48
  <div class="card-details__label">User</div>
49
49
  <div class="card-details__value">
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>
50
+ {{ details.userCred?.name ?? details.user?.name ?? "N/A" }}
51
+ <template v-if="details.user?.email">({{ details.user.email }})</template>
60
52
  </div>
61
53
  </div>
62
54
 
63
- <div v-if="holderType">
55
+ <div v-if="details.userType">
64
56
  <div class="card-details__label">User Type</div>
65
- <div class="card-details__value">{{ holderType }}</div>
57
+ <div class="card-details__value">{{ details.userType }}</div>
66
58
  </div>
67
59
 
68
60
  <!-- Visitor / Resident Credentials -->
@@ -224,51 +216,6 @@ const sortedHistory = computed(() =>
224
216
  )
225
217
  );
226
218
 
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
-
272
219
  watch(
273
220
  () => props.modelValue,
274
221
  async (val) => {
@@ -18,98 +18,50 @@
18
18
 
19
19
  <v-card-text class="pa-4">
20
20
  <div class="text-caption text-fontgray mb-4">
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>
21
+ Card: <strong class="text-body-2">{{ selectedCardInUnit?.cardNo }}</strong>
30
22
  </div>
31
23
 
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"
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"
43
29
  density="compact"
44
- item-title="label"
45
- item-value="value"
46
- hide-details
30
+ variant="outlined"
31
+ rounded="pill"
47
32
  class="mb-4"
48
- @update:model-value="onAssignCategoryChange"
49
- />
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> -->
50
39
 
51
40
  <!-- Person Selector -->
52
41
  <InputLabel title="Person" required class="mb-1" />
53
42
  <v-select
54
43
  v-model="assignPerson"
55
- :items="assignPeopleItems"
44
+ :items="filteredPeopleItems"
56
45
  density="compact"
57
46
  item-title="name"
58
- item-value="assigneeId"
47
+ item-value="_id"
59
48
  hide-details="auto"
60
49
  placeholder="Select person..."
61
50
  persistent-placeholder
62
51
  :loading="peopleLoading"
63
- :disabled="peopleLoading"
64
52
  return-object
65
- :no-data-text="assignNoDataText"
53
+ no-data-text="No residents or tenants found for this unit"
66
54
  >
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
- -->
73
55
  <template #item="{ props: itemProps, item }">
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>
56
+ <v-list-item v-bind="itemProps" :subtitle="personTypeLabel(item.raw)" />
84
57
  </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
- -->
90
58
  <template #selection="{ item }">
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>
59
+ <span>{{ item.raw.name }}</span>
60
+ <v-chip size="x-small" class="ml-2" variant="tonal">
61
+ {{ personTypeLabel(item.raw) }}
62
+ </v-chip>
100
63
  </template>
101
64
  </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>
113
65
  <p v-if="assignError" class="text-error text-caption mt-2">{{ assignError }}</p>
114
66
  </v-card-text>
115
67
 
@@ -124,7 +76,7 @@
124
76
  <v-btn
125
77
  variant="flat"
126
78
  class="text-none screen-btn-primary"
127
- :disabled="!assignPerson?.assigneeId || assignLoading"
79
+ :disabled="!assignPerson || assignLoading"
128
80
  :loading="assignLoading"
129
81
  @click="confirmAssign"
130
82
  >
@@ -452,54 +404,12 @@ const emit = defineEmits<{
452
404
 
453
405
  const { getResidents, assignUser } = useAccessManagement();
454
406
  const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
455
- const { getAll: getMembers } = useMember();
456
- const { getSiteMembers } = useServiceProvider();
457
407
 
458
408
  const historyDialog = ref(false);
459
409
  const showAssignForm = ref(false);
410
+ const assignPersonType = ref<string>("");
460
411
  const assignPerson = ref<Record<string, any> | null>(null);
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[]>([]);
412
+ const peopleItems = ref<Record<string, any>[]>([]);
503
413
  const peopleLoading = ref(false);
504
414
  const assignLoading = ref(false);
505
415
  const assignError = ref("");
@@ -542,6 +452,12 @@ const isSelectedCardDeletable = computed(() => {
542
452
  ].some((c) => c._id === id);
543
453
  });
544
454
 
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
+
545
461
  const personTypeLabel = (person: Record<string, any>) => {
546
462
  if (person.isOwner) return "Unit Owner";
547
463
  if (person.type === "resident") return "Resident";
@@ -549,173 +465,34 @@ const personTypeLabel = (person: Record<string, any>) => {
549
465
  return person.type ?? "";
550
466
  };
551
467
 
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
-
695
468
  async function openAssignForm() {
696
469
  showAssignForm.value = true;
697
470
  assignPerson.value = null;
698
- assignCategory.value = "residents";
471
+ assignPersonType.value = "";
699
472
  assignError.value = "";
700
- assignPeopleItems.value = [];
473
+ peopleItems.value = [];
701
474
  encryptedAcmUrl.value = "";
702
475
 
703
476
  if (!props.unit?._id || !props.orgId || !props.siteId) return;
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();
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
+ }
712
490
  }
713
491
 
714
492
  function cancelAssign() {
715
493
  showAssignForm.value = false;
716
494
  assignPerson.value = null;
717
- assignCategory.value = "residents";
718
- assignPeopleItems.value = [];
495
+ assignPersonType.value = "";
719
496
  assignError.value = "";
720
497
  }
721
498
 
@@ -730,16 +507,12 @@ const selectedCardType = computed(() => {
730
507
  });
731
508
 
732
509
  async function confirmAssign() {
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;
510
+ if (!assignPerson.value || !props.unit?._id) return;
738
511
  assignLoading.value = true;
739
512
  assignError.value = "";
740
513
  try {
741
514
  await assignUser({
742
- assignees: [assigneeId],
515
+ assignees: [assignPerson.value._id],
743
516
  unit: props.unit._id,
744
517
  type: selectedCardType.value,
745
518
  acm_url: encryptedAcmUrl.value,
@@ -747,9 +520,7 @@ async function confirmAssign() {
747
520
  });
748
521
  emit("assign-to-person", {
749
522
  card: props.selectedCardInUnit,
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,
523
+ person: assignPerson.value,
753
524
  });
754
525
  showToast("Card assigned successfully.", "success");
755
526
  cancelAssign();
@@ -773,15 +544,3 @@ function toggleCard(card: Record<string, any>) {
773
544
  );
774
545
  }
775
546
  </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>