@7365admin1/layer-common 4.82.1-staging.476 → 4.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -4
- package/assets/css/primitives.css +0 -57
- package/assets/css/screens.css +4 -8
- package/components/AccessCardDetailsDialog.vue +4 -57
- package/components/AccessCardPreviewDialog.vue +50 -291
- package/components/AppSelect.vue +4 -143
- package/components/AttendanceDetailsDialog.vue +1 -48
- package/components/AttendanceMain.vue +10 -104
- package/components/AttendanceSettingsDialog.vue +1 -53
- package/components/BuildingUnitFormEdit.vue +0 -6
- package/components/BulletinBoardView.vue +1 -1
- 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 +250 -1573
- package/components/InventoryMain.vue +2 -11
- package/components/InvitationClientForm.vue +1 -19
- package/components/MyAttendanceMain.vue +2 -60
- package/components/NotificationBell.vue +2 -14
- package/components/TableMain.vue +7 -22
- package/components/VisitorForm.vue +0 -12
- package/components/VisitorManagement.vue +5 -267
- package/composables/useAttendance.ts +0 -53
- package/composables/useAttendancePermission.ts +0 -25
- package/composables/useCleaningPermission.ts +0 -5
- 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/useLandscapingPermission.ts +0 -5
- package/composables/useMember.ts +0 -12
- package/composables/usePeople.ts +0 -19
- package/composables/usePestControlPermission.ts +0 -5
- package/composables/usePoolMaintenancePermission.ts +0 -5
- package/composables/useSecurityPermission.ts +0 -5
- package/composables/useServiceProvider.ts +2 -40
- package/constants/permissions.ts +0 -1
- package/nuxt.config.ts +0 -18
- package/package.json +2 -4
- 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/attendance.d.ts +1 -13
- 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/notification-target.ts +4 -43
- package/components/AttendanceProxyDialog.vue +0 -391
- 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/useSiteActions.ts +0 -29
- package/composables/useSiteCategory.ts +0 -45
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
- package/server/plugins/no-powered-by.ts +0 -7
- package/utils/attendance-proxy.ts +0 -110
- package/utils/hid-enrolment-subject.ts +0 -144
- 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/sanitize-html.ts +0 -16
- package/utils/security-headers.ts +0 -52
- package/utils/service-type.ts +0 -30
- package/utils/site-actions.ts +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.84.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 57abba1: Suspended access: when the API answers ACCESS_SUSPENDED (every membership of the person is suspended), the app signs out and the sign-in page shows the server's message. Role names are unique per site, so role pickers and the roles table show "Admin · St. Regis Residences" (`roleLabel`), and the Members role chip uses the same "Role · Site" form.
|
|
8
|
+
|
|
9
|
+
## 4.83.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- b69f1d9: "Open as Seven365 staff": persistent staff-view banner with Exit in every app's top bar, and staff in an open view work at that client with full setup rights without a membership.
|
|
14
|
+
|
|
3
15
|
## 4.82.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1962,11 +1974,11 @@
|
|
|
1962
1974
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1963
1975
|
own rule from two endpoints the console already calls, unprojected:
|
|
1964
1976
|
|
|
1965
|
-
|
|
1966
|
-
|
|
1977
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1978
|
+
GET /api/roles/id/:role that membership's role document
|
|
1967
1979
|
|
|
1968
|
-
|
|
1969
|
-
|
|
1980
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1981
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1970
1982
|
|
|
1971
1983
|
`role.default` is the marker because it is the only property of a platform
|
|
1972
1984
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -775,63 +775,6 @@ select option {
|
|
|
775
775
|
box-shadow: var(--shadow-menu);
|
|
776
776
|
}
|
|
777
777
|
|
|
778
|
-
/* The filter box of a `searchable` AppSelect. Sticky rather than scrolled
|
|
779
|
-
away with the options: the list it filters is long enough to need scrolling,
|
|
780
|
-
which is the only reason the box is there. */
|
|
781
|
-
.app-select__search {
|
|
782
|
-
position: sticky;
|
|
783
|
-
top: -6px;
|
|
784
|
-
z-index: 1;
|
|
785
|
-
display: flex;
|
|
786
|
-
align-items: center;
|
|
787
|
-
gap: 6px;
|
|
788
|
-
margin: -6px -6px 4px;
|
|
789
|
-
padding: 8px 10px;
|
|
790
|
-
border-bottom: 1px solid var(--border);
|
|
791
|
-
background: var(--card);
|
|
792
|
-
color: var(--muted);
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
.app-select__search-input {
|
|
796
|
-
flex: 1;
|
|
797
|
-
min-width: 0;
|
|
798
|
-
border: none;
|
|
799
|
-
outline: none;
|
|
800
|
-
background: none;
|
|
801
|
-
font-family: var(--font-sans);
|
|
802
|
-
font-size: var(--fs-cell);
|
|
803
|
-
font-weight: 500;
|
|
804
|
-
color: var(--text);
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
.app-select__search-input::placeholder {
|
|
808
|
-
color: var(--muted);
|
|
809
|
-
font-weight: 500;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
.app-select__empty {
|
|
813
|
-
margin: 0;
|
|
814
|
-
padding: 10px 8px;
|
|
815
|
-
font-family: var(--font-sans);
|
|
816
|
-
font-size: var(--fs-cell);
|
|
817
|
-
color: var(--muted);
|
|
818
|
-
text-align: center;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/* The footer of a paging AppSelect. Same voice as the empty state - it is the
|
|
822
|
-
same kind of message about the list rather than an option in it - with a rule
|
|
823
|
-
above so it does not read as one more row to click. */
|
|
824
|
-
.app-select__loading {
|
|
825
|
-
margin: 6px 0 0;
|
|
826
|
-
padding: 9px 8px;
|
|
827
|
-
border-top: 1px solid var(--border);
|
|
828
|
-
font-family: var(--font-sans);
|
|
829
|
-
font-size: var(--fs-cell);
|
|
830
|
-
font-weight: 600;
|
|
831
|
-
color: var(--muted);
|
|
832
|
-
text-align: center;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
778
|
.app-select__option {
|
|
836
779
|
display: flex;
|
|
837
780
|
align-items: center;
|
package/assets/css/screens.css
CHANGED
|
@@ -809,14 +809,10 @@
|
|
|
809
809
|
color: var(--muted);
|
|
810
810
|
}
|
|
811
811
|
|
|
812
|
-
/* The
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
flex: 0 0 auto;
|
|
817
|
-
width: 260px;
|
|
818
|
-
max-width: 100%;
|
|
819
|
-
min-width: 0;
|
|
812
|
+
/* The in-card variant: 34px, 10px radius, as drawn. */
|
|
813
|
+
.table-card__search .v-field {
|
|
814
|
+
border-radius: var(--r-inner);
|
|
815
|
+
font-size: var(--fs-cell);
|
|
820
816
|
}
|
|
821
817
|
|
|
822
818
|
.table-card__primary {
|
|
@@ -47,22 +47,14 @@
|
|
|
47
47
|
<div>
|
|
48
48
|
<div class="card-details__label">User</div>
|
|
49
49
|
<div class="card-details__value">
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<template v-if="details.user?.email">({{ details.user.email }})</template>
|
|
53
|
-
</template>
|
|
54
|
-
<!-- Assigned, but the server did not send the name back. Saying
|
|
55
|
-
so beats "N/A", which reads as "nobody holds this card". -->
|
|
56
|
-
<template v-else-if="holderState === 'assigned'">
|
|
57
|
-
<span class="text-medium-emphasis">Assigned — name unavailable</span>
|
|
58
|
-
</template>
|
|
59
|
-
<template v-else>Unassigned</template>
|
|
50
|
+
{{ details.userCred?.name ?? details.user?.name ?? "N/A" }}
|
|
51
|
+
<template v-if="details.user?.email">({{ details.user.email }})</template>
|
|
60
52
|
</div>
|
|
61
53
|
</div>
|
|
62
54
|
|
|
63
|
-
<div v-if="
|
|
55
|
+
<div v-if="details.userType">
|
|
64
56
|
<div class="card-details__label">User Type</div>
|
|
65
|
-
<div class="card-details__value">{{
|
|
57
|
+
<div class="card-details__value">{{ details.userType }}</div>
|
|
66
58
|
</div>
|
|
67
59
|
|
|
68
60
|
<!-- Visitor / Resident Credentials -->
|
|
@@ -224,51 +216,6 @@ const sortedHistory = computed(() =>
|
|
|
224
216
|
)
|
|
225
217
|
);
|
|
226
218
|
|
|
227
|
-
/**
|
|
228
|
-
* WHO HOLDS THIS CARD.
|
|
229
|
-
*
|
|
230
|
-
* `userCred` is written by the visitor-pass flow and carries the person's
|
|
231
|
-
* details on the card itself. `user` is the resolved account - the card only
|
|
232
|
-
* stores `userId`, so the server has to join it.
|
|
233
|
-
*
|
|
234
|
-
* `card-details` does not do that join today (the lookup is commented out in
|
|
235
|
-
* core's `getCardDetailsRepo`), so this is empty for every card assigned
|
|
236
|
-
* through Assign to Person and the field falls through to `holderState`.
|
|
237
|
-
* Nothing here needs to change when the join is restored.
|
|
238
|
-
*/
|
|
239
|
-
const holderName = computed(
|
|
240
|
-
() => details.value?.userCred?.name ?? details.value?.user?.name ?? "",
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* A card with a `userId` IS assigned, whether or not the name came back.
|
|
245
|
-
*
|
|
246
|
-
* Printing "N/A" for both cases is the actual defect here: it reads as "nobody
|
|
247
|
-
* holds this card", which is the opposite of the truth for an assigned one and
|
|
248
|
-
* could have someone hand out a card that is already in use.
|
|
249
|
-
*/
|
|
250
|
-
const holderState = computed<"named" | "assigned" | "unassigned">(() => {
|
|
251
|
-
if (holderName.value) return "named";
|
|
252
|
-
return details.value?.userId ? "assigned" : "unassigned";
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* WHICH KIND OF PERSON HOLDS THIS CARD — Visitor/Resident, Management or
|
|
257
|
-
* Service Provider, matching the groups Assign to Person offers.
|
|
258
|
-
*
|
|
259
|
-
* `holderType` is what the server should send once it records the category the
|
|
260
|
-
* card was assigned under. `userType` is NOT that: it is the card's own pool,
|
|
261
|
-
* and `getCardDetailsRepo` filters on `EAccessCardUserTypes.DEFAULT`, so every
|
|
262
|
-
* card this dialog can load has the literal value "Visitor/Resident" whoever
|
|
263
|
-
* actually holds it. Preferring `holderType` means a card given to Management
|
|
264
|
-
* stops describing itself as a resident's the day the field arrives.
|
|
265
|
-
*
|
|
266
|
-
* Falls back to `userType` so nothing disappears in the meantime.
|
|
267
|
-
*/
|
|
268
|
-
const holderType = computed(
|
|
269
|
-
() => details.value?.holderType ?? details.value?.userType ?? "",
|
|
270
|
-
);
|
|
271
|
-
|
|
272
219
|
watch(
|
|
273
220
|
() => props.modelValue,
|
|
274
221
|
async (val) => {
|
|
@@ -18,98 +18,50 @@
|
|
|
18
18
|
|
|
19
19
|
<v-card-text class="pa-4">
|
|
20
20
|
<div class="text-caption text-fontgray mb-4">
|
|
21
|
-
|
|
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 { getSiteMembers } = useServiceProvider();
|
|
457
407
|
|
|
458
408
|
const historyDialog = ref(false);
|
|
459
409
|
const showAssignForm = ref(false);
|
|
410
|
+
const assignPersonType = ref<string>("");
|
|
460
411
|
const assignPerson = ref<Record<string, any> | null>(null);
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* WHO A CARD CAN BE GIVEN TO.
|
|
464
|
-
*
|
|
465
|
-
* The dialog only ever listed the unit's residents and tenants. Management
|
|
466
|
-
* (the site's own members) and Service Providers hold cards on a site too, so
|
|
467
|
-
* the operator picks the group first and then the person.
|
|
468
|
-
*
|
|
469
|
-
* `value` is only ever read here; nothing is sent to the server, which takes a
|
|
470
|
-
* flat list of user ids and does not know about these groups.
|
|
471
|
-
*/
|
|
472
|
-
type AssignCategory = "residents" | "management" | "providers";
|
|
473
|
-
|
|
474
|
-
const assignCategories: { value: AssignCategory; label: string }[] = [
|
|
475
|
-
{ value: "residents", label: "Resident / Tenant" },
|
|
476
|
-
{ value: "management", label: "Management" },
|
|
477
|
-
{ value: "providers", label: "Service Provider" },
|
|
478
|
-
];
|
|
479
|
-
|
|
480
|
-
const assignCategory = ref<AssignCategory>("residents");
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* One row shape for all three groups, so the picker does not care which it is
|
|
484
|
-
* showing.
|
|
485
|
-
*
|
|
486
|
-
* `assigneeId` is the person's USER id - the id `assignUser` sends and the
|
|
487
|
-
* server writes onto the card. Residents already come from the users
|
|
488
|
-
* collection, and a member row carries its `user`. A row that cannot supply
|
|
489
|
-
* one keeps `assigneeId: ""`, which is what disables Assign for it rather than
|
|
490
|
-
* sending an id that binds the card to nobody.
|
|
491
|
-
*/
|
|
492
|
-
type AssignRow = {
|
|
493
|
-
assigneeId: string;
|
|
494
|
-
name: string;
|
|
495
|
-
/** Second line - the email, or what kind of resident this is. */
|
|
496
|
-
subtitle: string;
|
|
497
|
-
/** Third line, under the subtitle - the member's role or the provider's service. */
|
|
498
|
-
meta?: string;
|
|
499
|
-
raw: Record<string, any>;
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
const assignPeopleItems = ref<AssignRow[]>([]);
|
|
412
|
+
const peopleItems = ref<Record<string, any>[]>([]);
|
|
503
413
|
const peopleLoading = ref(false);
|
|
504
414
|
const assignLoading = ref(false);
|
|
505
415
|
const assignError = ref("");
|
|
@@ -542,6 +452,12 @@ const isSelectedCardDeletable = computed(() => {
|
|
|
542
452
|
].some((c) => c._id === id);
|
|
543
453
|
});
|
|
544
454
|
|
|
455
|
+
const filteredPeopleItems = computed(() => {
|
|
456
|
+
if (!assignPersonType.value) return peopleItems.value;
|
|
457
|
+
if (assignPersonType.value === "owner") return peopleItems.value.filter((p) => p.isOwner);
|
|
458
|
+
return peopleItems.value.filter((p) => p.type === assignPersonType.value && !p.isOwner);
|
|
459
|
+
});
|
|
460
|
+
|
|
545
461
|
const personTypeLabel = (person: Record<string, any>) => {
|
|
546
462
|
if (person.isOwner) return "Unit Owner";
|
|
547
463
|
if (person.type === "resident") return "Resident";
|
|
@@ -549,173 +465,34 @@ const personTypeLabel = (person: Record<string, any>) => {
|
|
|
549
465
|
return person.type ?? "";
|
|
550
466
|
};
|
|
551
467
|
|
|
552
|
-
const assignNoDataText = computed(() => {
|
|
553
|
-
if (peopleLoading.value) return "Loading...";
|
|
554
|
-
if (assignCategory.value === "management") return "No management members assigned to this unit";
|
|
555
|
-
if (assignCategory.value === "providers") return "No service provider members assigned to this unit";
|
|
556
|
-
return "No residents or tenants found for this unit";
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
const assignUnassignableText = computed(
|
|
560
|
-
() => "This person has no account on record, so a card cannot be assigned to them.",
|
|
561
|
-
);
|
|
562
|
-
|
|
563
|
-
/** Residents and tenants of THIS UNIT - the list the dialog has always shown. */
|
|
564
|
-
async function loadResidentRows(): Promise<AssignRow[]> {
|
|
565
|
-
if (!props.unit?._id || !props.orgId || !props.siteId) return [];
|
|
566
|
-
const res = await getResidents({
|
|
567
|
-
orgId: props.orgId,
|
|
568
|
-
siteId: props.siteId,
|
|
569
|
-
unitId: props.unit._id,
|
|
570
|
-
});
|
|
571
|
-
const rows = res?.data ?? [];
|
|
572
|
-
return rows.map((person: Record<string, any>) => ({
|
|
573
|
-
// Already the users collection's own `_id` - see core's residents repo.
|
|
574
|
-
assigneeId: String(person._id ?? ""),
|
|
575
|
-
name: person.name ?? "",
|
|
576
|
-
subtitle: person.email ?? "",
|
|
577
|
-
meta: personTypeLabel(person),
|
|
578
|
-
raw: person,
|
|
579
|
-
}));
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/**
|
|
583
|
-
* Management - the site's own members ATTACHED TO THIS UNIT.
|
|
584
|
-
*
|
|
585
|
-
* `unit` comes from core #2515 (Unit Management tab), which gave members a
|
|
586
|
-
* `unit`/`unitName` and taught `GET /api/members` to filter on it. Narrowing
|
|
587
|
-
* to the unit matches what the card is: it belongs to a unit, so the people
|
|
588
|
-
* offered are the ones that unit has.
|
|
589
|
-
*
|
|
590
|
-
* `type` is the member type of whichever app this layer is running in
|
|
591
|
-
* (`property_management_agency`, `security_agency`, ...) - the endpoint
|
|
592
|
-
* requires it and rejects an empty string, and `limit` is capped at 50 there.
|
|
593
|
-
*/
|
|
594
|
-
async function loadManagementRows(): Promise<AssignRow[]> {
|
|
595
|
-
if (!props.orgId || !props.siteId || !props.unit?._id) return [];
|
|
596
|
-
const res: any = await getMembers({
|
|
597
|
-
org: props.orgId,
|
|
598
|
-
siteId: props.siteId,
|
|
599
|
-
unit: props.unit._id,
|
|
600
|
-
type: String(useRuntimeConfig().public.APP ?? ""),
|
|
601
|
-
status: "active",
|
|
602
|
-
page: 1,
|
|
603
|
-
limit: 50,
|
|
604
|
-
});
|
|
605
|
-
const rows: Record<string, any>[] = res?.items ?? res?.data ?? [];
|
|
606
|
-
return rows.map((member) => ({
|
|
607
|
-
// `user` is the account; `_id` is the membership record and is NOT it.
|
|
608
|
-
assigneeId: String(member.user ?? ""),
|
|
609
|
-
name: member.name ?? member.email ?? "",
|
|
610
|
-
// One line under the name: "Access Mgmt Only · towefa4573@aghism.com".
|
|
611
|
-
// The role first, because that is what distinguishes two members of the
|
|
612
|
-
// same unit from each other. Either half is dropped if the row lacks it,
|
|
613
|
-
// so the separator never dangles.
|
|
614
|
-
subtitle: [member.roleName, member.email].filter(Boolean).join(" · "),
|
|
615
|
-
raw: member,
|
|
616
|
-
}));
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Service Providers - the provider PEOPLE attached to this unit.
|
|
621
|
-
*
|
|
622
|
-
* `GET /api/service-providers/site-members?siteId&unit=<id>`. A card is held by
|
|
623
|
-
* a person, so this lists provider staff rather than the companies
|
|
624
|
-
* `GET /api/service-providers` returns, and the `unit` filter keeps it to the
|
|
625
|
-
* people that unit actually has - the same scoping Management uses.
|
|
626
|
-
*
|
|
627
|
-
* The response also carries a `companies` summary; only `items` is people.
|
|
628
|
-
*/
|
|
629
|
-
async function loadProviderRows(): Promise<AssignRow[]> {
|
|
630
|
-
if (!props.siteId || !props.unit?._id) return [];
|
|
631
|
-
const res = await getSiteMembers(props.siteId, props.unit._id);
|
|
632
|
-
const rows = res?.items ?? [];
|
|
633
|
-
return rows.map((member: Record<string, any>) => ({
|
|
634
|
-
/*
|
|
635
|
-
* `user` when the endpoint returns it, otherwise the MEMBERSHIP `_id`.
|
|
636
|
-
*
|
|
637
|
-
* `site-members` does not carry `user` today (the repository projects it,
|
|
638
|
-
* the service layer drops it), and the product decision is to assign on
|
|
639
|
-
* `_id` rather than wait. Worth knowing what that means: the card's
|
|
640
|
-
* `userId` then holds a `service-provider-members` id, so the card list's
|
|
641
|
-
* `userId -> users` lookup finds nothing and the holder column reads blank
|
|
642
|
-
* for these cards. The door access itself is unaffected - the ACM command
|
|
643
|
-
* is built from the CARD's id, not the assignee's.
|
|
644
|
-
*
|
|
645
|
-
* `user` is preferred so that the day it starts arriving, these cards
|
|
646
|
-
* begin resolving to real people with no change here.
|
|
647
|
-
*/
|
|
648
|
-
assigneeId: String(member.user ?? member._id ?? ""),
|
|
649
|
-
name: member.name || member.email || "",
|
|
650
|
-
subtitle: member.email ?? "",
|
|
651
|
-
/*
|
|
652
|
-
* The service the company provides, plus this person's role - two people
|
|
653
|
-
* from the same company differ only by role ("VMS Access" vs "Security
|
|
654
|
-
* Access"). `typeLabel` is computed by the endpoint; the local converter is
|
|
655
|
-
* the fallback for a row that predates it.
|
|
656
|
-
*/
|
|
657
|
-
meta:
|
|
658
|
-
[member.typeLabel || serviceTypeLabel(member.type), member.role]
|
|
659
|
-
.filter(Boolean)
|
|
660
|
-
.join(" · "),
|
|
661
|
-
raw: member,
|
|
662
|
-
}));
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/** Load the chosen group, dropping a response that a later switch superseded. */
|
|
666
|
-
let assignLoadSeq = 0;
|
|
667
|
-
async function loadAssignPeople() {
|
|
668
|
-
const seq = ++assignLoadSeq;
|
|
669
|
-
const category = assignCategory.value;
|
|
670
|
-
peopleLoading.value = true;
|
|
671
|
-
assignPeopleItems.value = [];
|
|
672
|
-
try {
|
|
673
|
-
const rows =
|
|
674
|
-
category === "management"
|
|
675
|
-
? await loadManagementRows()
|
|
676
|
-
: category === "providers"
|
|
677
|
-
? await loadProviderRows()
|
|
678
|
-
: await loadResidentRows();
|
|
679
|
-
if (seq !== assignLoadSeq) return;
|
|
680
|
-
assignPeopleItems.value = rows;
|
|
681
|
-
} catch {
|
|
682
|
-
if (seq !== assignLoadSeq) return;
|
|
683
|
-
assignPeopleItems.value = [];
|
|
684
|
-
} finally {
|
|
685
|
-
if (seq === assignLoadSeq) peopleLoading.value = false;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function onAssignCategoryChange() {
|
|
690
|
-
assignPerson.value = null;
|
|
691
|
-
assignError.value = "";
|
|
692
|
-
loadAssignPeople();
|
|
693
|
-
}
|
|
694
|
-
|
|
695
468
|
async function openAssignForm() {
|
|
696
469
|
showAssignForm.value = true;
|
|
697
470
|
assignPerson.value = null;
|
|
698
|
-
|
|
471
|
+
assignPersonType.value = "";
|
|
699
472
|
assignError.value = "";
|
|
700
|
-
|
|
473
|
+
peopleItems.value = [];
|
|
701
474
|
encryptedAcmUrl.value = "";
|
|
702
475
|
|
|
703
476
|
if (!props.unit?._id || !props.orgId || !props.siteId) return;
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
477
|
+
peopleLoading.value = true;
|
|
478
|
+
try {
|
|
479
|
+
const [residentsRes, acmUrl] = await Promise.all([
|
|
480
|
+
getResidents({ orgId: props.orgId, siteId: props.siteId, unitId: props.unit._id }),
|
|
481
|
+
getEncryptedAcmUrl(props.siteId),
|
|
482
|
+
]);
|
|
483
|
+
peopleItems.value = residentsRes?.data ?? [];
|
|
484
|
+
encryptedAcmUrl.value = acmUrl ?? "";
|
|
485
|
+
} catch {
|
|
486
|
+
peopleItems.value = [];
|
|
487
|
+
} finally {
|
|
488
|
+
peopleLoading.value = false;
|
|
489
|
+
}
|
|
712
490
|
}
|
|
713
491
|
|
|
714
492
|
function cancelAssign() {
|
|
715
493
|
showAssignForm.value = false;
|
|
716
494
|
assignPerson.value = null;
|
|
717
|
-
|
|
718
|
-
assignPeopleItems.value = [];
|
|
495
|
+
assignPersonType.value = "";
|
|
719
496
|
assignError.value = "";
|
|
720
497
|
}
|
|
721
498
|
|
|
@@ -730,16 +507,12 @@ const selectedCardType = computed(() => {
|
|
|
730
507
|
});
|
|
731
508
|
|
|
732
509
|
async function confirmAssign() {
|
|
733
|
-
|
|
734
|
-
// lists and only this field is the user account the card is written to. The
|
|
735
|
-
// Assign button is already disabled without one; this is the guard behind it.
|
|
736
|
-
const assigneeId = assignPerson.value?.assigneeId;
|
|
737
|
-
if (!assigneeId || !props.unit?._id) return;
|
|
510
|
+
if (!assignPerson.value || !props.unit?._id) return;
|
|
738
511
|
assignLoading.value = true;
|
|
739
512
|
assignError.value = "";
|
|
740
513
|
try {
|
|
741
514
|
await assignUser({
|
|
742
|
-
assignees: [
|
|
515
|
+
assignees: [assignPerson.value._id],
|
|
743
516
|
unit: props.unit._id,
|
|
744
517
|
type: selectedCardType.value,
|
|
745
518
|
acm_url: encryptedAcmUrl.value,
|
|
@@ -747,9 +520,7 @@ async function confirmAssign() {
|
|
|
747
520
|
});
|
|
748
521
|
emit("assign-to-person", {
|
|
749
522
|
card: props.selectedCardInUnit,
|
|
750
|
-
|
|
751
|
-
// shape they always have.
|
|
752
|
-
person: assignPerson.value?.raw ?? assignPerson.value,
|
|
523
|
+
person: assignPerson.value,
|
|
753
524
|
});
|
|
754
525
|
showToast("Card assigned successfully.", "success");
|
|
755
526
|
cancelAssign();
|
|
@@ -773,15 +544,3 @@ function toggleCard(card: Record<string, any>) {
|
|
|
773
544
|
);
|
|
774
545
|
}
|
|
775
546
|
</script>
|
|
776
|
-
|
|
777
|
-
<style scoped>
|
|
778
|
-
/* The chosen person, two lines inside the field. `min-width: 0` is what lets
|
|
779
|
-
`text-truncate` work at all here - the selection sits in a flex row, and a
|
|
780
|
-
flex child will not shrink below its content without it, so a long email
|
|
781
|
-
would push the field wide instead of ellipsing. */
|
|
782
|
-
.assign-selection {
|
|
783
|
-
min-width: 0;
|
|
784
|
-
line-height: 1.25;
|
|
785
|
-
padding-block: 2px;
|
|
786
|
-
}
|
|
787
|
-
</style>
|