@7365admin1/layer-common 4.40.1 → 4.41.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.41.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7052a86: Notification bell: every notification now opens its page. Rows route on `params.kind` (else their `module`) to the matching page — work order, feedback, incident, visitors, cameras, patrol logs, documents, emergency contacts, events, facility bookings, online forms, bulletin board, inventory, service providers, marketplace orders, and the Seven365 staff robot/client pages — in this app when it has the page, otherwise in the app that does (by its `APP_*` URL), with the organisation and site in the link. Rows with nowhere to go are no longer drawn as links. New `chooseNavigation`, `notificationKind`, `WEB_TARGET_KINDS`.
8
+
3
9
  ## 4.40.1
4
10
 
5
11
  ### Patch Changes
@@ -1465,11 +1471,11 @@
1465
1471
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1466
1472
  own rule from two endpoints the console already calls, unprojected:
1467
1473
 
1468
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1469
- GET /api/roles/id/:role that membership's role document
1474
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1475
+ GET /api/roles/id/:role that membership's role document
1470
1476
 
1471
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1472
- staff = member.type === "admin" && role.type === "admin"
1477
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1478
+ staff = member.type === "admin" && role.type === "admin"
1473
1479
 
1474
1480
  `role.default` is the marker because it is the only property of a platform
1475
1481
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -70,7 +70,10 @@
70
70
  :key="item._id"
71
71
  type="button"
72
72
  class="notif-card"
73
- :class="{ 'notif-card--unread': !item.hasRead }"
73
+ :class="{
74
+ 'notif-card--unread': !item.hasRead,
75
+ 'notif-card--plain': !targetFor(item),
76
+ }"
74
77
  @click="openNotification(item)"
75
78
  >
76
79
  <div class="notif-card__top">
@@ -134,7 +137,7 @@ import {
134
137
  timeAgo,
135
138
  type TInboxItem,
136
139
  } from "../utils/notification-inbox";
137
- import { externalTargetUrl, notificationTarget } from "../utils/notification-target";
140
+ import { chooseNavigation, notificationTarget } from "../utils/notification-target";
138
141
 
139
142
  /**
140
143
  * The top-bar notification feed for the web, in every signed-in app.
@@ -304,26 +307,59 @@ function markRead(item: Record<string, any>) {
304
307
  * layer: the ten apps that have no order queue simply do not match it, and the
305
308
  * click there stays what it always was.
306
309
  */
