@7365admin1/layer-common 4.72.0 → 4.73.1-staging.452
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 +10 -4
- package/assets/css/primitives.css +43 -0
- package/components/AccessCardPreviewDialog.vue +286 -50
- package/components/AppSelect.vue +77 -3
- 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 +845 -155
- package/components/InventoryHistoryDialog.vue +205 -0
- package/components/InventoryMain.vue +23 -2
- package/components/InventoryMyStockTab.vue +63 -8
- package/components/InventoryPhotoThumbs.vue +67 -0
- package/components/InventoryReportsTab.vue +154 -28
- package/components/InventoryUsageDialog.vue +119 -28
- package/components/InvitationClientForm.vue +19 -1
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- 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/useInventory.ts +11 -3
- package/composables/useMember.ts +12 -0
- package/composables/usePeople.ts +19 -0
- package/composables/useSecurityPermission.ts +20 -0
- package/composables/useServiceProvider.ts +33 -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/inventory.d.ts +18 -2
- package/types/member.d.ts +4 -0
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/utils/hid-permission-assignments.ts +190 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/inventory-usage.ts +94 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.73.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cd5501e: Inventory: usage drafts (save the before photo first, add the after photo and submit later; edit or discard with a confirm; My drafts cards with an old-draft warning; "in drafts / free" on holdings); History per item and "My usage" with date, item and place filters; Usage report as the store owner's usage tracking (status incl. in-progress drafts, person, item, place, work order, flags, search; totals by item / person / company; photo thumbnails with times; draft and submit times; how the stock reached the person; Excel with the same filters and photo links; click a person or an item for its full history); a provider's supervisor gets "Company usage" for its own people at the main store.
|
|
8
|
+
|
|
3
9
|
## 4.72.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -1879,11 +1885,11 @@
|
|
|
1879
1885
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1880
1886
|
own rule from two endpoints the console already calls, unprojected:
|
|
1881
1887
|
|
|
1882
|
-
|
|
1883
|
-
|
|
1888
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1889
|
+
GET /api/roles/id/:role that membership's role document
|
|
1884
1890
|
|
|
1885
|
-
|
|
1886
|
-
|
|
1891
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1892
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1887
1893
|
|
|
1888
1894
|
`role.default` is the marker because it is the only property of a platform
|
|
1889
1895
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -775,6 +775,49 @@ 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
|
+
|
|
778
821
|
.app-select__option {
|
|
779
822
|
display: flex;
|
|
780
823
|
align-items: center;
|
|
@@ -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
|
-
|
|
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
|
<v-select
|
|
43
54
|
v-model="assignPerson"
|
|
44
|
-
:items="
|
|
55
|
+
:items="assignPeopleItems"
|
|
45
56
|
density="compact"
|
|
46
57
|
item-title="name"
|
|
47
|
-
item-value="
|
|
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="
|
|
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" :
|
|
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
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 { getAll: getServiceProviders } = 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
|
-
|
|
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,168 @@ 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 providers assigned to this unit";
|
|
556
|
+
return "No residents or tenants found for this unit";
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const assignUnassignableText = computed(() =>
|
|
560
|
+
assignCategory.value === "providers"
|
|
561
|
+
? "A card is held by a person, and this is the provider company. Assigning to service providers needs the server to say which account the card belongs to."
|
|
562
|
+
: "This person has no user account, so a card cannot be assigned to them.",
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
/** Residents and tenants of THIS UNIT - the list the dialog has always shown. */
|
|
566
|
+
async function loadResidentRows(): Promise<AssignRow[]> {
|
|
567
|
+
if (!props.unit?._id || !props.orgId || !props.siteId) return [];
|
|
568
|
+
const res = await getResidents({
|
|
569
|
+
orgId: props.orgId,
|
|
570
|
+
siteId: props.siteId,
|
|
571
|
+
unitId: props.unit._id,
|
|
572
|
+
});
|
|
573
|
+
const rows = res?.data ?? [];
|
|
574
|
+
return rows.map((person: Record<string, any>) => ({
|
|
575
|
+
// Already the users collection's own `_id` - see core's residents repo.
|
|
576
|
+
assigneeId: String(person._id ?? ""),
|
|
577
|
+
name: person.name ?? "",
|
|
578
|
+
subtitle: person.email ?? "",
|
|
579
|
+
meta: personTypeLabel(person),
|
|
580
|
+
raw: person,
|
|
581
|
+
}));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Management - the site's own members ATTACHED TO THIS UNIT.
|
|
586
|
+
*
|
|
587
|
+
* `unit` comes from core #2515 (Unit Management tab), which gave members a
|
|
588
|
+
* `unit`/`unitName` and taught `GET /api/members` to filter on it. Narrowing
|
|
589
|
+
* to the unit matches what the card is: it belongs to a unit, so the people
|
|
590
|
+
* offered are the ones that unit has.
|
|
591
|
+
*
|
|
592
|
+
* `type` is the member type of whichever app this layer is running in
|
|
593
|
+
* (`property_management_agency`, `security_agency`, ...) - the endpoint
|
|
594
|
+
* requires it and rejects an empty string, and `limit` is capped at 50 there.
|
|
595
|
+
*/
|
|
596
|
+
async function loadManagementRows(): Promise<AssignRow[]> {
|
|
597
|
+
if (!props.orgId || !props.siteId || !props.unit?._id) return [];
|
|
598
|
+
const res: any = await getMembers({
|
|
599
|
+
org: props.orgId,
|
|
600
|
+
siteId: props.siteId,
|
|
601
|
+
unit: props.unit._id,
|
|
602
|
+
type: String(useRuntimeConfig().public.APP ?? ""),
|
|
603
|
+
status: "active",
|
|
604
|
+
page: 1,
|
|
605
|
+
limit: 50,
|
|
606
|
+
});
|
|
607
|
+
const rows: Record<string, any>[] = res?.items ?? res?.data ?? [];
|
|
608
|
+
return rows.map((member) => ({
|
|
609
|
+
// `user` is the account; `_id` is the membership record and is NOT it.
|
|
610
|
+
assigneeId: String(member.user ?? ""),
|
|
611
|
+
name: member.name ?? member.email ?? "",
|
|
612
|
+
// One line under the name: "Access Mgmt Only · towefa4573@aghism.com".
|
|
613
|
+
// The role first, because that is what distinguishes two members of the
|
|
614
|
+
// same unit from each other. Either half is dropped if the row lacks it,
|
|
615
|
+
// so the separator never dangles.
|
|
616
|
+
subtitle: [member.roleName, member.email].filter(Boolean).join(" · "),
|
|
617
|
+
raw: member,
|
|
618
|
+
}));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Service Providers ATTACHED TO THIS UNIT.
|
|
623
|
+
*
|
|
624
|
+
* `GET /api/service-providers?siteId&status=active&unit=<id>` - the same list
|
|
625
|
+
* and the same unit filter the Unit Management tab uses (core #2515), so the
|
|
626
|
+
* providers offered are the ones this unit actually has.
|
|
627
|
+
*
|
|
628
|
+
* These rows are COMPANIES, not people: the list has `name`, `email` and the
|
|
629
|
+
* service `type`, and there is no user account behind a company. See
|
|
630
|
+
* `assigneeId` below.
|
|
631
|
+
*/
|
|
632
|
+
async function loadProviderRows(): Promise<AssignRow[]> {
|
|
633
|
+
if (!props.siteId || !props.unit?._id) return [];
|
|
634
|
+
const res: any = await getServiceProviders({
|
|
635
|
+
siteId: props.siteId,
|
|
636
|
+
unit: props.unit._id,
|
|
637
|
+
status: "active",
|
|
638
|
+
page: 1,
|
|
639
|
+
limit: 50,
|
|
640
|
+
});
|
|
641
|
+
const rows: Record<string, any>[] = res?.items ?? [];
|
|
642
|
+
return rows.map((provider) => ({
|
|
643
|
+
/*
|
|
644
|
+
* Empty on purpose, and not a missing field: a card is assigned by writing
|
|
645
|
+
* a USER id onto it, and a provider company has no user account. Which id a
|
|
646
|
+
* company-held card should carry - the `service-providers` row, the
|
|
647
|
+
* provider `orgId`, or a person from that company - is a backend decision
|
|
648
|
+
* that has not been made. Sending one anyway would write an id the server
|
|
649
|
+
* stores without checking and that matches no user.
|
|
650
|
+
*/
|
|
651
|
+
assigneeId: "",
|
|
652
|
+
name: provider.name ?? "",
|
|
653
|
+
subtitle: provider.email ?? "",
|
|
654
|
+
// The service code is a database value; show what people call it.
|
|
655
|
+
meta: serviceTypeLabel(provider.type),
|
|
656
|
+
raw: provider,
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Load the chosen group, dropping a response that a later switch superseded. */
|
|
661
|
+
let assignLoadSeq = 0;
|
|
662
|
+
async function loadAssignPeople() {
|
|
663
|
+
const seq = ++assignLoadSeq;
|
|
664
|
+
const category = assignCategory.value;
|
|
665
|
+
peopleLoading.value = true;
|
|
666
|
+
assignPeopleItems.value = [];
|
|
667
|
+
try {
|
|
668
|
+
const rows =
|
|
669
|
+
category === "management"
|
|
670
|
+
? await loadManagementRows()
|
|
671
|
+
: category === "providers"
|
|
672
|
+
? await loadProviderRows()
|
|
673
|
+
: await loadResidentRows();
|
|
674
|
+
if (seq !== assignLoadSeq) return;
|
|
675
|
+
assignPeopleItems.value = rows;
|
|
676
|
+
} catch {
|
|
677
|
+
if (seq !== assignLoadSeq) return;
|
|
678
|
+
assignPeopleItems.value = [];
|
|
679
|
+
} finally {
|
|
680
|
+
if (seq === assignLoadSeq) peopleLoading.value = false;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function onAssignCategoryChange() {
|
|
685
|
+
assignPerson.value = null;
|
|
686
|
+
assignError.value = "";
|
|
687
|
+
loadAssignPeople();
|
|
688
|
+
}
|
|
689
|
+
|
|
468
690
|
async function openAssignForm() {
|
|
469
691
|
showAssignForm.value = true;
|
|
470
692
|
assignPerson.value = null;
|
|
471
|
-
|
|
693
|
+
assignCategory.value = "residents";
|
|
472
694
|
assignError.value = "";
|
|
473
|
-
|
|
695
|
+
assignPeopleItems.value = [];
|
|
474
696
|
encryptedAcmUrl.value = "";
|
|
475
697
|
|
|
476
698
|
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
|
-
}
|
|
699
|
+
|
|
700
|
+
// The ACM URL is needed whichever group the card goes to, so it is fetched
|
|
701
|
+
// once here rather than with each list.
|
|
702
|
+
getEncryptedAcmUrl(props.siteId)
|
|
703
|
+
.then((url) => { encryptedAcmUrl.value = url ?? ""; })
|
|
704
|
+
.catch(() => { encryptedAcmUrl.value = ""; });
|
|
705
|
+
|
|
706
|
+
await loadAssignPeople();
|
|
490
707
|
}
|
|
491
708
|
|
|
492
709
|
function cancelAssign() {
|
|
493
710
|
showAssignForm.value = false;
|
|
494
711
|
assignPerson.value = null;
|
|
495
|
-
|
|
712
|
+
assignCategory.value = "residents";
|
|
713
|
+
assignPeopleItems.value = [];
|
|
496
714
|
assignError.value = "";
|
|
497
715
|
}
|
|
498
716
|
|
|
@@ -507,12 +725,16 @@ const selectedCardType = computed(() => {
|
|
|
507
725
|
});
|
|
508
726
|
|
|
509
727
|
async function confirmAssign() {
|
|
510
|
-
|
|
728
|
+
// `assigneeId` rather than `_id`: the rows now come from three different
|
|
729
|
+
// lists and only this field is the user account the card is written to. The
|
|
730
|
+
// Assign button is already disabled without one; this is the guard behind it.
|
|
731
|
+
const assigneeId = assignPerson.value?.assigneeId;
|
|
732
|
+
if (!assigneeId || !props.unit?._id) return;
|
|
511
733
|
assignLoading.value = true;
|
|
512
734
|
assignError.value = "";
|
|
513
735
|
try {
|
|
514
736
|
await assignUser({
|
|
515
|
-
assignees: [
|
|
737
|
+
assignees: [assigneeId],
|
|
516
738
|
unit: props.unit._id,
|
|
517
739
|
type: selectedCardType.value,
|
|
518
740
|
acm_url: encryptedAcmUrl.value,
|
|
@@ -520,7 +742,9 @@ async function confirmAssign() {
|
|
|
520
742
|
});
|
|
521
743
|
emit("assign-to-person", {
|
|
522
744
|
card: props.selectedCardInUnit,
|
|
523
|
-
|
|
745
|
+
// The original record, not the picker's row, so listeners see the same
|
|
746
|
+
// shape they always have.
|
|
747
|
+
person: assignPerson.value?.raw ?? assignPerson.value,
|
|
524
748
|
});
|
|
525
749
|
showToast("Card assigned successfully.", "success");
|
|
526
750
|
cancelAssign();
|
|
@@ -544,3 +768,15 @@ function toggleCard(card: Record<string, any>) {
|
|
|
544
768
|
);
|
|
545
769
|
}
|
|
546
770
|
</script>
|
|
771
|
+
|
|
772
|
+
<style scoped>
|
|
773
|
+
/* The chosen person, two lines inside the field. `min-width: 0` is what lets
|
|
774
|
+
`text-truncate` work at all here - the selection sits in a flex row, and a
|
|
775
|
+
flex child will not shrink below its content without it, so a long email
|
|
776
|
+
would push the field wide instead of ellipsing. */
|
|
777
|
+
.assign-selection {
|
|
778
|
+
min-width: 0;
|
|
779
|
+
line-height: 1.25;
|
|
780
|
+
padding-block: 2px;
|
|
781
|
+
}
|
|
782
|
+
</style>
|
package/components/AppSelect.vue
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
<template>
|
|
19
19
|
<v-menu
|
|
20
20
|
v-if="!multiple"
|
|
21
|
-
|
|
21
|
+
v-model="singleOpen"
|
|
22
|
+
:close-on-content-click="!searchable"
|
|
22
23
|
:disabled="disabled"
|
|
23
24
|
location="bottom start"
|
|
24
25
|
offset="4"
|
|
@@ -53,8 +54,21 @@
|
|
|
53
54
|
</template>
|
|
54
55
|
|
|
55
56
|
<div class="app-select__menu" role="listbox">
|
|
57
|
+
<div v-if="searchable" class="app-select__search">
|
|
58
|
+
<v-icon icon="mdi-magnify" size="16" />
|
|
59
|
+
<input
|
|
60
|
+
ref="singleSearchInput"
|
|
61
|
+
v-model="search"
|
|
62
|
+
type="text"
|
|
63
|
+
class="app-select__search-input"
|
|
64
|
+
:placeholder="searchPlaceholder"
|
|
65
|
+
aria-label="Search options"
|
|
66
|
+
@keydown.enter.prevent="selectFirstMatch"
|
|
67
|
+
@keydown.esc.stop="singleOpen = false"
|
|
68
|
+
>
|
|
69
|
+
</div>
|
|
56
70
|
<button
|
|
57
|
-
v-for="opt in
|
|
71
|
+
v-for="opt in visibleOptions"
|
|
58
72
|
:key="String(opt.value)"
|
|
59
73
|
type="button"
|
|
60
74
|
class="app-select__option"
|
|
@@ -66,6 +80,9 @@
|
|
|
66
80
|
<span class="app-select__option-text">{{ opt.title }}</span>
|
|
67
81
|
<v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
|
|
68
82
|
</button>
|
|
83
|
+
<p v-if="searchable && !visibleOptions.length" class="app-select__empty">
|
|
84
|
+
{{ normalized.length ? "No matches" : "Nothing to choose from" }}
|
|
85
|
+
</p>
|
|
69
86
|
</div>
|
|
70
87
|
</v-menu>
|
|
71
88
|
|
|
@@ -122,8 +139,18 @@
|
|
|
122
139
|
</template>
|
|
123
140
|
|
|
124
141
|
<div class="app-select__menu">
|
|
142
|
+
<div v-if="searchable" class="app-select__search">
|
|
143
|
+
<v-icon icon="mdi-magnify" size="16" />
|
|
144
|
+
<input
|
|
145
|
+
v-model="search"
|
|
146
|
+
type="text"
|
|
147
|
+
class="app-select__search-input"
|
|
148
|
+
:placeholder="searchPlaceholder"
|
|
149
|
+
aria-label="Search options"
|
|
150
|
+
>
|
|
151
|
+
</div>
|
|
125
152
|
<button
|
|
126
|
-
v-for="opt in
|
|
153
|
+
v-for="opt in visibleOptions"
|
|
127
154
|
:key="String(opt.value)"
|
|
128
155
|
type="button"
|
|
129
156
|
class="app-select__option"
|
|
@@ -138,6 +165,9 @@
|
|
|
138
165
|
/>
|
|
139
166
|
<span class="app-select__option-text">{{ opt.title }}</span>
|
|
140
167
|
</button>
|
|
168
|
+
<p v-if="searchable && !visibleOptions.length" class="app-select__empty">
|
|
169
|
+
{{ normalized.length ? "No matches" : "Nothing to choose from" }}
|
|
170
|
+
</p>
|
|
141
171
|
</div>
|
|
142
172
|
</v-menu>
|
|
143
173
|
</template>
|
|
@@ -168,6 +198,13 @@ const props = defineProps({
|
|
|
168
198
|
returnObject: { type: Boolean, default: false },
|
|
169
199
|
clearable: { type: Boolean, default: false },
|
|
170
200
|
placeholder: { type: String, default: "" },
|
|
201
|
+
/**
|
|
202
|
+
* A filter box at the top of the popover. Opt-in, so the 44 consumers that
|
|
203
|
+
* do not ask for it render exactly the DOM they render today - a list short
|
|
204
|
+
* enough to read does not want a search field in front of it.
|
|
205
|
+
*/
|
|
206
|
+
searchable: { type: Boolean, default: false },
|
|
207
|
+
searchPlaceholder: { type: String, default: "Search" },
|
|
171
208
|
});
|
|
172
209
|
|
|
173
210
|
const normalized = computed(() =>
|
|
@@ -178,6 +215,38 @@ const normalized = computed(() =>
|
|
|
178
215
|
)
|
|
179
216
|
);
|
|
180
217
|
|
|
218
|
+
/* ---- search ---------------------------------------------------------- */
|
|
219
|
+
|
|
220
|
+
const search = ref("");
|
|
221
|
+
const singleOpen = ref(false);
|
|
222
|
+
const singleSearchInput = ref<HTMLInputElement | null>(null);
|
|
223
|
+
|
|
224
|
+
/* Case-insensitive substring over what the option READS AS, not its value:
|
|
225
|
+
the title is the only part the user can see to type. */
|
|
226
|
+
const visibleOptions = computed(() => {
|
|
227
|
+
const term = search.value.trim().toLowerCase();
|
|
228
|
+
if (!props.searchable || !term) return normalized.value;
|
|
229
|
+
return normalized.value.filter((opt) =>
|
|
230
|
+
String(opt.title ?? "").toLowerCase().includes(term)
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/* A stale term would hide the list the next time the popover opens, so it is
|
|
235
|
+
dropped on every open - and the caret goes into the box, since opening a
|
|
236
|
+
searchable select is almost always a prelude to typing. */
|
|
237
|
+
watch(singleOpen, async (open) => {
|
|
238
|
+
if (!props.searchable) return;
|
|
239
|
+
search.value = "";
|
|
240
|
+
if (!open) return;
|
|
241
|
+
await nextTick();
|
|
242
|
+
singleSearchInput.value?.focus();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
function selectFirstMatch() {
|
|
246
|
+
const [first] = visibleOptions.value;
|
|
247
|
+
if (first) selectSingle(first);
|
|
248
|
+
}
|
|
249
|
+
|
|
181
250
|
/* A model entry is either the plain value or - under `return-object` - the
|
|
182
251
|
source item, so everything compares on this one key. */
|
|
183
252
|
const keyOf = (v: any) =>
|
|
@@ -205,6 +274,11 @@ const isSingleOn = (opt: any) =>
|
|
|
205
274
|
function selectSingle(opt: any) {
|
|
206
275
|
if (props.disabled) return;
|
|
207
276
|
model.value = props.returnObject ? opt.raw : String(opt.value);
|
|
277
|
+
// `close-on-content-click` has to be OFF while searching - it fires on the
|
|
278
|
+
// click that puts the caret in the search box, so the popover would shut the
|
|
279
|
+
// moment it was typed into. With it off, picking an option has to close the
|
|
280
|
+
// popover by hand.
|
|
281
|
+
if (props.searchable) singleOpen.value = false;
|
|
208
282
|
}
|
|
209
283
|
|
|
210
284
|
function clearSingle() {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
<v-tab value="general">General</v-tab>
|
|
11
11
|
<v-tab value="residents">Residents/Tenants</v-tab>
|
|
12
12
|
<v-tab value="vehicles">Vehicles</v-tab>
|
|
13
|
+
<v-tab value="management">Management</v-tab>
|
|
13
14
|
<v-tab value="others">Others</v-tab>
|
|
14
15
|
</v-tabs>
|
|
15
16
|
</v-toolbar>
|
|
@@ -170,6 +171,11 @@
|
|
|
170
171
|
</TableMain>
|
|
171
172
|
</template>
|
|
172
173
|
|
|
174
|
+
<template v-else-if="tab === 'management'">
|
|
175
|
+
<BuildingUnitManagement :unit-id="buildingUnit._id" :site-id="buildingUnit?.site?._id"
|
|
176
|
+
:can-update="canUpdateUnit" />
|
|
177
|
+
</template>
|
|
178
|
+
|
|
173
179
|
<template v-else-if="tab === 'others'">
|
|
174
180
|
<v-row>
|
|
175
181
|
<!-- <v-col cols="12" md="6" class="mt-2">
|