@7365admin1/layer-common 4.36.0 → 4.38.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 +21 -4
- package/assets/css/robot-hero.css +2 -2
- package/components/NotificationBell.vue +15 -4
- package/components/Robot/BuildingMap.vue +16 -9
- package/components/Robot/LiftQueue.vue +9 -4
- package/components/Robot/MapEditor.vue +1 -8
- package/components/ServiceProviderInvitationPrompt.vue +10 -3
- package/components/ServiceProviderInvitationsReceived.vue +113 -0
- package/composables/usePropertyPermission.ts +17 -0
- package/composables/useRobotBuilding.ts +22 -3
- package/composables/useRobots.ts +13 -0
- package/composables/useSecurityPermission.ts +19 -0
- package/composables/useServiceProviderInvitation.ts +32 -0
- package/package.json +2 -2
- package/plugins/API.ts +11 -0
- package/utils/notification-target.ts +54 -1
- package/utils/robot-fleet-core.ts +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.38.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 59ce7ed: Passes & Keys permissions and the membership the app is working in (DV-0409).
|
|
8
|
+
|
|
9
|
+
- Passes And Keys Management: new `add-passes-and-keys`, `update-passes-and-keys` and `delete-passes-and-keys` permissions in both the Security and the Property Management role catalogues (`useSecurityPermission`, `usePropertyPermission`). API-core now enforces them on `/api/keys` and `/api/qr-code-templates`; "See All Passes And Keys" is what shows the Templates and Passes & Keys pages.
|
|
10
|
+
- `plugins/API.ts` sends `X-App` (this app's member type, from `runtimeConfig.public.APP`) and `X-Org` (the organisation in the address) with every request, so core checks the role of the membership the screen is using when a person holds several memberships at one site. Only chooses among the user's own memberships; grants nothing. A server that does not read them is unaffected.
|
|
11
|
+
|
|
12
|
+
- 27c17b7: Service-provider invitations can be answered in the app: the notification bell opens "<Company> has invited you to <site>" on the organisation app's Invitations page (other apps send the click there by URL), the new `ServiceProviderInvitationsReceived` section lists invitations from other organisations with Accept / Decline, `useServiceProviderInvitation` gains `acceptInvitation` and `getReceivedInvitations`, and the invitation card shows a failed accept or decline instead of swallowing it.
|
|
13
|
+
|
|
14
|
+
## 4.37.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- 8ccdca4: HERO building map and lift queue show the HERO Fleet Core building at sites with fleet robots: walls, doors (open doors highlighted) and lift shafts from the fleet, refreshed every 5 s, read-only (no Edit map); the lift queue shows each fleet lift's car floor and the robot holding it. The map editor's duplicate "Full screen" button is removed - the page header's one full-screens the viewer or the editor.
|
|
19
|
+
|
|
3
20
|
## 4.36.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
|
@@ -1424,11 +1441,11 @@
|
|
|
1424
1441
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1425
1442
|
own rule from two endpoints the console already calls, unprojected:
|
|
1426
1443
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1444
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1445
|
+
GET /api/roles/id/:role that membership's role document
|
|
1429
1446
|
|
|
1430
|
-
|
|
1431
|
-
|
|
1447
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1448
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1432
1449
|
|
|
1433
1450
|
`role.default` is the marker because it is the only property of a platform
|
|
1434
1451
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -951,6 +951,7 @@
|
|
|
951
951
|
.hero-bmap__wall { stroke: var(--hero-text); stroke-width: 3; opacity: 0.8; }
|
|
952
952
|
.hero-bmap__zone { fill: color-mix(in srgb, var(--hero-text) 10%, transparent); stroke: var(--hero-text); stroke-width: 1; stroke-dasharray: 4 3; }
|
|
953
953
|
.hero-bmap__doorline { stroke: var(--hero-accent); stroke-width: 3; }
|
|
954
|
+
.hero-bmap__doorline--open { stroke: rgb(var(--v-theme-success)); stroke-dasharray: 2 2; }
|
|
954
955
|
.hero-bmap__anchor { position: absolute; width: 0; height: 0; transform-style: preserve-3d; }
|
|
955
956
|
.hero-bmap__routetag { position: absolute; left: -6px; top: -30px; transform-origin: 0 0; white-space: nowrap; font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 800; padding: 1px 6px; background: var(--hero-accent); color: #fff; }
|
|
956
957
|
.hero-bmap__cp { position: absolute; left: -6px; top: -6px; width: 12px; height: 12px; box-sizing: border-box; border: 2px solid var(--hero-accent); background: var(--hero-bg); display: grid; place-items: center; font-size: 7px; font-weight: 800; color: var(--hero-accent); }
|
|
@@ -985,6 +986,7 @@
|
|
|
985
986
|
.hero-bmap__legend { position: absolute; left: 14px; bottom: 12px; z-index: 3; font-size: 11px; opacity: 0.65; display: flex; gap: 14px; white-space: nowrap; }
|
|
986
987
|
.hero-bmap__key { display: inline-block; width: 8px; height: 8px; vertical-align: middle; margin-right: 5px; }
|
|
987
988
|
.hero-bmap__key--door { background: var(--hero-accent); }
|
|
989
|
+
.hero-bmap__key--dooropen { background: rgb(var(--v-theme-success)); }
|
|
988
990
|
.hero-bmap__key--shaft { background: color-mix(in srgb, var(--hero-accent) 30%, transparent); border: 1px solid var(--hero-accent); box-sizing: border-box; }
|
|
989
991
|
.hero-bmap__key--bot { background: var(--hero-text); }
|
|
990
992
|
.hero-bmap__key--route { width: 12px; height: 0; border-top: 2px dashed var(--hero-accent); }
|
|
@@ -1010,8 +1012,6 @@
|
|
|
1010
1012
|
.hero .hero-input.hero-editor__floor { width: auto; min-height: 32px; padding: 4px 26px 4px 8px; }
|
|
1011
1013
|
.hero .hero-input.hero-editor__zone { width: 220px; min-height: 32px; }
|
|
1012
1014
|
.hero-editor__hint { font-size: 12px; opacity: 0.7; order: 1; flex-basis: 100%; } /* own line: switching tools never moves the plan */
|
|
1013
|
-
.hero .v-btn.hero-editor__fs { margin-left: auto; }
|
|
1014
|
-
.hero .v-btn.hero-editor__fs .v-btn__content { gap: 8px; }
|
|
1015
1015
|
.hero-editor__notice { padding: 10px 12px; font-size: 12.5px; border-left: 2px solid var(--hero-accent); background: var(--hero-surface); }
|
|
1016
1016
|
.hero-editor__notice--ok { border-left-color: rgb(var(--v-theme-success)); }
|
|
1017
1017
|
.hero-editor__split { display: flex; flex-wrap: wrap; gap: 24px; align-items: stretch; flex: 1; }
|
|
@@ -134,7 +134,7 @@ import {
|
|
|
134
134
|
timeAgo,
|
|
135
135
|
type TInboxItem,
|
|
136
136
|
} from "../utils/notification-inbox";
|
|
137
|
-
import { notificationTarget } from "../utils/notification-target";
|
|
137
|
+
import { externalTargetUrl, notificationTarget } from "../utils/notification-target";
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
140
|
* The top-bar notification feed for the web, in every signed-in app.
|
|
@@ -168,6 +168,7 @@ const { getAll, getUnreadCount, markAsRead, markAllAsRead } = useNotification();
|
|
|
168
168
|
const { currentUser } = useLocalAuth();
|
|
169
169
|
const route = useRoute();
|
|
170
170
|
const router = useRouter();
|
|
171
|
+
const appConfig = useRuntimeConfig();
|
|
171
172
|
|
|
172
173
|
const open = ref(false);
|
|
173
174
|
const loading = ref(false);
|
|
@@ -305,14 +306,24 @@ function markRead(item: Record<string, any>) {
|
|
|
305
306
|
*/
|
|
306
307
|
async function goToSubject(item: Record<string, any>) {
|
|
307
308
|
const target = notificationTarget(item as any, {
|
|
308
|
-
|
|
309
|
+
// The org app nests under `organization`; the service apps under `org`.
|
|
310
|
+
organization: String(route.params?.organization ?? route.params?.org ?? ""),
|
|
309
311
|
});
|
|
310
312
|
if (!target) return;
|
|
311
|
-
|
|
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;
|
|
322
|
+
}
|
|
312
323
|
|
|
313
324
|
open.value = false;
|
|
314
325
|
try {
|
|
315
|
-
await router.push(target);
|
|
326
|
+
await router.push({ name: target.name, params: target.params, query: target.query });
|
|
316
327
|
} catch {
|
|
317
328
|
// A cancelled or duplicate navigation is not worth an error in the top bar.
|
|
318
329
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" /></svg>
|
|
19
19
|
Full screen
|
|
20
20
|
</v-btn>
|
|
21
|
-
<v-btn v-if="canEditMap" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
|
|
21
|
+
<v-btn v-if="canEditMap && !fleet" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
|
|
22
22
|
{{ editing ? "Exit edit mode" : "Edit map" }}
|
|
23
23
|
</v-btn>
|
|
24
24
|
</div>
|
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
<div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
27
27
|
<div v-else-if="!loading && emptyNote" class="hero-notice">{{ emptyNote }}</div>
|
|
28
28
|
|
|
29
|
+
<div v-if="fleet" class="hero-notice" role="status">Live building from HERO Fleet Core - doors and lifts update every few seconds. It is managed by the fleet, so it cannot be edited here.</div>
|
|
30
|
+
|
|
29
31
|
<RobotMapEditor
|
|
30
|
-
v-if="editing && canEditMap"
|
|
32
|
+
v-if="editing && canEditMap && !fleet"
|
|
31
33
|
ref="editor"
|
|
32
34
|
:map="map"
|
|
33
35
|
:floors="floors"
|
|
@@ -105,9 +107,11 @@
|
|
|
105
107
|
class="hero-bmap__floor"
|
|
106
108
|
:style="{ transform: `translateZ(${i * FLOOR_GAP_PX}px)`, opacity: f.dim, '--hero-fill': look.fill }"
|
|
107
109
|
>
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
<template v-if="!fleet">
|
|
111
|
+
<span class="hero-bmap__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
|
|
112
|
+
<span class="hero-bmap__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
|
|
113
|
+
<span class="hero-bmap__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
|
|
114
|
+
</template>
|
|
111
115
|
<template v-if="!map">
|
|
112
116
|
<span class="hero-bmap__door" style="left: 42%; top: 22%" aria-hidden="true" />
|
|
113
117
|
<span class="hero-bmap__door" style="left: 58%; top: 30%" aria-hidden="true" />
|
|
@@ -116,7 +120,7 @@
|
|
|
116
120
|
<template v-for="s in f.shapes" :key="s.id">
|
|
117
121
|
<polygon v-if="s.kind === 'zone'" class="hero-bmap__zone" :points="svgPoints(s)" vector-effect="non-scaling-stroke" />
|
|
118
122
|
<line v-else-if="s.kind === 'wall'" class="hero-bmap__wall" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
|
|
119
|
-
<line v-else-if="s.kind === 'door'" class="hero-bmap__doorline" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
|
|
123
|
+
<line v-else-if="s.kind === 'door'" class="hero-bmap__doorline" :class="{ 'hero-bmap__doorline--open': openDoors.has(String(s.meta?.name)) }" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
|
|
120
124
|
</template>
|
|
121
125
|
</svg>
|
|
122
126
|
|
|
@@ -165,6 +169,7 @@
|
|
|
165
169
|
|
|
166
170
|
<div class="hero-bmap__legend">
|
|
167
171
|
<span><i class="hero-bmap__key hero-bmap__key--door" />Door</span>
|
|
172
|
+
<span v-if="fleet"><i class="hero-bmap__key hero-bmap__key--dooropen" />Door open</span>
|
|
168
173
|
<span><i class="hero-bmap__key hero-bmap__key--shaft" />Lift shaft</span>
|
|
169
174
|
<span><i class="hero-bmap__key hero-bmap__key--bot" />Robot (live)</span>
|
|
170
175
|
<span v-if="type === 'security'"><i class="hero-bmap__key hero-bmap__key--route" />Patrol route · numbered checkpoints</span>
|
|
@@ -197,6 +202,7 @@ import RobotMapEditor from "./MapEditor.vue";
|
|
|
197
202
|
import RobotReconnecting from "./Reconnecting.vue";
|
|
198
203
|
import useRobotBuilding from "../../composables/useRobotBuilding";
|
|
199
204
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
205
|
+
import { openDoorNames } from "../../utils/robot-fleet-core";
|
|
200
206
|
import {
|
|
201
207
|
brandName,
|
|
202
208
|
floorName,
|
|
@@ -258,11 +264,12 @@ const emit = defineEmits<{
|
|
|
258
264
|
status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
|
|
259
265
|
}>();
|
|
260
266
|
|
|
261
|
-
const { rows, map, names, loading, error, reload } = useRobotBuilding(
|
|
267
|
+
const { rows, map, fleet, names, loading, error, reload } = useRobotBuilding(
|
|
262
268
|
toRef(props, "organization"),
|
|
263
269
|
toRef(props, "site"),
|
|
264
270
|
toRef(props, "type"),
|
|
265
271
|
);
|
|
272
|
+
const openDoors = computed(() => openDoorNames(map.value));
|
|
266
273
|
const siteLabel = computed(() => props.siteName || names.value.siteName || "This site");
|
|
267
274
|
|
|
268
275
|
/* ---- live states + patrol routes of the robots on screen ---- */
|
|
@@ -381,8 +388,8 @@ function fullscreen(el?: HTMLElement | null) {
|
|
|
381
388
|
else el?.requestFullscreen?.();
|
|
382
389
|
}
|
|
383
390
|
const editing = ref(false);
|
|
384
|
-
watch(() => props.canEditMap, (can) => {
|
|
385
|
-
if (!can) editing.value = false;
|
|
391
|
+
watch([() => props.canEditMap, fleet], ([can, isFleet]) => {
|
|
392
|
+
if (!can || isFleet) editing.value = false;
|
|
386
393
|
});
|
|
387
394
|
/** The robot a first save is attached to (maps are stored per robot): one of this site's. */
|
|
388
395
|
const ownerRobotId = computed(() => String(map.value?.robotId ?? rows.value[0]?._id ?? ""));
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
18
|
<div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
19
|
+
<div v-else-if="fleet" class="hero-notice" role="status">Live lifts from HERO Fleet Core - the fleet calls and releases lifts itself.</div>
|
|
19
20
|
<div v-else-if="!connected" class="hero-notice" role="status">Reconnecting… the lift queue shows its last reading.</div>
|
|
20
21
|
|
|
21
22
|
<div v-if="loading && !cards.length" class="hero-lifts" role="status" aria-label="Loading the lift queue">
|
|
@@ -75,7 +76,7 @@
|
|
|
75
76
|
<span class="hero-lift-row__wait">{{ q.wait }}</span>
|
|
76
77
|
<span class="hero-chip" :class="`hero-tone--${liftState(q.state).tone}`">{{ liftState(q.state).label }}</span>
|
|
77
78
|
<button
|
|
78
|
-
v-if="canCommand"
|
|
79
|
+
v-if="canCommand && !fleet"
|
|
79
80
|
type="button"
|
|
80
81
|
class="hero-lift-row__release"
|
|
81
82
|
:style="{ visibility: q.state === 'riding' || q.state === 'requested' ? 'visible' : 'hidden' }"
|
|
@@ -88,7 +89,7 @@
|
|
|
88
89
|
</div>
|
|
89
90
|
</div>
|
|
90
91
|
|
|
91
|
-
<form v-if="canCommand && demoRobots.length" class="hero-lift__ask" @submit.prevent="ask(l)">
|
|
92
|
+
<form v-if="canCommand && demoRobots.length && !fleet" class="hero-lift__ask" @submit.prevent="ask(l)">
|
|
92
93
|
<select v-model="askRobot[l.name]" class="hero-input hero-select hero-select--sm" aria-label="Robot">
|
|
93
94
|
<option value="" disabled>Robot</option>
|
|
94
95
|
<option v-for="r in demoRobots" :key="String(r._id)" :value="String(r._id)">{{ r.name }}</option>
|
|
@@ -115,6 +116,7 @@ import RobotToast from "./Toast.vue";
|
|
|
115
116
|
import useRobotBuilding from "../../composables/useRobotBuilding";
|
|
116
117
|
import { floorName, liftState, robotErrorMessage } from "../../utils/robot-display";
|
|
117
118
|
import { liftCard, type TLiftRowView } from "../../utils/robot-building";
|
|
119
|
+
import { fleetLiftItems } from "../../utils/robot-fleet-core";
|
|
118
120
|
|
|
119
121
|
const props = withDefaults(
|
|
120
122
|
defineProps<{
|
|
@@ -137,7 +139,7 @@ const emit = defineEmits<{
|
|
|
137
139
|
status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
|
|
138
140
|
}>();
|
|
139
141
|
|
|
140
|
-
const { rows, map, names, loading: mapLoading, error: mapError } = useRobotBuilding(
|
|
142
|
+
const { rows, all, map, fleet, names, loading: mapLoading, error: mapError } = useRobotBuilding(
|
|
141
143
|
toRef(props, "organization"),
|
|
142
144
|
toRef(props, "site"),
|
|
143
145
|
toRef(props, "type"),
|
|
@@ -154,6 +156,8 @@ const loading = computed(() => mapLoading.value || queueLoading.value);
|
|
|
154
156
|
const error = computed(() => mapError.value);
|
|
155
157
|
|
|
156
158
|
async function poll() {
|
|
159
|
+
// The fleet building has no lift sessions of ours: its lifts come live with the building (5 s refresh).
|
|
160
|
+
if (fleet.value) return void (items.value = fleetLiftItems(map.value, all.value), (queueLoading.value = false));
|
|
157
161
|
const id = map.value?._id;
|
|
158
162
|
if (!id) return void (items.value = []);
|
|
159
163
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
@@ -169,9 +173,10 @@ async function poll() {
|
|
|
169
173
|
}
|
|
170
174
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
171
175
|
watch(
|
|
172
|
-
() => map.value?._id,
|
|
176
|
+
() => (fleet.value ? map.value : map.value?._id),
|
|
173
177
|
(id) => {
|
|
174
178
|
clearInterval(timer);
|
|
179
|
+
if (fleet.value) return void poll();
|
|
175
180
|
items.value = [];
|
|
176
181
|
if (!id) return;
|
|
177
182
|
queueLoading.value = true;
|
|
@@ -38,10 +38,6 @@
|
|
|
38
38
|
@input="dispatch({ type: 'rename', name: zoneName })"
|
|
39
39
|
/>
|
|
40
40
|
<span class="hero-editor__hint">{{ TOOL_HINTS[tool] }}</span>
|
|
41
|
-
<v-btn variant="outlined" class="hero-editor__fs" @click="fullscreen">
|
|
42
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" /></svg>
|
|
43
|
-
Full screen
|
|
44
|
-
</v-btn>
|
|
45
41
|
</div>
|
|
46
42
|
|
|
47
43
|
<div v-if="state.notice" class="hero-editor__notice" :class="{ 'hero-editor__notice--ok': state.notice.ok }" role="status" aria-live="polite">{{ state.notice.text }}</div>
|
|
@@ -482,9 +478,6 @@ const isFs = ref(false);
|
|
|
482
478
|
const onFs = () => (isFs.value = document.fullscreenElement === root.value);
|
|
483
479
|
onMounted(() => document.addEventListener("fullscreenchange", onFs));
|
|
484
480
|
onBeforeUnmount(() => document.removeEventListener("fullscreenchange", onFs));
|
|
485
|
-
|
|
486
|
-
if (document.fullscreenElement) document.exitFullscreen?.();
|
|
487
|
-
else root.value?.requestFullscreen?.();
|
|
488
|
-
}
|
|
481
|
+
// Full screen is the page header's one button (BuildingMap), which full-screens `root` while editing.
|
|
489
482
|
defineExpose({ root });
|
|
490
483
|
</script>
|
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
:label="invitation.statusLabel"
|
|
58
58
|
/>
|
|
59
59
|
</div>
|
|
60
|
+
|
|
61
|
+
<!-- An accept or decline that failed: said on the card, not swallowed. -->
|
|
62
|
+
<div v-if="error" class="px-4 pb-4 text-error sp-invite-prompt__body" role="alert">
|
|
63
|
+
{{ error }}
|
|
64
|
+
</div>
|
|
60
65
|
</AppCard>
|
|
61
66
|
|
|
62
67
|
<AppCard v-else-if="error">
|
|
@@ -82,8 +87,8 @@ const props = defineProps({
|
|
|
82
87
|
|
|
83
88
|
const emit = defineEmits<{ (e: "answered", status: string): void }>();
|
|
84
89
|
|
|
85
|
-
const { getInvitation, declineInvitation } =
|
|
86
|
-
|
|
90
|
+
const { getInvitation, declineInvitation, acceptInvitation } =
|
|
91
|
+
useServiceProviderInvitation();
|
|
87
92
|
|
|
88
93
|
const invitation = ref<any>(null);
|
|
89
94
|
const error = ref("");
|
|
@@ -112,9 +117,10 @@ async function load() {
|
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
async function accept() {
|
|
120
|
+
error.value = "";
|
|
115
121
|
working.value = "accept";
|
|
116
122
|
try {
|
|
117
|
-
await
|
|
123
|
+
await acceptInvitation(props.invitationId);
|
|
118
124
|
await load();
|
|
119
125
|
emit("answered", "complete");
|
|
120
126
|
} catch (err: any) {
|
|
@@ -125,6 +131,7 @@ async function accept() {
|
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
async function decline() {
|
|
134
|
+
error.value = "";
|
|
128
135
|
working.value = "decline";
|
|
129
136
|
try {
|
|
130
137
|
await declineInvitation(props.invitationId);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Nothing to answer and nothing linked: the section stays out of the way. -->
|
|
3
|
+
<section v-if="ids.length" class="sp-received mb-4" aria-labelledby="sp-received-title">
|
|
4
|
+
<div class="mb-2">
|
|
5
|
+
<div id="sp-received-title" class="screen-card-title">
|
|
6
|
+
Invitations from other organisations
|
|
7
|
+
</div>
|
|
8
|
+
<div class="screen-hint">
|
|
9
|
+
Another organisation has asked your company to provide services at one
|
|
10
|
+
of its sites. Accept to add the site to your Sites list, or decline.
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="d-flex flex-column ga-3">
|
|
15
|
+
<div
|
|
16
|
+
v-for="id in ids"
|
|
17
|
+
:key="id"
|
|
18
|
+
:class="{ 'sp-received__linked': id === linked }"
|
|
19
|
+
>
|
|
20
|
+
<Suspense>
|
|
21
|
+
<ServiceProviderInvitationPrompt
|
|
22
|
+
:invitation-id="id"
|
|
23
|
+
@answered="(status: string) => onAnswered(id, status)"
|
|
24
|
+
/>
|
|
25
|
+
</Suspense>
|
|
26
|
+
|
|
27
|
+
<div v-if="answered[id] === 'complete'" class="sp-received__done mt-2">
|
|
28
|
+
{{ doneLine(id) }}
|
|
29
|
+
<NuxtLink v-if="sitesLink(id)" :to="sitesLink(id)">Open Sites</NuxtLink>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</section>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script setup lang="ts">
|
|
37
|
+
/**
|
|
38
|
+
* "Invitations from other organisations" — where a service provider answers an
|
|
39
|
+
* invitation another organisation sent it (owner report 2026-09-24: the bell
|
|
40
|
+
* said "Open the invitation to accept or decline" and there was nowhere to do
|
|
41
|
+
* either).
|
|
42
|
+
*
|
|
43
|
+
* The list comes from the server keyed on the signed-in account, so it is the
|
|
44
|
+
* same whichever organisation is on screen. The row the bell linked to
|
|
45
|
+
* (`?invite=<id>`) is always shown — even once answered or expired, so the
|
|
46
|
+
* person sees what happened to it — and is outlined.
|
|
47
|
+
*/
|
|
48
|
+
import type { TReceivedServiceProviderInvitation } from "../composables/useServiceProviderInvitation";
|
|
49
|
+
|
|
50
|
+
const route = useRoute();
|
|
51
|
+
const { getReceivedInvitations } = useServiceProviderInvitation();
|
|
52
|
+
|
|
53
|
+
const items = ref<TReceivedServiceProviderInvitation[]>([]);
|
|
54
|
+
const answered = reactive<Record<string, string>>({});
|
|
55
|
+
|
|
56
|
+
const linked = computed(() => {
|
|
57
|
+
const value = String(route.query?.invite ?? "").trim();
|
|
58
|
+
return /^[a-f0-9]{24}$/i.test(value) ? value : "";
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const ids = computed(() => {
|
|
62
|
+
const listed = items.value.map((i) => String(i._id));
|
|
63
|
+
return linked.value && !listed.includes(linked.value)
|
|
64
|
+
? [linked.value, ...listed]
|
|
65
|
+
: listed;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function itemFor(id: string) {
|
|
69
|
+
return items.value.find((i) => String(i._id) === id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function doneLine(id: string) {
|
|
73
|
+
const item = itemFor(id);
|
|
74
|
+
const whose = item?.providerOrgName ? `${item.providerOrgName}'s` : "your";
|
|
75
|
+
return item?.siteName
|
|
76
|
+
? `${item.siteName} is now on ${whose} Sites list.`
|
|
77
|
+
: `The site is now on ${whose} Sites list.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sitesLink(id: string) {
|
|
81
|
+
const org = itemFor(id)?.providerOrgId || String(route.params?.organization ?? "");
|
|
82
|
+
return org ? `/org/${org}/sites` : "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function onAnswered(id: string, status: string) {
|
|
86
|
+
answered[id] = status;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function load() {
|
|
90
|
+
try {
|
|
91
|
+
const res = await getReceivedInvitations();
|
|
92
|
+
items.value = Array.isArray(res?.items) ? res.items : [];
|
|
93
|
+
} catch {
|
|
94
|
+
// The section is an addition to the Invitations page; if it cannot load,
|
|
95
|
+
// the rest of the page must still work. A linked row still renders and
|
|
96
|
+
// reports its own error.
|
|
97
|
+
items.value = [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await load();
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<style scoped>
|
|
105
|
+
.sp-received__linked :deep(.app-card) {
|
|
106
|
+
outline: 2px solid rgb(var(--v-theme-primary));
|
|
107
|
+
outline-offset: 2px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.sp-received__done {
|
|
111
|
+
font-size: var(--fs-cell);
|
|
112
|
+
}
|
|
113
|
+
</style>
|
|
@@ -579,6 +579,23 @@ export default function usePropertyPermission() {
|
|
|
579
579
|
description:
|
|
580
580
|
"Allows the user to view the list of all passes and keys on mobile. Checked by the iMA365 mobile app.",
|
|
581
581
|
},
|
|
582
|
+
// DV-0396: the same three writes Security offers, enforced by API-core on
|
|
583
|
+
// /api/keys and /api/qr-code-templates for every app that calls them.
|
|
584
|
+
"add-passes-and-keys": {
|
|
585
|
+
check: true,
|
|
586
|
+
description:
|
|
587
|
+
"Allows the user to add pass templates and to create or generate passes and keys.",
|
|
588
|
+
},
|
|
589
|
+
"update-passes-and-keys": {
|
|
590
|
+
check: true,
|
|
591
|
+
description:
|
|
592
|
+
"Allows the user to edit pass templates, passes and keys, restore them, and change a pass or key back to Available.",
|
|
593
|
+
},
|
|
594
|
+
"delete-passes-and-keys": {
|
|
595
|
+
check: true,
|
|
596
|
+
description:
|
|
597
|
+
"Allows the user to delete pass templates, passes and keys.",
|
|
598
|
+
},
|
|
582
599
|
},
|
|
583
600
|
"online-forms": {
|
|
584
601
|
"see-all-online-forms": {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { computed, ref, watch, type Ref } from "vue";
|
|
1
|
+
import { computed, onBeforeUnmount, ref, watch, type Ref } from "vue";
|
|
2
|
+
import { hasFleetRobots, isFleetBuilding } from "../utils/robot-fleet-core";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* One site's building for the HERO building map and lift queue: the robots
|
|
@@ -7,6 +8,10 @@ import { computed, ref, watch, type Ref } from "vue";
|
|
|
7
8
|
* SITE (`/building-maps?site=`), whichever robot type it was saved from: the
|
|
8
9
|
* building belongs to the site, so a map drawn in Hygiene shows in Security.
|
|
9
10
|
*
|
|
11
|
+
* A site with HERO Fleet Core robots draws the FLEET building instead (read
|
|
12
|
+
* through from the fleet server, in the same plan frame as those robots,
|
|
13
|
+
* read-only), refreshed every 5 s so doors and lifts move.
|
|
14
|
+
*
|
|
10
15
|
* ponytail: the first 50 robots at the site; the map read is one call.
|
|
11
16
|
*/
|
|
12
17
|
export default function useRobotBuilding(
|
|
@@ -14,7 +19,7 @@ export default function useRobotBuilding(
|
|
|
14
19
|
site: Ref<string>,
|
|
15
20
|
type: Ref<"cleaning" | "security">,
|
|
16
21
|
) {
|
|
17
|
-
const { getSiteFleet, getSiteSummary, listSiteBuildingMaps, getBuildingMap } = useRobots();
|
|
22
|
+
const { getSiteFleet, getSiteSummary, listSiteBuildingMaps, getBuildingMap, getFleetCoreBuilding } = useRobots();
|
|
18
23
|
|
|
19
24
|
/** Every robot at the site this app may see, both types where the app shows both. */
|
|
20
25
|
const all = ref<Array<Record<string, any>>>([]);
|
|
@@ -27,10 +32,24 @@ export default function useRobotBuilding(
|
|
|
27
32
|
const rows = computed(() => all.value.filter((r) => r.type === type.value));
|
|
28
33
|
|
|
29
34
|
async function loadMap() {
|
|
35
|
+
if (hasFleetRobots(all.value)) {
|
|
36
|
+
const building = await getFleetCoreBuilding(site.value).catch(() => null);
|
|
37
|
+
if (building) return building;
|
|
38
|
+
}
|
|
30
39
|
const newest = (await listSiteBuildingMaps(site.value, { limit: 1 }))?.items?.[0];
|
|
31
40
|
return newest ? await getBuildingMap(String(newest._id)) : null;
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
/** Live doors and lifts of the fleet building; a failed refresh keeps the last reading. */
|
|
44
|
+
const fleet = computed(() => isFleetBuilding(map.value));
|
|
45
|
+
const timer = setInterval(async () => {
|
|
46
|
+
if (!fleet.value || (typeof document !== "undefined" && document.visibilityState === "hidden")) return;
|
|
47
|
+
const mine = run;
|
|
48
|
+
const next = await getFleetCoreBuilding(site.value).catch(() => null);
|
|
49
|
+
if (next && mine === run) map.value = next;
|
|
50
|
+
}, 5000);
|
|
51
|
+
onBeforeUnmount(() => clearInterval(timer));
|
|
52
|
+
|
|
34
53
|
let run = 0;
|
|
35
54
|
async function load() {
|
|
36
55
|
const mine = ++run;
|
|
@@ -59,5 +78,5 @@ export default function useRobotBuilding(
|
|
|
59
78
|
|
|
60
79
|
watch([organization, site], load, { immediate: true });
|
|
61
80
|
|
|
62
|
-
return { all, rows, map, names, loading, error, reload: load };
|
|
81
|
+
return { all, rows, map, fleet, names, loading, error, reload: load };
|
|
63
82
|
}
|
package/composables/useRobots.ts
CHANGED
|
@@ -293,6 +293,18 @@ export default function useRobots() {
|
|
|
293
293
|
});
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/**
|
|
297
|
+
* The HERO Fleet Core building at a site: levels, walls, doors, lifts (in the
|
|
298
|
+
* HERO plan frame) with live door and lift states, read through from the
|
|
299
|
+
* fleet server - nothing stored. 404 where the server has no fleet.
|
|
300
|
+
*/
|
|
301
|
+
function getFleetCoreBuilding(site: string) {
|
|
302
|
+
return useNuxtApp().$api<Record<string, any>>("/api/robots/fleet-core/building", {
|
|
303
|
+
method: "GET",
|
|
304
|
+
query: { site, appSlug },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
296
308
|
/* ------------------------------------------ alerts + activity log */
|
|
297
309
|
|
|
298
310
|
/**
|
|
@@ -394,6 +406,7 @@ export default function useRobots() {
|
|
|
394
406
|
return {
|
|
395
407
|
listBuildingMaps,
|
|
396
408
|
listSiteBuildingMaps,
|
|
409
|
+
getFleetCoreBuilding,
|
|
397
410
|
getSiteFaults,
|
|
398
411
|
getSiteActivity,
|
|
399
412
|
getNetworkActivity,
|
|
@@ -669,12 +669,31 @@ export default function useSecurityPermission() {
|
|
|
669
669
|
},
|
|
670
670
|
// Property's descriptions minus its "Checked by the iMA365 mobile app."
|
|
671
671
|
// sentence, which is not true of this application.
|
|
672
|
+
// Add / update / delete (DV-0396), also offered by Property Management and
|
|
673
|
+
// enforced by API-core on /api/keys and /api/qr-code-templates. The security web app's
|
|
674
|
+
// Templates and Passes & Keys pages and the guard app's pass & key screen
|
|
675
|
+
// read them. See All is also what shows those two web pages.
|
|
672
676
|
"passes-and-keys-mgmt": {
|
|
673
677
|
"see-all-passes-and-keys": {
|
|
674
678
|
check: true,
|
|
675
679
|
description:
|
|
676
680
|
"Allows the user to view the list of all passes and keys on mobile.",
|
|
677
681
|
},
|
|
682
|
+
"add-passes-and-keys": {
|
|
683
|
+
check: true,
|
|
684
|
+
description:
|
|
685
|
+
"Allows the user to add pass templates and to create or generate passes and keys.",
|
|
686
|
+
},
|
|
687
|
+
"update-passes-and-keys": {
|
|
688
|
+
check: true,
|
|
689
|
+
description:
|
|
690
|
+
"Allows the user to edit pass templates, passes and keys, restore them, and change a pass or key back to Available.",
|
|
691
|
+
},
|
|
692
|
+
"delete-passes-and-keys": {
|
|
693
|
+
check: true,
|
|
694
|
+
description:
|
|
695
|
+
"Allows the user to delete pass templates, passes and keys.",
|
|
696
|
+
},
|
|
678
697
|
},
|
|
679
698
|
"resident-transactions": {
|
|
680
699
|
"see-all-resident-transactions": {
|
|
@@ -26,6 +26,19 @@ export type TServiceProviderInvitation = {
|
|
|
26
26
|
}>;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
/** One row of "Invitations from other organisations". */
|
|
30
|
+
export type TReceivedServiceProviderInvitation = {
|
|
31
|
+
_id: string;
|
|
32
|
+
orgName: string;
|
|
33
|
+
siteName: string;
|
|
34
|
+
status: string;
|
|
35
|
+
statusLabel: string;
|
|
36
|
+
expireAt?: string;
|
|
37
|
+
/** The invited company's own organisation (the one whose list gains the site). */
|
|
38
|
+
providerOrgId?: string;
|
|
39
|
+
providerOrgName?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
29
42
|
/** The words on screen. Kept beside the server's own list on purpose — a
|
|
30
43
|
* status the server invents must never render as a raw stored value. */
|
|
31
44
|
export const SERVICE_PROVIDER_INVITE_LABELS: Record<string, string> = {
|
|
@@ -130,6 +143,23 @@ export default function useServiceProviderInvitation() {
|
|
|
130
143
|
});
|
|
131
144
|
}
|
|
132
145
|
|
|
146
|
+
/** Adds the site to the invited company's list. The invited account only. */
|
|
147
|
+
function acceptInvitation(id: string) {
|
|
148
|
+
return useNuxtApp().$api<{ message: string }>(`${base}/${id}/accept`, {
|
|
149
|
+
method: "PUT",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Invitations from other organisations still waiting for the signed-in
|
|
155
|
+
* account's answer — keyed on the account, not on the organisation on screen.
|
|
156
|
+
*/
|
|
157
|
+
function getReceivedInvitations(): Promise<{
|
|
158
|
+
items: TReceivedServiceProviderInvitation[];
|
|
159
|
+
}> {
|
|
160
|
+
return useNuxtApp().$api(`${base}/received`, { method: "GET" });
|
|
161
|
+
}
|
|
162
|
+
|
|
133
163
|
return {
|
|
134
164
|
getInvitations,
|
|
135
165
|
cancelInvitation,
|
|
@@ -141,5 +171,7 @@ export default function useServiceProviderInvitation() {
|
|
|
141
171
|
rejectInvitation,
|
|
142
172
|
getInvitation,
|
|
143
173
|
declineInvitation,
|
|
174
|
+
acceptInvitation,
|
|
175
|
+
getReceivedInvitations,
|
|
144
176
|
};
|
|
145
177
|
}
|
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.38.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.",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"build": "nuxt build .playground",
|
|
37
37
|
"generate": "nuxt generate .playground",
|
|
38
38
|
"preview": "nuxt preview .playground",
|
|
39
|
-
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
|
|
39
|
+
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
|
|
40
40
|
"test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
|
|
41
41
|
"release": "yarn run build && changeset publish"
|
|
42
42
|
},
|
package/plugins/API.ts
CHANGED
|
@@ -11,6 +11,17 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
11
11
|
onRequest({ options }) {
|
|
12
12
|
const sid = useCookie("sid", cookieConfig).value ?? "";
|
|
13
13
|
options.headers.set("Authorization", sid);
|
|
14
|
+
// Which of the user's memberships this screen is working in (DV-0396):
|
|
15
|
+
// this app's member type and the organisation in the address. With
|
|
16
|
+
// several memberships pinned to one site, the server then checks the
|
|
17
|
+
// same role the screen was drawn from (core `requireSiteReach`). Only
|
|
18
|
+
// chooses among the user's own memberships; it grants nothing.
|
|
19
|
+
const app = useRuntimeConfig().public.APP;
|
|
20
|
+
if (app) options.headers.set("X-App", String(app));
|
|
21
|
+
const org = (nuxtApp as any).$router?.currentRoute?.value?.params?.org;
|
|
22
|
+
if (typeof org === "string" && /^[0-9a-f]{24}$/i.test(org)) {
|
|
23
|
+
options.headers.set("X-Org", org);
|
|
24
|
+
}
|
|
14
25
|
},
|
|
15
26
|
// "You do not have permission to ..." - the role changed since the page
|
|
16
27
|
// read it. Re-read it in the background (plugins/secure-member.client.ts)
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
/** Only the fields a destination is decided from. */
|
|
32
32
|
export type TTargetRow = {
|
|
33
33
|
module?: string;
|
|
34
|
-
|
|
34
|
+
screen?: string;
|
|
35
|
+
params?: { id?: string; org?: string } | null;
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
/** What the surrounding app knows that the stored row does not. */
|
|
@@ -43,6 +44,13 @@ export type TTargetContext = {
|
|
|
43
44
|
export type TNotificationTarget = {
|
|
44
45
|
name: string;
|
|
45
46
|
params: Record<string, string>;
|
|
47
|
+
query?: Record<string, string>;
|
|
48
|
+
/**
|
|
49
|
+
* Set when the page lives in another web app. The bell opens
|
|
50
|
+
* `<that app's URL><path>` when its own router has no route by `name`.
|
|
51
|
+
*/
|
|
52
|
+
app?: "org";
|
|
53
|
+
path?: string;
|
|
46
54
|
};
|
|
47
55
|
|
|
48
56
|
/** Nuxt's generated names for `pages/org/[organization]/marketplace/orders/`. */
|
|
@@ -52,12 +60,42 @@ export const ORDER_DETAIL_ROUTE = "org-organization-marketplace-orders-id";
|
|
|
52
60
|
/** The stored `module` tag on a row sent to a vendor's staff. */
|
|
53
61
|
export const VENDOR_ORDER_MODULE = "marketplace-vendor-order";
|
|
54
62
|
|
|
63
|
+
/**
|
|
64
|
+
* "<Company> has invited you to <site>" — core `serviceProviderInvited` stores
|
|
65
|
+
* `module: "other"` (the apps route on it; it never changes) with this
|
|
66
|
+
* `screen`, the invitation id in `params.id`, and, since 2026-09-24, the
|
|
67
|
+
* invited company's own organisation in `params.org`.
|
|
68
|
+
*
|
|
69
|
+
* It opens the organisation app's Invitations page, where "Invitations from
|
|
70
|
+
* other organisations" lists it with Accept / Decline. Only the organisation
|
|
71
|
+
* app has that page; every other app sends the click there by URL.
|
|
72
|
+
*/
|
|
73
|
+
export const SP_INVITATION_SCREEN = "/service-provider-invitation";
|
|
74
|
+
export const INVITATIONS_ROUTE = "org-organization-invitations-status-status";
|
|
75
|
+
|
|
55
76
|
export function notificationTarget(
|
|
56
77
|
item: TTargetRow | null | undefined,
|
|
57
78
|
context: TTargetContext = {},
|
|
58
79
|
): TNotificationTarget | null {
|
|
59
80
|
const organization = String(context.organization ?? "").trim();
|
|
60
81
|
|
|
82
|
+
if (String(item?.screen ?? "") === SP_INVITATION_SCREEN) {
|
|
83
|
+
// The invited company's organisation when the row names it (the invitation
|
|
84
|
+
// belongs there), otherwise the one on screen. The list on that page is
|
|
85
|
+
// keyed on the signed-in account, so either shows the invitation.
|
|
86
|
+
const org = String(item?.params?.org ?? "").trim() || organization;
|
|
87
|
+
if (!org) return null;
|
|
88
|
+
const id = String(item?.params?.id ?? "").trim();
|
|
89
|
+
const query: Record<string, string> = id ? { invite: id } : {};
|
|
90
|
+
return {
|
|
91
|
+
name: INVITATIONS_ROUTE,
|
|
92
|
+
params: { organization: org, status: "pending" },
|
|
93
|
+
query,
|
|
94
|
+
app: "org",
|
|
95
|
+
path: `/org/${encodeURIComponent(org)}/invitations/status/pending`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
61
99
|
if (String(item?.module ?? "") !== VENDOR_ORDER_MODULE) return null;
|
|
62
100
|
|
|
63
101
|
// Every order route is nested under the organization. Without one there is no
|
|
@@ -73,3 +111,18 @@ export function notificationTarget(
|
|
|
73
111
|
|
|
74
112
|
return { name: ORDER_DETAIL_ROUTE, params: { organization, id } };
|
|
75
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The full URL for a target whose page lives in another web app, or "" when
|
|
117
|
+
* there is none to build (no path, or that app's URL is not configured).
|
|
118
|
+
*/
|
|
119
|
+
export function externalTargetUrl(
|
|
120
|
+
target: TNotificationTarget | null | undefined,
|
|
121
|
+
appUrls: { org?: string } = {},
|
|
122
|
+
): 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}` : ""}`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HERO Fleet Core building helpers (pure, tested in test/robot-fleet-core.test.mjs).
|
|
3
|
+
*
|
|
4
|
+
* The fleet building (`GET /api/robots/fleet-core/building`) comes in the same
|
|
5
|
+
* shape as a saved building map - levels / doors / lifts in the HERO plan frame -
|
|
6
|
+
* plus live `doorStates` and `liftStates` in the lift / door contract shape.
|
|
7
|
+
* Not part of the mobile-synced rules (`robot-display.ts`), so web only for now.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const FLEET_CORE_BRAND = "fleet-core";
|
|
11
|
+
|
|
12
|
+
/** Is this building the read-only fleet building rather than a saved map? */
|
|
13
|
+
export const isFleetBuilding = (map: Record<string, any> | null | undefined) => map?.source === FLEET_CORE_BRAND;
|
|
14
|
+
|
|
15
|
+
/** Does the site have fleet robots (then their building is the one to draw)? */
|
|
16
|
+
export const hasFleetRobots = (rows: Array<Record<string, any>>) => rows.some((r) => r?.brand === FLEET_CORE_BRAND);
|
|
17
|
+
|
|
18
|
+
/** "L2" -> 2, "B1" -> -1, "G" -> 0 (the fleet names its floors). */
|
|
19
|
+
export function fleetFloorNo(name: unknown): number | null {
|
|
20
|
+
if (typeof name !== "string") return null;
|
|
21
|
+
const m = /^(L|B)(\d{1,3})$/i.exec(name.trim());
|
|
22
|
+
if (m) return m[1]!.toUpperCase() === "B" ? -Number(m[2]) : Number(m[2]);
|
|
23
|
+
return /^G$/i.test(name.trim()) ? 0 : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Names of the doors that read OPEN (door contract mode 2). */
|
|
27
|
+
export function openDoorNames(map: Record<string, any> | null | undefined): Set<string> {
|
|
28
|
+
return new Set((map?.doorStates ?? []).filter((d: any) => d?.currentMode === 2).map((d: any) => String(d.doorName)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The fleet's lifts as lift-queue items (the shape `liftCard` reads): the car's
|
|
33
|
+
* floor from the live state, and the robot holding the lift (its session is
|
|
34
|
+
* "<fleet>/<robot>", our robotSn) as the one riding, from its floor to the
|
|
35
|
+
* lift's destination. The fleet runs no waiting queue we can read.
|
|
36
|
+
*/
|
|
37
|
+
export function fleetLiftItems(map: Record<string, any> | null | undefined, rows: Array<Record<string, any>>) {
|
|
38
|
+
if (!isFleetBuilding(map)) return [];
|
|
39
|
+
const states = new Map<string, any>((map!.liftStates ?? []).map((s: any) => [String(s.liftName), s]));
|
|
40
|
+
return (map!.lifts ?? []).map((lift: any) => {
|
|
41
|
+
const st = states.get(String(lift.name));
|
|
42
|
+
const current = fleetFloorNo(st?.currentFloor);
|
|
43
|
+
const dest = fleetFloorNo(st?.destinationFloor);
|
|
44
|
+
const holder = st?.sessionId ? rows.find((r) => r?.robotSn === st.sessionId) : null;
|
|
45
|
+
return {
|
|
46
|
+
liftName: String(lift.name),
|
|
47
|
+
floors: lift.floors ?? [],
|
|
48
|
+
state: { currentFloor: current },
|
|
49
|
+
active: holder
|
|
50
|
+
? { _id: `fleet-${lift.name}`, robotId: String(holder._id), robotName: holder.name, fromFloor: current ?? dest, toFloor: dest ?? current }
|
|
51
|
+
: null,
|
|
52
|
+
queue: [],
|
|
53
|
+
recent: [],
|
|
54
|
+
simulated: false,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|