307
- async function goToSubject(item: Record<string, any>) {
308
- const target = notificationTarget(item as any, {
310
+ function targetFor(item: Record<string, any>) {
311
+ return notificationTarget(item as any, {
309
312
  // The org app nests under `organization`; the service apps under `org`.
310
- organization: String(route.params?.organization ?? route.params?.org ?? ""),
313
+ // Staff pages and the org app have neither, so the person's own default
314
+ // organisation builds a service-app link from there.
315
+ organization: String(
316
+ route.params?.organization ?? route.params?.org ?? currentUser.value?.defaultOrg ?? "",
317
+ ),
318
+ site: String(route.params?.site ?? ""),
311
319
  });
312
- if (!target) return;
313
-
314
- if (typeof router.hasRoute !== "function" || !router.hasRoute(target.name)) {
315
- // The page lives in another web app (the invitation is answered in the
316
- // organisation app): go there by URL, or stay put if it is not configured.
317
- const href = externalTargetUrl(target, { org: String(appConfig?.public?.APP_ORG ?? "") });
318
- if (!href) return;
319
- open.value = false;
320
- window.location.assign(href);
321
- return;
320
+ }
321
+
322
+ /** A path this app really has a page for (every app has a 404 fallback, so check the match). */
323
+ function hasLocalPage(path: string) {
324
+ try {
325
+ const resolved = router.resolve(path);
326
+ return resolved.matched.length > 0 && !resolved.matched.some((m) => m.path.includes("pathMatch"));
327
+ } catch {
328
+ return false;
322
329
  }
330
+ }
331
+
332
+ /** Every web app's URL, keyed like `TAppKey`, from the layer's runtime config. */
333
+ function appUrls() {
334
+ const p = appConfig?.public ?? {};
335
+ return {
336
+ org: String(p.APP_ORG ?? ""),
337
+ security: String(p.APP_SECURITY ?? ""),
338
+ "property-management": String(p.APP_PROPERTY_MANAGEMENT ?? ""),
339
+ hygiene: String(p.APP_HYGIENE ?? ""),
340
+ "mechanical-electrical": String(p.APP_MECHANICAL_ELECTRICAL ?? ""),
341
+ "pest-control": String(p.APP_PEST_CONTROL ?? ""),
342
+ landscaping: String(p.APP_LANDSCAPING ?? ""),
343
+ "pool-maintenance": String(p.APP_POOL_MAINTENANCE ?? ""),
344
+ };
345
+ }
346
+
347
+ /** The decision is `chooseNavigation` (unit-tested); this only carries it out. */
348
+ async function goToSubject(item: Record<string, any>) {
349
+ const nav = chooseNavigation(targetFor(item), {
350
+ hasRoute: (name) => typeof router.hasRoute === "function" && router.hasRoute(name),
351
+ hasPage: hasLocalPage,
352
+ appUrls: appUrls(),
353
+ });
354
+ if (!nav) return;
323
355
 
324
356
  open.value = false;
357
+ if (nav.type === "href") {
358
+ window.location.assign(nav.href);
359
+ return;
360
+ }
325
361
  try {
326
- await router.push({ name: target.name, params: target.params, query: target.query });
362
+ await router.push(nav.to as any);
327
363
  } catch {
328
364
  // A cancelled or duplicate navigation is not worth an error in the top bar.
329
365
  }
@@ -462,6 +498,11 @@ onBeforeUnmount(() => {
462
498
  background: var(--hover);
463
499
  }
464
500
 
501
+ /* Nothing to open: still marks read on click, but does not look like a link. */
502
+ .notif-card--plain {
503
+ cursor: default;
504
+ }
505
+
465
506
  .notif-card__top {
466
507
  display: flex;
467
508
  align-items: baseline;
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.40.1",
5
+ "version": "4.41.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.",
@@ -24,33 +24,68 @@
24
24
  * Eleven web apps render this bell and only `iservice365-web-app-org` has an
25
25
  * order queue. Rather than make eleven apps configure a map, the name is
26
26
  * written down once here and the bell asks its own router whether the route
27
- * exists before pushing — so in the ten apps without the page the click stays
28
- * exactly what it was: mark as read, go nowhere.
27
+ * exists before pushing.
28
+ *
29
+ * EVERY KIND SINCE 2026-09-24 (owner: "the notification never routes me")
30
+ * Each row names what it is about (`params.kind`, else its `module`), and the
31
+ * tables below say which web apps have that page. The bell opens this app's
32
+ * own page when it has one and otherwise jumps to the app that does, by its
33
+ * `APP_*` URL, with the organisation and site in the path.
29
34
  */
30
35
 
31
36
  /** Only the fields a destination is decided from. */
32
37
  export type TTargetRow = {
33
38
  module?: string;
34
39
  screen?: string;
35
- params?: { id?: string; org?: string } | null;
40
+ title?: string;
41
+ appSlug?: string;
42
+ siteId?: string | null;
43
+ /**
44
+ * `kind` is the click-through target every sender sets since 2026-09-24
45
+ * (core `withNotificationKind`); `site` stands in for `siteId` on rows kept
46
+ * unscoped on purpose. Older rows have neither and are read from `module`.
47
+ */
48
+ params?: { id?: string; org?: string; kind?: string; site?: string } | null;
36
49
  };
37
50
 
38
51
  /** What the surrounding app knows that the stored row does not. */
39
52
  export type TTargetContext = {
40
53
  /** The organization in the current URL. Org routes are all nested under it. */
41
54
  organization?: string;
55
+ /** The site in the current URL, used when the row names none. */
56
+ site?: string;
42
57
  };
43
58
 
59
+ /** The web apps a click can land in, keyed like their `APP_*` runtime URLs. */
60
+ export type TAppKey =
61
+ | "org"
62
+ | "security"
63
+ | "property-management"
64
+ | "hygiene"
65
+ | "mechanical-electrical"
66
+ | "pest-control"
67
+ | "landscaping"
68
+ | "pool-maintenance";
69
+
70
+ /** One place the record can be opened: a path inside one web app. */
71
+ export type TCandidate = { app: TAppKey; path: string };
72
+
44
73
  export type TNotificationTarget = {
45
- name: string;
46
- params: Record<string, string>;
74
+ /** A route name in the current app, tried first (the two original targets). */
75
+ name?: string;
76
+ params?: Record<string, string>;
47
77
  query?: Record<string, string>;
48
78
  /**
49
79
  * Set when the page lives in another web app. The bell opens
50
80
  * `<that app's URL><path>` when its own router has no route by `name`.
51
81
  */
52
- app?: "org";
82
+ app?: TAppKey;
53
83
  path?: string;
84
+ /**
85
+ * Every page that shows the record, best first. The bell opens the first one
86
+ * its own router has; otherwise it jumps to the first whose app URL is set.
87
+ */
88
+ candidates?: TCandidate[];
54
89
  };
55
90
 
56
91
  /** Nuxt's generated names for `pages/org/[organization]/marketplace/orders/`. */
@@ -73,13 +108,138 @@ export const VENDOR_ORDER_MODULE = "marketplace-vendor-order";
73
108
  export const SP_INVITATION_SCREEN = "/service-provider-invitation";
74
109
  export const INVITATIONS_ROUTE = "org-organization-client-invitations";
75
110
 
111
+ /**
112
+ * What a row is about. `params.kind` when the sender set it; for older rows the
113
+ * `module`, except where the module is shared: `platform` (a new client, an
114
+ * accepted invitation, a robot) and `other` (both halves of a service-provider
115
+ * invitation) are told apart by the fixed titles their senders use.
116
+ */
117
+ export function notificationKind(item: TTargetRow | null | undefined): string {
118
+ const kind = String(item?.params?.kind ?? "").trim();
119
+ if (kind) return kind;
120
+ const module = String(item?.module ?? "");
121
+ const title = String(item?.title ?? "");
122
+ if (String(item?.screen ?? "") === SP_INVITATION_SCREEN) return "serviceProviderInvite";
123
+ if (module === "platform") {
124
+ if (/^robot /i.test(title)) return "robot";
125
+ if (/^new client/i.test(title)) return "client";
126
+ if (/^invitation accepted/i.test(title)) return "clientInvitation";
127
+ return "";
128
+ }
129
+ if (module === "other") {
130
+ return /(accepted|declined) your invitation$/i.test(title) ? "serviceProviderInviteAnswer" : "";
131
+ }
132
+ return module;
133
+ }
134
+
135
+ /**
136
+ * The web app a mobile-tagged row's work lives in. A work order sent to the
137
+ * iClean365 app is managed in the Hygiene web app, and so on; anything from the
138
+ * resident or iMA365 apps is the property manager's.
139
+ */
140
+ const APP_FOR_SLUG: Record<string, TAppKey> = {
141
+ "iservice365-hygiene-mobile-app": "hygiene",
142
+ "iservice365-m-and-e-mobile-app": "mechanical-electrical",
143
+ "iservice365-pest-control-mobile-app": "pest-control",
144
+ "iservice365-pool-maintenance-mobile-app": "pool-maintenance",
145
+ "iservice365-landscaping-mobile-app": "landscaping",
146
+ "iservice365-landscape-mobile-app": "landscaping",
147
+ };
148
+
149
+ const SERVICE_APPS: TAppKey[] = [
150
+ "property-management",
151
+ "security",
152
+ "hygiene",
153
+ "mechanical-electrical",
154
+ "pest-control",
155
+ "landscaping",
156
+ "pool-maintenance",
157
+ ];
158
+
159
+ /**
160
+ * Site pages, `/<org>/<site>/<page>` in the service web apps, and which apps
161
+ * have them. "any" = every service app has the page; the row's own app leads.
162
+ */
163
+ type TSitePage = { apps: TAppKey[] | "any"; page: (id: string) => string };
164
+ const withId = (list: string) => (id: string) => (id ? `${list}/${id}` : list);
165
+ const SITE_PAGES: Record<string, TSitePage[]> = {
166
+ workOrder: [{ apps: "any", page: withId("work-orders") }],
167
+ feedback: [{ apps: "any", page: withId("feedbacks") }],
168
+ bulletinBoard: [{ apps: "any", page: () => "bulletin-board" }],
169
+ inventory: [{ apps: "any", page: () => "inventory" }],
170
+ event: [{ apps: ["property-management"], page: () => "event-mgmt" }],
171
+ facilityBooking: [{ apps: ["property-management"], page: () => "facility-booking" }],
172
+ onlineForm: [{ apps: ["property-management"], page: () => "online-forms" }],
173
+ documentExpiry: [{ apps: ["property-management"], page: () => "document-mgmt" }],
174
+ emergencyContact: [{ apps: ["property-management"], page: () => "emergency-contacts" }],
175
+ incidentReport: [{ apps: ["security", "property-management"], page: withId("incident-reports") }],
176
+ visitors: [
177
+ { apps: ["security"], page: () => "visitor-management" },
178
+ { apps: ["property-management"], page: () => "visitor-mgmt" },
179
+ ],
180
+ virtualPatrol: [{ apps: ["security"], page: () => "virtual-patrol/logs" }],
181
+ cameraFault: [
182
+ { apps: ["security"], page: () => "virtual-patrol/cctv" },
183
+ { apps: ["property-management"], page: () => "cctv" },
184
+ ],
185
+ serviceProviderInviteAnswer: [
186
+ { apps: SERVICE_APPS.filter((a) => a !== "hygiene"), page: () => "service-providers" },
187
+ ],
188
+ };
189
+ SITE_PAGES.myVisitors = SITE_PAGES.visitors;
190
+
191
+ /** Every kind the web can open, for tests and for anyone asking "is this clickable?". */
192
+ export const WEB_TARGET_KINDS = [
193
+ ...Object.keys(SITE_PAGES),
194
+ "serviceProviderInvite",
195
+ VENDOR_ORDER_MODULE,
196
+ "robot",
197
+ "client",
198
+ "clientInvitation",
199
+ ];
200
+
201
+ function sitePageTarget(
202
+ kind: string,
203
+ item: TTargetRow,
204
+ organization: string,
205
+ site: string,
206
+ ): TNotificationTarget | null {
207
+ const pages = SITE_PAGES[kind];
208
+ if (!pages || !organization || !site) return null;
209
+ const id = encodeURIComponent(String(item.params?.id ?? "").trim());
210
+ const home = APP_FOR_SLUG[String(item.appSlug ?? "")] ?? "property-management";
211
+ const base = `/${encodeURIComponent(organization)}/${encodeURIComponent(site)}/`;
212
+ const candidates: TCandidate[] = [];
213
+ for (const { apps, page } of pages) {
214
+ const list = apps === "any" ? [home, ...SERVICE_APPS.filter((a) => a !== home)] : apps;
215
+ for (const app of list) candidates.push({ app, path: base + page(id) });
216
+ }
217
+ return { candidates };
218
+ }
219
+
220
+ /** Seven365 staff pages, in the organisation app. */
221
+ function staffTarget(kind: string, item: TTargetRow): TNotificationTarget | null {
222
+ const id = String(item.params?.id ?? "").trim();
223
+ if (kind === "robot") return { candidates: [{ app: "org", path: "/super-admin/robots" }] };
224
+ if (kind === "clientInvitation") {
225
+ return { candidates: [{ app: "org", path: "/super-admin/client-list" }] };
226
+ }
227
+ if (kind === "client") {
228
+ return id
229
+ ? { candidates: [{ app: "org", path: "/super-admin/client-details" }], query: { org: id } }
230
+ : { candidates: [{ app: "org", path: "/super-admin/client-list" }] };
231
+ }
232
+ return null;
233
+ }
234
+
76
235
  export function notificationTarget(
77
236
  item: TTargetRow | null | undefined,
78
237
  context: TTargetContext = {},
79
238
  ): TNotificationTarget | null {
80
239
  const organization = String(context.organization ?? "").trim();
240
+ const kind = notificationKind(item);
81
241
 
82
- if (String(item?.screen ?? "") === SP_INVITATION_SCREEN) {
242
+ if (kind === "serviceProviderInvite") {
83
243
  // The invited company's organisation when the row names it (the invitation
84
244
  // belongs there), otherwise the one on screen. The list on that page is
85
245
  // keyed on the signed-in account, so either shows the invitation.
@@ -96,7 +256,14 @@ export function notificationTarget(
96
256
  };
97
257
  }
98
258
 
99
- if (String(item?.module ?? "") !== VENDOR_ORDER_MODULE) return null;
259
+ if (!item) return null;
260
+ const staff = staffTarget(kind, item);
261
+ if (staff) return staff;
262
+
263
+ if (kind !== VENDOR_ORDER_MODULE) {
264
+ const site = String(item.siteId ?? item.params?.site ?? context.site ?? "").trim();
265
+ return sitePageTarget(kind, item, organization, site);
266
+ }
100
267
 
101
268
  // Every order route is nested under the organization. Without one there is no
102
269
  // URL to build, and guessing an organization for somebody who belongs to
@@ -104,12 +271,23 @@ export function notificationTarget(
104
271
  if (!organization) return null;
105
272
 
106
273
  const id = String(item?.params?.id ?? "").trim();
274
+ const orders = `/org/${encodeURIComponent(organization)}/marketplace/orders`;
107
275
 
108
276
  // No id — an older row, or one whose params were dropped — still has an
109
277
  // honest destination: the queue the order is sitting in.
110
- if (!id) return { name: ORDER_QUEUE_ROUTE, params: { organization } };
278
+ if (!id) {
279
+ return {
280
+ name: ORDER_QUEUE_ROUTE,
281
+ params: { organization },
282
+ candidates: [{ app: "org", path: orders }],
283
+ };
284
+ }
111
285
 
112
- return { name: ORDER_DETAIL_ROUTE, params: { organization, id } };
286
+ return {
287
+ name: ORDER_DETAIL_ROUTE,
288
+ params: { organization, id },
289
+ candidates: [{ app: "org", path: `${orders}/${encodeURIComponent(id)}` }],
290
+ };
113
291
  }
114
292
 
115
293
  /**
@@ -118,11 +296,49 @@ export function notificationTarget(
118
296
  */
119
297
  export function externalTargetUrl(
120
298
  target: TNotificationTarget | null | undefined,
121
- appUrls: { org?: string } = {},
299
+ appUrls: Partial<Record<TAppKey, string>> = {},
122
300
  ): string {
123
- if (!target?.app || !target.path) return "";
124
- const base = String(appUrls[target.app] ?? "").trim().replace(/\/+$/, "");
125
- if (!base) return "";
126
- const qs = new URLSearchParams(target.query ?? {}).toString();
127
- return `${base}${target.path}${qs ? `?${qs}` : ""}`;
301
+ const options: TCandidate[] = [
302
+ ...(target?.app && target.path ? [{ app: target.app, path: target.path }] : []),
303
+ ...(target?.candidates ?? []),
304
+ ];
305
+ const qs = new URLSearchParams(target?.query ?? {}).toString();
306
+ for (const { app, path } of options) {
307
+ const base = String(appUrls[app] ?? "").trim().replace(/\/+$/, "");
308
+ if (base) return `${base}${path}${qs ? `?${qs}` : ""}`;
309
+ }
310
+ return "";
311
+ }
312
+
313
+ /** What the bell does with a click: move inside this app, open another app, or nothing. */
314
+ export type TNavigation =
315
+ | {
316
+ type: "route";
317
+ to: { name?: string; params?: Record<string, string>; path?: string; query?: Record<string, string> };
318
+ }
319
+ | { type: "href"; href: string }
320
+ | null;
321
+
322
+ /**
323
+ * This app's own page first — by route name, then by path (the site is in the
324
+ * path, so opening it switches the site) — else the first app that has the
325
+ * page and whose URL is configured. The page itself still checks the person may
326
+ * see it: a link grants nothing.
327
+ */
328
+ export function chooseNavigation(
329
+ target: TNotificationTarget | null | undefined,
330
+ app: {
331
+ hasRoute: (name: string) => boolean;
332
+ hasPage: (path: string) => boolean;
333
+ appUrls?: Partial<Record<TAppKey, string>>;
334
+ },
335
+ ): TNavigation {
336
+ if (!target) return null;
337
+ if (target.name && app.hasRoute(target.name)) {
338
+ return { type: "route", to: { name: target.name, params: target.params, query: target.query } };
339
+ }
340
+ const local = target.candidates?.find((c) => app.hasPage(c.path));
341
+ if (local) return { type: "route", to: { path: local.path, query: target.query } };
342
+ const href = externalTargetUrl(target, app.appUrls);
343
+ return href ? { type: "href", href } : null;
128
344
  }