@7365admin1/layer-common 4.90.1 → 4.90.2-staging.484
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -4
- package/assets/css/primitives.css +57 -0
- package/assets/css/robot-hero.css +3 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +57 -4
- package/components/AccessCardPreviewDialog.vue +291 -50
- package/components/AppSelect.vue +143 -4
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/ClientMain.vue +56 -14
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +2239 -276
- package/components/InvitationClientForm.vue +19 -1
- package/components/MemberInformation.vue +4 -4
- package/components/PassInformation.vue +2 -2
- package/components/TableMain.vue +22 -7
- package/components/VisitorForm.vue +65 -19
- 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/useMember.ts +12 -0
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +8 -1
- package/composables/usePeople.ts +19 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/hid-enrolment-subject.ts +344 -0
- package/utils/hid-permission-assignments.ts +198 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -2016,11 +2016,11 @@
|
|
|
2016
2016
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
2017
2017
|
own rule from two endpoints the console already calls, unprojected:
|
|
2018
2018
|
|
|
2019
|
-
|
|
2020
|
-
|
|
2019
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
2020
|
+
GET /api/roles/id/:role that membership's role document
|
|
2021
2021
|
|
|
2022
|
-
|
|
2023
|
-
|
|
2022
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
2023
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
2024
2024
|
|
|
2025
2025
|
`role.default` is the marker because it is the only property of a platform
|
|
2026
2026
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -775,6 +775,63 @@ 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
|
+
|
|
778
835
|
.app-select__option {
|
|
779
836
|
display: flex;
|
|
780
837
|
align-items: center;
|
|
@@ -324,6 +324,9 @@
|
|
|
324
324
|
}
|
|
325
325
|
/* The site fleet's strip: always six columns (README §2). */
|
|
326
326
|
.hero-summary--six { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
327
|
+
/* Phones (owner 2026-10-02): six cells in one row cut every label ("TOTA",
|
|
328
|
+
"ONL"); three per row keeps each label whole. */
|
|
329
|
+
@media (max-width: 599.98px) { .hero-summary--six { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
|
327
330
|
.hero-cell {
|
|
328
331
|
--hero-cell-color: var(--hero-text);
|
|
329
332
|
position: relative;
|
package/assets/css/screens.css
CHANGED
|
@@ -809,10 +809,14 @@
|
|
|
809
809
|
color: var(--muted);
|
|
810
810
|
}
|
|
811
811
|
|
|
812
|
-
/* The
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
812
|
+
/* The title-row search beside the primary button. `--search` alone is
|
|
813
|
+
`flex: 1`, written for a filter row; in the actions group that collapses it,
|
|
814
|
+
so it gets a fixed width here and never more than the row on a phone. */
|
|
815
|
+
.table-card__search.app-field--search {
|
|
816
|
+
flex: 0 0 auto;
|
|
817
|
+
width: 260px;
|
|
818
|
+
max-width: 100%;
|
|
819
|
+
min-width: 0;
|
|
816
820
|
}
|
|
817
821
|
|
|
818
822
|
.table-card__primary {
|
|
@@ -47,14 +47,22 @@
|
|
|
47
47
|
<div>
|
|
48
48
|
<div class="card-details__label">User</div>
|
|
49
49
|
<div class="card-details__value">
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
<template v-if="holderState === 'named'">
|
|
51
|
+
{{ holderName }}
|
|
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>
|
|
52
60
|
</div>
|
|
53
61
|
</div>
|
|
54
62
|
|
|
55
|
-
<div v-if="
|
|
63
|
+
<div v-if="holderType">
|
|
56
64
|
<div class="card-details__label">User Type</div>
|
|
57
|
-
<div class="card-details__value">{{
|
|
65
|
+
<div class="card-details__value">{{ holderType }}</div>
|
|
58
66
|
</div>
|
|
59
67
|
|
|
60
68
|
<!-- Visitor / Resident Credentials -->
|
|
@@ -216,6 +224,51 @@ const sortedHistory = computed(() =>
|
|
|
216
224
|
)
|
|
217
225
|
);
|
|
218
226
|
|
|
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
|
+
|
|
219
272
|
watch(
|
|
220
273
|
() => props.modelValue,
|
|
221
274
|
async (val) => {
|
|
@@ -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 { getSiteMembers } = 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,173 @@ 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 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
|
+
|
|
468
695
|
async function openAssignForm() {
|
|
469
696
|
showAssignForm.value = true;
|
|
470
697
|
assignPerson.value = null;
|
|
471
|
-
|
|
698
|
+
assignCategory.value = "residents";
|
|
472
699
|
assignError.value = "";
|
|
473
|
-
|
|
700
|
+
assignPeopleItems.value = [];
|
|
474
701
|
encryptedAcmUrl.value = "";
|
|
475
702
|
|
|
476
703
|
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
|
-
}
|
|
704
|
+
|
|
705
|
+
// The ACM URL is needed whichever group the card goes to, so it is fetched
|
|
706
|
+
// once here rather than with each list.
|
|
707
|
+
getEncryptedAcmUrl(props.siteId)
|
|
708
|
+
.then((url) => { encryptedAcmUrl.value = url ?? ""; })
|
|
709
|
+
.catch(() => { encryptedAcmUrl.value = ""; });
|
|
710
|
+
|
|
711
|
+
await loadAssignPeople();
|
|
490
712
|
}
|
|
491
713
|
|
|
492
714
|
function cancelAssign() {
|
|
493
715
|
showAssignForm.value = false;
|
|
494
716
|
assignPerson.value = null;
|
|
495
|
-
|
|
717
|
+
assignCategory.value = "residents";
|
|
718
|
+
assignPeopleItems.value = [];
|
|
496
719
|
assignError.value = "";
|
|
497
720
|
}
|
|
498
721
|
|
|
@@ -507,12 +730,16 @@ const selectedCardType = computed(() => {
|
|
|
507
730
|
});
|
|
508
731
|
|
|
509
732
|
async function confirmAssign() {
|
|
510
|
-
|
|
733
|
+
// `assigneeId` rather than `_id`: the rows now come from three different
|
|
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;
|
|
511
738
|
assignLoading.value = true;
|
|
512
739
|
assignError.value = "";
|
|
513
740
|
try {
|
|
514
741
|
await assignUser({
|
|
515
|
-
assignees: [
|
|
742
|
+
assignees: [assigneeId],
|
|
516
743
|
unit: props.unit._id,
|
|
517
744
|
type: selectedCardType.value,
|
|
518
745
|
acm_url: encryptedAcmUrl.value,
|
|
@@ -520,7 +747,9 @@ async function confirmAssign() {
|
|
|
520
747
|
});
|
|
521
748
|
emit("assign-to-person", {
|
|
522
749
|
card: props.selectedCardInUnit,
|
|
523
|
-
|
|
750
|
+
// The original record, not the picker's row, so listeners see the same
|
|
751
|
+
// shape they always have.
|
|
752
|
+
person: assignPerson.value?.raw ?? assignPerson.value,
|
|
524
753
|
});
|
|
525
754
|
showToast("Card assigned successfully.", "success");
|
|
526
755
|
cancelAssign();
|
|
@@ -544,3 +773,15 @@ function toggleCard(card: Record<string, any>) {
|
|
|
544
773
|
);
|
|
545
774
|
}
|
|
546
775
|
</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>
|