@7365admin1/layer-common 4.82.1-staging.474 → 4.82.1-staging.476

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.
@@ -149,6 +149,9 @@ const props = defineProps({
149
149
  });
150
150
 
151
151
  const movedFromEquipment = useRoute().query.moved === "equipment";
152
+ // A notification link (`?storeId=&tab=`, NotificationBell): open that store on that tab, once.
153
+ const linkQuery = useRoute().query;
154
+ const linked = { storeId: String(linkQuery.storeId ?? ""), tab: String(linkQuery.tab ?? "") };
152
155
 
153
156
  const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
154
157
  const { getSites } = useSite();
@@ -227,7 +230,10 @@ async function loadStores() {
227
230
  loadError.value = "";
228
231
  try {
229
232
  stores.value = (await getStores(props.siteId)) ?? [];
230
- if (!stores.value.some((s) => s._id === storeId.value)) {
233
+ const fromLink = stores.value.find((s) => s._id === linked.storeId);
234
+ linked.storeId = "";
235
+ if (fromLink) storeId.value = fromLink._id;
236
+ else if (!stores.value.some((s) => s._id === storeId.value)) {
231
237
  // A service provider working at a property store sees it first.
232
238
  storeId.value = stores.value[0]?._id ?? "";
233
239
  }
@@ -295,7 +301,10 @@ async function createStore() {
295
301
  }
296
302
 
297
303
  watch(storeId, () => {
298
- tab.value = "stock";
304
+ // The linked tab only when this person has it (tabs are hidden, never greyed).
305
+ const wanted = linked.tab;
306
+ linked.tab = "";
307
+ tab.value = wanted && tabs.value.some((t) => t.value === wanted) ? wanted : "stock";
299
308
  refreshCounts();
300
309
  probeCompanyUsage();
301
310
  });
@@ -127,6 +127,13 @@
127
127
  <v-btn variant="text" class="text-none" @click="viewFromToast">View</v-btn>
128
128
  </template>
129
129
  </v-snackbar>
130
+
131
+ <v-snackbar v-model="nowhere.show" location="top right" :timeout="6000" color="surface">
132
+ <div class="d-flex align-center ga-3">
133
+ <v-icon icon="mdi-information-outline" color="primary" />
134
+ <div class="text-body-2">{{ nowhere.text }}</div>
135
+ </div>
136
+ </v-snackbar>
130
137
  </div>
131
138
  </template>
132
139
 
@@ -137,7 +144,7 @@ import {
137
144
  timeAgo,
138
145
  type TInboxItem,
139
146
  } from "../utils/notification-inbox";
140
- import { appUrlsFrom, chooseNavigation, notificationTarget } from "../utils/notification-target";
147
+ import { appUrlsFrom, chooseNavigation, noTargetMessage, notificationTarget } from "../utils/notification-target";
141
148
 
142
149
  /**
143
150
  * The top-bar notification feed for the web, in every signed-in app.
@@ -180,6 +187,8 @@ const markingAll = ref(false);
180
187
  const items = ref<Array<Record<string, any>>>([]);
181
188
  const unread = ref(0);
182
189
  const toast = reactive({ show: false, title: "", body: "" });
190
+ /** Said when a clicked row has no page to open - a click is never a silent nothing. */
191
+ const nowhere = reactive({ show: false, text: "" });
183
192
 
184
193
  const userId = computed(() => String(currentUser.value?._id ?? ""));
185
194
  const badgeLabel = computed(() => badgeText(unread.value));
@@ -341,7 +350,10 @@ async function goToSubject(item: Record<string, any>) {
341
350
  hasPage: hasLocalPage,
342
351
  appUrls: appUrls(),
343
352
  });
344
- if (!nav) return;
353
+ if (!nav) {
354
+ Object.assign(nowhere, { show: true, text: noTargetMessage(item as any) });
355
+ return;
356
+ }
345
357
 
346
358
  open.value = false;
347
359
  if (nav.type === "href") {
@@ -0,0 +1,29 @@
1
+ import { NO_SITE_ACTIONS, siteActionsOf, type TSiteActions } from "../utils/site-actions";
2
+
3
+ /**
4
+ * May the signed-in person add / delete sites (and add organisations) in this
5
+ * organisation? Re-asked whenever the organisation changes; hidden until core
6
+ * answers. See `utils/site-actions.ts`.
7
+ */
8
+ export default function useSiteActions(org: MaybeRefOrGetter<string>) {
9
+ const actions = ref<TSiteActions>({ ...NO_SITE_ACTIONS });
10
+
11
+ watch(
12
+ () => toValue(org),
13
+ async (id) => {
14
+ actions.value = { ...NO_SITE_ACTIONS };
15
+ if (!id) return;
16
+ try {
17
+ const res = await useNuxtApp().$api<Record<string, any>>("/api/customer-sites", {
18
+ query: { org: id, status: "active", limit: 1, withActions: true },
19
+ });
20
+ if (toValue(org) === id) actions.value = siteActionsOf(res);
21
+ } catch {
22
+ // stays hidden - the server refuses the action anyway
23
+ }
24
+ },
25
+ { immediate: true },
26
+ );
27
+
28
+ return actions;
29
+ }
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.82.1-staging.474",
5
+ "version": "4.82.1-staging.476",
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.",
@@ -45,7 +45,7 @@ export type TTargetRow = {
45
45
  * (core `withNotificationKind`); `site` stands in for `siteId` on rows kept
46
46
  * unscoped on purpose. Older rows have neither and are read from `module`.
47
47
  */
48
- params?: { id?: string; org?: string; kind?: string; site?: string } | null;
48
+ params?: { id?: string; org?: string; kind?: string; site?: string; storeId?: string } | null;
49
49
  };
50
50
 
51
51
  /** What the surrounding app knows that the stored row does not. */
@@ -127,9 +127,26 @@ export function notificationKind(item: TTargetRow | null | undefined): string {
127
127
  return "";
128
128
  }
129
129
  if (module === "other") {
130
- return /(accepted|declined) your invitation$/i.test(title) ? "serviceProviderInviteAnswer" : "";
130
+ if (/(accepted|declined) your invitation$/i.test(title)) return "serviceProviderInviteAnswer";
131
+ // Inventory alerts sent before core had an inventory module were stored as "other".
132
+ return /request waiting|request (was )?(approved|rejected)|low (on )?stock|stock handed to you|hand-over (confirmed|declined)/i.test(title)
133
+ ? "inventory"
134
+ : "";
131
135
  }
132
- return module;
136
+ // Older rows spelled the work order module with a hyphen.
137
+ return module === "work-order" ? "workOrder" : module;
138
+ }
139
+
140
+ /**
141
+ * Which inventory tab an alert is about. Alerts carry the store id but no tab,
142
+ * so the tab follows the alert's fixed title (API-core inventory-notify.service)
143
+ * - the same rule the mobile apps use. Values are the web tabs' own names.
144
+ */
145
+ export function inventoryTabForAlert(title: string): "requests" | "stock" | "mine" | "" {
146
+ if (/stock handed to you|hand-over (confirmed|declined)/i.test(title)) return "mine";
147
+ if (/request waiting|request (was )?(approved|rejected)/i.test(title)) return "requests";
148
+ if (/low (on )?stock/i.test(title)) return "stock";
149
+ return "";
133
150
  }
134
151
 
135
152
  /**
@@ -182,6 +199,8 @@ const SITE_PAGES: Record<string, TSitePage[]> = {
182
199
  { apps: ["security"], page: () => "virtual-patrol/cctv" },
183
200
  { apps: ["property-management"], page: () => "cctv" },
184
201
  ],
202
+ // A robot at the site raised an alarm (core safety alert, category robotAlert).
203
+ robotAlert: [{ apps: ["security", "property-management", "hygiene"], page: () => "robot-management/alerts" }],
185
204
  // hygiene has its Service Providers page too since 2026-09-28
186
205
  serviceProviderInviteAnswer: [{ apps: SERVICE_APPS, page: () => "service-providers" }],
187
206
  };
@@ -214,7 +233,12 @@ function sitePageTarget(
214
233
  const list = apps === "any" ? [home, ...SERVICE_APPS.filter((a) => a !== home)] : apps;
215
234
  for (const app of list) candidates.push({ app, path: base + page(id) });
216
235
  }
217
- return { candidates };
236
+ if (kind !== "inventory") return { candidates };
237
+ // Open the alert's own store on the tab it is about (InventoryMain reads ?storeId=&tab=).
238
+ const storeId = String(item.params?.storeId ?? item.params?.id ?? "").trim();
239
+ const tab = inventoryTabForAlert(String(item.title ?? ""));
240
+ const query: Record<string, string> = { ...(storeId ? { storeId } : {}), ...(tab ? { tab } : {}) };
241
+ return Object.keys(query).length ? { candidates, query } : { candidates };
218
242
  }
219
243
 
220
244
  /** Seven365 staff pages, in the organisation app. */
@@ -294,6 +318,21 @@ export function notificationTarget(
294
318
  };
295
319
  }
296
320
 
321
+ /** Kinds that only the iService365 phone apps can open (residents' own screens). */
322
+ const PHONE_ONLY_KINDS = new Set(["prelovedMarketplace", "marketplace-order"]);
323
+
324
+ /**
325
+ * What the bell says when a click has nowhere to go, so a click is never a
326
+ * silent nothing. A link grants nothing either way: the page opened still
327
+ * checks the person may see it.
328
+ */
329
+ export function noTargetMessage(item: TTargetRow | null | undefined): string {
330
+ if (PHONE_ONLY_KINDS.has(notificationKind(item))) {
331
+ return "This notification opens in the iService365 app on your phone.";
332
+ }
333
+ return "There is no page to open for this notification here. It has been marked as read.";
334
+ }
335
+
297
336
  /**
298
337
  * The full URL for a target whose page lives in another web app, or "" when
299
338
  * there is none to build (no path, or that app's URL is not configured).
@@ -0,0 +1,18 @@
1
+ /**
2
+ * What the signed-in person may do with an organisation's sites, as core
3
+ * answers it (`GET /api/customer-sites?withActions=true` -> `siteActions`).
4
+ *
5
+ * Owner rule 2026-10-01: creating, deleting or removing sites is for Seven365
6
+ * Super Admin, the organisation's owner and organisation-level members with the
7
+ * right; a member confined to sites never - whatever their role says. The
8
+ * server decides and refuses (403); the UI only hides what would be refused.
9
+ * Anything missing or unreadable reads as "no" (the button stays hidden).
10
+ */
11
+ export type TSiteActions = { create: boolean; delete: boolean; createCustomer: boolean };
12
+
13
+ export const NO_SITE_ACTIONS: TSiteActions = Object.freeze({ create: false, delete: false, createCustomer: false });
14
+
15
+ export function siteActionsOf(response: unknown): TSiteActions {
16
+ const a = (response as { siteActions?: Record<string, unknown> } | null)?.siteActions ?? {};
17
+ return { create: a.create === true, delete: a.delete === true, createCustomer: a.createCustomer === true };
18
+ }