@7365admin1/layer-common 4.73.1-staging.456 → 4.75.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.
- package/CHANGELOG.md +21 -4
- package/assets/css/primitives.css +0 -43
- package/components/AccessCardPreviewDialog.vue +50 -286
- package/components/AppSelect.vue +3 -77
- package/components/BuildingUnitFormEdit.vue +0 -6
- package/components/Dialog/UpdateMoreAction.vue +1 -11
- package/components/DocumentForm.vue +112 -407
- package/components/DocumentManagement.vue +12 -213
- package/components/Facility/BookingSetup.vue +0 -63
- package/components/HidAccessLogDashboard.vue +17 -74
- package/components/HidIntercomManagement.vue +14 -183
- package/components/HidQrCodeConfiguration.vue +195 -17
- package/components/HidReaderManagement.vue +0 -398
- package/components/HidReaderUserRoster.vue +3 -44
- package/components/HidUserEnrollment.vue +155 -845
- package/components/InvitationClientForm.vue +1 -19
- package/components/MemberMain.vue +3 -6
- package/components/VisitorForm.vue +0 -12
- package/components/VisitorManagement.vue +5 -267
- package/composables/useDocument.ts +0 -7
- package/composables/useFacility.ts +0 -11
- package/composables/useHidAmico.ts +0 -165
- package/composables/useHidNavigation.ts +1 -25
- package/composables/useMember.ts +0 -12
- package/composables/usePeople.ts +0 -19
- package/composables/useServiceProvider.ts +2 -33
- 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-readers/index.vue +1 -1
- package/types/document.d.ts +0 -14
- package/types/facility.d.ts +0 -4
- package/types/member.d.ts +0 -4
- package/types/people.d.ts +1 -28
- package/types/service-provider.d.ts +0 -5
- package/utils/role.ts +1 -6
- package/components/BuildingUnitManagement.vue +0 -298
- package/components/HidAccessPermissions.vue +0 -424
- package/components/HidProfileQrCode.vue +0 -332
- package/composables/useHidReaderSelection.ts +0 -50
- package/composables/useSiteCategory.ts +0 -45
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
- package/utils/hid-permission-assignments.ts +0 -190
- package/utils/hid-reader-selection.ts +0 -52
- package/utils/occupancy-role.ts +0 -109
- package/utils/service-type.ts +0 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.75.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- aa8c658: Organization app Members: lists every person of the organisation, including people limited to specific sites (one row each, labelled "Limited to: Site A, Site B"; organisation-wide people read "All sites"); a Site access button on every row; the actions column stays pinned on the right; shorter dates; at organisation level the "role from another site" banner is gone and such a role reads "Security (The Loft)". Needs core with `GET /members?people=1` (older servers refuse the flag).
|
|
8
|
+
|
|
9
|
+
## 4.74.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 92ff5ab: Members: the "role from another site" banner gets "Fix roles from other sites" — a preview of each member, their site, the current role and the role proposed at their own site (warnings for fewer permissions or several/no matches, never pre-ticked), Apply, an automatic undo file download and an Undo button. Uses `GET/POST /api/members/cross-site-roles/fix` and `POST /api/members/cross-site-roles/undo` (core). The list and the banner count refresh after it.
|
|
14
|
+
- 8cca55f: Security role editor: NFC Patrol now offers Add, Update and Delete routes, and Site Settings offers Manage NFC Management and Manage NFC Patrol Settings. The strings and descriptions are Property Management's, so no new spelling is introduced; the security web app's NFC Patrol pages and Site Settings panels gate on them.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- 8df8f59: Robot maps: a robot that reports its floor but no position on the plan (Ounitech, now that its API reports the floor) is listed under the map ("can't be placed on the map, so it is listed but not drawn") instead of silently missing.
|
|
19
|
+
|
|
3
20
|
## 4.73.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
|
@@ -1885,11 +1902,11 @@
|
|
|
1885
1902
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1886
1903
|
own rule from two endpoints the console already calls, unprojected:
|
|
1887
1904
|
|
|
1888
|
-
|
|
1889
|
-
|
|
1905
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1906
|
+
GET /api/roles/id/:role that membership's role document
|
|
1890
1907
|
|
|
1891
|
-
|
|
1892
|
-
|
|
1908
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1909
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1893
1910
|
|
|
1894
1911
|
`role.default` is the marker because it is the only property of a platform
|
|
1895
1912
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -775,49 +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
778
|
.app-select__option {
|
|
822
779
|
display: flex;
|
|
823
780
|
align-items: center;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
hide-details
|
|
30
|
+
variant="outlined"
|
|
31
|
+
rounded="pill"
|
|
47
32
|
class="mb-4"
|
|
48
|
-
|
|
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="
|
|
44
|
+
:items="filteredPeopleItems"
|
|
56
45
|
density="compact"
|
|
57
46
|
item-title="name"
|
|
58
|
-
item-value="
|
|
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
|
-
|
|
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" :
|
|
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
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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 { getAll: getServiceProviders } = 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,168 +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 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
|
-
|
|
690
468
|
async function openAssignForm() {
|
|
691
469
|
showAssignForm.value = true;
|
|
692
470
|
assignPerson.value = null;
|
|
693
|
-
|
|
471
|
+
assignPersonType.value = "";
|
|
694
472
|
assignError.value = "";
|
|
695
|
-
|
|
473
|
+
peopleItems.value = [];
|
|
696
474
|
encryptedAcmUrl.value = "";
|
|
697
475
|
|
|
698
476
|
if (!props.unit?._id || !props.orgId || !props.siteId) return;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
+
}
|
|
707
490
|
}
|
|
708
491
|
|
|
709
492
|
function cancelAssign() {
|
|
710
493
|
showAssignForm.value = false;
|
|
711
494
|
assignPerson.value = null;
|
|
712
|
-
|
|
713
|
-
assignPeopleItems.value = [];
|
|
495
|
+
assignPersonType.value = "";
|
|
714
496
|
assignError.value = "";
|
|
715
497
|
}
|
|
716
498
|
|
|
@@ -725,16 +507,12 @@ const selectedCardType = computed(() => {
|
|
|
725
507
|
});
|
|
726
508
|
|
|
727
509
|
async function confirmAssign() {
|
|
728
|
-
|
|
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;
|
|
510
|
+
if (!assignPerson.value || !props.unit?._id) return;
|
|
733
511
|
assignLoading.value = true;
|
|
734
512
|
assignError.value = "";
|
|
735
513
|
try {
|
|
736
514
|
await assignUser({
|
|
737
|
-
assignees: [
|
|
515
|
+
assignees: [assignPerson.value._id],
|
|
738
516
|
unit: props.unit._id,
|
|
739
517
|
type: selectedCardType.value,
|
|
740
518
|
acm_url: encryptedAcmUrl.value,
|
|
@@ -742,9 +520,7 @@ async function confirmAssign() {
|
|
|
742
520
|
});
|
|
743
521
|
emit("assign-to-person", {
|
|
744
522
|
card: props.selectedCardInUnit,
|
|
745
|
-
|
|
746
|
-
// shape they always have.
|
|
747
|
-
person: assignPerson.value?.raw ?? assignPerson.value,
|
|
523
|
+
person: assignPerson.value,
|
|
748
524
|
});
|
|
749
525
|
showToast("Card assigned successfully.", "success");
|
|
750
526
|
cancelAssign();
|
|
@@ -768,15 +544,3 @@ function toggleCard(card: Record<string, any>) {
|
|
|
768
544
|
);
|
|
769
545
|
}
|
|
770
546
|
</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,8 +18,7 @@
|
|
|
18
18
|
<template>
|
|
19
19
|
<v-menu
|
|
20
20
|
v-if="!multiple"
|
|
21
|
-
|
|
22
|
-
:close-on-content-click="!searchable"
|
|
21
|
+
:close-on-content-click="true"
|
|
23
22
|
:disabled="disabled"
|
|
24
23
|
location="bottom start"
|
|
25
24
|
offset="4"
|
|
@@ -54,21 +53,8 @@
|
|
|
54
53
|
</template>
|
|
55
54
|
|
|
56
55
|
<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>
|
|
70
56
|
<button
|
|
71
|
-
v-for="opt in
|
|
57
|
+
v-for="opt in normalized"
|
|
72
58
|
:key="String(opt.value)"
|
|
73
59
|
type="button"
|
|
74
60
|
class="app-select__option"
|
|
@@ -80,9 +66,6 @@
|
|
|
80
66
|
<span class="app-select__option-text">{{ opt.title }}</span>
|
|
81
67
|
<v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
|
|
82
68
|
</button>
|
|
83
|
-
<p v-if="searchable && !visibleOptions.length" class="app-select__empty">
|
|
84
|
-
{{ normalized.length ? "No matches" : "Nothing to choose from" }}
|
|
85
|
-
</p>
|
|
86
69
|
</div>
|
|
87
70
|
</v-menu>
|
|
88
71
|
|
|
@@ -139,18 +122,8 @@
|
|
|
139
122
|
</template>
|
|
140
123
|
|
|
141
124
|
<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>
|
|
152
125
|
<button
|
|
153
|
-
v-for="opt in
|
|
126
|
+
v-for="opt in normalized"
|
|
154
127
|
:key="String(opt.value)"
|
|
155
128
|
type="button"
|
|
156
129
|
class="app-select__option"
|
|
@@ -165,9 +138,6 @@
|
|
|
165
138
|
/>
|
|
166
139
|
<span class="app-select__option-text">{{ opt.title }}</span>
|
|
167
140
|
</button>
|
|
168
|
-
<p v-if="searchable && !visibleOptions.length" class="app-select__empty">
|
|
169
|
-
{{ normalized.length ? "No matches" : "Nothing to choose from" }}
|
|
170
|
-
</p>
|
|
171
141
|
</div>
|
|
172
142
|
</v-menu>
|
|
173
143
|
</template>
|
|
@@ -198,13 +168,6 @@ const props = defineProps({
|
|
|
198
168
|
returnObject: { type: Boolean, default: false },
|
|
199
169
|
clearable: { type: Boolean, default: false },
|
|
200
170
|
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" },
|
|
208
171
|
});
|
|
209
172
|
|
|
210
173
|
const normalized = computed(() =>
|
|
@@ -215,38 +178,6 @@ const normalized = computed(() =>
|
|
|
215
178
|
)
|
|
216
179
|
);
|
|
217
180
|
|
|
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
|
-
|
|
250
181
|
/* A model entry is either the plain value or - under `return-object` - the
|
|
251
182
|
source item, so everything compares on this one key. */
|
|
252
183
|
const keyOf = (v: any) =>
|
|
@@ -274,11 +205,6 @@ const isSingleOn = (opt: any) =>
|
|
|
274
205
|
function selectSingle(opt: any) {
|
|
275
206
|
if (props.disabled) return;
|
|
276
207
|
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;
|
|
282
208
|
}
|
|
283
209
|
|
|
284
210
|
function clearSingle() {
|
|
@@ -10,7 +10,6 @@
|
|
|
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>
|
|
14
13
|
<v-tab value="others">Others</v-tab>
|
|
15
14
|
</v-tabs>
|
|
16
15
|
</v-toolbar>
|
|
@@ -171,11 +170,6 @@
|
|
|
171
170
|
</TableMain>
|
|
172
171
|
</template>
|
|
173
172
|
|
|
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
|
-
|
|
179
173
|
<template v-else-if="tab === 'others'">
|
|
180
174
|
<v-row>
|
|
181
175
|
<!-- <v-col cols="12" md="6" class="mt-2">
|