@7365admin1/layer-common 4.86.0 → 4.87.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.87.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4be2d7c: Notification bell: robot alarms open the site's Robot alerts page, inventory alerts open their store on the right tab (Requests / Stock / My Stock), older "work-order" and "other" inventory rows route, and a click with nowhere to go now says why instead of doing nothing.
8
+
3
9
  ## 4.86.0
4
10
 
5
11
  ### Minor Changes
@@ -1986,11 +1992,11 @@
1986
1992
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1987
1993
  own rule from two endpoints the console already calls, unprojected:
1988
1994
 
1989
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1990
- GET /api/roles/id/:role that membership's role document
1995
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1996
+ GET /api/roles/id/:role that membership's role document
1991
1997
 
1992
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1993
- staff = member.type === "admin" && role.type === "admin"
1998
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1999
+ staff = member.type === "admin" && role.type === "admin"
1994
2000
 
1995
2001
  `role.default` is the marker because it is the only property of a platform
1996
2002
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -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") {
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.86.0",
5
+ "version": "4.87.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.",
@@ -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).