@7365admin1/layer-common 4.39.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,23 @@
|
|
|
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
|
+
|
|
9
|
+
## 4.40.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 783ffe2: Edit Camera: the device password is write-only. On edit the Password field is optional, starts blank with the placeholder "Saved · leave blank to keep", and is sent only when a new one is typed, so an admin can switch a camera's Status without re-typing its login. A status-only change now enables Submit. Password stays required when adding a camera.
|
|
14
|
+
|
|
15
|
+
## 4.40.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 3a96438: Client invitations: an accepted invitation whose site never reached the company's Sites list now says so in plain words and offers "Add <site> to your Sites" (`linkInvitationSite`, `PUT /service-provider-invitations/:id/link-site`). Needs core 3.100.0.
|
|
20
|
+
|
|
3
21
|
## 4.39.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
|
@@ -1453,11 +1471,11 @@
|
|
|
1453
1471
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1454
1472
|
own rule from two endpoints the console already calls, unprojected:
|
|
1455
1473
|
|
|
1456
|
-
|
|
1457
|
-
|
|
1474
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1475
|
+
GET /api/roles/id/:role that membership's role document
|
|
1458
1476
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1477
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1478
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1461
1479
|
|
|
1462
1480
|
`role.default` is the marker because it is the only property of a platform
|
|
1463
1481
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -161,14 +161,21 @@
|
|
|
161
161
|
</v-col>
|
|
162
162
|
|
|
163
163
|
<v-col v-if="isANPR" cols="12">
|
|
164
|
-
|
|
164
|
+
<!-- Write-only: the saved password is never sent to the browser, so
|
|
165
|
+
on edit a blank field keeps it. Required only for a new camera. -->
|
|
166
|
+
<InputLabel
|
|
167
|
+
class="text-capitalize"
|
|
168
|
+
title="Password"
|
|
169
|
+
:required="!isEdit"
|
|
170
|
+
/>
|
|
165
171
|
<v-text-field
|
|
166
172
|
v-model.trim="camera.password"
|
|
167
173
|
type="password"
|
|
174
|
+
autocomplete="new-password"
|
|
168
175
|
class="filter-field"
|
|
169
176
|
density="comfortable"
|
|
170
|
-
:rules="[requiredRule]"
|
|
171
|
-
placeholder="Enter password"
|
|
177
|
+
:rules="isEdit ? [] : [requiredRule]"
|
|
178
|
+
:placeholder="isEdit ? 'Saved · leave blank to keep' : 'Enter password'"
|
|
172
179
|
/>
|
|
173
180
|
</v-col>
|
|
174
181
|
|
|
@@ -419,8 +426,12 @@ const camera = ref<TSiteCamera>({
|
|
|
419
426
|
|
|
420
427
|
camera.value.site = prop.site;
|
|
421
428
|
|
|
422
|
-
|
|
429
|
+
const isEdit = prop.mode === "edit";
|
|
430
|
+
|
|
431
|
+
if (isEdit) {
|
|
423
432
|
camera.value = JSON.parse(JSON.stringify(prop.camera));
|
|
433
|
+
// Never pre-filled: blank means "keep the saved password".
|
|
434
|
+
camera.value.password = "";
|
|
424
435
|
}
|
|
425
436
|
camera.value.site = prop.site;
|
|
426
437
|
camera.value.type = prop.type;
|
|
@@ -576,7 +587,9 @@ const hasChanges = computed(() => {
|
|
|
576
587
|
return (
|
|
577
588
|
camera.value.host !== prop.camera.host ||
|
|
578
589
|
camera.value.username !== prop.camera.username ||
|
|
579
|
-
|
|
590
|
+
// A typed password is a change; blank keeps the saved one.
|
|
591
|
+
!!camera.value.password ||
|
|
592
|
+
camera.value.status !== prop.camera.status ||
|
|
580
593
|
camera.value.category !== prop.camera.category ||
|
|
581
594
|
camera.value.guardPost !== prop.camera.guardPost ||
|
|
582
595
|
camera.value.direction !== prop.camera.direction ||
|
|
@@ -895,7 +908,8 @@ async function save(
|
|
|
895
908
|
const res = await updateSiteCamera(camera.value._id ?? "", {
|
|
896
909
|
...addressPayload(),
|
|
897
910
|
username: camera.value.username,
|
|
898
|
-
|
|
911
|
+
// Sent only when typed; left out, the server keeps the saved one.
|
|
912
|
+
...(camera.value.password ? { password: camera.value.password } : {}),
|
|
899
913
|
category: camera.value.category,
|
|
900
914
|
guardPost: camera.value.guardPost,
|
|
901
915
|
direction: camera.value.direction,
|
|
@@ -78,7 +78,17 @@
|
|
|
78
78
|
</div>
|
|
79
79
|
|
|
80
80
|
<div v-else-if="item.status === 'complete'" class="px-4 pb-4">
|
|
81
|
-
<
|
|
81
|
+
<v-btn
|
|
82
|
+
v-if="item.needsSiteLink"
|
|
83
|
+
variant="flat"
|
|
84
|
+
class="screen-btn-primary"
|
|
85
|
+
:loading="working[item._id] === 'link'"
|
|
86
|
+
:disabled="Boolean(working[item._id])"
|
|
87
|
+
@click="addToSites(item)"
|
|
88
|
+
>
|
|
89
|
+
Add {{ item.siteName }} to your Sites
|
|
90
|
+
</v-btn>
|
|
91
|
+
<NuxtLink v-else-if="item.onThisOrg" :to="`/org/${org}/sites`">Open Sites</NuxtLink>
|
|
82
92
|
<NuxtLink
|
|
83
93
|
v-else-if="item.providerOrgId && item.providerOrgId !== org"
|
|
84
94
|
:to="`/org/${item.providerOrgId}/sites`"
|
|
@@ -124,8 +134,13 @@ const tabs: Array<{ value: Tab; label: string }> = [
|
|
|
124
134
|
];
|
|
125
135
|
|
|
126
136
|
const route = useRoute();
|
|
127
|
-
const {
|
|
128
|
-
|
|
137
|
+
const {
|
|
138
|
+
getReceivedInvitations,
|
|
139
|
+
getInvitation,
|
|
140
|
+
acceptInvitation,
|
|
141
|
+
declineInvitation,
|
|
142
|
+
linkInvitationSite,
|
|
143
|
+
} = useServiceProviderInvitation();
|
|
129
144
|
|
|
130
145
|
const tab = ref<Tab>("pending");
|
|
131
146
|
const items = ref<TReceivedServiceProviderInvitation[]>([]);
|
|
@@ -180,6 +195,9 @@ function stateLine(item: TReceivedServiceProviderInvitation) {
|
|
|
180
195
|
if (item.onThisOrg) {
|
|
181
196
|
return `Accepted${on(item.answeredAt)} — ${item.siteName} is on your Sites list as a site you serve for ${item.orgName}.`;
|
|
182
197
|
}
|
|
198
|
+
if (item.needsSiteLink) {
|
|
199
|
+
return "This invitation was accepted, but the site wasn't added to your Sites. Add it now.";
|
|
200
|
+
}
|
|
183
201
|
if (!item.providerOrgId || item.providerOrgId === props.org) {
|
|
184
202
|
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
203
|
}
|
|
@@ -228,6 +246,20 @@ async function answer(item: TReceivedServiceProviderInvitation, action: "accept"
|
|
|
228
246
|
}
|
|
229
247
|
}
|
|
230
248
|
|
|
249
|
+
/** Accepted but never added to the Sites list: add it now, then show it. */
|
|
250
|
+
async function addToSites(item: TReceivedServiceProviderInvitation) {
|
|
251
|
+
errors[item._id] = "";
|
|
252
|
+
working[item._id] = "link";
|
|
253
|
+
try {
|
|
254
|
+
await linkInvitationSite(item._id, props.org);
|
|
255
|
+
await load();
|
|
256
|
+
} catch (err: any) {
|
|
257
|
+
errors[item._id] = err?.data?.message || "Could not add this site to your Sites.";
|
|
258
|
+
} finally {
|
|
259
|
+
working[item._id] = "";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
231
263
|
// Opened from the bell: start on the tab the linked invitation is in.
|
|
232
264
|
if (linked.value) {
|
|
233
265
|
try {
|
|
@@ -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;
|
|
@@ -48,6 +48,8 @@ export type TReceivedServiceProviderInvitation = {
|
|
|
48
48
|
providerOrgName?: string;
|
|
49
49
|
/** Accepted AND on the Sites list of the company on screen. */
|
|
50
50
|
onThisOrg?: boolean;
|
|
51
|
+
/** Accepted, but the site is on no company's Sites list: offer to add it. */
|
|
52
|
+
needsSiteLink?: boolean;
|
|
51
53
|
};
|
|
52
54
|
|
|
53
55
|
/** The words on screen. Kept beside the server's own list on purpose — a
|
|
@@ -171,6 +173,17 @@ export default function useServiceProviderInvitation() {
|
|
|
171
173
|
});
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/**
|
|
177
|
+
* An accepted invitation whose site never reached this company's Sites
|
|
178
|
+
* list: add it now. Safe to repeat - the server adds it once.
|
|
179
|
+
*/
|
|
180
|
+
function linkInvitationSite(id: string, org: string) {
|
|
181
|
+
return useNuxtApp().$api<{ message: string }>(`${base}/${id}/link-site`, {
|
|
182
|
+
method: "PUT",
|
|
183
|
+
body: { org },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
174
187
|
/**
|
|
175
188
|
* "Client invitations": one tab of the invitations sent to the signed-in
|
|
176
189
|
* account, plus the count in every tab. `org` is the company on screen.
|
|
@@ -200,6 +213,7 @@ export default function useServiceProviderInvitation() {
|
|
|
200
213
|
getInvitation,
|
|
201
214
|
declineInvitation,
|
|
202
215
|
acceptInvitation,
|
|
216
|
+
linkInvitationSite,
|
|
203
217
|
getReceivedInvitations,
|
|
204
218
|
};
|
|
205
219
|
}
|
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.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
|
|
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
|
}
|