@7365admin1/layer-common 4.96.0 → 4.97.1-staging.500

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 (65) hide show
  1. package/CHANGELOG.md +10 -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 +424 -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 +2239 -276
  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/VehicleQrStickerDialog.vue +64 -4
  30. package/components/VisitorForm.vue +12 -0
  31. package/components/VisitorManagement.vue +267 -5
  32. package/composables/useAccessManagement.ts +17 -1
  33. package/composables/useCustomerSite.ts +29 -0
  34. package/composables/useDocument.ts +7 -0
  35. package/composables/useFacility.ts +11 -0
  36. package/composables/useHidAmico.ts +165 -0
  37. package/composables/useHidNavigation.ts +25 -1
  38. package/composables/useHidReaderSelection.ts +50 -0
  39. package/composables/useMember.ts +12 -0
  40. package/composables/useNFCPatrolReport.ts +36 -32
  41. package/composables/useNFCPatrolReportFilters.ts +21 -3
  42. package/composables/useNFCPatrolRoute.ts +10 -3
  43. package/composables/usePeople.ts +19 -0
  44. package/composables/useServiceProvider.ts +45 -2
  45. package/composables/useSiteCategory.ts +45 -0
  46. package/package.json +1 -1
  47. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  48. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  49. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  50. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  51. package/types/document.d.ts +14 -0
  52. package/types/facility.d.ts +4 -0
  53. package/types/member.d.ts +4 -0
  54. package/types/nfc-patrol-report.ts +0 -2
  55. package/types/people.d.ts +28 -1
  56. package/types/service-provider.d.ts +5 -0
  57. package/types/site.d.ts +7 -1
  58. package/utils/hid-enrolment-subject.ts +344 -0
  59. package/utils/hid-permission-assignments.ts +198 -0
  60. package/utils/hid-reader-selection.ts +52 -0
  61. package/utils/nfc-patrol-time.ts +99 -0
  62. package/utils/occupancy-role.ts +109 -0
  63. package/utils/service-type.ts +30 -0
  64. package/utils/vehicle-qr-design.ts +304 -20
  65. package/utils/vehicle-qr-sticker.ts +126 -20
@@ -18,50 +18,108 @@
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
- <v-select
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
43
61
  v-model="assignPerson"
44
- :items="filteredPeopleItems"
62
+ v-model:search="personSearch"
63
+ :items="assignPeopleItems"
64
+ :custom-filter="filterPerson"
45
65
  density="compact"
46
66
  item-title="name"
47
- item-value="_id"
67
+ item-value="assigneeId"
48
68
  hide-details="auto"
49
- placeholder="Select person..."
69
+ placeholder="Search or select person..."
50
70
  persistent-placeholder
51
71
  :loading="peopleLoading"
72
+ :disabled="peopleLoading"
52
73
  return-object
53
- no-data-text="No residents or tenants found for this unit"
74
+ :hide-no-data="false"
75
+ :no-data-text="assignNoDataText"
54
76
  >
77
+ <!--
78
+ Three lines: who they are, their email, and what they are here -
79
+ the member's role, or the provider's line of business. `title` is
80
+ bound rather than left to `itemProps` so the subtitle slot can add
81
+ the third line under it.
82
+ -->
55
83
  <template #item="{ props: itemProps, item }">
56
- <v-list-item v-bind="itemProps" :subtitle="personTypeLabel(item.raw)" />
84
+ <v-list-item v-bind="itemProps" :title="item.raw.name">
85
+ <template v-if="item.raw.subtitle || item.raw.meta" #subtitle>
86
+ <div v-if="item.raw.subtitle" class="text-truncate">
87
+ {{ item.raw.subtitle }}
88
+ </div>
89
+ <div v-if="item.raw.meta" class="text-caption text-medium-emphasis">
90
+ {{ item.raw.meta }}
91
+ </div>
92
+ </template>
93
+ </v-list-item>
57
94
  </template>
95
+ <!--
96
+ The same two lines once a person is chosen. A member's name is
97
+ often their email, so a selection showing the name alone would drop
98
+ the one thing that tells two of them apart - their role.
99
+ -->
58
100
  <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>
101
+ <div class="assign-selection">
102
+ <div class="text-truncate">{{ item.raw.name }}</div>
103
+ <div
104
+ v-if="item.raw.subtitle || item.raw.meta"
105
+ class="text-caption text-medium-emphasis text-truncate"
106
+ >
107
+ {{ [item.raw.subtitle, item.raw.meta].filter(Boolean).join(" · ") }}
108
+ </div>
109
+ </div>
63
110
  </template>
