@7365admin1/layer-common 4.37.0 → 4.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -4
- package/components/ClientInvitations.vue +259 -0
- package/components/NotificationBell.vue +15 -4
- package/components/ServiceProviderMain.vue +5 -2
- package/composables/usePropertyPermission.ts +17 -0
- package/composables/useSecurityPermission.ts +19 -0
- package/composables/useServiceProviderInvitation.ts +62 -2
- package/package.json +1 -1
- package/plugins/API.ts +11 -0
- package/utils/notification-target.ts +54 -1
- package/components/ServiceProviderInvitationPrompt.vue +0 -155
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.39.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3f37bf3: Client invitations: a dedicated module for a service-provider company to answer invitations from its clients (Pending / Accepted / Declined / Expired, plain-language status on every card, Accept / Decline only while waiting). Accepting adds the site to the company on screen. The bell's invitation row opens it. The inviting site's list now also shows accepted invitations. Removes the combined prompt from the member-invitations page.
|
|
8
|
+
|
|
9
|
+
## 4.38.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 59ce7ed: Passes & Keys permissions and the membership the app is working in (DV-0409).
|
|
14
|
+
|
|
15
|
+
- Passes And Keys Management: new `add-passes-and-keys`, `update-passes-and-keys` and `delete-passes-and-keys` permissions in both the Security and the Property Management role catalogues (`useSecurityPermission`, `usePropertyPermission`). API-core now enforces them on `/api/keys` and `/api/qr-code-templates`; "See All Passes And Keys" is what shows the Templates and Passes & Keys pages.
|
|
16
|
+
- `plugins/API.ts` sends `X-App` (this app's member type, from `runtimeConfig.public.APP`) and `X-Org` (the organisation in the address) with every request, so core checks the role of the membership the screen is using when a person holds several memberships at one site. Only chooses among the user's own memberships; grants nothing. A server that does not read them is unaffected.
|
|
17
|
+
|
|
18
|
+
- 27c17b7: Service-provider invitations can be answered in the app: the notification bell opens "<Company> has invited you to <site>" on the organisation app's Invitations page (other apps send the click there by URL), the new `ServiceProviderInvitationsReceived` section lists invitations from other organisations with Accept / Decline, `useServiceProviderInvitation` gains `acceptInvitation` and `getReceivedInvitations`, and the invitation card shows a failed accept or decline instead of swallowing it.
|
|
19
|
+
|
|
3
20
|
## 4.37.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
|
@@ -1430,11 +1447,11 @@
|
|
|
1430
1447
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1431
1448
|
own rule from two endpoints the console already calls, unprojected:
|
|
1432
1449
|
|
|
1433
|
-
|
|
1434
|
-
|
|
1450
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1451
|
+
GET /api/roles/id/:role that membership's role document
|
|
1435
1452
|
|
|
1436
|
-
|
|
1437
|
-
|
|
1453
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1454
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1438
1455
|
|
|
1439
1456
|
`role.default` is the marker because it is the only property of a platform
|
|
1440
1457
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<PageHeader
|
|
4
|
+
title="Client invitations"
|
|
5
|
+
subtitle="Other organisations asking your company to provide services at their sites."
|
|
6
|
+
/>
|
|
7
|
+
|
|
8
|
+
<AppCard class="table-card">
|
|
9
|
+
<CardToolbar>
|
|
10
|
+
<template #tabs>
|
|
11
|
+
<button
|
|
12
|
+
v-for="t in tabs"
|
|
13
|
+
:key="t.value"
|
|
14
|
+
type="button"
|
|
15
|
+
role="tab"
|
|
16
|
+
class="app-tab"
|
|
17
|
+
:class="{ 'app-tab--active': tab === t.value }"
|
|
18
|
+
:aria-selected="tab === t.value"
|
|
19
|
+
@click="tab = t.value"
|
|
20
|
+
>
|
|
21
|
+
{{ t.label }}<span v-if="counts[t.value]"> ({{ counts[t.value] }})</span>
|
|
22
|
+
</button>
|
|
23
|
+
</template>
|
|
24
|
+
</CardToolbar>
|
|
25
|
+
|
|
26
|
+
<div class="pa-4 d-flex flex-column ga-3">
|
|
27
|
+
<div v-if="loading" class="screen-hint">Loading…</div>
|
|
28
|
+
<div v-else-if="loadError" class="text-error" role="alert">{{ loadError }}</div>
|
|
29
|
+
<div v-else-if="!items.length" class="screen-hint">{{ emptyLine }}</div>
|
|
30
|
+
|
|
31
|
+
<AppCard
|
|
32
|
+
v-for="item in items"
|
|
33
|
+
:key="item._id"
|
|
34
|
+
class="client-invite"
|
|
35
|
+
:class="{ 'client-invite--linked': item._id === linked }"
|
|
36
|
+
>
|
|
37
|
+
<div class="d-flex align-start ga-3 pa-4">
|
|
38
|
+
<v-avatar color="primary" variant="tonal" size="44">
|
|
39
|
+
<v-icon>mdi-office-building-marker-outline</v-icon>
|
|
40
|
+
</v-avatar>
|
|
41
|
+
<div class="flex-grow-1">
|
|
42
|
+
<div class="screen-card-title">
|
|
43
|
+
{{ item.orgName }} has invited your company to {{ item.siteName }}
|
|
44
|
+
</div>
|
|
45
|
+
<div class="screen-hint client-invite__meta">
|
|
46
|
+
<span v-if="item.service">Services: {{ serviceLabel(item.service) }} · </span>
|
|
47
|
+
<span v-if="item.invitedByName">Invited by {{ item.invitedByName }} · </span>
|
|
48
|
+
<span>Sent {{ day(item.createdAt) }}</span>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<StatusChip :status="item.status" :label="item.statusLabel" />
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="px-4 pb-4 client-invite__body">
|
|
55
|
+
{{ stateLine(item) }}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<!-- Buttons only while the invitation is waiting for an answer. -->
|
|
59
|
+
<div v-if="item.status === 'pending'" class="d-flex align-center ga-2 px-4 pb-4">
|
|
60
|
+
<v-btn
|
|
61
|
+
variant="flat"
|
|
62
|
+
class="screen-btn-primary"
|
|
63
|
+
:loading="working[item._id] === 'accept'"
|
|
64
|
+
:disabled="Boolean(working[item._id])"
|
|
65
|
+
@click="answer(item, 'accept')"
|
|
66
|
+
>
|
|
67
|
+
Accept
|
|
68
|
+
</v-btn>
|
|
69
|
+
<v-btn
|
|
70
|
+
variant="outlined"
|
|
71
|
+
class="screen-btn-ghost"
|
|
72
|
+
:loading="working[item._id] === 'decline'"
|
|
73
|
+
:disabled="Boolean(working[item._id])"
|
|
74
|
+
@click="answer(item, 'decline')"
|
|
75
|
+
>
|
|
76
|
+
Decline
|
|
77
|
+
</v-btn>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div v-else-if="item.status === 'complete'" class="px-4 pb-4">
|
|
81
|
+
<NuxtLink v-if="item.onThisOrg" :to="`/org/${org}/sites`">Open Sites</NuxtLink>
|
|
82
|
+
<NuxtLink
|
|
83
|
+
v-else-if="item.providerOrgId && item.providerOrgId !== org"
|
|
84
|
+
:to="`/org/${item.providerOrgId}/sites`"
|
|
85
|
+
>
|
|
86
|
+
Open {{ item.providerOrgName || "that company" }}'s Sites
|
|
87
|
+
</NuxtLink>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div v-if="errors[item._id]" class="px-4 pb-4 text-error client-invite__body" role="alert">
|
|
91
|
+
{{ errors[item._id] }}
|
|
92
|
+
</div>
|
|
93
|
+
</AppCard>
|
|
94
|
+
</div>
|
|
95
|
+
</AppCard>
|
|
96
|
+
</div>
|
|
97
|
+
</template>
|
|
98
|
+
|
|
99
|
+
<script setup lang="ts">
|
|
100
|
+
/**
|
|
101
|
+
* "Client invitations" (owner request 2026-09-24): where a service-provider
|
|
102
|
+
* company answers invitations from its clients, in four tabs, with one plain
|
|
103
|
+
* sentence per card saying what happened. Accept / Decline appear only while
|
|
104
|
+
* an invitation is waiting - an answered one has no buttons at all.
|
|
105
|
+
*
|
|
106
|
+
* Accepting adds the site to the company ON SCREEN (`org`), which is what makes
|
|
107
|
+
* it appear on this company's Sites list and in the service app's site
|
|
108
|
+
* switcher. The server refuses a company the person is not a member of.
|
|
109
|
+
*/
|
|
110
|
+
import type { TReceivedServiceProviderInvitation } from "../composables/useServiceProviderInvitation";
|
|
111
|
+
|
|
112
|
+
const props = defineProps({
|
|
113
|
+
/** The organisation on screen - the company that accepts. */
|
|
114
|
+
org: { type: String, required: true },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
type Tab = "pending" | "accepted" | "declined" | "expired";
|
|
118
|
+
|
|
119
|
+
const tabs: Array<{ value: Tab; label: string }> = [
|
|
120
|
+
{ value: "pending", label: "Pending" },
|
|
121
|
+
{ value: "accepted", label: "Accepted" },
|
|
122
|
+
{ value: "declined", label: "Declined" },
|
|
123
|
+
{ value: "expired", label: "Expired" },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const route = useRoute();
|
|
127
|
+
const { getReceivedInvitations, getInvitation, acceptInvitation, declineInvitation } =
|
|
128
|
+
useServiceProviderInvitation();
|
|
129
|
+
|
|
130
|
+
const tab = ref<Tab>("pending");
|
|
131
|
+
const items = ref<TReceivedServiceProviderInvitation[]>([]);
|
|
132
|
+
const counts = ref<Record<string, number>>({});
|
|
133
|
+
const loading = ref(false);
|
|
134
|
+
const loadError = ref("");
|
|
135
|
+
const working = reactive<Record<string, string>>({});
|
|
136
|
+
const errors = reactive<Record<string, string>>({});
|
|
137
|
+
|
|
138
|
+
/** The invitation the bell linked to (`?invite=<id>`), outlined. */
|
|
139
|
+
const linked = computed(() => {
|
|
140
|
+
const value = String(route.query?.invite ?? "").trim();
|
|
141
|
+
return /^[a-f0-9]{24}$/i.test(value) ? value : "";
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const emptyLine = computed(
|
|
145
|
+
() =>
|
|
146
|
+
({
|
|
147
|
+
pending: "Nothing is waiting for your answer.",
|
|
148
|
+
accepted: "You have not accepted any invitations yet.",
|
|
149
|
+
declined: "You have not declined any invitations.",
|
|
150
|
+
expired: "No expired or withdrawn invitations.",
|
|
151
|
+
})[tab.value],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
function day(value?: string | null) {
|
|
155
|
+
if (!value) return "";
|
|
156
|
+
const d = new Date(value);
|
|
157
|
+
return Number.isNaN(d.getTime())
|
|
158
|
+
? ""
|
|
159
|
+
: d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** " on 24 Sep 2026", or nothing when the date is unknown. */
|
|
163
|
+
function on(value?: string | null) {
|
|
164
|
+
const text = day(value);
|
|
165
|
+
return text ? ` on ${text}` : "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function serviceLabel(value: string) {
|
|
169
|
+
return value.replace(/[-_]/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One sentence: where the invitation stands and what, if anything, to do. */
|
|
173
|
+
function stateLine(item: TReceivedServiceProviderInvitation) {
|
|
174
|
+
switch (item.status) {
|
|
175
|
+
case "pending":
|
|
176
|
+
return `Waiting for you. Accepting adds ${item.siteName} to this company's Sites list${
|
|
177
|
+
item.expireAt ? ` — please answer by ${day(item.expireAt)}` : ""
|
|
178
|
+
}.`;
|
|
179
|
+
case "complete":
|
|
180
|
+
if (item.onThisOrg) {
|
|
181
|
+
return `Accepted${on(item.answeredAt)} — ${item.siteName} is on your Sites list as a site you serve for ${item.orgName}.`;
|
|
182
|
+
}
|
|
183
|
+
if (!item.providerOrgId || item.providerOrgId === props.org) {
|
|
184
|
+
return `Accepted${on(item.answeredAt)}, but ${item.siteName} is not on this company's Sites list. Please contact Seven365 support so we can add it.`;
|
|
185
|
+
}
|
|
186
|
+
return `Accepted${on(item.answeredAt)} for ${
|
|
187
|
+
item.providerOrgName || "another of your companies"
|
|
188
|
+
} — the site is on that company's Sites list, not this one's.`;
|
|
189
|
+
case "declined":
|
|
190
|
+
return `Declined${on(item.answeredAt)}. ${item.orgName} has been told.`;
|
|
191
|
+
case "cancelled":
|
|
192
|
+
return `${item.orgName} withdrew this invitation${on(item.answeredAt)}.`;
|
|
193
|
+
default:
|
|
194
|
+
return `This invitation expired${on(item.expireAt)} without an answer. Ask ${item.orgName} to send a new one if you still want this site.`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function load() {
|
|
199
|
+
loading.value = true;
|
|
200
|
+
loadError.value = "";
|
|
201
|
+
try {
|
|
202
|
+
const res = await getReceivedInvitations({ org: props.org, status: tab.value });
|
|
203
|
+
items.value = Array.isArray(res?.items) ? res.items : [];
|
|
204
|
+
counts.value = res?.counts ?? {};
|
|
205
|
+
} catch (err: any) {
|
|
206
|
+
items.value = [];
|
|
207
|
+
loadError.value = err?.data?.message || "Could not load your client invitations. Please try again.";
|
|
208
|
+
} finally {
|
|
209
|
+
loading.value = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function answer(item: TReceivedServiceProviderInvitation, action: "accept" | "decline") {
|
|
214
|
+
errors[item._id] = "";
|
|
215
|
+
working[item._id] = action;
|
|
216
|
+
try {
|
|
217
|
+
if (action === "accept") await acceptInvitation(item._id, props.org);
|
|
218
|
+
else await declineInvitation(item._id);
|
|
219
|
+
// Follow the invitation to the tab it now belongs to (the tab watcher
|
|
220
|
+
// reloads the list).
|
|
221
|
+
tab.value = action === "accept" ? "accepted" : "declined";
|
|
222
|
+
} catch (err: any) {
|
|
223
|
+
errors[item._id] =
|
|
224
|
+
err?.data?.message ||
|
|
225
|
+
(action === "accept" ? "Could not accept this invitation." : "Could not decline this invitation.");
|
|
226
|
+
} finally {
|
|
227
|
+
working[item._id] = "";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Opened from the bell: start on the tab the linked invitation is in.
|
|
232
|
+
if (linked.value) {
|
|
233
|
+
try {
|
|
234
|
+
const one: any = await getInvitation(linked.value, props.org);
|
|
235
|
+
if (one?.tab && tabs.some((t) => t.value === one.tab)) tab.value = one.tab;
|
|
236
|
+
} catch {
|
|
237
|
+
// Not ours or gone: the page still lists the rest.
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
await load();
|
|
241
|
+
watch(tab, load);
|
|
242
|
+
</script>
|
|
243
|
+
|
|
244
|
+
<style scoped>
|
|
245
|
+
.client-invite--linked {
|
|
246
|
+
outline: 2px solid rgb(var(--v-theme-primary));
|
|
247
|
+
outline-offset: 2px;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.client-invite :deep(.screen-card-title) {
|
|
251
|
+
display: block;
|
|
252
|
+
line-height: 1.35;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.client-invite__meta,
|
|
256
|
+
.client-invite__body {
|
|
257
|
+
font-size: var(--fs-cell);
|
|
258
|
+
}
|
|
259
|
+
</style>
|
|
@@ -134,7 +134,7 @@ import {
|
|
|
134
134
|
timeAgo,
|
|
135
135
|
type TInboxItem,
|
|
136
136
|
} from "../utils/notification-inbox";
|
|
137
|
-
import { notificationTarget } from "../utils/notification-target";
|
|
137
|
+
import { externalTargetUrl, notificationTarget } from "../utils/notification-target";
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
140
|
* The top-bar notification feed for the web, in every signed-in app.
|
|
@@ -168,6 +168,7 @@ const { getAll, getUnreadCount, markAsRead, markAllAsRead } = useNotification();
|
|
|
168
168
|
const { currentUser } = useLocalAuth();
|
|
169
169
|
const route = useRoute();
|
|
170
170
|
const router = useRouter();
|
|
171
|
+
const appConfig = useRuntimeConfig();
|
|
171
172
|
|
|
172
173
|
const open = ref(false);
|
|
173
174
|
const loading = ref(false);
|
|
@@ -305,14 +306,24 @@ function markRead(item: Record<string, any>) {
|
|
|
305
306
|
*/
|
|
306
307
|
async function goToSubject(item: Record<string, any>) {
|
|
307
308
|
const target = notificationTarget(item as any, {
|
|
308
|
-
|
|
309
|
+
// The org app nests under `organization`; the service apps under `org`.
|
|
310
|
+
organization: String(route.params?.organization ?? route.params?.org ?? ""),
|
|
309
311
|
});
|
|
310
312
|
if (!target) return;
|
|
311
|
-
|
|
313
|
+
|
|
314
|
+
if (typeof router.hasRoute !== "function" || !router.hasRoute(target.name)) {
|
|
315
|
+
// The page lives in another web app (the invitation is answered in the
|
|
316
|
+
// organisation app): go there by URL, or stay put if it is not configured.
|
|
317
|
+
const href = externalTargetUrl(target, { org: String(appConfig?.public?.APP_ORG ?? "") });
|
|
318
|
+
if (!href) return;
|
|
319
|
+
open.value = false;
|
|
320
|
+
window.location.assign(href);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
312
323
|
|
|
313
324
|
open.value = false;
|
|
314
325
|
try {
|
|
315
|
-
await router.push(target);
|
|
326
|
+
await router.push({ name: target.name, params: target.params, query: target.query });
|
|
316
327
|
} catch {
|
|
317
328
|
// A cancelled or duplicate navigation is not worth an error in the top bar.
|
|
318
329
|
}
|
|
@@ -827,7 +827,10 @@ const listTab = ref<ListTab>("active");
|
|
|
827
827
|
/** The same three tabs, in the same order, with the same labels. */
|
|
828
828
|
const listTabs: Array<{ value: ListTab; label: string }> = [
|
|
829
829
|
{ value: "active", label: "Service Providers" },
|
|
830
|
-
|
|
830
|
+
// Every invitation this site sent, whatever became of it - including the
|
|
831
|
+
// accepted ones, so the client can see "Accepted" and not just a row that
|
|
832
|
+
// vanished (owner request 2026-09-24).
|
|
833
|
+
{ value: "pending", label: "Invitations" },
|
|
831
834
|
{ value: "inactive", label: "In-Active" },
|
|
832
835
|
];
|
|
833
836
|
const page = ref(1);
|
|
@@ -1292,7 +1295,7 @@ async function loadList() {
|
|
|
1292
1295
|
const data = await getInvitations({
|
|
1293
1296
|
org: props.orgId,
|
|
1294
1297
|
status:
|
|
1295
|
-
"awaiting-approval,pending,rejected,declined,cancelled,expired",
|
|
1298
|
+
"awaiting-approval,pending,complete,rejected,declined,cancelled,expired",
|
|
1296
1299
|
page: page.value,
|
|
1297
1300
|
search: searchText.value?.trim() || "",
|
|
1298
1301
|
limit: pageSize,
|
|
@@ -579,6 +579,23 @@ export default function usePropertyPermission() {
|
|
|
579
579
|
description:
|
|
580
580
|
"Allows the user to view the list of all passes and keys on mobile. Checked by the iMA365 mobile app.",
|
|
581
581
|
},
|
|
582
|
+
// DV-0396: the same three writes Security offers, enforced by API-core on
|
|
583
|
+
// /api/keys and /api/qr-code-templates for every app that calls them.
|
|
584
|
+
"add-passes-and-keys": {
|
|
585
|
+
check: true,
|
|
586
|
+
description:
|
|
587
|
+
"Allows the user to add pass templates and to create or generate passes and keys.",
|
|
588
|
+
},
|
|
589
|
+
"update-passes-and-keys": {
|
|
590
|
+
check: true,
|
|
591
|
+
description:
|
|
592
|
+
"Allows the user to edit pass templates, passes and keys, restore them, and change a pass or key back to Available.",
|
|
593
|
+
},
|
|
594
|
+
"delete-passes-and-keys": {
|
|
595
|
+
check: true,
|
|
596
|
+
description:
|
|
597
|
+
"Allows the user to delete pass templates, passes and keys.",
|
|
598
|
+
},
|
|
582
599
|
},
|
|
583
600
|
"online-forms": {
|
|
584
601
|
"see-all-online-forms": {
|
|
@@ -669,12 +669,31 @@ export default function useSecurityPermission() {
|
|
|
669
669
|
},
|
|
670
670
|
// Property's descriptions minus its "Checked by the iMA365 mobile app."
|
|
671
671
|
// sentence, which is not true of this application.
|
|
672
|
+
// Add / update / delete (DV-0396), also offered by Property Management and
|
|
673
|
+
// enforced by API-core on /api/keys and /api/qr-code-templates. The security web app's
|
|
674
|
+
// Templates and Passes & Keys pages and the guard app's pass & key screen
|
|
675
|
+
// read them. See All is also what shows those two web pages.
|
|
672
676
|
"passes-and-keys-mgmt": {
|
|
673
677
|
"see-all-passes-and-keys": {
|
|
674
678
|
check: true,
|
|
675
679
|
description:
|
|
676
680
|
"Allows the user to view the list of all passes and keys on mobile.",
|
|
677
681
|
},
|
|
682
|
+
"add-passes-and-keys": {
|
|
683
|
+
check: true,
|
|
684
|
+
description:
|
|
685
|
+
"Allows the user to add pass templates and to create or generate passes and keys.",
|
|
686
|
+
},
|
|
687
|
+
"update-passes-and-keys": {
|
|
688
|
+
check: true,
|
|
689
|
+
description:
|
|
690
|
+
"Allows the user to edit pass templates, passes and keys, restore them, and change a pass or key back to Available.",
|
|
691
|
+
},
|
|
692
|
+
"delete-passes-and-keys": {
|
|
693
|
+
check: true,
|
|
694
|
+
description:
|
|
695
|
+
"Allows the user to delete pass templates, passes and keys.",
|
|
696
|
+
},
|
|
678
697
|
},
|
|
679
698
|
"resident-transactions": {
|
|
680
699
|
"see-all-resident-transactions": {
|
|
@@ -26,6 +26,30 @@ export type TServiceProviderInvitation = {
|
|
|
26
26
|
}>;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
/** One card of "Client invitations" (the service provider's side). */
|
|
30
|
+
export type TReceivedServiceProviderInvitation = {
|
|
31
|
+
_id: string;
|
|
32
|
+
/** The client organisation that sent it. */
|
|
33
|
+
orgName: string;
|
|
34
|
+
siteName: string;
|
|
35
|
+
siteId?: string;
|
|
36
|
+
/** The service asked for, when the sender recorded one (e.g. "security"). */
|
|
37
|
+
service?: string;
|
|
38
|
+
invitedByName?: string;
|
|
39
|
+
createdAt?: string;
|
|
40
|
+
expireAt?: string;
|
|
41
|
+
/** When it was accepted, declined or withdrawn. */
|
|
42
|
+
answeredAt?: string | null;
|
|
43
|
+
/** pending | complete | declined | expired | cancelled */
|
|
44
|
+
status: string;
|
|
45
|
+
statusLabel: string;
|
|
46
|
+
/** The company that accepted it (whose Sites list gained the site). */
|
|
47
|
+
providerOrgId?: string;
|
|
48
|
+
providerOrgName?: string;
|
|
49
|
+
/** Accepted AND on the Sites list of the company on screen. */
|
|
50
|
+
onThisOrg?: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
29
53
|
/** The words on screen. Kept beside the server's own list on purpose — a
|
|
30
54
|
* status the server invents must never render as a raw stored value. */
|
|
31
55
|
export const SERVICE_PROVIDER_INVITE_LABELS: Record<string, string> = {
|
|
@@ -120,8 +144,14 @@ export default function useServiceProviderInvitation() {
|
|
|
120
144
|
|
|
121
145
|
// ---- the invited service provider ----------------------------------------
|
|
122
146
|
|
|
123
|
-
function getInvitation(
|
|
124
|
-
|
|
147
|
+
function getInvitation(
|
|
148
|
+
id: string,
|
|
149
|
+
org?: string,
|
|
150
|
+
): Promise<TReceivedServiceProviderInvitation & { tab?: string | null }> {
|
|
151
|
+
return useNuxtApp().$api(`${base}/${id}`, {
|
|
152
|
+
method: "GET",
|
|
153
|
+
query: org ? { org } : {},
|
|
154
|
+
});
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
function declineInvitation(id: string) {
|
|
@@ -130,6 +160,34 @@ export default function useServiceProviderInvitation() {
|
|
|
130
160
|
});
|
|
131
161
|
}
|
|
132
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Adds the site to `org`'s Sites list - the company on screen, which the
|
|
165
|
+
* server checks the invited account belongs to.
|
|
166
|
+
*/
|
|
167
|
+
function acceptInvitation(id: string, org?: string) {
|
|
168
|
+
return useNuxtApp().$api<{ message: string }>(`${base}/${id}/accept`, {
|
|
169
|
+
method: "PUT",
|
|
170
|
+
body: org ? { org } : {},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* "Client invitations": one tab of the invitations sent to the signed-in
|
|
176
|
+
* account, plus the count in every tab. `org` is the company on screen.
|
|
177
|
+
*/
|
|
178
|
+
function getReceivedInvitations({
|
|
179
|
+
org,
|
|
180
|
+
status = "pending",
|
|
181
|
+
}: { org?: string; status?: string } = {}): Promise<{
|
|
182
|
+
items: TReceivedServiceProviderInvitation[];
|
|
183
|
+
counts?: Record<string, number>;
|
|
184
|
+
}> {
|
|
185
|
+
return useNuxtApp().$api(`${base}/received`, {
|
|
186
|
+
method: "GET",
|
|
187
|
+
query: { status, ...(org ? { org } : {}) },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
133
191
|
return {
|
|
134
192
|
getInvitations,
|
|
135
193
|
cancelInvitation,
|
|
@@ -141,5 +199,7 @@ export default function useServiceProviderInvitation() {
|
|
|
141
199
|
rejectInvitation,
|
|
142
200
|
getInvitation,
|
|
143
201
|
declineInvitation,
|
|
202
|
+
acceptInvitation,
|
|
203
|
+
getReceivedInvitations,
|
|
144
204
|
};
|
|
145
205
|
}
|
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.39.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.",
|
package/plugins/API.ts
CHANGED
|
@@ -11,6 +11,17 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
11
11
|
onRequest({ options }) {
|
|
12
12
|
const sid = useCookie("sid", cookieConfig).value ?? "";
|
|
13
13
|
options.headers.set("Authorization", sid);
|
|
14
|
+
// Which of the user's memberships this screen is working in (DV-0396):
|
|
15
|
+
// this app's member type and the organisation in the address. With
|
|
16
|
+
// several memberships pinned to one site, the server then checks the
|
|
17
|
+
// same role the screen was drawn from (core `requireSiteReach`). Only
|
|
18
|
+
// chooses among the user's own memberships; it grants nothing.
|
|
19
|
+
const app = useRuntimeConfig().public.APP;
|
|
20
|
+
if (app) options.headers.set("X-App", String(app));
|
|
21
|
+
const org = (nuxtApp as any).$router?.currentRoute?.value?.params?.org;
|
|
22
|
+
if (typeof org === "string" && /^[0-9a-f]{24}$/i.test(org)) {
|
|
23
|
+
options.headers.set("X-Org", org);
|
|
24
|
+
}
|
|
14
25
|
},
|
|
15
26
|
// "You do not have permission to ..." - the role changed since the page
|
|
16
27
|
// read it. Re-read it in the background (plugins/secure-member.client.ts)
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
/** Only the fields a destination is decided from. */
|
|
32
32
|
export type TTargetRow = {
|
|
33
33
|
module?: string;
|
|
34
|
-
|
|
34
|
+
screen?: string;
|
|
35
|
+
params?: { id?: string; org?: string } | null;
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
/** What the surrounding app knows that the stored row does not. */
|
|
@@ -43,6 +44,13 @@ export type TTargetContext = {
|
|
|
43
44
|
export type TNotificationTarget = {
|
|
44
45
|
name: string;
|
|
45
46
|
params: Record<string, string>;
|
|
47
|
+
query?: Record<string, string>;
|
|
48
|
+
/**
|
|
49
|
+
* Set when the page lives in another web app. The bell opens
|
|
50
|
+
* `<that app's URL><path>` when its own router has no route by `name`.
|
|
51
|
+
*/
|
|
52
|
+
app?: "org";
|
|
53
|
+
path?: string;
|
|
46
54
|
};
|
|
47
55
|
|
|
48
56
|
/** Nuxt's generated names for `pages/org/[organization]/marketplace/orders/`. */
|
|
@@ -52,12 +60,42 @@ export const ORDER_DETAIL_ROUTE = "org-organization-marketplace-orders-id";
|
|
|
52
60
|
/** The stored `module` tag on a row sent to a vendor's staff. */
|
|
53
61
|
export const VENDOR_ORDER_MODULE = "marketplace-vendor-order";
|
|
54
62
|
|
|
63
|
+
/**
|
|
64
|
+
* "<Company> has invited you to <site>" — core `serviceProviderInvited` stores
|
|
65
|
+
* `module: "other"` (the apps route on it; it never changes) with this
|
|
66
|
+
* `screen`, the invitation id in `params.id`, and, since 2026-09-24, the
|
|
67
|
+
* invited company's own organisation in `params.org`.
|
|
68
|
+
*
|
|
69
|
+
* It opens the organisation app's Invitations page, where "Invitations from
|
|
70
|
+
* other organisations" lists it with Accept / Decline. Only the organisation
|
|
71
|
+
* app has that page; every other app sends the click there by URL.
|
|
72
|
+
*/
|
|
73
|
+
export const SP_INVITATION_SCREEN = "/service-provider-invitation";
|
|
74
|
+
export const INVITATIONS_ROUTE = "org-organization-client-invitations";
|
|
75
|
+
|
|
55
76
|
export function notificationTarget(
|
|
56
77
|
item: TTargetRow | null | undefined,
|
|
57
78
|
context: TTargetContext = {},
|
|
58
79
|
): TNotificationTarget | null {
|
|
59
80
|
const organization = String(context.organization ?? "").trim();
|
|
60
81
|
|
|
82
|
+
if (String(item?.screen ?? "") === SP_INVITATION_SCREEN) {
|
|
83
|
+
// The invited company's organisation when the row names it (the invitation
|
|
84
|
+
// belongs there), otherwise the one on screen. The list on that page is
|
|
85
|
+
// keyed on the signed-in account, so either shows the invitation.
|
|
86
|
+
const org = String(item?.params?.org ?? "").trim() || organization;
|
|
87
|
+
if (!org) return null;
|
|
88
|
+
const id = String(item?.params?.id ?? "").trim();
|
|
89
|
+
const query: Record<string, string> = id ? { invite: id } : {};
|
|
90
|
+
return {
|
|
91
|
+
name: INVITATIONS_ROUTE,
|
|
92
|
+
params: { organization: org },
|
|
93
|
+
query,
|
|
94
|
+
app: "org",
|
|
95
|
+
path: `/org/${encodeURIComponent(org)}/client-invitations`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
61
99
|
if (String(item?.module ?? "") !== VENDOR_ORDER_MODULE) return null;
|
|
62
100
|
|
|
63
101
|
// Every order route is nested under the organization. Without one there is no
|
|
@@ -73,3 +111,18 @@ export function notificationTarget(
|
|
|
73
111
|
|
|
74
112
|
return { name: ORDER_DETAIL_ROUTE, params: { organization, id } };
|
|
75
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The full URL for a target whose page lives in another web app, or "" when
|
|
117
|
+
* there is none to build (no path, or that app's URL is not configured).
|
|
118
|
+
*/
|
|
119
|
+
export function externalTargetUrl(
|
|
120
|
+
target: TNotificationTarget | null | undefined,
|
|
121
|
+
appUrls: { org?: string } = {},
|
|
122
|
+
): string {
|
|
123
|
+
if (!target?.app || !target.path) return "";
|
|
124
|
+
const base = String(appUrls[target.app] ?? "").trim().replace(/\/+$/, "");
|
|
125
|
+
if (!base) return "";
|
|
126
|
+
const qs = new URLSearchParams(target.query ?? {}).toString();
|
|
127
|
+
return `${base}${target.path}${qs ? `?${qs}` : ""}`;
|
|
128
|
+
}
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<!-- `AppCard` is the design's card - 14px radius, a 1px `--border` rule and
|
|
3
|
-
the one-pixel shadow on `--card`. The `v-card rounded="lg"
|
|
4
|
-
variant="outlined"` it replaced was Vuetify's own card at its own
|
|
5
|
-
radius and border weight. -->
|
|
6
|
-
<AppCard v-if="invitation" class="sp-invite-prompt">
|
|
7
|
-
<div class="d-flex align-start ga-3 pa-4">
|
|
8
|
-
<v-avatar color="primary" variant="tonal" size="44">
|
|
9
|
-
<v-icon>mdi-office-building-marker-outline</v-icon>
|
|
10
|
-
</v-avatar>
|
|
11
|
-
<div class="flex-grow-1">
|
|
12
|
-
<div class="screen-card-title">
|
|
13
|
-
{{ invitation.orgName }} has invited you to {{ invitation.siteName }}
|
|
14
|
-
</div>
|
|
15
|
-
<div class="screen-hint sp-invite-prompt__sub">
|
|
16
|
-
Approved by Seven365 · {{ expiryLine }}
|
|
17
|
-
</div>
|
|
18
|
-
</div>
|
|
19
|
-
</div>
|
|
20
|
-
|
|
21
|
-
<div class="px-4 sp-invite-prompt__body">
|
|
22
|
-
{{ invitation.orgName }} would like your company to provide services at
|
|
23
|
-
<strong>{{ invitation.siteName }}</strong
|
|
24
|
-
>. If you accept, this site will be added to your list of sites.
|
|
25
|
-
</div>
|
|
26
|
-
|
|
27
|
-
<div class="d-flex align-center ga-2 px-4 py-4">
|
|
28
|
-
<!-- Both stay `v-btn`s: `loading` is the only signal that an accept or a
|
|
29
|
-
decline is in flight, and `AppButton` has no equivalent. They take
|
|
30
|
-
the design's two button classes instead. -->
|
|
31
|
-
<v-btn
|
|
32
|
-
variant="flat"
|
|
33
|
-
class="screen-btn-primary"
|
|
34
|
-
:loading="working === 'accept'"
|
|
35
|
-
:disabled="Boolean(working) || !open"
|
|
36
|
-
@click="accept"
|
|
37
|
-
>
|
|
38
|
-
Accept
|
|
39
|
-
</v-btn>
|
|
40
|
-
<v-btn
|
|
41
|
-
variant="outlined"
|
|
42
|
-
class="screen-btn-ghost"
|
|
43
|
-
:loading="working === 'decline'"
|
|
44
|
-
:disabled="Boolean(working) || !open"
|
|
45
|
-
@click="decline"
|
|
46
|
-
>
|
|
47
|
-
Decline
|
|
48
|
-
</v-btn>
|
|
49
|
-
<v-spacer />
|
|
50
|
-
<!-- Was a raw `v-chip` on Vuetify's fixed `success` green, or no colour
|
|
51
|
-
at all for every other outcome. `StatusChip` maps the status WORD
|
|
52
|
-
through the shared `utils/status.ts` tones, so "complete" and
|
|
53
|
-
"declined" come out the same colours they do everywhere else. -->
|
|
54
|
-
<StatusChip
|
|
55
|
-
v-if="!open"
|
|
56
|
-
:status="invitation.status"
|
|
57
|
-
:label="invitation.statusLabel"
|
|
58
|
-
/>
|
|
59
|
-
</div>
|
|
60
|
-
</AppCard>
|
|
61
|
-
|
|
62
|
-
<AppCard v-else-if="error">
|
|
63
|
-
<div class="pa-4 sp-invite-prompt__body">{{ error }}</div>
|
|
64
|
-
</AppCard>
|
|
65
|
-
</template>
|
|
66
|
-
|
|
67
|
-
<script setup lang="ts">
|
|
68
|
-
/**
|
|
69
|
-
* The invitation as the service provider sees it — inside the app they already
|
|
70
|
-
* use, from the session they already have.
|
|
71
|
-
*
|
|
72
|
-
* There is deliberately no sign-up and no one-time code anywhere on this
|
|
73
|
-
* screen: a company that already exists on iService365 never creates a second
|
|
74
|
-
* account. The email they receive only carries them here.
|
|
75
|
-
*
|
|
76
|
-
* An invitation that has not been approved by Seven365 is not readable at all —
|
|
77
|
-
* the server answers "not found" — so this component cannot show one.
|
|
78
|
-
*/
|
|
79
|
-
const props = defineProps({
|
|
80
|
-
invitationId: { type: String, required: true },
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const emit = defineEmits<{ (e: "answered", status: string): void }>();
|
|
84
|
-
|
|
85
|
-
const { getInvitation, declineInvitation } = useServiceProviderInvitation();
|
|
86
|
-
const { addViaInvite } = useCustomerSite();
|
|
87
|
-
|
|
88
|
-
const invitation = ref<any>(null);
|
|
89
|
-
const error = ref("");
|
|
90
|
-
const working = ref("");
|
|
91
|
-
|
|
92
|
-
const open = computed(() => invitation.value?.status === "pending");
|
|
93
|
-
|
|
94
|
-
const expiryLine = computed(() => {
|
|
95
|
-
const expires = invitation.value?.expireAt;
|
|
96
|
-
if (!expires) return "";
|
|
97
|
-
const days = Math.max(
|
|
98
|
-
0,
|
|
99
|
-
Math.ceil((new Date(expires).getTime() - Date.now()) / 86400000),
|
|
100
|
-
);
|
|
101
|
-
if (!open.value) return "no longer waiting for an answer";
|
|
102
|
-
return days <= 1 ? "expires within a day" : `expires in ${days} days`;
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
async function load() {
|
|
106
|
-
try {
|
|
107
|
-
invitation.value = await getInvitation(props.invitationId);
|
|
108
|
-
} catch (err: any) {
|
|
109
|
-
error.value =
|
|
110
|
-
err?.data?.message || "This invitation is no longer available.";
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function accept() {
|
|
115
|
-
working.value = "accept";
|
|
116
|
-
try {
|
|
117
|
-
await addViaInvite(props.invitationId);
|
|
118
|
-
await load();
|
|
119
|
-
emit("answered", "complete");
|
|
120
|
-
} catch (err: any) {
|
|
121
|
-
error.value = err?.data?.message || "Could not accept this invitation.";
|
|
122
|
-
} finally {
|
|
123
|
-
working.value = "";
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async function decline() {
|
|
128
|
-
working.value = "decline";
|
|
129
|
-
try {
|
|
130
|
-
await declineInvitation(props.invitationId);
|
|
131
|
-
await load();
|
|
132
|
-
emit("answered", "declined");
|
|
133
|
-
} catch (err: any) {
|
|
134
|
-
error.value = err?.data?.message || "Could not decline this invitation.";
|
|
135
|
-
} finally {
|
|
136
|
-
working.value = "";
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
await load();
|
|
141
|
-
</script>
|
|
142
|
-
|
|
143
|
-
<style scoped>
|
|
144
|
-
/* The title was a hand-set 1.05rem/600; `.screen-card-title` is the design's
|
|
145
|
-
15.5px/800, so only the wrap and the leading are left here. */
|
|
146
|
-
.sp-invite-prompt :deep(.screen-card-title) {
|
|
147
|
-
display: block;
|
|
148
|
-
line-height: 1.35;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
.sp-invite-prompt__sub,
|
|
152
|
-
.sp-invite-prompt__body {
|
|
153
|
-
font-size: var(--fs-cell);
|
|
154
|
-
}
|
|
155
|
-
</style>
|