@7365admin1/layer-common 4.97.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.
- package/CHANGELOG.md +4 -4
- package/assets/css/primitives.css +57 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +59 -6
- package/components/AccessCardPreviewDialog.vue +371 -53
- package/components/AppSelect.vue +143 -4
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +2239 -276
- package/components/InvitationClientForm.vue +19 -1
- package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
- package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
- package/components/ServiceProviderMain.vue +48 -0
- package/components/SiteProviderChange.vue +371 -0
- package/components/TableMain.vue +22 -7
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- package/composables/useAccessManagement.ts +17 -1
- package/composables/useCustomerSite.ts +29 -0
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useMember.ts +12 -0
- package/composables/useNFCPatrolReport.ts +36 -32
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +10 -3
- package/composables/usePeople.ts +19 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/nfc-patrol-report.ts +0 -2
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/hid-enrolment-subject.ts +344 -0
- package/utils/hid-permission-assignments.ts +198 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/nfc-patrol-time.ts +99 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
|
@@ -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
|
-
|
|
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
|
-
<!--
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
44
|
+
item-title="label"
|
|
45
|
+
item-value="value"
|
|
46
|
+
hide-details
|
|
32
47
|
class="mb-4"
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
<!--
|
|
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
|
-
:
|
|
62
|
+
v-model:search="personSearch"
|
|
63
|
+
:items="assignPeopleItems"
|
|
64
|
+
:custom-filter="filterPerson"
|
|
45
65
|
density="compact"
|
|
46
66
|
item-title="name"
|
|
47
|
-
item-value="
|
|
67
|
+
item-value="assigneeId"
|
|
48
68
|
hide-details="auto"
|
|
49
|
-
placeholder="
|
|
69
|
+
placeholder="Search or select person..."
|
|
50
70
|
persistent-placeholder
|
|
51
71
|
:loading="peopleLoading"
|
|
72
|
+
:disabled="peopleLoading"
|
|
52
73
|
return-object
|
|
53
|
-
no-data
|
|
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" :
|
|
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
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
771
|
+
personSearch.value = "";
|
|
772
|
+
assignCategory.value = "residents";
|
|
472
773
|
assignError.value = "";
|
|
473
|
-
|
|
774
|
+
assignPeopleItems.value = [];
|
|
474
775
|
encryptedAcmUrl.value = "";
|
|
475
776
|
|
|
476
777
|
if (!props.unit?._id || !props.orgId || !props.siteId) return;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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>
|