64
- </v-select>
111
+ </v-autocomplete>
112
+
113
+ <!--
114
+ A card is assigned by writing the person's USER id onto it, so a row
115
+ the list cannot give one for must not be sent - the server stores
116
+ whatever id it is handed without checking, which would silently bind
117
+ the card to nobody. Data-driven rather than per-group, so this
118
+ disappears by itself once the endpoint carries the id.
119
+ -->
120
+ <p v-if="assignPerson && !assignPerson.assigneeId" class="text-warning text-caption mt-2">
121
+ {{ assignUnassignableText }}
122
+ </p>
65
123
  <p v-if="assignError" class="text-error text-caption mt-2">{{ assignError }}</p>
66
124
  </v-card-text>
67
125
 
@@ -76,7 +134,7 @@
76
134
  <v-btn
77
135
  variant="flat"
78
136
  class="text-none screen-btn-primary"
79
- :disabled="!assignPerson || assignLoading"
137
+ :disabled="!assignPerson?.assigneeId || assignLoading"
80
138
  :loading="assignLoading"
81
139
  @click="confirmAssign"
82
140
  >
@@ -404,12 +462,104 @@ const emit = defineEmits<{
404
462
 
405
463
  const { getResidents, assignUser } = useAccessManagement();
406
464
  const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
465
+ const { getAll: getMembers } = useMember();
466
+ const { getSiteMembers } = useServiceProvider();
467
+
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 ?? "");
407
481
 
408
482
  const historyDialog = ref(false);
409
483
  const showAssignForm = ref(false);
410
- const assignPersonType = ref<string>("");
411
484
  const assignPerson = ref<Record<string, any> | null>(null);
412
- const peopleItems = ref<Record<string, any>[]>([]);
485
+
486
+ /**
487
+ * WHO A CARD CAN BE GIVEN TO.
488
+ *
489
+ * The dialog only ever listed the unit's residents and tenants. Management
490
+ * (the site's own members) and Service Providers hold cards on a site too, so
491
+ * the operator picks the group first and then the person.
492
+ *
493
+ * `value` is only ever read here; nothing is sent to the server, which takes a
494
+ * flat list of user ids and does not know about these groups.
495
+ */
496
+ type AssignCategory = "residents" | "management" | "providers";
497
+
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" },
514
+ ];
515
+
516
+ const assignCategory = ref<AssignCategory>("residents");
517
+
518
+ /**
519
+ * One row shape for all three groups, so the picker does not care which it is
520
+ * showing.
521
+ *
522
+ * `assigneeId` is the person's USER id - the id `assignUser` sends and the
523
+ * server writes onto the card. Residents already come from the users
524
+ * collection, and a member row carries its `user`. A row that cannot supply
525
+ * one keeps `assigneeId: ""`, which is what disables Assign for it rather than
526
+ * sending an id that binds the card to nobody.
527
+ */
528
+ type AssignRow = {
529
+ assigneeId: string;
530
+ name: string;
531
+ /** Second line - the email, or what kind of resident this is. */
532
+ subtitle: string;
533
+ /** Third line, under the subtitle - the member's role or the provider's service. */
534
+ meta?: string;
535
+ raw: Record<string, any>;
536
+ };
537
+
538
+ 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
+ }
413
563
  const peopleLoading = ref(false);
414
564
  const assignLoading = ref(false);
415
565
  const assignError = ref("");
@@ -452,12 +602,6 @@ const isSelectedCardDeletable = computed(() => {
452
602
  ].some((c) => c._id === id);
453
603
  });
