@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 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
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1445
- GET /api/roles/id/:role that membership's role document
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
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1448
- staff = member.type === "admin" && role.type === "admin"
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
- { 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,
@@ -1502,8 +1502,17 @@ const { org: orgId, site: siteId } = route.params as {
1502
1502
  site: string;
1503
1503
  };
1504
1504
 
1505
- const { enableUnregistered } = useANPRSettings(siteId);
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
- (!enableUnregisteredTab.value && requestedTab === "unregistered") ||
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
- export default function (siteId?: string) {
2
- const stateKey = computed(() =>
3
- siteId ? `anpr-settings-${siteId}` : "anpr-settings"
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 anprSettings = useState<Record<string, any> | null>(
7
- stateKey.value,
8
- () => null
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 = data ?? (siteId ? { site: siteId, userType: "site" } : undefined);
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
- if (response) {
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 (!siteId) {
67
+ if (!site.value) {
39
68
  return anprSettings.value;
40
69
  }
41
70
 
42
- if (!force && anprSettings.value) {
71
+ if (!force && loaded.value) {
43
72
  return anprSettings.value;
44
73
  }
45
74
 
46
- return await fetchANPRSettings({ site: siteId, userType: "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
- anprSettings.value = response;
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 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.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-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>