@7365admin1/layer-common 4.38.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 +10 -4
- package/components/ClientInvitations.vue +259 -0
- package/components/ServiceProviderMain.vue +5 -2
- package/composables/useServiceProviderInvitation.ts +39 -11
- package/package.json +1 -1
- package/utils/notification-target.ts +3 -3
- package/components/ServiceProviderInvitationPrompt.vue +0 -162
- package/components/ServiceProviderInvitationsReceived.vue +0 -113
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 4.38.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -1441,11 +1447,11 @@
|
|
|
1441
1447
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1442
1448
|
own rule from two endpoints the console already calls, unprojected:
|
|
1443
1449
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1450
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1451
|
+
GET /api/roles/id/:role that membership's role document
|
|
1446
1452
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1453
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1454
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1449
1455
|
|
|
1450
1456
|
`role.default` is the marker because it is the only property of a platform
|
|
1451
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>
|
|
@@ -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,
|
|
@@ -26,17 +26,28 @@ export type TServiceProviderInvitation = {
|
|
|
26
26
|
}>;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
/** One
|
|
29
|
+
/** One card of "Client invitations" (the service provider's side). */
|
|
30
30
|
export type TReceivedServiceProviderInvitation = {
|
|
31
31
|
_id: string;
|
|
32
|
+
/** The client organisation that sent it. */
|
|
32
33
|
orgName: string;
|
|
33
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 */
|
|
34
44
|
status: string;
|
|
35
45
|
statusLabel: string;
|
|
36
|
-
|
|
37
|
-
/** The invited company's own organisation (the one whose list gains the site). */
|
|
46
|
+
/** The company that accepted it (whose Sites list gained the site). */
|
|
38
47
|
providerOrgId?: string;
|
|
39
48
|
providerOrgName?: string;
|
|
49
|
+
/** Accepted AND on the Sites list of the company on screen. */
|
|
50
|
+
onThisOrg?: boolean;
|
|
40
51
|
};
|
|
41
52
|
|
|
42
53
|
/** The words on screen. Kept beside the server's own list on purpose — a
|
|
@@ -133,8 +144,14 @@ export default function useServiceProviderInvitation() {
|
|
|
133
144
|
|
|
134
145
|
// ---- the invited service provider ----------------------------------------
|
|
135
146
|
|
|
136
|
-
function getInvitation(
|
|
137
|
-
|
|
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
|
+
});
|
|
138
155
|
}
|
|
139
156
|
|
|
140
157
|
function declineInvitation(id: string) {
|
|
@@ -143,21 +160,32 @@ export default function useServiceProviderInvitation() {
|
|
|
143
160
|
});
|
|
144
161
|
}
|
|
145
162
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
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) {
|
|
148
168
|
return useNuxtApp().$api<{ message: string }>(`${base}/${id}/accept`, {
|
|
149
169
|
method: "PUT",
|
|
170
|
+
body: org ? { org } : {},
|
|
150
171
|
});
|
|
151
172
|
}
|
|
152
173
|
|
|
153
174
|
/**
|
|
154
|
-
*
|
|
155
|
-
* account
|
|
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.
|
|
156
177
|
*/
|
|
157
|
-
function getReceivedInvitations(
|
|
178
|
+
function getReceivedInvitations({
|
|
179
|
+
org,
|
|
180
|
+
status = "pending",
|
|
181
|
+
}: { org?: string; status?: string } = {}): Promise<{
|
|
158
182
|
items: TReceivedServiceProviderInvitation[];
|
|
183
|
+
counts?: Record<string, number>;
|
|
159
184
|
}> {
|
|
160
|
-
return useNuxtApp().$api(`${base}/received`, {
|
|
185
|
+
return useNuxtApp().$api(`${base}/received`, {
|
|
186
|
+
method: "GET",
|
|
187
|
+
query: { status, ...(org ? { org } : {}) },
|
|
188
|
+
});
|
|
161
189
|
}
|
|
162
190
|
|
|
163
191
|
return {
|
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.",
|
|
@@ -71,7 +71,7 @@ export const VENDOR_ORDER_MODULE = "marketplace-vendor-order";
|
|
|
71
71
|
* app has that page; every other app sends the click there by URL.
|
|
72
72
|
*/
|
|
73
73
|
export const SP_INVITATION_SCREEN = "/service-provider-invitation";
|
|
74
|
-
export const INVITATIONS_ROUTE = "org-organization-invitations
|
|
74
|
+
export const INVITATIONS_ROUTE = "org-organization-client-invitations";
|
|
75
75
|
|
|
76
76
|
export function notificationTarget(
|
|
77
77
|
item: TTargetRow | null | undefined,
|
|
@@ -89,10 +89,10 @@ export function notificationTarget(
|
|
|
89
89
|
const query: Record<string, string> = id ? { invite: id } : {};
|
|
90
90
|
return {
|
|
91
91
|
name: INVITATIONS_ROUTE,
|
|
92
|
-
params: { organization: org
|
|
92
|
+
params: { organization: org },
|
|
93
93
|
query,
|
|
94
94
|
app: "org",
|
|
95
|
-
path: `/org/${encodeURIComponent(org)}/invitations
|
|
95
|
+
path: `/org/${encodeURIComponent(org)}/client-invitations`,
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -1,162 +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
|
-
|
|
61
|
-
<!-- An accept or decline that failed: said on the card, not swallowed. -->
|
|
62
|
-
<div v-if="error" class="px-4 pb-4 text-error sp-invite-prompt__body" role="alert">
|
|
63
|
-
{{ error }}
|
|
64
|
-
</div>
|
|
65
|
-
</AppCard>
|
|
66
|
-
|
|
67
|
-
<AppCard v-else-if="error">
|
|
68
|
-
<div class="pa-4 sp-invite-prompt__body">{{ error }}</div>
|
|
69
|
-
</AppCard>
|
|
70
|
-
</template>
|
|
71
|
-
|
|
72
|
-
<script setup lang="ts">
|
|
73
|
-
/**
|
|
74
|
-
* The invitation as the service provider sees it — inside the app they already
|
|
75
|
-
* use, from the session they already have.
|
|
76
|
-
*
|
|
77
|
-
* There is deliberately no sign-up and no one-time code anywhere on this
|
|
78
|
-
* screen: a company that already exists on iService365 never creates a second
|
|
79
|
-
* account. The email they receive only carries them here.
|
|
80
|
-
*
|
|
81
|
-
* An invitation that has not been approved by Seven365 is not readable at all —
|
|
82
|
-
* the server answers "not found" — so this component cannot show one.
|
|
83
|
-
*/
|
|
84
|
-
const props = defineProps({
|
|
85
|
-
invitationId: { type: String, required: true },
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const emit = defineEmits<{ (e: "answered", status: string): void }>();
|
|
89
|
-
|
|
90
|
-
const { getInvitation, declineInvitation, acceptInvitation } =
|
|
91
|
-
useServiceProviderInvitation();
|
|
92
|
-
|
|
93
|
-
const invitation = ref<any>(null);
|
|
94
|
-
const error = ref("");
|
|
95
|
-
const working = ref("");
|
|
96
|
-
|
|
97
|
-
const open = computed(() => invitation.value?.status === "pending");
|
|
98
|
-
|
|
99
|
-
const expiryLine = computed(() => {
|
|
100
|
-
const expires = invitation.value?.expireAt;
|
|
101
|
-
if (!expires) return "";
|
|
102
|
-
const days = Math.max(
|
|
103
|
-
0,
|
|
104
|
-
Math.ceil((new Date(expires).getTime() - Date.now()) / 86400000),
|
|
105
|
-
);
|
|
106
|
-
if (!open.value) return "no longer waiting for an answer";
|
|
107
|
-
return days <= 1 ? "expires within a day" : `expires in ${days} days`;
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
async function load() {
|
|
111
|
-
try {
|
|
112
|
-
invitation.value = await getInvitation(props.invitationId);
|
|
113
|
-
} catch (err: any) {
|
|
114
|
-
error.value =
|
|
115
|
-
err?.data?.message || "This invitation is no longer available.";
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function accept() {
|
|
120
|
-
error.value = "";
|
|
121
|
-
working.value = "accept";
|
|
122
|
-
try {
|
|
123
|
-
await acceptInvitation(props.invitationId);
|
|
124
|
-
await load();
|
|
125
|
-
emit("answered", "complete");
|
|
126
|
-
} catch (err: any) {
|
|
127
|
-
error.value = err?.data?.message || "Could not accept this invitation.";
|
|
128
|
-
} finally {
|
|
129
|
-
working.value = "";
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function decline() {
|
|
134
|
-
error.value = "";
|
|
135
|
-
working.value = "decline";
|
|
136
|
-
try {
|
|
137
|
-
await declineInvitation(props.invitationId);
|
|
138
|
-
await load();
|
|
139
|
-
emit("answered", "declined");
|
|
140
|
-
} catch (err: any) {
|
|
141
|
-
error.value = err?.data?.message || "Could not decline this invitation.";
|
|
142
|
-
} finally {
|
|
143
|
-
working.value = "";
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
await load();
|
|
148
|
-
</script>
|
|
149
|
-
|
|
150
|
-
<style scoped>
|
|
151
|
-
/* The title was a hand-set 1.05rem/600; `.screen-card-title` is the design's
|
|
152
|
-
15.5px/800, so only the wrap and the leading are left here. */
|
|
153
|
-
.sp-invite-prompt :deep(.screen-card-title) {
|
|
154
|
-
display: block;
|
|
155
|
-
line-height: 1.35;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
.sp-invite-prompt__sub,
|
|
159
|
-
.sp-invite-prompt__body {
|
|
160
|
-
font-size: var(--fs-cell);
|
|
161
|
-
}
|
|
162
|
-
</style>
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<!-- Nothing to answer and nothing linked: the section stays out of the way. -->
|
|
3
|
-
<section v-if="ids.length" class="sp-received mb-4" aria-labelledby="sp-received-title">
|
|
4
|
-
<div class="mb-2">
|
|
5
|
-
<div id="sp-received-title" class="screen-card-title">
|
|
6
|
-
Invitations from other organisations
|
|
7
|
-
</div>
|
|
8
|
-
<div class="screen-hint">
|
|
9
|
-
Another organisation has asked your company to provide services at one
|
|
10
|
-
of its sites. Accept to add the site to your Sites list, or decline.
|
|
11
|
-
</div>
|
|
12
|
-
</div>
|
|
13
|
-
|
|
14
|
-
<div class="d-flex flex-column ga-3">
|
|
15
|
-
<div
|
|
16
|
-
v-for="id in ids"
|
|
17
|
-
:key="id"
|
|
18
|
-
:class="{ 'sp-received__linked': id === linked }"
|
|
19
|
-
>
|
|
20
|
-
<Suspense>
|
|
21
|
-
<ServiceProviderInvitationPrompt
|
|
22
|
-
:invitation-id="id"
|
|
23
|
-
@answered="(status: string) => onAnswered(id, status)"
|
|
24
|
-
/>
|
|
25
|
-
</Suspense>
|
|
26
|
-
|
|
27
|
-
<div v-if="answered[id] === 'complete'" class="sp-received__done mt-2">
|
|
28
|
-
{{ doneLine(id) }}
|
|
29
|
-
<NuxtLink v-if="sitesLink(id)" :to="sitesLink(id)">Open Sites</NuxtLink>
|
|
30
|
-
</div>
|
|
31
|
-
</div>
|
|
32
|
-
</div>
|
|
33
|
-
</section>
|
|
34
|
-
</template>
|
|
35
|
-
|
|
36
|
-
<script setup lang="ts">
|
|
37
|
-
/**
|
|
38
|
-
* "Invitations from other organisations" — where a service provider answers an
|
|
39
|
-
* invitation another organisation sent it (owner report 2026-09-24: the bell
|
|
40
|
-
* said "Open the invitation to accept or decline" and there was nowhere to do
|
|
41
|
-
* either).
|
|
42
|
-
*
|
|
43
|
-
* The list comes from the server keyed on the signed-in account, so it is the
|
|
44
|
-
* same whichever organisation is on screen. The row the bell linked to
|
|
45
|
-
* (`?invite=<id>`) is always shown — even once answered or expired, so the
|
|
46
|
-
* person sees what happened to it — and is outlined.
|
|
47
|
-
*/
|
|
48
|
-
import type { TReceivedServiceProviderInvitation } from "../composables/useServiceProviderInvitation";
|
|
49
|
-
|
|
50
|
-
const route = useRoute();
|
|
51
|
-
const { getReceivedInvitations } = useServiceProviderInvitation();
|
|
52
|
-
|
|
53
|
-
const items = ref<TReceivedServiceProviderInvitation[]>([]);
|
|
54
|
-
const answered = reactive<Record<string, string>>({});
|
|
55
|
-
|
|
56
|
-
const linked = computed(() => {
|
|
57
|
-
const value = String(route.query?.invite ?? "").trim();
|
|
58
|
-
return /^[a-f0-9]{24}$/i.test(value) ? value : "";
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const ids = computed(() => {
|
|
62
|
-
const listed = items.value.map((i) => String(i._id));
|
|
63
|
-
return linked.value && !listed.includes(linked.value)
|
|
64
|
-
? [linked.value, ...listed]
|
|
65
|
-
: listed;
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
function itemFor(id: string) {
|
|
69
|
-
return items.value.find((i) => String(i._id) === id);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function doneLine(id: string) {
|
|
73
|
-
const item = itemFor(id);
|
|
74
|
-
const whose = item?.providerOrgName ? `${item.providerOrgName}'s` : "your";
|
|
75
|
-
return item?.siteName
|
|
76
|
-
? `${item.siteName} is now on ${whose} Sites list.`
|
|
77
|
-
: `The site is now on ${whose} Sites list.`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function sitesLink(id: string) {
|
|
81
|
-
const org = itemFor(id)?.providerOrgId || String(route.params?.organization ?? "");
|
|
82
|
-
return org ? `/org/${org}/sites` : "";
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function onAnswered(id: string, status: string) {
|
|
86
|
-
answered[id] = status;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function load() {
|
|
90
|
-
try {
|
|
91
|
-
const res = await getReceivedInvitations();
|
|
92
|
-
items.value = Array.isArray(res?.items) ? res.items : [];
|
|
93
|
-
} catch {
|
|
94
|
-
// The section is an addition to the Invitations page; if it cannot load,
|
|
95
|
-
// the rest of the page must still work. A linked row still renders and
|
|
96
|
-
// reports its own error.
|
|
97
|
-
items.value = [];
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
await load();
|
|
102
|
-
</script>
|
|
103
|
-
|
|
104
|
-
<style scoped>
|
|
105
|
-
.sp-received__linked :deep(.app-card) {
|
|
106
|
-
outline: 2px solid rgb(var(--v-theme-primary));
|
|
107
|
-
outline-offset: 2px;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
.sp-received__done {
|
|
111
|
-
font-size: var(--fs-cell);
|
|
112
|
-
}
|
|
113
|
-
</style>
|