@7365admin1/layer-common 4.40.1 → 4.42.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 +16 -4
- package/components/Layout/NavigationDrawer.vue +4 -1
- package/components/NotificationBell.vue +57 -16
- package/components/RolePermissionFormCreate.vue +4 -3
- package/components/RolePermissionFormPreviewUpdate.vue +12 -5
- package/components/RolePermissionMain.vue +1 -0
- package/composables/useCleaningPermission.ts +2 -0
- package/composables/useCommonPermission.ts +32 -0
- package/composables/usePropertyPermission.ts +2 -0
- package/package.json +1 -1
- package/utils/module-routes.ts +8 -1
- package/utils/nav-sections.ts +8 -2
- package/utils/notification-target.ts +232 -16
- package/utils/permission-labels.ts +20 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.42.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 5472953: Roles and permissions (owner approval 2026-09-25): Robots offered in the Property Management and Cleaning role editors; the PM editor's camera-wall row reads "CCTV" (label only, `virtual-patrol:*` strings unchanged); PM's CCTV page follows the client's module list (`org-site-cctv` -> `virtual-patrol`, fails open); `NavigationDrawer` takes `sectionLabels` so an app can rename a sidebar heading.
|
|
8
|
+
|
|
9
|
+
## 4.41.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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`.
|
|
14
|
+
|
|
3
15
|
## 4.40.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1465,11 +1477,11 @@
|
|
|
1465
1477
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1466
1478
|
own rule from two endpoints the console already calls, unprojected:
|
|
1467
1479
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1480
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1481
|
+
GET /api/roles/id/:role that membership's role document
|
|
1470
1482
|
|
|
1471
|
-
|
|
1472
|
-
|
|
1483
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1484
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1473
1485
|
|
|
1474
1486
|
`role.default` is the marker because it is the only property of a platform
|
|
1475
1487
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -129,6 +129,9 @@ import { withoutDeniedRoutes } from "../../utils/module-routes";
|
|
|
129
129
|
const props = defineProps({
|
|
130
130
|
navigationItems: { type: Array, required: true },
|
|
131
131
|
title: { type: String, default: "" },
|
|
132
|
+
// An app's own name for a section heading (`withSections`), e.g.
|
|
133
|
+
// `{ "SECURITY OPERATIONS": "ESTATE SECURITY" }` in Property Management.
|
|
134
|
+
sectionLabels: { type: Object, default: () => ({}) },
|
|
132
135
|
});
|
|
133
136
|
|
|
134
137
|
const emit = defineEmits(["navigate"]);
|
|
@@ -196,7 +199,7 @@ const navigationItems = computed(() => {
|
|
|
196
199
|
return withoutDeniedRoutes(items, userAppModulesDenied.value);
|
|
197
200
|
});
|
|
198
201
|
|
|
199
|
-
const sectionedItems = computed(() => withSections(navigationItems.value));
|
|
202
|
+
const sectionedItems = computed(() => withSections(navigationItems.value, props.sectionLabels as Record<string, string>));
|
|
200
203
|
|
|
201
204
|
/**
|
|
202
205
|
* The design's brand tile carries the product's own mark. One icon for eleven
|
|
@@ -70,7 +70,10 @@
|
|
|
70
70
|
:key="item._id"
|
|
71
71
|
type="button"
|
|
72
72
|
class="notif-card"
|
|
73
|
-
:class="{
|
|
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 {
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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(
|
|
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;
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
</template>
|
|
140
140
|
<v-list-item-title>
|
|
141
141
|
<span class="text-capitalize role-create__row-title">
|
|
142
|
-
{{ formatResourceLabel(String(resourceKey)) }}
|
|
142
|
+
{{ formatResourceLabel(String(resourceKey), props.type) }}
|
|
143
143
|
</span>
|
|
144
144
|
</v-list-item-title>
|
|
145
145
|
<template #append>
|
|
@@ -279,6 +279,7 @@ const props = defineProps({
|
|
|
279
279
|
},
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
|
|
282
283
|
const emit = defineEmits(["cancel", "success", "success:create-more"]);
|
|
283
284
|
|
|
284
285
|
const validForm = ref(false);
|
|
@@ -537,14 +538,14 @@ const moduleSearch = ref<string | null>("");
|
|
|
537
538
|
/** `safePermissions`, narrowed to what the search box matches. */
|
|
538
539
|
const visiblePermissions = computed(() => {
|
|
539
540
|
const query = (moduleSearch.value ?? "").trim().toLowerCase();
|
|
540
|
-
const all = sortResourcesByLabel(safePermissions.value);
|
|
541
|
+
const all = sortResourcesByLabel(safePermissions.value, props.type);
|
|
541
542
|
if (!query) return all;
|
|
542
543
|
|
|
543
544
|
const matches: Record<string, any> = {};
|
|
544
545
|
for (const [resource, actions] of Object.entries(all)) {
|
|
545
546
|
const haystack = [
|
|
546
547
|
resource,
|
|
547
|
-
formatResourceLabel(resource),
|
|
548
|
+
formatResourceLabel(resource, props.type),
|
|
548
549
|
...Object.keys(actions ?? {}).flatMap((action) => [
|
|
549
550
|
action,
|
|
550
551
|
formatActionLabel(action),
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
/>
|
|
103
103
|
</template>
|
|
104
104
|
<v-list-item-title class="text-capitalize role-perm__row-title">
|
|
105
|
-
{{ formatResourceLabel(String(resourceKey)) }}
|
|
105
|
+
{{ formatResourceLabel(String(resourceKey), props.type) }}
|
|
106
106
|
</v-list-item-title>
|
|
107
107
|
<template #append>
|
|
108
108
|
<div class="role-perm__cat-actions">
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
<v-divider v-if="resourceIndex > 0" />
|
|
192
192
|
<v-list-item density="compact" :ripple="false">
|
|
193
193
|
<v-list-item-title class="text-capitalize role-perm__row-title">
|
|
194
|
-
{{ formatResourceLabel(String(resourceKey)) }}
|
|
194
|
+
{{ formatResourceLabel(String(resourceKey), props.type) }}
|
|
195
195
|
</v-list-item-title>
|
|
196
196
|
<v-list-item-subtitle class="role-perm__row-sub">
|
|
197
197
|
{{
|
|
@@ -311,8 +311,15 @@ const props = defineProps({
|
|
|
311
311
|
type: Number,
|
|
312
312
|
default: 0,
|
|
313
313
|
},
|
|
314
|
+
// The role type the editor was opened for; only changes a module's NAME
|
|
315
|
+
// where one app calls a key something else (`RESOURCE_LABELS_BY_TYPE`).
|
|
316
|
+
type: {
|
|
317
|
+
type: String,
|
|
318
|
+
default: "",
|
|
319
|
+
},
|
|
314
320
|
});
|
|
315
321
|
|
|
322
|
+
|
|
316
323
|
/** A role holding the wildcard has ONE stored entry and reaches everything. */
|
|
317
324
|
const holdsWildcard = computed(() => (definedModel.value ?? []).includes("*"));
|
|
318
325
|
|
|
@@ -486,14 +493,14 @@ const moduleSearch = ref<string | null>("");
|
|
|
486
493
|
|
|
487
494
|
const visiblePermissions = computed(() => {
|
|
488
495
|
const query = (moduleSearch.value ?? "").trim().toLowerCase();
|
|
489
|
-
const all = sortResourcesByLabel(safePermissions.value);
|
|
496
|
+
const all = sortResourcesByLabel(safePermissions.value, props.type);
|
|
490
497
|
if (!query) return all;
|
|
491
498
|
|
|
492
499
|
const matches: Record<string, any> = {};
|
|
493
500
|
for (const [resource, actions] of Object.entries(all)) {
|
|
494
501
|
const haystack = [
|
|
495
502
|
resource,
|
|
496
|
-
formatResourceLabel(resource),
|
|
503
|
+
formatResourceLabel(resource, props.type),
|
|
497
504
|
...Object.keys((actions as Record<string, unknown>) ?? {}).flatMap(
|
|
498
505
|
(action) => [action, formatActionLabel(action)]
|
|
499
506
|
),
|
|
@@ -509,7 +516,7 @@ const visiblePermissions = computed(() => {
|
|
|
509
516
|
// Same A-Z as the tree above it; a second list in a second order reads as a
|
|
510
517
|
// different kind of thing.
|
|
511
518
|
const sortedNotOffered = computed(() =>
|
|
512
|
-
sortResourcesByLabel(props.notOfferedPermissions as Record<string, any
|
|
519
|
+
sortResourcesByLabel(props.notOfferedPermissions as Record<string, any>, props.type)
|
|
513
520
|
);
|
|
514
521
|
|
|
515
522
|
/*
|
|
@@ -13,6 +13,7 @@ export default function useCleaningPermission() {
|
|
|
13
13
|
bulletinBoardPermissions,
|
|
14
14
|
|
|
15
15
|
inventoryPermissions,
|
|
16
|
+
robotPermissions,
|
|
16
17
|
} = useCommonPermissions();
|
|
17
18
|
const permissions: TPermissions = {
|
|
18
19
|
inventory: inventoryPermissions,
|
|
@@ -291,6 +292,7 @@ export default function useCleaningPermission() {
|
|
|
291
292
|
"Allows the user to view detailed information for item checkouts.",
|
|
292
293
|
},
|
|
293
294
|
},
|
|
295
|
+
"robot-mgmt": robotPermissions,
|
|
294
296
|
};
|
|
295
297
|
|
|
296
298
|
return {
|
|
@@ -331,7 +331,39 @@ export function useCommonPermissions() {
|
|
|
331
331
|
},
|
|
332
332
|
};
|
|
333
333
|
|
|
334
|
+
/*
|
|
335
|
+
* ROBOTS - owner approval 2026-09-25, item 1: the Property Management and
|
|
336
|
+
* Cleaning role editors offer Robots, so a narrower role can be given robot
|
|
337
|
+
* access. Same five strings, spelling and wording as `useSecurityPermission`
|
|
338
|
+
* and exactly what core `robot-permission.util.ts ROBOT_PERMISSIONS` enforces.
|
|
339
|
+
* Catalogue-only: offering an entry grants nobody anything until it is ticked.
|
|
340
|
+
*/
|
|
341
|
+
const robotPermissions = {
|
|
342
|
+
"add-robot": {
|
|
343
|
+
check: true,
|
|
344
|
+
description: "Allows the user to add a new robot to the system.",
|
|
345
|
+
},
|
|
346
|
+
"see-all-robots": {
|
|
347
|
+
check: true,
|
|
348
|
+
description: "Allows the user to view the list of all robots.",
|
|
349
|
+
},
|
|
350
|
+
"see-robot-details": {
|
|
351
|
+
check: true,
|
|
352
|
+
description: "Allows the user to view the details of a specific robot.",
|
|
353
|
+
},
|
|
354
|
+
"update-robot": {
|
|
355
|
+
check: true,
|
|
356
|
+
description: "Allows the user to update robot details.",
|
|
357
|
+
},
|
|
358
|
+
"delete-robot": {
|
|
359
|
+
check: true,
|
|
360
|
+
description:
|
|
361
|
+
"Allows the user to remove a robot from the system permanently.",
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
|
|
334
365
|
return {
|
|
366
|
+
robotPermissions,
|
|
335
367
|
inventoryPermissions,
|
|
336
368
|
invitationPermissions,
|
|
337
369
|
memberPermissions,
|
|
@@ -42,6 +42,7 @@ export default function usePropertyPermission() {
|
|
|
42
42
|
siteSettingsPermissions,
|
|
43
43
|
|
|
44
44
|
inventoryPermissions,
|
|
45
|
+
robotPermissions,
|
|
45
46
|
} = useCommonPermissions();
|
|
46
47
|
|
|
47
48
|
|
|
@@ -618,6 +619,7 @@ export default function usePropertyPermission() {
|
|
|
618
619
|
"Allows the user to view the list of all resident transactions on mobile. Checked by the iMA365 mobile app.",
|
|
619
620
|
},
|
|
620
621
|
},
|
|
622
|
+
"robot-mgmt": robotPermissions,
|
|
621
623
|
"site-settings": siteSettingsPermissions
|
|
622
624
|
};
|
|
623
625
|
|
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.
|
|
5
|
+
"version": "4.42.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.",
|
package/utils/module-routes.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* (checked against every app's own `useLocalPermission` nav gate, 2026-09-11).
|
|
13
13
|
* Shared or ambiguous pages stay out: calendar, keys (gated on `site-settings`),
|
|
14
14
|
* access-mgmt (`site-settings:manage-entry-pass` also opens it), equipment and
|
|
15
|
-
* inventory,
|
|
15
|
+
* inventory, service-providers and support tickets.
|
|
16
16
|
*
|
|
17
17
|
* NEVER MAPPED: the dashboard, settings, members, invitations and roles. A client
|
|
18
18
|
* must always be able to run its own people and roles; the server never denies
|
|
@@ -40,6 +40,13 @@ export const MODULE_ROUTES: Readonly<Record<string, string>> = Object.freeze({
|
|
|
40
40
|
// Includes its CCTV page: security's drawer shows it to whoever can see
|
|
41
41
|
// Virtual Patrol, so it follows the same module.
|
|
42
42
|
"org-site-virtual-patrol": "virtual-patrol",
|
|
43
|
+
// Property Management's camera wall (owner approval 2026-09-25, item 3). Its
|
|
44
|
+
// menu, page and the server's wall endpoint all read the four legacy
|
|
45
|
+
// `virtual-patrol:` strings (`useCameraViewPermission`, core
|
|
46
|
+
// `CAMERA_VIEW_PERMISSIONS`), and the PM role editor now calls that row
|
|
47
|
+
// "CCTV". So it follows the same module as Security's own CCTV page above.
|
|
48
|
+
// Fails open like every entry: a client with no module list keeps it.
|
|
49
|
+
"org-site-cctv": "virtual-patrol",
|
|
43
50
|
"org-site-robot-management": "robot-mgmt",
|
|
44
51
|
"org-site-attendance": "attendance-mgmt",
|
|
45
52
|
"org-site-my-attendance": "my-attendance",
|
package/utils/nav-sections.ts
CHANGED
|
@@ -125,7 +125,13 @@ export const SECTION_ORDER: string[] = [
|
|
|
125
125
|
* objects; only the order changes.
|
|
126
126
|
*/
|
|
127
127
|
export function withSections<T extends { title?: string }>(
|
|
128
|
-
items: T[]
|
|
128
|
+
items: T[],
|
|
129
|
+
/**
|
|
130
|
+
* An application's own name for a heading, e.g. Property Management prints
|
|
131
|
+
* SECURITY OPERATIONS as ESTATE SECURITY (owner approval 2026-09-25, item 6).
|
|
132
|
+
* Changes the printed label only; grouping and order are unchanged.
|
|
133
|
+
*/
|
|
134
|
+
labels: Readonly<Record<string, string>> = {}
|
|
129
135
|
): Array<TSectionedNavItem<T>> {
|
|
130
136
|
// An unmapped title inherits the section of the item above it.
|
|
131
137
|
let current = "";
|
|
@@ -144,7 +150,7 @@ export function withSections<T extends { title?: string }>(
|
|
|
144
150
|
|
|
145
151
|
let printed = "";
|
|
146
152
|
return grouped.map(({ item, section }) => {
|
|
147
|
-
const label = section === printed ? "" : section;
|
|
153
|
+
const label = section === printed ? "" : labels[section] ?? section;
|
|
148
154
|
printed = section;
|
|
149
155
|
return { item, section: label };
|
|
150
156
|
});
|
|
@@ -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
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
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?:
|
|
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 (
|
|
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 (
|
|
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)
|
|
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 {
|
|
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:
|
|
299
|
+
appUrls: Partial<Record<TAppKey, string>> = {},
|
|
122
300
|
): string {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
}
|
|
@@ -55,14 +55,31 @@ export const RESOURCE_LABELS: Record<string, string> = {
|
|
|
55
55
|
roles: "Roles and permissions",
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Names that differ BY APPLICATION, keyed by the role `type` the editor is
|
|
60
|
+
* opened with. Only where one key means a different screen in one app.
|
|
61
|
+
*
|
|
62
|
+
* Owner approval 2026-09-25, item 2: in the Property Management editor the
|
|
63
|
+
* `virtual-patrol` row is the CCTV camera wall (the four legacy strings
|
|
64
|
+
* `useCameraViewPermission` and core `CAMERA_VIEW_PERMISSIONS` read), not the
|
|
65
|
+
* Security app's Virtual Patrol. Label only: the key and every stored string
|
|
66
|
+
* stay `virtual-patrol:*`, so every role that holds them keeps working.
|
|
67
|
+
*/
|
|
68
|
+
export const RESOURCE_LABELS_BY_TYPE: Record<string, Record<string, string>> = {
|
|
69
|
+
property_management_agency: { "virtual-patrol": "CCTV" },
|
|
70
|
+
real_estate_developer: { "virtual-patrol": "CCTV" },
|
|
71
|
+
};
|
|
72
|
+
|
|
58
73
|
/**
|
|
59
74
|
* `work_orders` -> `Work orders`.
|
|
60
75
|
*
|
|
61
76
|
* Sentence case, not Title Case: a list of 47 Title Cased phrases is harder to
|
|
62
77
|
* scan, not easier.
|
|
63
78
|
*/
|
|
64
|
-
export function formatResourceLabel(key: string): string {
|
|
79
|
+
export function formatResourceLabel(key: string, type?: string): string {
|
|
65
80
|
if (!key) return "";
|
|
81
|
+
const byType = type ? RESOURCE_LABELS_BY_TYPE[type]?.[key] : undefined;
|
|
82
|
+
if (byType) return byType;
|
|
66
83
|
if (RESOURCE_LABELS[key]) return RESOURCE_LABELS[key];
|
|
67
84
|
|
|
68
85
|
const words = key
|
|
@@ -115,11 +132,12 @@ export function formatActionLabel(action: string): string {
|
|
|
115
132
|
*/
|
|
116
133
|
export function sortResourcesByLabel<T>(
|
|
117
134
|
tree: Record<string, T> | null | undefined,
|
|
135
|
+
type?: string,
|
|
118
136
|
): Record<string, T> {
|
|
119
137
|
if (!tree || typeof tree !== "object") return {};
|
|
120
138
|
const sorted: Record<string, T> = {};
|
|
121
139
|
for (const key of Object.keys(tree).sort((a, b) =>
|
|
122
|
-
formatResourceLabel(a).localeCompare(formatResourceLabel(b), undefined, {
|
|
140
|
+
formatResourceLabel(a, type).localeCompare(formatResourceLabel(b, type), undefined, {
|
|
123
141
|
sensitivity: "base",
|
|
124
142
|
numeric: true,
|
|
125
143
|
}),
|