@7365admin1/layer-common 4.65.2 → 4.66.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 +30 -4
- package/components/InvitationMain.vue +24 -1
- package/components/MemberMain.vue +60 -7
- package/components/MemberServiceProviders.vue +327 -0
- package/components/RolePermissionMain.vue +5 -1
- package/components/ServiceProviderMain.vue +15 -1
- package/components/VisitorManagement.vue +47 -10
- package/composables/useServiceProvider.ts +59 -0
- package/package.json +1 -1
- package/utils/invitations-empty-text.ts +14 -0
- package/utils/notification-target.ts +7 -3
- package/utils/role.ts +21 -0
- package/utils/site-provider-members.ts +96 -0
- package/utils/visitor-search.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.66.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c8c7091: Members > Service Providers: new `MemberServiceProviders` component listing every person from the site's service providers who can open its information - company chips with head counts, service and company filters, search, role at the site, date added, status, "Invited by" for subcontractors, Export to Excel and an "Email company" action. `MemberMain` takes `extraTabs` so an app can add the tab beside Active / Inactive.
|
|
8
|
+
- 4a3ea41: Service Providers screen: a subcontractor another provider brought onto the site reads "Invited by <provider>" (list and details), the invite form says Seven365 approves every invitation and calls the field "Service", and the bell opens SP Approvals for a waiting invitation and the hygiene Service Providers page for an answered one.
|
|
9
|
+
|
|
10
|
+
## 4.65.3
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- dcb2632: Invitations list: a site app now lists only the current site's invitations.
|
|
15
|
+
`InvitationMain` sends the page's `siteId` to `GET /api/verifications` (it was
|
|
16
|
+
commented out, so every site showed the whole organisation's invitations - owner
|
|
17
|
+
report 2026-09-28, Wing Tai / The LakeGarden Residences). The org console sends
|
|
18
|
+
no site and is unchanged. The empty table now reads "No pending invitations for
|
|
19
|
+
<site>. Use Invite member to add someone." / "No expired invitations."
|
|
20
|
+
|
|
21
|
+
Members → Assign Role: a member's role from ANOTHER site (roleState other-site,
|
|
22
|
+
other-org, deleted, missing) is no longer pre-filled or offered ("Role already
|
|
23
|
+
assigned"); only this site's roles are listed. A site with no role says "No roles
|
|
24
|
+
created for <site> yet. Create one in Roles & Permissions." with a link that
|
|
25
|
+
returns to Members after the role is created (`?returnTo=`).
|
|
26
|
+
|
|
27
|
+
- 9e895e2: Visitor Management search box: waits 350 ms after typing and searches from 2 characters (trimmed), so a burst of typing, a paste or a scanner no longer leaves the table showing the first letter's results; a late reply for an older search is never shown; a new search goes back to page 1; clearing the box resets at once; the address-bar sync no longer puts back an older value over letters typed since; the empty table says "No visitors match '<search>'"; Download Report uses the same search as the table.
|
|
28
|
+
|
|
3
29
|
## 4.65.2
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
|
@@ -1773,11 +1799,11 @@
|
|
|
1773
1799
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1774
1800
|
own rule from two endpoints the console already calls, unprojected:
|
|
1775
1801
|
|
|
1776
|
-
|
|
1777
|
-
|
|
1802
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1803
|
+
GET /api/roles/id/:role that membership's role document
|
|
1778
1804
|
|
|
1779
|
-
|
|
1780
|
-
|
|
1805
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1806
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1781
1807
|
|
|
1782
1808
|
`role.default` is the marker because it is the only property of a platform
|
|
1783
1809
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
hide-default-footer
|
|
51
51
|
style="max-height: calc(100vh - (126px))"
|
|
52
52
|
:loading="loading"
|
|
53
|
+
:no-data-text="emptyText"
|
|
53
54
|
>
|
|
54
55
|
<template #item.permissions="{ value }">
|
|
55
56
|
<span class="text-caption font-weight-bold text-capitalize">
|
|
@@ -137,6 +138,9 @@
|
|
|
137
138
|
</template>
|
|
138
139
|
|
|
139
140
|
<script setup lang="ts">
|
|
141
|
+
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
142
|
+
import { invitationsEmptyText } from "../utils/invitations-empty-text";
|
|
143
|
+
|
|
140
144
|
/** The same two tabs, same labels, same routes. */
|
|
141
145
|
const invitationTabs = [
|
|
142
146
|
{ status: "pending", label: "Pending" },
|
|
@@ -260,7 +264,10 @@ const {
|
|
|
260
264
|
type: "member-invite",
|
|
261
265
|
app: props.app,
|
|
262
266
|
orgId: props.org,
|
|
263
|
-
//
|
|
267
|
+
// A site app lists only THIS site's invitations; the server checks the
|
|
268
|
+
// caller reaches it (owner report 2026-09-28: LakeGarden's invitations
|
|
269
|
+
// showed on every Wing Tai site). The org console passes no site.
|
|
270
|
+
siteId: props.siteId,
|
|
264
271
|
}),
|
|
265
272
|
{
|
|
266
273
|
watch: [() => props.status]
|
|
@@ -269,6 +276,22 @@ const {
|
|
|
269
276
|
|
|
270
277
|
const loading = computed(() => getVerificationsStatus.value === "pending");
|
|
271
278
|
|
|
279
|
+
// A plain empty state instead of "No data available" (QA sweep 2026-09-28).
|
|
280
|
+
const siteName = ref("");
|
|
281
|
+
if (props.siteId) {
|
|
282
|
+
useSite()
|
|
283
|
+
.getSiteById(props.siteId)
|
|
284
|
+
.then((site) => (siteName.value = site?.name ?? ""))
|
|
285
|
+
.catch(() => {});
|
|
286
|
+
}
|
|
287
|
+
const emptyText = computed(() =>
|
|
288
|
+
invitationsEmptyText({
|
|
289
|
+
status: props.status,
|
|
290
|
+
siteName: props.siteId ? siteName.value || "this site" : "",
|
|
291
|
+
canInvite: props.inviteMember,
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
|
|
272
295
|
watchEffect(() => {
|
|
273
296
|
if (getVerificationsReq.value) {
|
|
274
297
|
items.value = getVerificationsReq.value.items;
|
|
@@ -250,7 +250,11 @@
|
|
|
250
250
|
<v-col cols="12">
|
|
251
251
|
<v-row no-gutters>
|
|
252
252
|
<InputLabel class="text-capitalize" title="Name" required />
|
|
253
|
-
<v-col cols="12">
|
|
253
|
+
<v-col v-if="noSiteRoles" cols="12" class="py-2" data-test="assign-role-empty">
|
|
254
|
+
<p class="text-body-2 mb-2">{{ noSiteRolesText }}</p>
|
|
255
|
+
<NuxtLink v-if="rolesPageLink" :to="rolesPageLink">Go to Roles & Permissions</NuxtLink>
|
|
256
|
+
</v-col>
|
|
257
|
+
<v-col v-else cols="12">
|
|
254
258
|
<v-select
|
|
255
259
|
v-model="selectedRole"
|
|
256
260
|
:items="pickerRoles"
|
|
@@ -287,7 +291,7 @@
|
|
|
287
291
|
<v-btn
|
|
288
292
|
class="screen-btn-primary"
|
|
289
293
|
variant="flat"
|
|
290
|
-
:disabled="!memberRoleForm"
|
|
294
|
+
:disabled="!memberRoleForm || noSiteRoles"
|
|
291
295
|
type="submit"
|
|
292
296
|
>
|
|
293
297
|
Submit
|
|
@@ -300,7 +304,14 @@
|
|
|
300
304
|
</template>
|
|
301
305
|
|
|
302
306
|
<script setup lang="ts">
|
|
303
|
-
import {
|
|
307
|
+
import {
|
|
308
|
+
memberRoleChip,
|
|
309
|
+
noRolesForSiteText,
|
|
310
|
+
otherSiteBannerText,
|
|
311
|
+
roleUsableHere,
|
|
312
|
+
safeReturnTo,
|
|
313
|
+
withCurrentRole,
|
|
314
|
+
} from "../utils/role";
|
|
304
315
|
|
|
305
316
|
const props = defineProps({
|
|
306
317
|
/** The design's page title. Every app that mounts this screen calls it
|
|
@@ -391,6 +402,15 @@ const props = defineProps({
|
|
|
391
402
|
},
|
|
392
403
|
],
|
|
393
404
|
},
|
|
405
|
+
/**
|
|
406
|
+
* Tabs after Active / Inactive that the app renders itself on the same
|
|
407
|
+
* route (e.g. property management's "Service Providers"). Clicking one
|
|
408
|
+
* sets the route's `status` like the built-in tabs do.
|
|
409
|
+
*/
|
|
410
|
+
extraTabs: {
|
|
411
|
+
type: Array as PropType<Array<{ name: string; status: string }>>,
|
|
412
|
+
default: () => [],
|
|
413
|
+
},
|
|
394
414
|
});
|
|
395
415
|
|
|
396
416
|
const items = ref<Array<Record<string, any>>>([]);
|
|
@@ -431,6 +451,7 @@ const normalizedRouteParams = computed(() => {
|
|
|
431
451
|
const tabOptions = [
|
|
432
452
|
{ name: "Active", status: "active" },
|
|
433
453
|
{ name: "Inactive", status: "suspended" },
|
|
454
|
+
...props.extraTabs,
|
|
434
455
|
];
|
|
435
456
|
|
|
436
457
|
function toRoute(status: any) {
|
|
@@ -547,9 +568,14 @@ function setMember({
|
|
|
547
568
|
}
|
|
548
569
|
|
|
549
570
|
selectedMemberId.value = role._id;
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
571
|
+
// A role the member cannot use HERE (another site's, another organisation's,
|
|
572
|
+
// deleted, none) is neither pre-filled nor offered (owner report 2026-09-28,
|
|
573
|
+
// One Pearl Bank: The Loft's "Security" appeared as if created for this site).
|
|
574
|
+
const usable = roleUsableHere(role);
|
|
575
|
+
selectedRole.value = usable ? role.role : null;
|
|
576
|
+
selectedMemberRole.value = usable ? role.role : "";
|
|
577
|
+
selectedMemberRoleName.value = usable ? role.roleName ?? "" : "";
|
|
578
|
+
if (dialog) refreshRoles();
|
|
553
579
|
}
|
|
554
580
|
|
|
555
581
|
const roles = ref<Array<Record<string, any>>>([]);
|
|
@@ -593,7 +619,7 @@ const pickerRoles = computed(() =>
|
|
|
593
619
|
* filter would be decorative -- the same trap the server's own Redis key had.
|
|
594
620
|
*/
|
|
595
621
|
const { getRoles } = useRole();
|
|
596
|
-
const { data: getAllRoleReq } = useLazyAsyncData(
|
|
622
|
+
const { data: getAllRoleReq, refresh: refreshRoles } = useLazyAsyncData(
|
|
597
623
|
`get-roles-${props.type}-${props.orgId}-${props.siteId}`,
|
|
598
624
|
() =>
|
|
599
625
|
getRoles({
|
|
@@ -610,6 +636,33 @@ watchEffect(() => {
|
|
|
610
636
|
}
|
|
611
637
|
});
|
|
612
638
|
|
|
639
|
+
/* No role exists for this site yet: say so and point at Roles & Permissions,
|
|
640
|
+
which sends the user back here after creating one (`returnTo`). */
|
|
641
|
+
const noSiteRoles = computed(
|
|
642
|
+
() => !!props.siteId && !!getAllRoleReq.value && pickerRoles.value.length === 0
|
|
643
|
+
);
|
|
644
|
+
const assignSiteName = ref("");
|
|
645
|
+
if (props.siteId) {
|
|
646
|
+
useSite()
|
|
647
|
+
.getSiteById(props.siteId)
|
|
648
|
+
.then((site) => (assignSiteName.value = site?.name ?? ""))
|
|
649
|
+
.catch(() => {});
|
|
650
|
+
}
|
|
651
|
+
const noSiteRolesText = computed(() => noRolesForSiteText(assignSiteName.value));
|
|
652
|
+
const memberRoute = useRoute();
|
|
653
|
+
const rolesPageLink = computed(() => {
|
|
654
|
+
const here = memberRoute.path;
|
|
655
|
+
const path = here.replace(/\/members(\/.*)?$/, "/roles-permissions");
|
|
656
|
+
if (path === here) return "";
|
|
657
|
+
try {
|
|
658
|
+
return useRouter().resolve(path).matched.length
|
|
659
|
+
? `${path}?returnTo=${encodeURIComponent(here)}`
|
|
660
|
+
: "";
|
|
661
|
+
} catch {
|
|
662
|
+
return "";
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
613
666
|
watchEffect(() => {
|
|
614
667
|
if (selectedRole.value) {
|
|
615
668
|
message.value = "";
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<!--
|
|
4
|
+
Members > Service Providers (owner request 2026-09-28). The property
|
|
5
|
+
manager sees who from its service providers can open this site's
|
|
6
|
+
information. Read only: each provider manages its own people.
|
|
7
|
+
-->
|
|
8
|
+
<PageHeader
|
|
9
|
+
:title="props.title"
|
|
10
|
+
subtitle="People from your service providers who can open this site's information. Their company manages who is on this list."
|
|
11
|
+
>
|
|
12
|
+
<template #actions>
|
|
13
|
+
<AppButton
|
|
14
|
+
variant="ghost"
|
|
15
|
+
:loading="exporting"
|
|
16
|
+
:disabled="!visible.length"
|
|
17
|
+
data-test="sp-members-export"
|
|
18
|
+
@click="exportExcel"
|
|
19
|
+
>
|
|
20
|
+
Export to Excel
|
|
21
|
+
</AppButton>
|
|
22
|
+
</template>
|
|
23
|
+
</PageHeader>
|
|
24
|
+
|
|
25
|
+
<!-- One chip per provider company; a click filters to that company. -->
|
|
26
|
+
<div v-if="companies.length" class="sp-chips mb-3" data-test="sp-members-companies">
|
|
27
|
+
<button
|
|
28
|
+
v-for="c in companies"
|
|
29
|
+
:key="c.orgId"
|
|
30
|
+
type="button"
|
|
31
|
+
class="sp-chip"
|
|
32
|
+
:class="{ 'sp-chip--active': company === c.orgId }"
|
|
33
|
+
:aria-pressed="company === c.orgId"
|
|
34
|
+
@click="company = company === c.orgId ? '' : c.orgId"
|
|
35
|
+
>
|
|
36
|
+
<span class="sp-chip__name">{{ companyChipText(c.name, c.people) }}</span>
|
|
37
|
+
<span class="sp-chip__type">{{ c.typeLabel }}<template v-if="c.invitedVia"> · via {{ c.invitedVia }}</template></span>
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<AppCard class="table-card">
|
|
42
|
+
<CardToolbar :count="countText">
|
|
43
|
+
<template #tabs>
|
|
44
|
+
<button
|
|
45
|
+
v-for="tab in props.tabs"
|
|
46
|
+
:key="tab.status"
|
|
47
|
+
type="button"
|
|
48
|
+
role="tab"
|
|
49
|
+
class="app-tab"
|
|
50
|
+
:class="{ 'app-tab--active': tab.status === props.status }"
|
|
51
|
+
:aria-selected="tab.status === props.status"
|
|
52
|
+
@click="toTab(tab.status)"
|
|
53
|
+
>
|
|
54
|
+
{{ tab.name }}
|
|
55
|
+
</button>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<template #right>
|
|
59
|
+
<AppField
|
|
60
|
+
v-model="search"
|
|
61
|
+
search
|
|
62
|
+
compact
|
|
63
|
+
placeholder="Search name, email, company"
|
|
64
|
+
klass="app-toolbar__search"
|
|
65
|
+
/>
|
|
66
|
+
<v-select
|
|
67
|
+
v-model="type"
|
|
68
|
+
:items="typeItems"
|
|
69
|
+
item-title="title"
|
|
70
|
+
item-value="value"
|
|
71
|
+
density="compact"
|
|
72
|
+
variant="outlined"
|
|
73
|
+
hide-details
|
|
74
|
+
class="sp-type-filter"
|
|
75
|
+
aria-label="Service"
|
|
76
|
+
data-test="sp-members-type"
|
|
77
|
+
/>
|
|
78
|
+
<AppButton
|
|
79
|
+
variant="icon"
|
|
80
|
+
icon="mdi-refresh"
|
|
81
|
+
aria-label="Refresh"
|
|
82
|
+
@click="load()"
|
|
83
|
+
/>
|
|
84
|
+
</template>
|
|
85
|
+
</CardToolbar>
|
|
86
|
+
|
|
87
|
+
<div class="app-table-scroll">
|
|
88
|
+
<v-data-table
|
|
89
|
+
:headers="headers"
|
|
90
|
+
:items="visible"
|
|
91
|
+
item-value="_id"
|
|
92
|
+
:items-per-page="50"
|
|
93
|
+
fixed-header
|
|
94
|
+
:loading="loading"
|
|
95
|
+
style="max-height: calc(100vh - 320px)"
|
|
96
|
+
>
|
|
97
|
+
<template #item.name="{ item }">
|
|
98
|
+
<div class="font-weight-medium">{{ item.name || "-" }}</div>
|
|
99
|
+
<div class="sp-sub">{{ item.email }}</div>
|
|
100
|
+
</template>
|
|
101
|
+
<template #item.company="{ item }">
|
|
102
|
+
<div>{{ item.company || "-" }}</div>
|
|
103
|
+
<div v-if="item.invitedVia" class="sp-sub">Invited by {{ item.invitedVia }}</div>
|
|
104
|
+
</template>
|
|
105
|
+
<template #item.typeLabel="{ item }">
|
|
106
|
+
<StatusChip :dot="false" pill tone="info" :label="item.typeLabel" />
|
|
107
|
+
</template>
|
|
108
|
+
<template #item.role="{ item }">
|
|
109
|
+
{{ item.role || "No role" }}
|
|
110
|
+
</template>
|
|
111
|
+
<template #item.addedAt="{ item }">
|
|
112
|
+
{{ spDay(item.addedAt) || "-" }}
|
|
113
|
+
</template>
|
|
114
|
+
<template #item.status="{ item }">
|
|
115
|
+
<StatusChip
|
|
116
|
+
:tone="item.status === 'active' ? 'ok' : 'neutral'"
|
|
117
|
+
:label="item.status === 'active' ? 'Active' : 'Inactive'"
|
|
118
|
+
/>
|
|
119
|
+
</template>
|
|
120
|
+
<template #item.actions="{ item }">
|
|
121
|
+
<v-btn
|
|
122
|
+
v-if="mailFor(item)"
|
|
123
|
+
variant="text"
|
|
124
|
+
size="small"
|
|
125
|
+
class="text-none"
|
|
126
|
+
:href="mailFor(item)"
|
|
127
|
+
data-test="sp-members-email"
|
|
128
|
+
>
|
|
129
|
+
Email company
|
|
130
|
+
</v-btn>
|
|
131
|
+
</template>
|
|
132
|
+
<template #no-data>
|
|
133
|
+
<div class="pa-8 text-center text-body-2 text-medium-emphasis" data-test="sp-members-empty">
|
|
134
|
+
{{ emptyText }}
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
137
|
+
</v-data-table>
|
|
138
|
+
</div>
|
|
139
|
+
</AppCard>
|
|
140
|
+
|
|
141
|
+
<Snackbar v-model="snackbar" :text="message" color="error" />
|
|
142
|
+
</div>
|
|
143
|
+
</template>
|
|
144
|
+
|
|
145
|
+
<script setup lang="ts">
|
|
146
|
+
import type {
|
|
147
|
+
TSiteProviderCompany,
|
|
148
|
+
TSiteProviderMember,
|
|
149
|
+
} from "../composables/useServiceProvider";
|
|
150
|
+
import {
|
|
151
|
+
companyChipText,
|
|
152
|
+
concernMailto,
|
|
153
|
+
filterProviderMembers,
|
|
154
|
+
providerMembersEmptyText,
|
|
155
|
+
serviceTypeOptions,
|
|
156
|
+
} from "../utils/site-provider-members";
|
|
157
|
+
import { spDay } from "../utils/sp-inviter-row";
|
|
158
|
+
import { saveBlob } from "../composables/useInventory";
|
|
159
|
+
|
|
160
|
+
const props = defineProps({
|
|
161
|
+
title: { type: String, default: "Members" },
|
|
162
|
+
siteId: { type: String, default: "" },
|
|
163
|
+
siteName: { type: String, default: "" },
|
|
164
|
+
/** the page's current tab (route `status`) */
|
|
165
|
+
status: { type: String, default: "service-providers" },
|
|
166
|
+
/** the Members tabs, in order; clicking one changes the route's `status` */
|
|
167
|
+
tabs: {
|
|
168
|
+
type: Array as PropType<Array<{ name: string; status: string }>>,
|
|
169
|
+
default: () => [
|
|
170
|
+
{ name: "Active", status: "active" },
|
|
171
|
+
{ name: "Inactive", status: "suspended" },
|
|
172
|
+
{ name: "Service Providers", status: "service-providers" },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const { getSiteMembers, exportSiteMembers } = useServiceProvider();
|
|
178
|
+
const { getSiteById } = useSite();
|
|
179
|
+
// The site in words, for the export file name and the email subject.
|
|
180
|
+
const siteLabel = ref(props.siteName);
|
|
181
|
+
|
|
182
|
+
const items = ref<TSiteProviderMember[]>([]);
|
|
183
|
+
const companies = ref<TSiteProviderCompany[]>([]);
|
|
184
|
+
const loading = ref(false);
|
|
185
|
+
const exporting = ref(false);
|
|
186
|
+
const search = ref("");
|
|
187
|
+
const type = ref("");
|
|
188
|
+
const company = ref("");
|
|
189
|
+
const snackbar = ref(false);
|
|
190
|
+
const message = ref("");
|
|
191
|
+
|
|
192
|
+
const headers = [
|
|
193
|
+
{ title: "Name", key: "name", sortable: true, minWidth: "180px" },
|
|
194
|
+
{ title: "Company", key: "company", sortable: true, minWidth: "160px" },
|
|
195
|
+
{ title: "Service", key: "typeLabel", sortable: true },
|
|
196
|
+
{ title: "Role at this site", key: "role", sortable: true },
|
|
197
|
+
{ title: "Added", key: "addedAt", sortable: true },
|
|
198
|
+
{ title: "Status", key: "status", sortable: true },
|
|
199
|
+
{ title: "", key: "actions", sortable: false, align: "end" as const },
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
const typeItems = computed(() => [
|
|
203
|
+
{ title: "All services", value: "" },
|
|
204
|
+
...serviceTypeOptions(items.value),
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const filters = computed(() => ({
|
|
208
|
+
type: type.value,
|
|
209
|
+
company: company.value,
|
|
210
|
+
search: search.value,
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
const visible = computed(() => filterProviderMembers(items.value, filters.value));
|
|
214
|
+
|
|
215
|
+
const countText = computed(() =>
|
|
216
|
+
loading.value && !items.value.length
|
|
217
|
+
? ""
|
|
218
|
+
: `${visible.value.length} of ${items.value.length}`,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const emptyText = computed(() =>
|
|
222
|
+
providerMembersEmptyText({
|
|
223
|
+
companies: companies.value.length,
|
|
224
|
+
total: items.value.length,
|
|
225
|
+
filtered: Boolean(type.value || company.value || search.value.trim()),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
function mailFor(item: TSiteProviderMember) {
|
|
230
|
+
const c = companies.value.find((x) => x.orgId === item.companyId);
|
|
231
|
+
return concernMailto({
|
|
232
|
+
companyEmail: c?.email,
|
|
233
|
+
person: item.name || item.email,
|
|
234
|
+
siteName: siteLabel.value,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function load() {
|
|
239
|
+
if (!props.siteId) return;
|
|
240
|
+
loading.value = true;
|
|
241
|
+
try {
|
|
242
|
+
const data = await getSiteMembers(props.siteId);
|
|
243
|
+
items.value = Array.isArray(data?.items) ? data.items : [];
|
|
244
|
+
companies.value = Array.isArray(data?.companies) ? data.companies : [];
|
|
245
|
+
} catch (error: any) {
|
|
246
|
+
items.value = [];
|
|
247
|
+
companies.value = [];
|
|
248
|
+
message.value = error?.data?.message || "Could not load your service providers' people.";
|
|
249
|
+
snackbar.value = true;
|
|
250
|
+
} finally {
|
|
251
|
+
loading.value = false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function exportExcel() {
|
|
256
|
+
exporting.value = true;
|
|
257
|
+
try {
|
|
258
|
+
const blob = await exportSiteMembers(props.siteId, filters.value);
|
|
259
|
+
saveBlob(blob, `${siteLabel.value || "Site"} - service provider people.xlsx`);
|
|
260
|
+
} catch {
|
|
261
|
+
message.value = "Could not export the list.";
|
|
262
|
+
snackbar.value = true;
|
|
263
|
+
} finally {
|
|
264
|
+
exporting.value = false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const route = useRoute();
|
|
269
|
+
function toTab(status: string) {
|
|
270
|
+
if (status === props.status) return;
|
|
271
|
+
navigateTo({
|
|
272
|
+
name: route.name as string,
|
|
273
|
+
params: { ...(route.params as Record<string, string>), status },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
watch(() => props.siteId, load);
|
|
278
|
+
onMounted(async () => {
|
|
279
|
+
load();
|
|
280
|
+
if (!siteLabel.value && props.siteId) {
|
|
281
|
+
const site: any = await getSiteById(props.siteId).catch(() => null);
|
|
282
|
+
siteLabel.value = site?.name ?? "";
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
</script>
|
|
286
|
+
|
|
287
|
+
<style scoped>
|
|
288
|
+
.sp-chips {
|
|
289
|
+
display: flex;
|
|
290
|
+
flex-wrap: wrap;
|
|
291
|
+
gap: 8px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.sp-chip {
|
|
295
|
+
display: flex;
|
|
296
|
+
flex-direction: column;
|
|
297
|
+
align-items: flex-start;
|
|
298
|
+
padding: 8px 12px;
|
|
299
|
+
border: 1px solid var(--border);
|
|
300
|
+
border-radius: var(--r-inner);
|
|
301
|
+
background: var(--card);
|
|
302
|
+
color: var(--text);
|
|
303
|
+
text-align: left;
|
|
304
|
+
cursor: pointer;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.sp-chip--active {
|
|
308
|
+
border-color: var(--accent-strong);
|
|
309
|
+
box-shadow: inset 0 0 0 1px var(--accent-strong);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.sp-chip__name {
|
|
313
|
+
font-weight: 600;
|
|
314
|
+
font-size: 14px;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.sp-chip__type,
|
|
318
|
+
.sp-sub {
|
|
319
|
+
font-size: 12px;
|
|
320
|
+
color: var(--muted);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.sp-type-filter {
|
|
324
|
+
min-width: 170px;
|
|
325
|
+
max-width: 220px;
|
|
326
|
+
}
|
|
327
|
+
</style>
|
|
@@ -204,7 +204,7 @@
|
|
|
204
204
|
|
|
205
205
|
<script setup lang="ts">
|
|
206
206
|
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
207
|
-
import { showSiteField } from "../utils/role";
|
|
207
|
+
import { safeReturnTo, showSiteField } from "../utils/role";
|
|
208
208
|
import { holdsPermission } from "../utils/permission-spellings";
|
|
209
209
|
import {
|
|
210
210
|
catalogueForOrg,
|
|
@@ -509,6 +509,10 @@ const siteFieldShown = computed(() =>
|
|
|
509
509
|
function success() {
|
|
510
510
|
createDialog.value = false;
|
|
511
511
|
getRoles();
|
|
512
|
+
// Came here from Members -> Assign Role with no role for the site: go back to
|
|
513
|
+
// finish assigning (same-app path only).
|
|
514
|
+
const back = safeReturnTo(useRoute().query.returnTo);
|
|
515
|
+
if (back) navigateTo(back);
|
|
512
516
|
}
|
|
513
517
|
|
|
514
518
|
const previewDialog = ref(false);
|
|
@@ -106,6 +106,10 @@
|
|
|
106
106
|
:class="{ 'sp-muted': item.companyKnown === false }"
|
|
107
107
|
>{{ item.companyName }}</span>
|
|
108
108
|
<div v-if="item.contactPhone" class="sp-sub">{{ item.contactPhone }}</div>
|
|
109
|
+
<!-- A subcontractor another provider brought onto this site. -->
|
|
110
|
+
<div v-if="item.invitedVia" class="sp-sub" data-test="sp-invited-via">
|
|
111
|
+
Invited by {{ item.invitedVia }}
|
|
112
|
+
</div>
|
|
109
113
|
</template>
|
|
110
114
|
<template #item.invited="{ item }">
|
|
111
115
|
<span class="d-block text-truncate">{{ item.invitedLine || "-" }}</span>
|
|
@@ -631,7 +635,7 @@
|
|
|
631
635
|
<template v-if="!existingAccount">
|
|
632
636
|
<v-col cols="12">
|
|
633
637
|
<v-row no-gutters>
|
|
634
|
-
<InputLabel class="text-capitalize" title="
|
|
638
|
+
<InputLabel class="text-capitalize" title="Service" required />
|
|
635
639
|
<v-col cols="12">
|
|
636
640
|
<v-select
|
|
637
641
|
v-model="serviceProviderInvite.app"
|
|
@@ -667,6 +671,12 @@
|
|
|
667
671
|
</v-col>
|
|
668
672
|
</template>
|
|
669
673
|
|
|
674
|
+
<v-col cols="12">
|
|
675
|
+
<div class="sp-sub" data-test="sp-invite-approval-note">
|
|
676
|
+
Seven365 approves every invitation before the provider receives it.
|
|
677
|
+
</div>
|
|
678
|
+
</v-col>
|
|
679
|
+
|
|
670
680
|
<!-- Error message -->
|
|
671
681
|
<v-col cols="12" class="my-2">
|
|
672
682
|
<v-row no-gutters>
|
|
@@ -752,6 +762,8 @@ type TableRow = {
|
|
|
752
762
|
/** shown under a "Not approved" row, in Seven365's own words */
|
|
753
763
|
rejectionReason?: string;
|
|
754
764
|
siteName?: string;
|
|
765
|
+
/** a subcontractor: the provider that brought it onto this site */
|
|
766
|
+
invitedVia?: string;
|
|
755
767
|
};
|
|
756
768
|
|
|
757
769
|
type ServiceProviderMemberRow = {
|
|
@@ -1259,6 +1271,7 @@ const providerLines = computed<Array<[string, string]>>(() => {
|
|
|
1259
1271
|
["Email", row.contactEmail || row.email],
|
|
1260
1272
|
];
|
|
1261
1273
|
if (row.contactPhone) lines.push(["Phone", row.contactPhone]);
|
|
1274
|
+
if (row.invitedVia) lines.push(["Invited by", row.invitedVia]);
|
|
1262
1275
|
if (row.source === "invite") {
|
|
1263
1276
|
lines.push(["Invited", row.invitedLine || "-"]);
|
|
1264
1277
|
if (row.answeredAt) lines.push(["Accepted", spDay(row.answeredAt)]);
|
|
@@ -1337,6 +1350,7 @@ function mapProviderRows(raw: Record<string, any>[]): TableRow[] {
|
|
|
1337
1350
|
members: formatMembersCell(row.members),
|
|
1338
1351
|
status: statusDisplay(row.status ?? "active", "provider"),
|
|
1339
1352
|
source: row.source === "engagement" ? "engagement" : "provider",
|
|
1353
|
+
invitedVia: String(row.invitedVia ?? ""),
|
|
1340
1354
|
}));
|
|
1341
1355
|
}
|
|
1342
1356
|
|
|
@@ -85,7 +85,8 @@
|
|
|
85
85
|
<v-text-field
|
|
86
86
|
v-model="searchInput"
|
|
87
87
|
density="compact"
|
|
88
|
-
placeholder="
|
|
88
|
+
placeholder="Name, plate, phone, unit..."
|
|
89
|
+
aria-label="Search visitors"
|
|
89
90
|
clearable
|
|
90
91
|
max-width="300"
|
|
91
92
|
append-inner-icon="mdi-magnify"
|
|
@@ -592,6 +593,12 @@
|
|
|
592
593
|
>
|
|
593
594
|
</span>
|
|
594
595
|
</template>
|
|
596
|
+
<template #no-data>
|
|
597
|
+
<div class="table-card__empty">
|
|
598
|
+
<v-icon icon="mdi-magnify-close" size="32" />
|
|
599
|
+
<span>{{ visitorSearchEmptyText(searchQuery) }}</span>
|
|
600
|
+
</div>
|
|
601
|
+
</template>
|
|
595
602
|
</TableMain>
|
|
596
603
|
|
|
597
604
|
<v-dialog v-model="dialog.showSelection" width="450" persistent>
|
|
@@ -1455,6 +1462,11 @@
|
|
|
1455
1462
|
<script setup lang="ts">
|
|
1456
1463
|
import moment from "moment-timezone";
|
|
1457
1464
|
import QRCode from "qrcode";
|
|
1465
|
+
import {
|
|
1466
|
+
VISITOR_SEARCH_DEBOUNCE_MS,
|
|
1467
|
+
visitorSearchEmptyText,
|
|
1468
|
+
visitorSearchQuery,
|
|
1469
|
+
} from "../utils/visitor-search";
|
|
1458
1470
|
|
|
1459
1471
|
|
|
1460
1472
|
const { downloadPDF } = usePDFDownload();
|
|
@@ -1648,6 +1660,20 @@ watch(hidQrDialog, (open) => {
|
|
|
1648
1660
|
|
|
1649
1661
|
//filter states
|
|
1650
1662
|
const searchInput = ref("");
|
|
1663
|
+
// What is actually searched: the box, trimmed, from 2 characters, sent
|
|
1664
|
+
// VISITOR_SEARCH_DEBOUNCE_MS after the last keystroke (cleared at once). The
|
|
1665
|
+
// list used to refetch on every keystroke, and a burst of typing (or a paste,
|
|
1666
|
+
// or a scanner) could leave the table showing the FIRST letter's results.
|
|
1667
|
+
const searchQuery = ref("");
|
|
1668
|
+
// Reads the box when it fires, so a clear that lands inside the wait is not
|
|
1669
|
+
// overwritten by the text that was there before it.
|
|
1670
|
+
const applySearch = debounce(() => {
|
|
1671
|
+
searchQuery.value = visitorSearchQuery(searchInput.value);
|
|
1672
|
+
}, VISITOR_SEARCH_DEBOUNCE_MS);
|
|
1673
|
+
watch(searchInput, (value) => {
|
|
1674
|
+
if (!visitorSearchQuery(value)) searchQuery.value = "";
|
|
1675
|
+
else applySearch();
|
|
1676
|
+
});
|
|
1651
1677
|
|
|
1652
1678
|
const today = new Date();
|
|
1653
1679
|
const oneMonthAgo = new Date();
|
|
@@ -2154,7 +2180,7 @@ const {
|
|
|
2154
2180
|
const params: any = {
|
|
2155
2181
|
page: page.value,
|
|
2156
2182
|
site: siteId,
|
|
2157
|
-
search:
|
|
2183
|
+
search: searchQuery.value,
|
|
2158
2184
|
dateTo: dateTo.value
|
|
2159
2185
|
? moment(dateTo.value).endOf("day").toISOString()
|
|
2160
2186
|
: undefined,
|
|
@@ -2195,7 +2221,9 @@ const {
|
|
|
2195
2221
|
// }
|
|
2196
2222
|
|
|
2197
2223
|
try {
|
|
2198
|
-
|
|
2224
|
+
const res = await getVisitors(params);
|
|
2225
|
+
// Remember which search this answers, so a late reply is not shown.
|
|
2226
|
+
return res ? { ...res, _search: params.search } : res;
|
|
2199
2227
|
} catch (error: any) {
|
|
2200
2228
|
// Refused by the server: say so with its own message, hide the tab and
|
|
2201
2229
|
// go back to Registered - no empty "No data available" table.
|
|
@@ -2212,7 +2240,7 @@ const {
|
|
|
2212
2240
|
{
|
|
2213
2241
|
watch: [
|
|
2214
2242
|
page,
|
|
2215
|
-
|
|
2243
|
+
searchQuery,
|
|
2216
2244
|
dateFrom,
|
|
2217
2245
|
dateTo,
|
|
2218
2246
|
filterTypes,
|
|
@@ -2224,6 +2252,11 @@ const {
|
|
|
2224
2252
|
);
|
|
2225
2253
|
|
|
2226
2254
|
watch(getVisitorReq, (newData: any) => {
|
|
2255
|
+
// An answer for an older search: ask again for the current one.
|
|
2256
|
+
if (newData && newData._search !== searchQuery.value) {
|
|
2257
|
+
getVisitorRefresh();
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2227
2260
|
if (newData) {
|
|
2228
2261
|
items.value = newData.items ?? [];
|
|
2229
2262
|
pages.value = newData.pages ?? 0;
|
|
@@ -2722,7 +2755,7 @@ async function handleNfcCheckout() {
|
|
|
2722
2755
|
function buildReportParams(): any {
|
|
2723
2756
|
const params: any = {
|
|
2724
2757
|
site: siteId,
|
|
2725
|
-
search:
|
|
2758
|
+
search: searchQuery.value,
|
|
2726
2759
|
dateTo: dateTo.value
|
|
2727
2760
|
? moment(dateTo.value).endOf("day").toISOString()
|
|
2728
2761
|
: undefined,
|
|
@@ -2766,7 +2799,7 @@ async function handleDownloadPDFReport() {
|
|
|
2766
2799
|
|
|
2767
2800
|
// Build URL with query parameters
|
|
2768
2801
|
const queryParams = new URLSearchParams();
|
|
2769
|
-
if (
|
|
2802
|
+
if (searchQuery.value) queryParams.append("search", searchQuery.value);
|
|
2770
2803
|
if (dateFrom.value) queryParams.append("dateFrom", dateFrom.value);
|
|
2771
2804
|
if (dateTo.value) queryParams.append("dateTo", dateTo.value);
|
|
2772
2805
|
if (filterTypes.value.length > 0)
|
|
@@ -2915,7 +2948,7 @@ const updateRouteQuery = debounce(
|
|
|
2915
2948
|
);
|
|
2916
2949
|
|
|
2917
2950
|
watch(
|
|
2918
|
-
[
|
|
2951
|
+
[searchQuery, dateFrom, dateTo, filterTypes, activeTab, displayNotCheckedOut],
|
|
2919
2952
|
([search, from, to, types, tab, checkedOut]) => {
|
|
2920
2953
|
const normalizedFrom = normalizeDateOnly(from);
|
|
2921
2954
|
const normalizedTo = normalizeDateOnly(to);
|
|
@@ -2935,7 +2968,7 @@ watch(
|
|
|
2935
2968
|
},
|
|
2936
2969
|
{ deep: true }
|
|
2937
2970
|
);
|
|
2938
|
-
watch([
|
|
2971
|
+
watch([searchQuery, dateFrom, dateTo, filterTypes, activeTab], () => {
|
|
2939
2972
|
page.value = 1;
|
|
2940
2973
|
});
|
|
2941
2974
|
|
|
@@ -2955,9 +2988,13 @@ const syncVisitorFiltersFromRoute = () => {
|
|
|
2955
2988
|
activeTab.value = nextTab;
|
|
2956
2989
|
}
|
|
2957
2990
|
|
|
2958
|
-
|
|
2959
|
-
|
|
2991
|
+
// Compared with the SEARCHED text, not the box: the route only ever holds
|
|
2992
|
+
// what was searched, so comparing with the box put back an older value over
|
|
2993
|
+
// letters typed since. A different value is an outside link (e.g. ?search=).
|
|
2994
|
+
const nextSearch = visitorSearchQuery(route.query.search);
|
|
2995
|
+
if (searchQuery.value !== nextSearch) {
|
|
2960
2996
|
searchInput.value = nextSearch;
|
|
2997
|
+
searchQuery.value = nextSearch;
|
|
2961
2998
|
}
|
|
2962
2999
|
|
|
2963
3000
|
if (route.query.dateFrom) {
|
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
/** One person from a provider company with a membership at this site. */
|
|
2
|
+
export type TSiteProviderMember = {
|
|
3
|
+
_id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
email: string;
|
|
6
|
+
companyId: string;
|
|
7
|
+
company: string;
|
|
8
|
+
/** the company's service code (e.g. "security_agency") */
|
|
9
|
+
type: string;
|
|
10
|
+
typeLabel: string;
|
|
11
|
+
role: string;
|
|
12
|
+
status: string;
|
|
13
|
+
addedAt?: string | null;
|
|
14
|
+
/** a subcontractor: the provider that brought its company onto the site */
|
|
15
|
+
invitedVia?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TSiteProviderCompany = {
|
|
19
|
+
orgId: string;
|
|
20
|
+
name: string;
|
|
21
|
+
type: string;
|
|
22
|
+
typeLabel: string;
|
|
23
|
+
email?: string;
|
|
24
|
+
since?: string | null;
|
|
25
|
+
invitedVia?: string;
|
|
26
|
+
people: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
1
29
|
export default function useServiceProvider() {
|
|
2
30
|
const serviceProviders = useState<Array<TServiceProvider>>(
|
|
3
31
|
"serviceProviders",
|
|
@@ -259,7 +287,38 @@ export default function useServiceProvider() {
|
|
|
259
287
|
);
|
|
260
288
|
}
|
|
261
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Members > Service Providers: the provider people with access to this
|
|
292
|
+
* site, and one entry per provider company with its head count. The
|
|
293
|
+
* site's own organisation only - the server refuses anyone else.
|
|
294
|
+
*/
|
|
295
|
+
function getSiteMembers(siteId: string): Promise<{
|
|
296
|
+
items: TSiteProviderMember[];
|
|
297
|
+
companies: TSiteProviderCompany[];
|
|
298
|
+
}> {
|
|
299
|
+
return useNuxtApp().$api("/api/service-providers/site-members", {
|
|
300
|
+
method: "GET",
|
|
301
|
+
query: { siteId },
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The same list as an Excel file, under the same filters. */
|
|
306
|
+
function exportSiteMembers(
|
|
307
|
+
siteId: string,
|
|
308
|
+
filters: { type?: string; company?: string; search?: string } = {},
|
|
309
|
+
): Promise<Blob> {
|
|
310
|
+
const query: Record<string, string> = { siteId, format: "xlsx" };
|
|
311
|
+
for (const [k, v] of Object.entries(filters)) if (v) query[k] = v;
|
|
312
|
+
return useNuxtApp().$api<Blob>("/api/service-providers/site-members", {
|
|
313
|
+
method: "GET",
|
|
314
|
+
query,
|
|
315
|
+
responseType: "blob",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
262
319
|
return {
|
|
320
|
+
getSiteMembers,
|
|
321
|
+
exportSiteMembers,
|
|
263
322
|
serviceProviders,
|
|
264
323
|
serviceProvider,
|
|
265
324
|
serviceProviderCategories,
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.66.0",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Invitations table's empty state (QA sweep 2026-09-28), e.g.
|
|
3
|
+
* "No pending invitations for The LakeGarden Residences. Use Invite member to
|
|
4
|
+
* add someone." / "No expired invitations."
|
|
5
|
+
*/
|
|
6
|
+
export function invitationsEmptyText({
|
|
7
|
+
status = "pending",
|
|
8
|
+
siteName = "",
|
|
9
|
+
canInvite = true,
|
|
10
|
+
}: { status?: string; siteName?: string; canInvite?: boolean } = {}): string {
|
|
11
|
+
if (status === "expired") return "No expired invitations.";
|
|
12
|
+
const where = siteName ? ` for ${siteName}` : "";
|
|
13
|
+
return `No pending invitations${where}.${canInvite ? " Use Invite member to add someone." : ""}`;
|
|
14
|
+
}
|
|
@@ -182,9 +182,8 @@ const SITE_PAGES: Record<string, TSitePage[]> = {
|
|
|
182
182
|
{ apps: ["security"], page: () => "virtual-patrol/cctv" },
|
|
183
183
|
{ apps: ["property-management"], page: () => "cctv" },
|
|
184
184
|
],
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
],
|
|
185
|
+
// hygiene has its Service Providers page too since 2026-09-28
|
|
186
|
+
serviceProviderInviteAnswer: [{ apps: SERVICE_APPS, page: () => "service-providers" }],
|
|
188
187
|
};
|
|
189
188
|
SITE_PAGES.myVisitors = SITE_PAGES.visitors;
|
|
190
189
|
|
|
@@ -196,6 +195,7 @@ export const WEB_TARGET_KINDS = [
|
|
|
196
195
|
"robot",
|
|
197
196
|
"client",
|
|
198
197
|
"clientInvitation",
|
|
198
|
+
"spApproval",
|
|
199
199
|
];
|
|
200
200
|
|
|
201
201
|
function sitePageTarget(
|
|
@@ -221,6 +221,10 @@ function sitePageTarget(
|
|
|
221
221
|
function staffTarget(kind: string, item: TTargetRow): TNotificationTarget | null {
|
|
222
222
|
const id = String(item.params?.id ?? "").trim();
|
|
223
223
|
if (kind === "robot") return { candidates: [{ app: "org", path: "/super-admin/robots" }] };
|
|
224
|
+
// A service-provider invitation waiting in SP Approvals.
|
|
225
|
+
if (kind === "spApproval") {
|
|
226
|
+
return { candidates: [{ app: "org", path: "/super-admin/sp-approvals" }] };
|
|
227
|
+
}
|
|
224
228
|
if (kind === "clientInvitation") {
|
|
225
229
|
return { candidates: [{ app: "org", path: "/super-admin/client-list" }] };
|
|
226
230
|
}
|
package/utils/role.ts
CHANGED
|
@@ -73,3 +73,24 @@ export function otherSiteBannerText(count: unknown): string | null {
|
|
|
73
73
|
? "1 member here has a role from another site. Assign them a role for this site."
|
|
74
74
|
: `${n} members here have a role from another site. Assign each one a role for this site.`;
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Can this member's CURRENT role be used on the screen's site? The server's
|
|
79
|
+
* `roleState` is empty for a usable role and names the problem otherwise
|
|
80
|
+
* ("other-site", "other-org", "deleted", "missing"). An unusable role is never
|
|
81
|
+
* pre-filled in Assign Role nor offered in its list (owner report 2026-09-28).
|
|
82
|
+
*/
|
|
83
|
+
export function roleUsableHere(member: { role?: unknown; roleState?: unknown }): boolean {
|
|
84
|
+
return !!member?.role && !member?.roleState;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Assign Role's empty state when the site has no role of its own yet. */
|
|
88
|
+
export function noRolesForSiteText(siteName?: string | null): string {
|
|
89
|
+
return `No roles created for ${siteName || "this site"} yet. Create one in Roles & Permissions.`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A same-app path to return to (`?returnTo=`), or "" - never another origin. */
|
|
93
|
+
export function safeReturnTo(value: unknown): string {
|
|
94
|
+
const path = Array.isArray(value) ? value[0] : value;
|
|
95
|
+
return typeof path === "string" && /^\/(?!\/)[^\s\\]*$/.test(path) ? path : "";
|
|
96
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Members > Service Providers (owner request 2026-09-28): the property
|
|
3
|
+
* manager sees every person from its service providers who can open this
|
|
4
|
+
* site's information. Pure helpers, so the screen's wording and filters are
|
|
5
|
+
* tested without mounting it. The server applies the same filter rule to the
|
|
6
|
+
* Excel export (`filterSiteProviderMembers` in core).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type TProviderMemberRow = {
|
|
10
|
+
name?: string;
|
|
11
|
+
email?: string;
|
|
12
|
+
company?: string;
|
|
13
|
+
companyId?: string;
|
|
14
|
+
type?: string;
|
|
15
|
+
role?: string;
|
|
16
|
+
invitedVia?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type TProviderMemberFilters = {
|
|
20
|
+
type?: string;
|
|
21
|
+
company?: string;
|
|
22
|
+
search?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function filterProviderMembers<T extends TProviderMemberRow>(
|
|
26
|
+
items: T[],
|
|
27
|
+
{ type = "", company = "", search = "" }: TProviderMemberFilters,
|
|
28
|
+
): T[] {
|
|
29
|
+
const needle = search.trim().toLowerCase();
|
|
30
|
+
return items.filter(
|
|
31
|
+
(m) =>
|
|
32
|
+
(!type || m.type === type) &&
|
|
33
|
+
(!company || m.companyId === company) &&
|
|
34
|
+
(!needle ||
|
|
35
|
+
[m.name, m.email, m.company, m.role, m.invitedVia]
|
|
36
|
+
.map((v) => String(v ?? "").toLowerCase())
|
|
37
|
+
.some((v) => v.includes(needle))),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "Aigle Security · 12 people" */
|
|
42
|
+
export function companyChipText(name: string, people: number): string {
|
|
43
|
+
return `${name || "Unnamed company"} · ${people} ${people === 1 ? "person" : "people"}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What an empty table says: nothing on site, nobody added yet, or the filters. */
|
|
47
|
+
export function providerMembersEmptyText({
|
|
48
|
+
companies,
|
|
49
|
+
total,
|
|
50
|
+
filtered,
|
|
51
|
+
}: {
|
|
52
|
+
companies: number;
|
|
53
|
+
total: number;
|
|
54
|
+
filtered: boolean;
|
|
55
|
+
}): string {
|
|
56
|
+
if (!companies) return "No service providers work at this site yet.";
|
|
57
|
+
if (!total) return "Your service providers have not given anyone access to this site yet.";
|
|
58
|
+
if (filtered) return "No one matches these filters.";
|
|
59
|
+
return "No one to show.";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The service types present, for the filter, in a stable order. */
|
|
63
|
+
export function serviceTypeOptions(
|
|
64
|
+
items: Array<{ type?: string; typeLabel?: string }>,
|
|
65
|
+
): Array<{ title: string; value: string }> {
|
|
66
|
+
const seen = new Map<string, string>();
|
|
67
|
+
for (const m of items) {
|
|
68
|
+
const value = String(m.type ?? "");
|
|
69
|
+
if (value && !seen.has(value)) seen.set(value, String(m.typeLabel || value));
|
|
70
|
+
}
|
|
71
|
+
return [...seen.entries()]
|
|
72
|
+
.map(([value, title]) => ({ title, value }))
|
|
73
|
+
.sort((a, b) => a.title.localeCompare(b.title));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* "Email the company" about one person: a mail to the provider company,
|
|
78
|
+
* naming the person and the site. Nothing is changed in the app - the
|
|
79
|
+
* provider manages its own people.
|
|
80
|
+
*/
|
|
81
|
+
export function concernMailto({
|
|
82
|
+
companyEmail,
|
|
83
|
+
person,
|
|
84
|
+
siteName,
|
|
85
|
+
}: {
|
|
86
|
+
companyEmail?: string;
|
|
87
|
+
person: string;
|
|
88
|
+
siteName?: string;
|
|
89
|
+
}): string {
|
|
90
|
+
const to = String(companyEmail ?? "").trim();
|
|
91
|
+
if (!to) return "";
|
|
92
|
+
const where = siteName ? ` at ${siteName}` : "";
|
|
93
|
+
const subject = `About ${person}${where}`;
|
|
94
|
+
const body = `Hello,\n\nWe would like to talk about ${person}'s access${where}.\n\n`;
|
|
95
|
+
return `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visitor Management search box rules (VisitorManagement.vue). The server does
|
|
3
|
+
* the matching (name, plate, phone, NRIC, company, purpose, block/unit - see
|
|
4
|
+
* @7365admin1/core visitor-search.util); this only decides WHEN to ask.
|
|
5
|
+
* Browser-safe: no Node globals. Tested by utils/visitor-search.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Characters needed before a search is sent; shorter means "no search". */
|
|
9
|
+
export const VISITOR_SEARCH_MIN_LENGTH = 2;
|
|
10
|
+
|
|
11
|
+
/** Wait after the last keystroke before searching. */
|
|
12
|
+
export const VISITOR_SEARCH_DEBOUNCE_MS = 350;
|
|
13
|
+
|
|
14
|
+
/** The text to send for what is in the box: trimmed, or "" when too short. */
|
|
15
|
+
export function visitorSearchQuery(raw: unknown): string {
|
|
16
|
+
const text = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
|
|
17
|
+
return text.length >= VISITOR_SEARCH_MIN_LENGTH ? text : "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The empty-table sentence: names the search when there is one. */
|
|
21
|
+
export function visitorSearchEmptyText(query: string): string {
|
|
22
|
+
return query ? `No visitors match '${query}'` : "No data available";
|
|
23
|
+
}
|