@7365admin1/layer-common 4.38.0 → 4.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -4
- package/components/ClientInvitations.vue +259 -0
- package/components/ServiceProviderMain.vue +5 -2
- package/components/VisitorManagement.vue +18 -5
- package/components/VisitorSocketPopUp.vue +2 -1
- package/composables/useANPRSettings.ts +45 -15
- package/composables/useServiceProviderInvitation.ts +39 -11
- package/package.json +2 -2
- 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,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.39.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b1c2177: Visitor Mgmt: the Unregistered tab now appears on first load and after switching site, without a hard refresh. `useANPRSettings` takes a ref or getter for the site and keeps one entry per site; the Visitor Mgmt page reads its own site's ANPR switches instead of relying on the layout's plate toast, which kept the first site's switches after a site switch. A plate-toast link to the Unregistered tab is no longer bounced to Registered while the switches are still loading.
|
|
8
|
+
|
|
9
|
+
## 4.39.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 4.38.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1441,11 +1453,11 @@
|
|
|
1441
1453
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1442
1454
|
own rule from two endpoints the console already calls, unprojected:
|
|
1443
1455
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1456
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1457
|
+
GET /api/roles/id/:role that membership's role document
|
|
1446
1458
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1459
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1460
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1449
1461
|
|
|
1450
1462
|
`role.default` is the marker because it is the only property of a platform
|
|
1451
1463
|
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,
|
|
@@ -1502,8 +1502,17 @@ const { org: orgId, site: siteId } = route.params as {
|
|
|
1502
1502
|
site: string;
|
|
1503
1503
|
};
|
|
1504
1504
|
|
|
1505
|
-
|
|
1505
|
+
// The page reads its own site's switches rather than relying on the layout's
|
|
1506
|
+
// toast to have fetched them - that copy could belong to the previous site.
|
|
1507
|
+
const {
|
|
1508
|
+
enableUnregistered,
|
|
1509
|
+
loaded: anprSettingsLoaded,
|
|
1510
|
+
ensureANPRSettings,
|
|
1511
|
+
} = useANPRSettings(siteId);
|
|
1506
1512
|
const enableUnregisteredTab = computed(() => enableUnregistered.value);
|
|
1513
|
+
ensureANPRSettings(true).catch(() => {
|
|
1514
|
+
// No settings = no Unregistered tab, same as before.
|
|
1515
|
+
});
|
|
1507
1516
|
|
|
1508
1517
|
const {
|
|
1509
1518
|
getVisitors,
|
|
@@ -2933,7 +2942,11 @@ watch([searchInput, dateFrom, dateTo, filterTypes, activeTab], () => {
|
|
|
2933
2942
|
const syncVisitorFiltersFromRoute = () => {
|
|
2934
2943
|
const requestedTab = (route.query.tab as string) || "registered";
|
|
2935
2944
|
const nextTab =
|
|
2936
|
-
|
|
2945
|
+
// Until the switches are read, keep a requested Unregistered tab (the plate
|
|
2946
|
+
// toast links here) instead of bouncing it to Registered.
|
|
2947
|
+
(anprSettingsLoaded.value &&
|
|
2948
|
+
!enableUnregisteredTab.value &&
|
|
2949
|
+
requestedTab === "unregistered") ||
|
|
2937
2950
|
(!showResidentTransactionsTab.value && requestedTab === "resident-transactions")
|
|
2938
2951
|
? "registered"
|
|
2939
2952
|
: requestedTab;
|
|
@@ -2992,9 +3005,9 @@ const { socketUnregisteredVisitorTrigger, visitorSocketData } =
|
|
|
2992
3005
|
useVisitorSocket();
|
|
2993
3006
|
|
|
2994
3007
|
watch(
|
|
2995
|
-
enableUnregisteredTab,
|
|
2996
|
-
(enabled) => {
|
|
2997
|
-
if (!enabled && activeTab.value === "unregistered") {
|
|
3008
|
+
[enableUnregisteredTab, anprSettingsLoaded],
|
|
3009
|
+
([enabled]) => {
|
|
3010
|
+
if (!enabled && anprSettingsLoaded.value && activeTab.value === "unregistered") {
|
|
2998
3011
|
activeTab.value = "registered";
|
|
2999
3012
|
}
|
|
3000
3013
|
},
|
|
@@ -137,8 +137,9 @@ const config = useRuntimeConfig();
|
|
|
137
137
|
const publicConfig = config.public as Record<string, string | undefined>;
|
|
138
138
|
const route = useRoute();
|
|
139
139
|
const { tab } = useVisitor();
|
|
140
|
+
// A getter, not the string: this component outlives site switches.
|
|
140
141
|
const { enableUnregistered, ensureANPRSettings } = useANPRSettings(
|
|
141
|
-
props.siteId
|
|
142
|
+
() => props.siteId
|
|
142
143
|
);
|
|
143
144
|
const enableUnregisteredSnackbar = computed(() => enableUnregistered.value);
|
|
144
145
|
const { socketUnregisteredVisitorTrigger, visitorSocketData } =
|
|
@@ -1,19 +1,50 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { computed, toValue, type MaybeRefOrGetter } from "vue";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* A site's ANPR switches (e.g. "Enable Unregistered Vehicles"), shared by every
|
|
5
|
+
* caller on the page: the Visitor Mgmt tabs, the plate toast in the layout, and
|
|
6
|
+
* Site Settings.
|
|
7
|
+
*
|
|
8
|
+
* `siteId` may be a ref or a getter. The layout's toast lives across site
|
|
9
|
+
* switches, so a plain string captured once kept answering for the FIRST site
|
|
10
|
+
* it saw - and the Visitor Mgmt page never fetched at all, so its
|
|
11
|
+
* "Unregistered" tab showed only when that stale copy happened to be for the
|
|
12
|
+
* right site (i.e. after a hard refresh on the page itself).
|
|
13
|
+
*
|
|
14
|
+
* One map keyed by site id: a key that is present means "fetched", even when
|
|
15
|
+
* the site has no settings yet (stored as `{}`).
|
|
16
|
+
*/
|
|
17
|
+
export default function (siteId?: MaybeRefOrGetter<string | undefined>) {
|
|
18
|
+
const site = computed(() => toValue(siteId) || "");
|
|
5
19
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
() =>
|
|
20
|
+
const bySite = useState<Record<string, Record<string, any>>>(
|
|
21
|
+
"anpr-settings-by-site",
|
|
22
|
+
() => ({})
|
|
9
23
|
);
|
|
10
24
|
|
|
25
|
+
const anprSettings = computed<Record<string, any> | null>({
|
|
26
|
+
get: () => bySite.value[site.value] ?? null,
|
|
27
|
+
set: (value) => {
|
|
28
|
+
bySite.value = { ...bySite.value, [site.value]: value ?? {} };
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** True once this site's settings have been read (or saved) at least once. */
|
|
33
|
+
const loaded = computed(() => site.value in bySite.value);
|
|
34
|
+
|
|
11
35
|
const enableUnregistered = computed(
|
|
12
36
|
() => anprSettings.value?.ANPRSwitches?.enableUnregistered === true
|
|
13
37
|
);
|
|
14
38
|
|
|
39
|
+
function store(forSite: unknown, response: Record<string, any> | null) {
|
|
40
|
+
const key = String(forSite ?? "");
|
|
41
|
+
if (!key) return;
|
|
42
|
+
bySite.value = { ...bySite.value, [key]: response ?? {} };
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
async function fetchANPRSettings(data?: any) {
|
|
16
|
-
const payload =
|
|
46
|
+
const payload =
|
|
47
|
+
data ?? (site.value ? { site: site.value, userType: "site" } : undefined);
|
|
17
48
|
|
|
18
49
|
if (!payload) {
|
|
19
50
|
return anprSettings.value;
|
|
@@ -27,23 +58,21 @@ export default function (siteId?: string) {
|
|
|
27
58
|
}
|
|
28
59
|
);
|
|
29
60
|
|
|
30
|
-
|
|
31
|
-
anprSettings.value = response;
|
|
32
|
-
}
|
|
61
|
+
store(payload.site, response);
|
|
33
62
|
|
|
34
63
|
return response;
|
|
35
64
|
}
|
|
36
65
|
|
|
37
66
|
async function ensureANPRSettings(force = false) {
|
|
38
|
-
if (!
|
|
67
|
+
if (!site.value) {
|
|
39
68
|
return anprSettings.value;
|
|
40
69
|
}
|
|
41
70
|
|
|
42
|
-
if (!force &&
|
|
71
|
+
if (!force && loaded.value) {
|
|
43
72
|
return anprSettings.value;
|
|
44
73
|
}
|
|
45
74
|
|
|
46
|
-
return await fetchANPRSettings({ site:
|
|
75
|
+
return await fetchANPRSettings({ site: site.value, userType: "site" });
|
|
47
76
|
}
|
|
48
77
|
|
|
49
78
|
async function getANPRSettingBySite(data: any) {
|
|
@@ -60,7 +89,7 @@ export default function (siteId?: string) {
|
|
|
60
89
|
);
|
|
61
90
|
|
|
62
91
|
if (response) {
|
|
63
|
-
|
|
92
|
+
store(data?.site, response);
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
return response;
|
|
@@ -68,6 +97,7 @@ export default function (siteId?: string) {
|
|
|
68
97
|
|
|
69
98
|
return {
|
|
70
99
|
anprSettings,
|
|
100
|
+
loaded,
|
|
71
101
|
enableUnregistered,
|
|
72
102
|
ensureANPRSettings,
|
|
73
103
|
getANPRSettingBySite,
|
|
@@ -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.1",
|
|
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.",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"build": "nuxt build .playground",
|
|
37
37
|
"generate": "nuxt generate .playground",
|
|
38
38
|
"preview": "nuxt preview .playground",
|
|
39
|
-
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
|
|
39
|
+
"test": "esbuild composables/useANPRSettings.ts --format=esm --outfile=test/.build/useANPRSettings.mjs --log-level=error && esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
|
|
40
40
|
"test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
|
|
41
41
|
"release": "yarn run build && changeset publish"
|
|
42
42
|
},
|
|
@@ -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>
|