@7365admin1/layer-common 4.122.2 → 4.123.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/Robot/FleetCoreButton.vue +25 -2
- package/components/Robot/LiftQueue.vue +5 -2
- package/components/Robot/MapEditor.vue +4 -1
- package/components/Robot/Setup.vue +184 -0
- package/components/Robot/SiteHeader.vue +4 -2
- package/components/Robot/SiteScreen.vue +28 -2
- package/package.json +1 -1
- package/utils/robot-building.ts +35 -6
- package/utils/robot-setup.ts +333 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.123.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9f32850: Robots: new "Setup" view on the Super Admin site screen (`view="setup"`). One checklist per site - robot module on, robots added (with vendor prerequisites), robots online (with the reason when not), building map saved, floors on the map, lifts with 2+ stops, lift gateway connected (and whether floor/door are decoded), HERO Fleet Core linked (optional), client roles with robot rights, and "Ready for client". Each step shows Done / Needs action / Waiting on others / Check manually, one plain line why, and one button to the existing screen that does it. Worked out only from existing reads; nothing is written. After adding a robot, Super Admin lands on Setup (`navigate` event). "Open HERO Fleet Core" (Super Admin) and "Import from HERO Fleet Core" now show disabled with a short reason instead of disappearing.
|
|
8
|
+
|
|
9
|
+
## 4.122.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 6d9f5ad: Lift queue: when no robot ride is being drawn and the lift's read-only gateway is connected, the car in the shaft now follows the real lift - it moves to the lift's current floor, the doors open and close with the lift's door state, the car shows an up/down arrow while moving, and it turns the alert colour in fire/emergency modes. Before, the card text updated but the car only ever moved for robot rides.
|
|
14
|
+
|
|
3
15
|
## 4.122.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -3240,11 +3252,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3240
3252
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3241
3253
|
own rule from two endpoints the console already calls, unprojected:
|
|
3242
3254
|
|
|
3243
|
-
|
|
3244
|
-
|
|
3255
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3256
|
+
GET /api/roles/id/:role that membership's role document
|
|
3245
3257
|
|
|
3246
|
-
|
|
3247
|
-
|
|
3258
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3259
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3248
3260
|
|
|
3249
3261
|
`role.default` is the marker because it is the only property of a platform
|
|
3250
3262
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -16,24 +16,42 @@
|
|
|
16
16
|
>
|
|
17
17
|
Open HERO Fleet Core
|
|
18
18
|
</v-btn>
|
|
19
|
+
<!-- `explain` (Super Admin screens): say why it cannot open instead of hiding it. -->
|
|
20
|
+
<span v-else-if="explain && checked" class="fleet-core-off">
|
|
21
|
+
<v-btn size="small" variant="tonal" prepend-icon="mdi-open-in-new" disabled>Open HERO Fleet Core</v-btn>
|
|
22
|
+
<span class="hero-muted fleet-core-off__why" role="status">{{ offReason }}</span>
|
|
23
|
+
</span>
|
|
19
24
|
<span v-if="failed" class="hero-tag hero-tag--neutral" role="status">Could not open HERO Fleet Core. Try again.</span>
|
|
20
25
|
</template>
|
|
21
26
|
|
|
22
27
|
<script setup lang="ts">
|
|
23
|
-
import { onMounted, ref } from "vue";
|
|
28
|
+
import { computed, onMounted, ref } from "vue";
|
|
24
29
|
import { openFleetCoreConsole } from "../../utils/robot-fleet-core";
|
|
25
30
|
|
|
31
|
+
const props = withDefaults(defineProps<{ explain?: boolean }>(), { explain: false });
|
|
26
32
|
const { getFleetCoreSession } = useRobots();
|
|
27
33
|
const available = ref(false);
|
|
34
|
+
const checked = ref(false);
|
|
35
|
+
const refusedWith = ref(0);
|
|
36
|
+
const offReason = computed(() =>
|
|
37
|
+
refusedWith.value === 404
|
|
38
|
+
? "Not switched on on this server yet - a Seven365 engineer sets it up."
|
|
39
|
+
: refusedWith.value === 403
|
|
40
|
+
? "Seven365 Super Admin only."
|
|
41
|
+
: "Could not reach HERO Fleet Core.",
|
|
42
|
+
);
|
|
28
43
|
const opening = ref(false);
|
|
29
44
|
const failed = ref(false);
|
|
30
45
|
|
|
31
46
|
onMounted(async () => {
|
|
32
47
|
try {
|
|
33
48
|
available.value = (await getFleetCoreSession({ check: true }))?.available === true;
|
|
34
|
-
|
|
49
|
+
if (!available.value) refusedWith.value = 404;
|
|
50
|
+
} catch (e: any) {
|
|
35
51
|
available.value = false;
|
|
52
|
+
refusedWith.value = Number(e?.statusCode ?? e?.status) || 0;
|
|
36
53
|
}
|
|
54
|
+
checked.value = props.explain;
|
|
37
55
|
});
|
|
38
56
|
|
|
39
57
|
async function open() {
|
|
@@ -46,3 +64,8 @@ async function open() {
|
|
|
46
64
|
opening.value = false;
|
|
47
65
|
}
|
|
48
66
|
</script>
|
|
67
|
+
|
|
68
|
+
<style scoped>
|
|
69
|
+
.fleet-core-off { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
70
|
+
.fleet-core-off__why { font-size: 12px; }
|
|
71
|
+
</style>
|
|
@@ -85,10 +85,13 @@
|
|
|
85
85
|
<div
|
|
86
86
|
class="hero-lift__carbox"
|
|
87
87
|
:class="`hero-lift__carbox--${l.carTone}`"
|
|
88
|
-
:style="{ height: `${l.carH}px`, top: `${l.fromTop}px`, '--from': `${l.fromTop}px`, '--to': `${l.toTop}px`, animation: l.riding ? 'heroLift 6s ease-in-out infinite alternate' : 'none' }"
|
|
88
|
+
:style="{ height: `${l.carH}px`, top: `${l.fromTop}px`, '--from': `${l.fromTop}px`, '--to': `${l.toTop}px`, animation: l.riding ? 'heroLift 6s ease-in-out infinite alternate' : 'none', transition: l.live ? 'top 1.8s linear' : 'none' }"
|
|
89
89
|
>
|
|
90
90
|
<span class="hero-lift__carlabel">{{ l.carLabel }}</span>
|
|
91
|
-
<div
|
|
91
|
+
<div
|
|
92
|
+
class="hero-lift__doors"
|
|
93
|
+
:style="l.live ? { transform: `scaleX(${1 - l.doors})`, transition: 'transform 0.8s ease' } : { animation: l.riding ? 'heroDoors 6s ease-in-out infinite' : 'none' }"
|
|
94
|
+
/>
|
|
92
95
|
</div>
|
|
93
96
|
</div>
|
|
94
97
|
</div>
|
|
@@ -354,8 +354,9 @@
|
|
|
354
354
|
<!-- ---- building file ---- -->
|
|
355
355
|
<div class="hero-aside__title hero-editor__asidetitle">Building file · HERO Fleet Core</div>
|
|
356
356
|
<div v-if="map?.rmf" class="hero-muted">{{ rmfLine(map.rmf) }}</div>
|
|
357
|
+
<div v-if="!fleetSite" class="hero-muted">{{ fleetImportOff }}</div>
|
|
357
358
|
<div class="hero-editor__actions">
|
|
358
|
-
<button
|
|
359
|
+
<button type="button" class="hero-editor__act" :disabled="!fleetSite || planBusy || saving || state.dirty" :title="!fleetSite ? fleetImportOff : state.dirty ? 'Save your drawing first.' : ''" @click="importFleet">Import from HERO Fleet Core</button>
|
|
359
360
|
<button type="button" class="hero-editor__act" :disabled="planBusy || saving" @click="pickFile('building')">Import building file</button>
|
|
360
361
|
<button type="button" class="hero-editor__act" :disabled="planBusy || saving" @click="pickFile('nav')">Import route map into {{ graphName(graph) }}</button>
|
|
361
362
|
<button type="button" class="hero-editor__act" :disabled="!map?._id" @click="download('building')">Download building file</button>
|
|
@@ -439,6 +440,8 @@ import {
|
|
|
439
440
|
type TShapeHandle,
|
|
440
441
|
} from "../../utils/robot-building";
|
|
441
442
|
|
|
443
|
+
// Shown instead of hiding "Import from HERO Fleet Core" (owner 2026-10-15: always say why).
|
|
444
|
+
const fleetImportOff = "Import from HERO Fleet Core: only for a site whose robots run through HERO Fleet Core (a Seven365 engineer links them today).";
|
|
442
445
|
const props = withDefaults(
|
|
443
446
|
defineProps<{
|
|
444
447
|
/** The site's saved building map, or null (the first save creates it). */
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
ROBOTICS SETUP (Seven365 Super Admin, owner 2026-10-15): one site's setup
|
|
4
|
+
as a checklist. Each step says Done / Needs action / Waiting on others /
|
|
5
|
+
Check manually, one plain line why, and ONE button to the screen that does
|
|
6
|
+
it. Worked out from the reads the robot screens already make
|
|
7
|
+
(`utils/robot-setup.ts`); nothing here writes.
|
|
8
|
+
-->
|
|
9
|
+
<div class="hero-head">
|
|
10
|
+
<div>
|
|
11
|
+
<button v-if="backToNetwork" type="button" class="hero-back hero-back--sm" @click="emit('network')">‹ Network</button>
|
|
12
|
+
<div class="hero-kicker hero-kicker--accent">{{ kicker }}</div>
|
|
13
|
+
<h2 class="hero-title">Robotics setup</h2>
|
|
14
|
+
</div>
|
|
15
|
+
<span class="hero-head__spacer" />
|
|
16
|
+
<span class="hero-tag hero-tag--neutral" role="status">Setup {{ progress.done }}/{{ progress.total }}</span>
|
|
17
|
+
<v-btn size="small" variant="text" :loading="busy" @click="refresh">Check again</v-btn>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
|
|
21
|
+
<div v-else-if="next" class="hero-notice hero-notice--panel setup-next" role="status">
|
|
22
|
+
<div class="hero-notice__title">Next step: {{ next.title }}</div>
|
|
23
|
+
<div class="hero-notice__body">{{ next.reason }}</div>
|
|
24
|
+
<div v-if="next.note" class="hero-notice__body hero-muted">{{ next.note }}</div>
|
|
25
|
+
<div v-if="next.button" class="setup-next__act">
|
|
26
|
+
<RobotFleetCoreButton v-if="next.button.target === 'fleetcore'" explain />
|
|
27
|
+
<v-btn v-else size="small" color="primary" variant="flat" @click="go(next.button.target)">{{ next.button.label }}</v-btn>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div v-else-if="!busy" class="hero-notice hero-notice--panel" role="status">
|
|
31
|
+
<div class="hero-notice__title">Ready for client</div>
|
|
32
|
+
<div class="hero-notice__body">Every step is done - the client can use their robots.</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<ol class="setup-list" aria-label="Setup steps">
|
|
36
|
+
<li v-for="(s, i) in steps" :key="s.key" class="setup-step" :data-status="s.status">
|
|
37
|
+
<span class="setup-step__n" aria-hidden="true">{{ i + 1 }}</span>
|
|
38
|
+
<div class="setup-step__body">
|
|
39
|
+
<div class="setup-step__title">
|
|
40
|
+
{{ s.title }}<span v-if="s.optional" class="hero-muted"> · optional</span>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="setup-step__reason">{{ busy && !loaded ? "Checking…" : s.reason }}</div>
|
|
43
|
+
<div v-if="s.note" class="setup-step__reason hero-muted">{{ s.note }}</div>
|
|
44
|
+
</div>
|
|
45
|
+
<span class="hero-chip setup-step__chip" :class="`hero-tone--${TONE[s.status]}`">{{ busy && !loaded ? "Checking" : STATUS_TEXT[s.status] }}</span>
|
|
46
|
+
<span v-if="s.button" class="setup-step__act">
|
|
47
|
+
<RobotFleetCoreButton v-if="s.button.target === 'fleetcore'" explain />
|
|
48
|
+
<v-btn v-else size="small" variant="outlined" @click="go(s.button.target)">{{ s.button.label }}</v-btn>
|
|
49
|
+
</span>
|
|
50
|
+
</li>
|
|
51
|
+
</ol>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<script setup lang="ts">
|
|
55
|
+
import { computed, ref, toRef, watch } from "vue";
|
|
56
|
+
import RobotFleetCoreButton from "./FleetCoreButton.vue";
|
|
57
|
+
import useRobotBuilding from "../../composables/useRobotBuilding";
|
|
58
|
+
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
59
|
+
import { robotSetupSteps, setupProgress, STATUS_TEXT, type TSetupStatus, type TSetupTarget } from "../../utils/robot-setup";
|
|
60
|
+
|
|
61
|
+
const props = withDefaults(
|
|
62
|
+
defineProps<{
|
|
63
|
+
organization: string;
|
|
64
|
+
site: string;
|
|
65
|
+
siteName?: string;
|
|
66
|
+
clientName?: string;
|
|
67
|
+
backToNetwork?: boolean;
|
|
68
|
+
}>(),
|
|
69
|
+
{ siteName: "", clientName: "", backToNetwork: false },
|
|
70
|
+
);
|
|
71
|
+
const emit = defineEmits<{
|
|
72
|
+
/** A step's button: a site view (fleet / map / lifts), the client's details, or Add robot. */
|
|
73
|
+
go: [target: TSetupTarget];
|
|
74
|
+
network: [];
|
|
75
|
+
}>();
|
|
76
|
+
|
|
77
|
+
const TONE: Record<TSetupStatus, string> = { done: "success", action: "warning", waiting: "info", check: "neutral" };
|
|
78
|
+
|
|
79
|
+
// Every robot at the site (both types): the building composable already reads them and the saved map.
|
|
80
|
+
const { all, saved, names, loading, error, reload } = useRobotBuilding(toRef(props, "organization"), toRef(props, "site"), ref<"cleaning" | "security">("cleaning"));
|
|
81
|
+
const kicker = computed(() => [props.clientName || names.value.clientName, props.siteName || names.value.siteName].filter(Boolean).join(" · "));
|
|
82
|
+
|
|
83
|
+
const ids = computed(() => all.value.map((r) => String(r._id)).slice(0, 50));
|
|
84
|
+
const { states } = useRobotLive(ids, siteStatesFeed(10000));
|
|
85
|
+
|
|
86
|
+
const { getLiftQueue, getFleetCoreSession } = useRobots();
|
|
87
|
+
const api = (url: string, query?: Record<string, any>) => useNuxtApp().$api<Record<string, any>>(url, { method: "GET", query });
|
|
88
|
+
const unwrap = (res: any) => res?.data ?? res;
|
|
89
|
+
|
|
90
|
+
const orgModules = ref<unknown[] | null | undefined>(undefined);
|
|
91
|
+
const siteModules = ref<unknown[] | null | undefined>(undefined);
|
|
92
|
+
const roles = ref<Array<Record<string, any>> | undefined>(undefined);
|
|
93
|
+
const lifts = ref<Array<Record<string, any>> | undefined>(undefined);
|
|
94
|
+
const fleetCore = ref<true | number | undefined>(undefined);
|
|
95
|
+
const extrasBusy = ref(false);
|
|
96
|
+
const loaded = ref(false);
|
|
97
|
+
|
|
98
|
+
async function loadExtras() {
|
|
99
|
+
extrasBusy.value = true;
|
|
100
|
+
const org = props.organization;
|
|
101
|
+
const site = props.site;
|
|
102
|
+
const [o, s, r, f] = await Promise.allSettled([
|
|
103
|
+
api(`/api/organizations/org/id/${org}`),
|
|
104
|
+
api(`/api/sites/${site}`),
|
|
105
|
+
// `site` keeps this a plain read: the whole-org page-1 list seeds default roles when empty.
|
|
106
|
+
api("/api/roles", { org, site, page: 1, limit: 100 }),
|
|
107
|
+
getFleetCoreSession({ check: true }),
|
|
108
|
+
]);
|
|
109
|
+
if (org !== props.organization || site !== props.site) return;
|
|
110
|
+
orgModules.value = o.status === "fulfilled" ? (unwrap(o.value)?.modules ?? null) : undefined;
|
|
111
|
+
siteModules.value = s.status === "fulfilled" ? (unwrap(s.value)?.metadata?.modules ?? null) : undefined;
|
|
112
|
+
roles.value = r.status === "fulfilled" ? (r.value?.items ?? unwrap(r.value)?.items ?? undefined) : undefined;
|
|
113
|
+
fleetCore.value =
|
|
114
|
+
f.status === "fulfilled" ? (f.value?.available === true ? true : 404) : Number((f.reason as any)?.statusCode ?? (f.reason as any)?.status) || undefined;
|
|
115
|
+
extrasBusy.value = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function loadLifts() {
|
|
119
|
+
const id = saved.value?._id;
|
|
120
|
+
if (!id) return void (lifts.value = []);
|
|
121
|
+
lifts.value = (await getLiftQueue(String(id)).catch(() => null))?.items ?? undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
watch(() => [props.organization, props.site], loadExtras, { immediate: true });
|
|
125
|
+
watch(() => saved.value?._id, loadLifts);
|
|
126
|
+
watch([loading, extrasBusy], ([a, b]) => {
|
|
127
|
+
if (!a && !b) loaded.value = true;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const busy = computed(() => loading.value || extrasBusy.value);
|
|
131
|
+
async function refresh() {
|
|
132
|
+
await Promise.all([reload(), loadExtras()]);
|
|
133
|
+
await loadLifts();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const steps = computed(() =>
|
|
137
|
+
robotSetupSteps({
|
|
138
|
+
orgModules: orgModules.value,
|
|
139
|
+
siteModules: siteModules.value,
|
|
140
|
+
robots: error.value ? undefined : all.value,
|
|
141
|
+
states: states.value as Record<string, Record<string, any>>,
|
|
142
|
+
savedMap: error.value ? undefined : saved.value,
|
|
143
|
+
lifts: lifts.value,
|
|
144
|
+
fleetCore: fleetCore.value,
|
|
145
|
+
roles: roles.value,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
const progress = computed(() => setupProgress(steps.value));
|
|
149
|
+
const next = computed(() => (loaded.value ? steps.value.find((s) => !s.optional && s.key !== "ready" && s.status !== "done") ?? null : null));
|
|
150
|
+
|
|
151
|
+
function go(target: TSetupTarget) {
|
|
152
|
+
if (target) emit("go", target);
|
|
153
|
+
}
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<style scoped>
|
|
157
|
+
.setup-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }
|
|
158
|
+
.setup-step {
|
|
159
|
+
display: grid;
|
|
160
|
+
grid-template-columns: 28px 1fr auto;
|
|
161
|
+
grid-template-areas: "n body chip" "n act act";
|
|
162
|
+
gap: 6px 10px;
|
|
163
|
+
align-items: start;
|
|
164
|
+
padding: 12px;
|
|
165
|
+
background: var(--hero-surface);
|
|
166
|
+
box-shadow: var(--hero-shadow-sm);
|
|
167
|
+
border-left: 3px solid var(--hero-divider);
|
|
168
|
+
}
|
|
169
|
+
.setup-step[data-status="done"] { border-left-color: rgb(var(--v-theme-success)); }
|
|
170
|
+
.setup-step[data-status="action"] { border-left-color: rgb(var(--v-theme-warning)); }
|
|
171
|
+
.setup-step[data-status="waiting"] { border-left-color: rgb(var(--v-theme-info)); }
|
|
172
|
+
.setup-step__n { grid-area: n; font-family: var(--hero-font); font-weight: 800; opacity: 0.5; }
|
|
173
|
+
.setup-step__body { grid-area: body; min-width: 0; }
|
|
174
|
+
.setup-step__title { font-family: var(--hero-font); font-weight: 700; }
|
|
175
|
+
.setup-step__reason { font-size: 13px; overflow-wrap: anywhere; }
|
|
176
|
+
.setup-step__chip { grid-area: chip; }
|
|
177
|
+
.setup-step__act { grid-area: act; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
178
|
+
.setup-next { margin-top: 8px; }
|
|
179
|
+
.setup-next__act { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
180
|
+
@media (max-width: 480px) {
|
|
181
|
+
.setup-step { grid-template-columns: 22px 1fr; grid-template-areas: "n body" "n chip" "n act"; }
|
|
182
|
+
.setup-step__chip { justify-self: start; }
|
|
183
|
+
}
|
|
184
|
+
</style>
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
</div>
|
|
36
36
|
<span class="hero-head__spacer" />
|
|
37
37
|
<div class="hero-header__right">
|
|
38
|
-
<RobotFleetCoreButton />
|
|
38
|
+
<RobotFleetCoreButton :explain="superAdmin" />
|
|
39
39
|
<RobotLiveIndicator :connected="connected" />
|
|
40
40
|
<span class="hero-tag hero-tag--neutral">{{ role }}</span>
|
|
41
41
|
</div>
|
|
@@ -58,8 +58,10 @@ withDefaults(
|
|
|
58
58
|
type?: TRobotType;
|
|
59
59
|
connected?: boolean;
|
|
60
60
|
role?: string;
|
|
61
|
+
/** Seven365 Super Admin: say why HERO Fleet Core cannot open instead of hiding it. */
|
|
62
|
+
superAdmin?: boolean;
|
|
61
63
|
}>(),
|
|
62
|
-
{ orgName: "", sites: () => [], site: "", types: () => ["cleaning"], type: "cleaning", connected: true, role: "View-only" },
|
|
64
|
+
{ superAdmin: false, orgName: "", sites: () => [], site: "", types: () => ["cleaning"], type: "cleaning", connected: true, role: "View-only" },
|
|
63
65
|
);
|
|
64
66
|
const emit = defineEmits<{ "update:site": [id: string]; "update:type": [type: TRobotType] }>();
|
|
65
67
|
</script>
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
:type="type"
|
|
18
18
|
:connected="connected"
|
|
19
19
|
:role="role"
|
|
20
|
+
:super-admin="superAdmin"
|
|
20
21
|
@update:site="(id) => emit('update:site', id)"
|
|
21
22
|
@update:type="setType"
|
|
22
23
|
/>
|
|
@@ -35,6 +36,17 @@
|
|
|
35
36
|
@removed="onRemoved"
|
|
36
37
|
@status="(s) => (connected = s.connected)"
|
|
37
38
|
/>
|
|
39
|
+
<RobotSetup
|
|
40
|
+
v-else-if="view === 'setup' && superAdmin"
|
|
41
|
+
:key="`setup:${site}:${setupKey}`"
|
|
42
|
+
:organization="organization"
|
|
43
|
+
:site="site"
|
|
44
|
+
:site-name="siteName || names.siteName"
|
|
45
|
+
:client-name="clientName || names.clientName"
|
|
46
|
+
:back-to-network="backToNetwork"
|
|
47
|
+
@go="onSetupGo"
|
|
48
|
+
@network="emit('network')"
|
|
49
|
+
/>
|
|
38
50
|
<RobotBuildingMap
|
|
39
51
|
v-else-if="view === 'map'"
|
|
40
52
|
:key="`map:${site}:${type}`"
|
|
@@ -129,6 +141,7 @@ import RobotLiftQueue from "./LiftQueue.vue";
|
|
|
129
141
|
import RobotSiteAlerts from "./SiteAlerts.vue";
|
|
130
142
|
import RobotActivityLog from "./ActivityLog.vue";
|
|
131
143
|
import RobotForm from "./Form.vue";
|
|
144
|
+
import RobotSetup from "./Setup.vue";
|
|
132
145
|
import RobotToast from "./Toast.vue";
|
|
133
146
|
import { roleLabel, type TRobotType } from "../../utils/robot-display";
|
|
134
147
|
|
|
@@ -148,8 +161,8 @@ const props = withDefaults(
|
|
|
148
161
|
type?: TRobotType;
|
|
149
162
|
/** One robot open, or none (the fleet). */
|
|
150
163
|
robotId?: string;
|
|
151
|
-
/** Which screen: the fleet (default), the 3D building map, the lift queue, alerts or the activity log. A robot open wins. */
|
|
152
|
-
view?: "fleet" | "map" | "lifts" | "alerts" | "log";
|
|
164
|
+
/** Which screen: the fleet (default), setup (Super Admin), the 3D building map, the lift queue, alerts or the activity log. A robot open wins. */
|
|
165
|
+
view?: "setup" | "fleet" | "map" | "lifts" | "alerts" | "log";
|
|
153
166
|
canSetup?: boolean;
|
|
154
167
|
canEdit?: boolean;
|
|
155
168
|
canCommand?: boolean;
|
|
@@ -182,6 +195,11 @@ const emit = defineEmits<{
|
|
|
182
195
|
open: [id: string];
|
|
183
196
|
back: [];
|
|
184
197
|
network: [];
|
|
198
|
+
/**
|
|
199
|
+
* Super Admin setup: a step's button asks the host to show a site view
|
|
200
|
+
* (fleet / map / lifts / setup) or the client's details page ("client").
|
|
201
|
+
*/
|
|
202
|
+
navigate: [target: string];
|
|
185
203
|
/** Live feed health + open faults, for a host drawing its own header. */
|
|
186
204
|
status: [status: { connected: boolean; faults: number }];
|
|
187
205
|
}>();
|
|
@@ -224,10 +242,18 @@ watch(
|
|
|
224
242
|
const addOpen = ref(false);
|
|
225
243
|
const toast = ref("");
|
|
226
244
|
const reloadKey = ref(0);
|
|
245
|
+
const setupKey = ref(0);
|
|
227
246
|
function onCreated() {
|
|
228
247
|
addOpen.value = false;
|
|
229
248
|
toast.value = "Robot saved · credentials hidden";
|
|
230
249
|
reloadKey.value++;
|
|
250
|
+
setupKey.value++;
|
|
251
|
+
// Super Admin: straight to the site's setup, which says what to do next.
|
|
252
|
+
if (props.superAdmin) emit("navigate", "setup");
|
|
253
|
+
}
|
|
254
|
+
function onSetupGo(target: string) {
|
|
255
|
+
if (target === "add") addOpen.value = true;
|
|
256
|
+
else emit("navigate", target);
|
|
231
257
|
}
|
|
232
258
|
function onRemoved(name: string) {
|
|
233
259
|
toast.value = `${name} removed`;
|
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.123.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/robot-building.ts
CHANGED
|
@@ -1460,6 +1460,21 @@ export function liftQueueRows(lift: Record<string, any>, now = Date.now()): TLif
|
|
|
1460
1460
|
const ROW_H = 44;
|
|
1461
1461
|
const CAR_H = ROW_H - 14;
|
|
1462
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* A floor as the real lift shows it ("B1", "L34", "34", "G") -> the car's y on the rails. Between two rails
|
|
1465
|
+
* (a floor the map has no rail for) the car sits proportionally between them; past the ends it sits on the end.
|
|
1466
|
+
*/
|
|
1467
|
+
export function liftFloorTop(display: unknown, floors: number[], topOf: (f: number) => number): number | null {
|
|
1468
|
+
const parsed = newFloorNo(String(display ?? "").replace(/\s+/g, ""), []);
|
|
1469
|
+
if (!("floorNo" in parsed) || !floors.length) return null;
|
|
1470
|
+
const v = parsed.floorNo;
|
|
1471
|
+
if (v <= floors[0]!) return topOf(floors[0]!);
|
|
1472
|
+
if (v >= floors.at(-1)!) return topOf(floors.at(-1)!);
|
|
1473
|
+
const i = floors.findIndex((f) => f >= v);
|
|
1474
|
+
const lo = floors[i - 1]!, hi = floors[i]!;
|
|
1475
|
+
return topOf(lo) + ((v - lo) / (hi - lo)) * (topOf(hi) - topOf(lo));
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1463
1478
|
/** The whole card: header words, car status, shaft geometry, rows (README §5). */
|
|
1464
1479
|
export function liftCard(lift: Record<string, any>, now = Date.now()) {
|
|
1465
1480
|
const rows = liftQueueRows(lift, now);
|
|
@@ -1472,23 +1487,37 @@ export function liftCard(lift: Record<string, any>, now = Date.now()) {
|
|
|
1472
1487
|
const rest = floors.includes(idleFloor) ? idleFloor : floors[0] ?? 1;
|
|
1473
1488
|
const from = riding ? riding.fromNo : rest;
|
|
1474
1489
|
const to = riding ? riding.toNo : rest;
|
|
1490
|
+
// The real lift (read-only gateway), when no robot ride is drawn: the car follows its floor, doors and direction.
|
|
1491
|
+
const gw = lift?.gateway;
|
|
1492
|
+
const real = !riding && gw?.status === "connected" && gw.state ? gw.state : null;
|
|
1493
|
+
const realTop = real ? liftFloorTop(real.currentFloor, floors, topOf) : null;
|
|
1494
|
+
const live = realTop != null;
|
|
1495
|
+
const realAlarm = live && [3, 4, 5].includes(Number(real.currentMode));
|
|
1496
|
+
const arrow = live ? (real.motionState === 1 ? "▲" : real.motionState === 2 ? "▼" : "") : "";
|
|
1475
1497
|
return {
|
|
1476
1498
|
name: String(lift?.liftName ?? "Lift"),
|
|
1477
1499
|
sub: rows.length ? `${riding ? 1 : 0} riding · ${waiting.length} waiting` : "idle",
|
|
1478
|
-
carText: riding
|
|
1479
|
-
|
|
1480
|
-
|
|
1500
|
+
carText: riding
|
|
1501
|
+
? `Car · ${riding.robot} · ${riding.from} → ${riding.to}`
|
|
1502
|
+
: live
|
|
1503
|
+
? `Car · floor ${real.currentFloor}${MOTION_TEXT[real.motionState] ? ` · ${MOTION_TEXT[real.motionState]!.toLowerCase()}` : ""}`
|
|
1504
|
+
: "Car idle",
|
|
1505
|
+
carLabel: riding ? riding.robot.replace(/^HERO-/, "") : arrow,
|
|
1506
|
+
carTone: riding ? "ink" : realAlarm || rows.some((r) => r.state === "timed out") ? "accent" : live ? "ink" : "neutral",
|
|
1507
|
+
/** Real lift drawn live: the car glides between readings; doors open (1) / half (0.5, moving) / shut (0). */
|
|
1508
|
+
live,
|
|
1509
|
+
doors: live ? (real.doorState === 2 ? 1 : real.doorState === 1 ? 0.5 : 0) : 0,
|
|
1481
1510
|
riding: !!riding,
|
|
1482
1511
|
empty: !rows.length,
|
|
1483
1512
|
shaftH: (n - 1) * ROW_H + 24,
|
|
1484
1513
|
carH: CAR_H,
|
|
1485
|
-
fromTop: topOf(from) - CAR_H / 2,
|
|
1486
|
-
toTop: topOf(to) - CAR_H / 2,
|
|
1514
|
+
fromTop: (live ? realTop! : topOf(from)) - CAR_H / 2,
|
|
1515
|
+
toTop: (live ? realTop! : topOf(to)) - CAR_H / 2,
|
|
1487
1516
|
rails: [...floors].reverse().map((f) => ({
|
|
1488
1517
|
floorNo: f,
|
|
1489
1518
|
name: floorName(f),
|
|
1490
1519
|
top: topOf(f),
|
|
1491
|
-
lit:
|
|
1520
|
+
lit: riding ? f === riding.fromNo || f === riding.toNo : live && realTop === topOf(f),
|
|
1492
1521
|
waiting: waiting.filter((w) => w.fromNo === f).map((w) => w.robot),
|
|
1493
1522
|
})),
|
|
1494
1523
|
rows,
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robotics setup checklist (Seven365 Super Admin, one site): what is done,
|
|
3
|
+
* what needs doing and what waits on someone else, worked out ONLY from what
|
|
4
|
+
* the robot screens already read - nothing is ticked by hand, nothing stored.
|
|
5
|
+
* Pure: no Vue, no Nuxt (tested in robot-setup.test.ts).
|
|
6
|
+
*
|
|
7
|
+
* Every input may be `undefined` = "could not read it": that step then says
|
|
8
|
+
* "Check manually" with its link, never a guess.
|
|
9
|
+
*/
|
|
10
|
+
import { floorName } from "./robot-display.ts";
|
|
11
|
+
|
|
12
|
+
export type TSetupStatus = "done" | "action" | "waiting" | "check";
|
|
13
|
+
/** Where a step's button goes: a site view, the Add robot dialog, or the client's details page. */
|
|
14
|
+
export type TSetupTarget = "client" | "add" | "fleet" | "map" | "lifts" | "fleetcore" | "";
|
|
15
|
+
|
|
16
|
+
export type TSetupStep = {
|
|
17
|
+
key: string;
|
|
18
|
+
title: string;
|
|
19
|
+
status: TSetupStatus;
|
|
20
|
+
/** One plain-English line: why this status. */
|
|
21
|
+
reason: string;
|
|
22
|
+
/** A second line when it helps (vendor prerequisites, a sub-state). */
|
|
23
|
+
note?: string;
|
|
24
|
+
button: { label: string; target: TSetupTarget } | null;
|
|
25
|
+
/** Optional steps never block "Ready for client" and are not counted. */
|
|
26
|
+
optional?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type TSetupInput = {
|
|
30
|
+
/** `organizations.modules` (absent/empty = every module). */
|
|
31
|
+
orgModules?: unknown[] | null;
|
|
32
|
+
/** `sites.metadata.modules` (absent/empty = every module the client has). */
|
|
33
|
+
siteModules?: unknown[] | null;
|
|
34
|
+
/** Every robot at the site (both types). */
|
|
35
|
+
robots?: Array<Record<string, any>>;
|
|
36
|
+
/** Live states by robot id (`/robots/states`); missing ids = not read yet. */
|
|
37
|
+
states?: Record<string, Record<string, any>>;
|
|
38
|
+
/** The site's SAVED building map (`null` = none saved). */
|
|
39
|
+
savedMap?: Record<string, any> | null;
|
|
40
|
+
/** The lift queue items of the saved map, each with its `gateway` (or null). */
|
|
41
|
+
lifts?: Array<Record<string, any>>;
|
|
42
|
+
/** HERO Fleet Core `?check=1`: true, or the HTTP status that refused (404 = not switched on). */
|
|
43
|
+
fleetCore?: true | number;
|
|
44
|
+
/** The client's roles that apply at this site. */
|
|
45
|
+
roles?: Array<Record<string, any>>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const STATUS_TEXT: Record<TSetupStatus, string> = {
|
|
49
|
+
done: "Done",
|
|
50
|
+
action: "Needs action",
|
|
51
|
+
waiting: "Waiting on others",
|
|
52
|
+
check: "Check manually",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const ROBOT_MODULE = "robot-mgmt";
|
|
56
|
+
const FLEET_CORE_BRAND = "fleet-core";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Is the robot module on at one tier? Absent or empty = everything (the module
|
|
60
|
+
* gate fails open). ponytail: the server also treats a list holding no
|
|
61
|
+
* governed module at all as "everything"; that rare list reads "off" here.
|
|
62
|
+
*/
|
|
63
|
+
function moduleOn(list: unknown[] | null | undefined) {
|
|
64
|
+
if (!Array.isArray(list) || !list.length) return true;
|
|
65
|
+
return list.some((m) => String(m) === ROBOT_MODULE);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A role that lets client staff see robots: `*`, an empty list (= all), or any robot-mgmt right. */
|
|
69
|
+
export function roleSeesRobots(role: Record<string, any> | null | undefined) {
|
|
70
|
+
const p = role?.permissions;
|
|
71
|
+
if (!Array.isArray(p)) return false;
|
|
72
|
+
return p.length === 0 || p.includes("*") || p.some((x) => typeof x === "string" && x.startsWith(`${ROBOT_MODULE}:`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const names = (list: Array<Record<string, any>>, max = 3) => {
|
|
76
|
+
const n = list.map((r) => String(r?.name || "A robot"));
|
|
77
|
+
return n.length > max ? `${n.slice(0, max).join(", ")} and ${n.length - max} more` : n.join(", ");
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Why one robot is not online, in words (vendor-specific hints where we know them). */
|
|
81
|
+
export function offlineReason(robot: Record<string, any>, state: Record<string, any> | undefined) {
|
|
82
|
+
const name = String(robot?.name || "A robot");
|
|
83
|
+
if (!state) return `${name}: still checking.`;
|
|
84
|
+
if (state.monitorable === false) return `${name}: this brand has no live link, so its status cannot be read.`;
|
|
85
|
+
if (state.ok === false) {
|
|
86
|
+
const why = state.reason ? ` (${state.reason})` : "";
|
|
87
|
+
if (robot?.brand === "ounitech") return `${name}: the Ounitech cloud did not answer${why}. Ask Ounitech to link this robot's serial to our account.`;
|
|
88
|
+
if (robot?.brand === "padbot") return `${name}: the PadBot cloud did not answer${why}. Check the robot's key and token.`;
|
|
89
|
+
return `${name}: its cloud did not answer${why}.`;
|
|
90
|
+
}
|
|
91
|
+
return `${name}: its cloud says it is offline - switched off, charging dock unplugged or out of Wi-Fi.`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const floorsOf = (map: Record<string, any> | null | undefined) =>
|
|
95
|
+
[...new Set<number>((map?.levels ?? []).map((l: any) => Number(l?.floorNo)).filter(Number.isInteger))].sort((a, b) => a - b);
|
|
96
|
+
|
|
97
|
+
/** The checklist, in order, ending with "Ready for client". */
|
|
98
|
+
export function robotSetupSteps(input: TSetupInput): TSetupStep[] {
|
|
99
|
+
const steps: TSetupStep[] = [];
|
|
100
|
+
const robots = input.robots;
|
|
101
|
+
const real = (robots ?? []).filter((r) => r?.brand !== "demo" && r?.demo !== true);
|
|
102
|
+
|
|
103
|
+
// 1. Robot module on for client + site.
|
|
104
|
+
if (input.orgModules === undefined || input.siteModules === undefined) {
|
|
105
|
+
steps.push({
|
|
106
|
+
key: "module",
|
|
107
|
+
title: "Robot module switched on",
|
|
108
|
+
status: "check",
|
|
109
|
+
reason: "Could not read the client's or the site's module list.",
|
|
110
|
+
button: { label: "Open client details", target: "client" },
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
const org = moduleOn(input.orgModules);
|
|
114
|
+
const site = moduleOn(input.siteModules);
|
|
115
|
+
steps.push({
|
|
116
|
+
key: "module",
|
|
117
|
+
title: "Robot module switched on",
|
|
118
|
+
status: org && site ? "done" : "action",
|
|
119
|
+
reason:
|
|
120
|
+
org && site
|
|
121
|
+
? "The client and this site both have the robot module."
|
|
122
|
+
: !org
|
|
123
|
+
? "The client's modules do not include Robot management, so their staff cannot see robots."
|
|
124
|
+
: "This site's modules do not include Robot management, so staff here cannot see robots.",
|
|
125
|
+
button: org && site ? null : { label: "Open client details", target: "client" },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Robots added.
|
|
130
|
+
const prereq = "Before adding: Ounitech must link each robot's serial to our Seven365 account; for PadBot, save the robot's points (charger, stops) in the PadBot app first.";
|
|
131
|
+
if (!robots) {
|
|
132
|
+
steps.push({ key: "robots", title: "Robots added", status: "check", reason: "Could not read this site's robots.", button: { label: "Open fleet", target: "fleet" } });
|
|
133
|
+
} else {
|
|
134
|
+
const demo = robots.length - real.length;
|
|
135
|
+
steps.push({
|
|
136
|
+
key: "robots",
|
|
137
|
+
title: "Robots added",
|
|
138
|
+
status: robots.length ? "done" : "action",
|
|
139
|
+
reason: robots.length
|
|
140
|
+
? `${robots.length} robot${robots.length === 1 ? "" : "s"} at this site${demo ? ` (${demo} demo)` : ""}.`
|
|
141
|
+
: "No robots at this site yet.",
|
|
142
|
+
note: robots.length ? undefined : prereq,
|
|
143
|
+
button: { label: robots.length ? "+ Add another robot" : "+ Add robot", target: "add" },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3. Robots online.
|
|
148
|
+
if (!robots || !robots.length) {
|
|
149
|
+
steps.push({ key: "online", title: "Robots online", status: "action", reason: "Add a robot first.", button: null });
|
|
150
|
+
} else {
|
|
151
|
+
const states = input.states ?? {};
|
|
152
|
+
const unread = robots.filter((r) => !states[String(r._id)]);
|
|
153
|
+
const down = robots.filter((r) => {
|
|
154
|
+
const s = states[String(r._id)];
|
|
155
|
+
return s && !(s.ok === true && s.online !== false);
|
|
156
|
+
});
|
|
157
|
+
const vendorSide = down.filter((r) => states[String(r._id)]?.ok === false);
|
|
158
|
+
steps.push({
|
|
159
|
+
key: "online",
|
|
160
|
+
title: "Robots online",
|
|
161
|
+
status: down.length ? (vendorSide.length === down.length ? "waiting" : "action") : unread.length ? "check" : "done",
|
|
162
|
+
reason: down.length
|
|
163
|
+
? `${robots.length - down.length} of ${robots.length} online. ${offlineReason(down[0]!, states[String(down[0]!._id)])}${down.length > 1 ? ` Also not online: ${names(down.slice(1))}.` : ""}`
|
|
164
|
+
: unread.length
|
|
165
|
+
? `Still checking ${names(unread)}…`
|
|
166
|
+
: `All ${robots.length} robot${robots.length === 1 ? " is" : "s are"} online.`,
|
|
167
|
+
button: { label: "Open fleet", target: "fleet" },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 4. Building map saved.
|
|
172
|
+
const map = input.savedMap;
|
|
173
|
+
if (map === undefined) {
|
|
174
|
+
steps.push({ key: "map", title: "Building map saved", status: "check", reason: "Could not read the saved building map.", button: { label: "Open building map", target: "map" } });
|
|
175
|
+
} else {
|
|
176
|
+
steps.push({
|
|
177
|
+
key: "map",
|
|
178
|
+
title: "Building map saved",
|
|
179
|
+
status: map ? "done" : "action",
|
|
180
|
+
reason: map
|
|
181
|
+
? `Saved${map.source === FLEET_CORE_BRAND ? " (imported from HERO Fleet Core)" : ""}.`
|
|
182
|
+
: "No building map saved yet. Draw walls, doors and lift shafts in Edit map.",
|
|
183
|
+
button: { label: map ? "Open building map" : "Draw the map", target: "map" },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 5. Floors present: one per floor the robots report.
|
|
188
|
+
const floors = floorsOf(map);
|
|
189
|
+
const reported = [
|
|
190
|
+
...new Set<number>(
|
|
191
|
+
Object.values(input.states ?? {})
|
|
192
|
+
.filter((s) => s?.ok && s.floorKnown === true && Number.isInteger(s.floorNo))
|
|
193
|
+
.map((s) => Number(s.floorNo)),
|
|
194
|
+
),
|
|
195
|
+
];
|
|
196
|
+
const missing = reported.filter((f) => !floors.includes(f));
|
|
197
|
+
if (map === undefined) {
|
|
198
|
+
steps.push({ key: "floors", title: "Floors on the map", status: "check", reason: "Could not read the saved building map.", button: { label: "Open building map", target: "map" } });
|
|
199
|
+
} else if (!map) {
|
|
200
|
+
steps.push({ key: "floors", title: "Floors on the map", status: "action", reason: "Save a building map first.", button: null });
|
|
201
|
+
} else {
|
|
202
|
+
steps.push({
|
|
203
|
+
key: "floors",
|
|
204
|
+
title: "Floors on the map",
|
|
205
|
+
status: !floors.length || missing.length ? "action" : "done",
|
|
206
|
+
reason: !floors.length
|
|
207
|
+
? "The saved map has no floors. Use Edit map → Add floor."
|
|
208
|
+
: missing.length
|
|
209
|
+
? `Robots report ${missing.map(floorName).join(", ")}, which the map does not have. Add ${missing.length === 1 ? "it" : "them"} in Edit map → Add floor.`
|
|
210
|
+
: `${floors.map(floorName).join(", ")}${reported.length ? " - every floor the robots report." : "."}`,
|
|
211
|
+
button: { label: "Edit floors", target: "map" },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 6. Lifts with 2+ stops.
|
|
216
|
+
const drawn = (map?.lifts ?? []) as Array<Record<string, any>>;
|
|
217
|
+
const usable = drawn.filter((l) => new Set(l?.floors ?? []).size >= 2);
|
|
218
|
+
const needLift = floors.length > 1 || reported.length > 1;
|
|
219
|
+
if (map === undefined) {
|
|
220
|
+
steps.push({ key: "lifts", title: "Lifts with 2 or more stops", status: "check", reason: "Could not read the saved building map.", button: { label: "Open building map", target: "map" } });
|
|
221
|
+
} else if (!usable.length && !needLift) {
|
|
222
|
+
steps.push({ key: "lifts", title: "Lifts with 2 or more stops", status: "done", reason: "One floor only - robots here do not need a lift.", button: null });
|
|
223
|
+
} else {
|
|
224
|
+
steps.push({
|
|
225
|
+
key: "lifts",
|
|
226
|
+
title: "Lifts with 2 or more stops",
|
|
227
|
+
status: usable.length ? "done" : "action",
|
|
228
|
+
reason: usable.length
|
|
229
|
+
? `${usable.map((l) => `${l.name || "Lift"} (${(l.floors ?? []).length} stops)`).join(", ")}.`
|
|
230
|
+
: drawn.length
|
|
231
|
+
? `${names(drawn)} ${drawn.length === 1 ? "has" : "have"} fewer than 2 stops. Tick each floor the lift stops at in Edit map.`
|
|
232
|
+
: "No lift drawn. Draw a lift shaft in Edit map and tick the floors it stops at.",
|
|
233
|
+
button: { label: usable.length ? "Open building map" : "Draw a lift", target: "map" },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 7. Lift gateway connected (+ floor/door decoded).
|
|
238
|
+
if (!usable.length) {
|
|
239
|
+
steps.push({
|
|
240
|
+
key: "gateway",
|
|
241
|
+
title: "Lift gateway connected",
|
|
242
|
+
status: needLift ? "action" : "done",
|
|
243
|
+
reason: needLift ? "Draw a lift with 2 or more stops first." : "No lift, so no gateway needed.",
|
|
244
|
+
button: null,
|
|
245
|
+
});
|
|
246
|
+
} else if (input.lifts === undefined) {
|
|
247
|
+
steps.push({ key: "gateway", title: "Lift gateway connected", status: "check", reason: "Could not read the lift queue.", button: { label: "Open lift queue", target: "lifts" } });
|
|
248
|
+
} else {
|
|
249
|
+
const gws = input.lifts.filter((l) => l?.gateway).map((l) => ({ name: String(l.liftName ?? "Lift"), g: l.gateway as Record<string, any> }));
|
|
250
|
+
const up = gws.filter((x) => x.g.status === "connected");
|
|
251
|
+
const decoded = up.filter((x) => x.g.state && (x.g.state.currentFloor || x.g.state.doorState != null));
|
|
252
|
+
const other = gws.find((x) => x.g.status !== "connected");
|
|
253
|
+
steps.push({
|
|
254
|
+
key: "gateway",
|
|
255
|
+
title: "Lift gateway connected",
|
|
256
|
+
status: up.length ? "done" : gws.length ? "waiting" : "action",
|
|
257
|
+
reason: up.length
|
|
258
|
+
? `${up.map((x) => x.name).join(", ")} connected.`
|
|
259
|
+
: other
|
|
260
|
+
? other.g.status === "waiting"
|
|
261
|
+
? `${other.name}: code made, waiting for the gateway box to report. Someone on site pastes the code into lift-gateway.ini and starts it.`
|
|
262
|
+
: other.g.status === "no-lift"
|
|
263
|
+
? `${other.name}: the gateway is online but the lift board does not answer - check the RS-485 wiring.`
|
|
264
|
+
: `${other.name}: the gateway stopped reporting - check its power and network.`
|
|
265
|
+
: "No gateway yet. Open the lift queue and press Connect gateway on the lift.",
|
|
266
|
+
note: up.length
|
|
267
|
+
? decoded.length === up.length
|
|
268
|
+
? "Floor and door are read from the real lift."
|
|
269
|
+
: "Floor and door not decoded yet - waiting on the lift company's register list."
|
|
270
|
+
: undefined,
|
|
271
|
+
button: { label: up.length ? "Open lift queue" : "Connect gateway", target: "lifts" },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 8. HERO Fleet Core (optional).
|
|
276
|
+
const fleetRobots = (robots ?? []).filter((r) => r?.brand === FLEET_CORE_BRAND);
|
|
277
|
+
steps.push({
|
|
278
|
+
key: "fleetcore",
|
|
279
|
+
title: "HERO Fleet Core linked",
|
|
280
|
+
optional: true,
|
|
281
|
+
status: fleetRobots.length || map?.source === FLEET_CORE_BRAND ? "done" : input.fleetCore === undefined ? "check" : "waiting",
|
|
282
|
+
reason: fleetRobots.length
|
|
283
|
+
? `${fleetRobots.length} robot${fleetRobots.length === 1 ? "" : "s"} run through HERO Fleet Core.`
|
|
284
|
+
: map?.source === FLEET_CORE_BRAND
|
|
285
|
+
? "The building map came from HERO Fleet Core."
|
|
286
|
+
: input.fleetCore === undefined
|
|
287
|
+
? "Could not check HERO Fleet Core."
|
|
288
|
+
: input.fleetCore === true
|
|
289
|
+
? "Optional. Linking this site's robots to HERO Fleet Core still needs a Seven365 engineer step today."
|
|
290
|
+
: input.fleetCore === 404
|
|
291
|
+
? "Optional. HERO Fleet Core is not switched on on this server yet - a Seven365 engineer sets it up."
|
|
292
|
+
: "Optional. HERO Fleet Core is for Seven365 Super Admin only.",
|
|
293
|
+
button: input.fleetCore === true ? { label: "Open HERO Fleet Core", target: "fleetcore" } : null,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// 9. Client roles with robot rights.
|
|
297
|
+
if (!input.roles) {
|
|
298
|
+
steps.push({ key: "access", title: "Client staff can see robots", status: "check", reason: "Could not read the client's roles.", button: { label: "Open client details", target: "client" } });
|
|
299
|
+
} else {
|
|
300
|
+
const seeing = input.roles.filter(roleSeesRobots);
|
|
301
|
+
const named = seeing.filter((r) => Array.isArray(r.permissions) && r.permissions.length && !r.permissions.includes("*"));
|
|
302
|
+
steps.push({
|
|
303
|
+
key: "access",
|
|
304
|
+
title: "Client staff can see robots",
|
|
305
|
+
status: named.length ? "done" : "action",
|
|
306
|
+
reason: named.length
|
|
307
|
+
? `${names(named)} ${named.length === 1 ? "has" : "have"} robot rights.`
|
|
308
|
+
: seeing.length
|
|
309
|
+
? "Only full-access roles (such as the owner) can see robots. Give staff roles the Robot management rights."
|
|
310
|
+
: "No role at this client has robot rights yet. Give staff roles the Robot management rights.",
|
|
311
|
+
button: named.length ? null : { label: "Open client details", target: "client" },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 10. Ready for client.
|
|
316
|
+
const required = steps.filter((s) => !s.optional);
|
|
317
|
+
const done = required.filter((s) => s.status === "done").length;
|
|
318
|
+
const next = required.find((s) => s.status !== "done");
|
|
319
|
+
steps.push({
|
|
320
|
+
key: "ready",
|
|
321
|
+
title: "Ready for client",
|
|
322
|
+
status: next ? (required.every((s) => s.status === "done" || s.status === "waiting") ? "waiting" : "action") : "done",
|
|
323
|
+
reason: next ? `${done} of ${required.length} steps done. Next: ${next.title}.` : "Every step is done - the client can use their robots.",
|
|
324
|
+
button: null,
|
|
325
|
+
});
|
|
326
|
+
return steps;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** "Setup 6/9" for a chip: required steps done of required steps (the final "Ready" excluded). */
|
|
330
|
+
export function setupProgress(steps: TSetupStep[]) {
|
|
331
|
+
const required = steps.filter((s) => !s.optional && s.key !== "ready");
|
|
332
|
+
return { done: required.filter((s) => s.status === "done").length, total: required.length };
|
|
333
|
+
}
|