@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 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
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1445
- GET /api/roles/id/:role that membership's role document
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
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1448
- staff = member.type === "admin" && role.type === "admin"
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
- { value: "pending", label: "Pending" },
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 row of "Invitations from other organisations". */
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
- expireAt?: string;
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(id: string): Promise<TServiceProviderInvitation> {
137
- return useNuxtApp().$api(`${base}/${id}`, { method: "GET" });
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
- /** Adds the site to the invited company's list. The invited account only. */
147
- function acceptInvitation(id: string) {
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
- * Invitations from other organisations still waiting for the signed-in
155
- * account's answer — keyed on the account, not on the organisation on screen.
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(): Promise<{
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`, { method: "GET" });
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.38.0",
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-status-status";
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, status: "pending" },
92
+ params: { organization: org },
93
93
  query,
94
94
  app: "org",
95
- path: `/org/${encodeURIComponent(org)}/invitations/status/pending`,
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>