454
604
 
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
605
  const personTypeLabel = (person: Record<string, any>) => {
462
606
  if (person.isOwner) return "Unit Owner";
463
607
  if (person.type === "resident") return "Resident";
@@ -465,34 +609,188 @@ const personTypeLabel = (person: Record<string, any>) => {
465
609
  return person.type ?? "";
466
610
  };
467
611
 
612
+ const assignNoDataText = computed(() => {
613
+ if (peopleLoading.value) return "Loading...";
614
+ if (assignCategory.value === "management") return "No management members assigned to this unit";
615
+ if (assignCategory.value === "providers") return "No service provider members assigned to this unit";
616
+ return "No residents or tenants found for this unit";
617
+ });
618
+
619
+ const assignUnassignableText = computed(
620
+ () => "This person has no account on record, so a card cannot be assigned to them.",
621
+ );
622
+
623
+ /** Residents and tenants of THIS UNIT - the list the dialog has always shown. */
624
+ async function loadResidentRows(): Promise<AssignRow[]> {
625
+ if (!props.unit?._id || !props.orgId || !props.siteId) return [];
626
+ const res = await getResidents({
627
+ orgId: props.orgId,
628
+ siteId: props.siteId,
629
+ unitId: props.unit._id,
630
+ });
631
+ const rows = res?.data ?? [];
632
+ return rows.map((person: Record<string, any>) => ({
633
+ // Already the users collection's own `_id` - see core's residents repo.
634
+ assigneeId: String(person._id ?? ""),
635
+ name: person.name ?? "",
636
+ subtitle: person.email ?? "",
637
+ meta: personTypeLabel(person),
638
+ raw: person,
639
+ }));
640
+ }
641
+
642
+ /**
643
+ * Management - the site's own members ATTACHED TO THIS UNIT.
644
+ *
645
+ * `unit` comes from core #2515 (Unit Management tab), which gave members a
646
+ * `unit`/`unitName` and taught `GET /api/members` to filter on it. Narrowing
647
+ * to the unit matches what the card is: it belongs to a unit, so the people
648
+ * offered are the ones that unit has.
649
+ *
650
+ * `type` is the member type of whichever app this layer is running in
651
+ * (`property_management_agency`, `security_agency`, ...) - the endpoint
652
+ * requires it and rejects an empty string, and `limit` is capped at 50 there.
653
+ */
654
+ async function loadManagementRows(): Promise<AssignRow[]> {
655
+ if (!props.orgId || !props.siteId || !props.unit?._id) return [];
656
+ const res: any = await getMembers({
657
+ org: props.orgId,
658
+ siteId: props.siteId,
659
+ unit: props.unit._id,
660
+ type: APP,
661
+ status: "active",
662
+ page: 1,
663
+ limit: 50,
664
+ });
665
+ const rows: Record<string, any>[] = res?.items ?? res?.data ?? [];
666
+ return rows.map((member) => ({
667
+ // `user` is the account; `_id` is the membership record and is NOT it.
668
+ assigneeId: String(member.user ?? ""),
669
+ name: member.name ?? member.email ?? "",
670
+ // One line under the name: "Access Mgmt Only · towefa4573@aghism.com".
671
+ // The role first, because that is what distinguishes two members of the
672
+ // same unit from each other. Either half is dropped if the row lacks it,
673
+ // so the separator never dangles.
674
+ subtitle: [member.roleName, member.email].filter(Boolean).join(" · "),
675
+ raw: member,
676
+ }));
677
+ }
678
+
679
+ /**
680
+ * Service Providers - the provider PEOPLE attached to this unit.
681
+ *
682
+ * `GET /api/service-providers/site-members?siteId&unit=<id>`. A card is held by
683
+ * a person, so this lists provider staff rather than the companies
684
+ * `GET /api/service-providers` returns, and the `unit` filter keeps it to the
685
+ * people that unit actually has - the same scoping Management uses.
686
+ *
687
+ * The response also carries a `companies` summary; only `items` is people.
688
+ */
689
+ async function loadProviderRows(): Promise<AssignRow[]> {
690
+ if (!props.siteId || !props.unit?._id) return [];
691
+ const res = await getSiteMembers(props.siteId, props.unit._id);
692
+ const rows = res?.items ?? [];
693
+ return rows.map((member: Record<string, any>) => ({
694
+ /*
695
+ * `user` when the endpoint returns it, otherwise the MEMBERSHIP `_id`.
696
+ *
697
+ * `site-members` does not carry `user` today (the repository projects it,
698
+ * the service layer drops it), and the product decision is to assign on
699
+ * `_id` rather than wait. Worth knowing what that means: the card's
700
+ * `userId` then holds a `service-provider-members` id, so the card list's
701
+ * `userId -> users` lookup finds nothing and the holder column reads blank
702
+ * for these cards. The door access itself is unaffected - the ACM command
703
+ * is built from the CARD's id, not the assignee's.
704
+ *
705
+ * `user` is preferred so that the day it starts arriving, these cards
706
+ * begin resolving to real people with no change here.
707
+ */
708
+ assigneeId: String(member.user ?? member._id ?? ""),
709
+ name: member.name || member.email || "",
710
+ subtitle: member.email ?? "",
711
+ /*
712
+ * The service the company provides, plus this person's role - two people
713
+ * from the same company differ only by role ("VMS Access" vs "Security
714
+ * Access"). `typeLabel` is computed by the endpoint; the local converter is
715
+ * the fallback for a row that predates it.
716
+ */
717
+ meta:
718
+ [member.typeLabel || serviceTypeLabel(member.type), member.role]
719
+ .filter(Boolean)
720
+ .join(" · "),
721
+ raw: member,
722
+ }));
723
+ }
724
+
725
+ /** Load the chosen group, dropping a response that a later switch superseded. */
726
+ let assignLoadSeq = 0;
727
+ async function loadAssignPeople() {
728
+ const seq = ++assignLoadSeq;
729
+ const category = assignCategory.value;
730
+ peopleLoading.value = true;
731
+ assignPeopleItems.value = [];
732
+ try {
733
+ const rows =
734
+ category === "management"
735
+ ? await loadManagementRows()
736
+ : category === "providers"
737
+ ? await loadProviderRows()
738
+ : await loadResidentRows();
739
+ if (seq !== assignLoadSeq) return;
740
+ assignPeopleItems.value = rows;
741
+ } catch (e: any) {
742
+ if (seq !== assignLoadSeq) return;
743
+ 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
+ } finally {
755
+ if (seq === assignLoadSeq) peopleLoading.value = false;
756
+ }
757
+ }
758
+
759
+ function onAssignCategoryChange() {
760
+ 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
+ assignError.value = "";
765
+ loadAssignPeople();
766
+ }
767
+
468
768
  async function openAssignForm() {
469
769
  showAssignForm.value = true;
470
770
  assignPerson.value = null;
471
- assignPersonType.value = "";
771
+ personSearch.value = "";
772
+ assignCategory.value = "residents";
472
773
  assignError.value = "";
473
- peopleItems.value = [];
774
+ assignPeopleItems.value = [];
474
775
  encryptedAcmUrl.value = "";
475
776
 
476
777
  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
- }
778
+
779
+ // The ACM URL is needed whichever group the card goes to, so it is fetched
780
+ // once here rather than with each list.
781
+ getEncryptedAcmUrl(props.siteId)
782
+ .then((url) => { encryptedAcmUrl.value = url ?? ""; })
783
+ .catch(() => { encryptedAcmUrl.value = ""; });
784
+
785
+ await loadAssignPeople();
490
786
  }
491
787
 
492
788
  function cancelAssign() {
493
789
  showAssignForm.value = false;
494
790
  assignPerson.value = null;
495
- assignPersonType.value = "";
791
+ personSearch.value = "";
792
+ assignCategory.value = "residents";
793
+ assignPeopleItems.value = [];
496
794
  assignError.value = "";
497
795
  }
498
796
 
@@ -507,20 +805,28 @@ const selectedCardType = computed(() => {
507
805
  });
508
806
 
509
807
  async function confirmAssign() {
510
- if (!assignPerson.value || !props.unit?._id) return;
808
+ // `assigneeId` rather than `_id`: the rows now come from three different
809
+ // lists and only this field is the user account the card is written to. The
810
+ // Assign button is already disabled without one; this is the guard behind it.
811
+ const assigneeId = assignPerson.value?.assigneeId;
812
+ if (!assigneeId || !props.unit?._id) return;
511
813
  assignLoading.value = true;
512
814
  assignError.value = "";
513
815
  try {
514
816
  await assignUser({
515
- assignees: [assignPerson.value._id],
817
+ assignees: [assigneeId],
516
818
  unit: props.unit._id,
517
819
  type: selectedCardType.value,
518
820
  acm_url: encryptedAcmUrl.value,
519
821
  id: props.selectedCardInUnit?._id,
822
+ holderType: assignCategories.find((c) => c.value === assignCategory.value)
823
+ ?.holderType,
520
824
  });
521
825
  emit("assign-to-person", {
522
826
  card: props.selectedCardInUnit,
523
- person: assignPerson.value,
827
+ // The original record, not the picker's row, so listeners see the same
828
+ // shape they always have.
829
+ person: assignPerson.value?.raw ?? assignPerson.value,
524
830
  });
525
831
  showToast("Card assigned successfully.", "success");
526
832
  cancelAssign();
@@ -544,3 +850,15 @@ function toggleCard(card: Record<string, any>) {
544
850
  );
545
851
  }
546
852
  </script>
853
+
854
+ <style scoped>
855
+ /* The chosen person, two lines inside the field. `min-width: 0` is what lets
856
+ `text-truncate` work at all here - the selection sits in a flex row, and a
857
+ flex child will not shrink below its content without it, so a long email
858
+ would push the field wide instead of ellipsing. */
859
+ .assign-selection {
860
+ min-width: 0;
861
+ line-height: 1.25;
862
+ padding-block: 2px;
863
+ }
864
+ </style>