@7365admin1/layer-common 4.124.2 → 4.125.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/assets/css/robot-hero.css +7 -3
- package/components/Robot/AlertsTable.vue +1 -1
- package/components/Robot/Card.vue +1 -1
- package/components/Robot/FloorLabel.vue +3 -2
- package/components/Robot/Setup.vue +24 -0
- package/components/Robot/SiteFleet.vue +1 -0
- package/components/Robot/SiteFleetCore.vue +156 -0
- package/components/Robot/SiteFloors.vue +220 -0
- package/components/Robot/SiteScreen.vue +3 -0
- package/composables/useRobots.ts +33 -0
- package/package.json +1 -1
- package/utils/robot-display.ts +4 -1
- package/utils/robot-floors.ts +95 -0
- package/utils/robot-setup.ts +14 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.125.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9accfa7: Robotics setup (Super Admin): Floor names panel (lift display names, which robot floor is which floor, preview before save, undo) and a HERO Fleet Core card per site (on/off, short name, robots, relay key shown once). Robot screens show the site's floor name when the server sends one; the Setup floor step uses the site floor.
|
|
8
|
+
|
|
9
|
+
## 4.124.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 2279860: Robot screens fit a 390px phone (header, map pickers, schedule rows, saved-points hint, 3D map readout wrap instead of running off screen). Robot setup now reports "Setup x/N" to its host so the Super Admin drill-down can show it beside the site views.
|
|
14
|
+
|
|
3
15
|
## 4.124.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -3276,11 +3288,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3276
3288
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3277
3289
|
own rule from two endpoints the console already calls, unprojected:
|
|
3278
3290
|
|
|
3279
|
-
|
|
3280
|
-
|
|
3291
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3292
|
+
GET /api/roles/id/:role that membership's role document
|
|
3281
3293
|
|
|
3282
|
-
|
|
3283
|
-
|
|
3294
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3295
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3284
3296
|
|
|
3285
3297
|
`role.default` is the marker because it is the only property of a platform
|
|
3286
3298
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -524,6 +524,7 @@
|
|
|
524
524
|
/* Asides: Needs attention, Clients drill down. */
|
|
525
525
|
.hero-aside__head {
|
|
526
526
|
display: flex;
|
|
527
|
+
flex-wrap: wrap;
|
|
527
528
|
align-items: baseline;
|
|
528
529
|
gap: 8px;
|
|
529
530
|
border-top: 2px solid var(--hero-divider);
|
|
@@ -531,7 +532,7 @@
|
|
|
531
532
|
margin-bottom: 8px;
|
|
532
533
|
}
|
|
533
534
|
.hero-aside__title { font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; font-weight: 600; }
|
|
534
|
-
.hero-aside__hint { opacity: 0.6; font-size: 12px; }
|
|
535
|
+
.hero-aside__hint { opacity: 0.6; font-size: 12px; overflow-wrap: anywhere; min-width: 0; }
|
|
535
536
|
.hero-aside__count { margin-left: auto; font-size: 12px; padding: 1px 7px; background: var(--hero-accent); color: #fff; font-weight: 600; }
|
|
536
537
|
.hero-aside__empty { opacity: 0.6; padding: 10px 0; }
|
|
537
538
|
.hero-attn { display: flex; flex-direction: column; gap: 1px; background: var(--hero-divider); }
|
|
@@ -610,7 +611,7 @@
|
|
|
610
611
|
.hero-brand__name { font-family: var(--hero-font); font-weight: 800; font-size: 17px; letter-spacing: 0.06em; line-height: 1; }
|
|
611
612
|
.hero-brand__org { font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; opacity: 0.6; margin-top: 3px; white-space: nowrap; }
|
|
612
613
|
.hero-header__site { display: flex; align-items: center; gap: 8px; }
|
|
613
|
-
.hero-header__right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
|
614
|
+
.hero-header__right { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-left: auto; min-width: 0; max-width: 100%; }
|
|
614
615
|
|
|
615
616
|
/* Cleaning | Security: selected = accent fill, white text. */
|
|
616
617
|
.hero-switch { display: flex; border: 1px solid var(--hero-divider); }
|
|
@@ -763,7 +764,8 @@
|
|
|
763
764
|
|
|
764
765
|
.hero-mini { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
|
765
766
|
.hero-seen { opacity: 0.65; }
|
|
766
|
-
.hero-mini__head { display: flex; align-items: center; gap: 8px; border-top: 2px solid var(--hero-divider); padding-top: 8px; }
|
|
767
|
+
.hero-mini__head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; border-top: 2px solid var(--hero-divider); padding-top: 8px; }
|
|
768
|
+
.hero-mini__head select { min-width: 0; max-width: 100%; }
|
|
767
769
|
.hero-mini__foot { font-size: 12px; opacity: 0.6; display: flex; gap: 12px; white-space: nowrap; }
|
|
768
770
|
.hero-mini__foot > span:first-child { overflow: hidden; text-overflow: ellipsis; }
|
|
769
771
|
.hero-mini__legend { display: inline-block; width: 12px; height: 0; border-top: 2px dashed var(--hero-accent); vertical-align: middle; margin-right: 5px; }
|
|
@@ -858,6 +860,7 @@
|
|
|
858
860
|
.hero-sched__row { display: grid; grid-template-columns: 90px 100px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--hero-divider); font-size: 14px; }
|
|
859
861
|
.hero-sched__row > span:nth-child(2) { font-variant-numeric: tabular-nums; }
|
|
860
862
|
.hero-sched__row > span:nth-child(3) { opacity: 0.8; }
|
|
863
|
+
.hero-sched__row > span { min-width: 0; overflow-wrap: anywhere; }
|
|
861
864
|
|
|
862
865
|
/* Detail plan: trail, crosshair, readout, radar, robot, spot points. */
|
|
863
866
|
.hero-trail { position: absolute; width: 6px; height: 6px; margin: -3px; background: var(--hero-accent); }
|
|
@@ -1280,6 +1283,7 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
|
|
|
1280
1283
|
.hero-lift { grid-template-columns: 1fr; }
|
|
1281
1284
|
.hero-bmap__bar { right: 14px; }
|
|
1282
1285
|
.hero-bmap__live { display: none; }
|
|
1286
|
+
.hero-bmap__readout { white-space: normal; }
|
|
1283
1287
|
}
|
|
1284
1288
|
|
|
1285
1289
|
@keyframes heroScan { from { background-position: 0 -60px; } to { background-position: 0 120%; } }
|
|
@@ -59,7 +59,7 @@ const emit = defineEmits<{ "update:page": [page: number]; open: [item: any] }>()
|
|
|
59
59
|
|
|
60
60
|
function floorOf(item: any) {
|
|
61
61
|
const s = props.states[String(item?.robot?._id ?? "")];
|
|
62
|
-
return s?.ok ? { floorNo: s.floorNo, floorKnown: s.floorKnown === true } : { floorNo: null, floorKnown: false };
|
|
62
|
+
return s?.ok ? { floorNo: s.floorNo, floorKnown: s.floorKnown === true, floorName: s.floorName ?? null } : { floorNo: null, floorKnown: false };
|
|
63
63
|
}
|
|
64
64
|
/** The robot's photos for this alert, when its live fault is this one and carries them. */
|
|
65
65
|
function photosOf(item: any) {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<div class="hero-card__name">{{ name }}</div>
|
|
13
13
|
<div class="hero-card__sub">
|
|
14
14
|
<template v-if="brandName">{{ brandName }} · </template>
|
|
15
|
-
<RobotFloorLabel :floor-no="state?.floorNo" :floor-known="state?.floorKnown" />
|
|
15
|
+
<RobotFloorLabel :floor-no="state?.floorNo" :floor-known="state?.floorKnown" :floor-name="state?.floorName" />
|
|
16
16
|
</div>
|
|
17
17
|
</div>
|
|
18
18
|
<RobotStatusChip :mode="state?.mode" :type="type" />
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { computed } from "vue";
|
|
7
7
|
import { floorLabel } from "../../utils/robot-display";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
const
|
|
9
|
+
// floorName: the site's name for the floor (lift display), from the server - shown instead of "L3" when present.
|
|
10
|
+
const props = defineProps<{ floorNo?: unknown; floorKnown?: boolean; floorName?: string | null }>();
|
|
11
|
+
const label = computed(() => floorLabel({ floorNo: props.floorNo, floorKnown: props.floorKnown, floorName: props.floorName }));
|
|
11
12
|
const unknown = computed(() => label.value === "Floor unknown");
|
|
12
13
|
</script>
|
|
@@ -49,11 +49,19 @@
|
|
|
49
49
|
</span>
|
|
50
50
|
</li>
|
|
51
51
|
</ol>
|
|
52
|
+
|
|
53
|
+
<!-- Super Admin set-up that lives on this page: floor names (lift display) and the site's HERO Fleet Core record. -->
|
|
54
|
+
<div ref="floorsEl">
|
|
55
|
+
<RobotSiteFloors :site="site" :robots="all" :states="states as Record<string, Record<string, any>>" :map-floors="mapFloors" @saved="floorsSaved" />
|
|
56
|
+
</div>
|
|
57
|
+
<RobotSiteFleetCore :site="site" />
|
|
52
58
|
</template>
|
|
53
59
|
|
|
54
60
|
<script setup lang="ts">
|
|
55
61
|
import { computed, ref, toRef, watch } from "vue";
|
|
56
62
|
import RobotFleetCoreButton from "./FleetCoreButton.vue";
|
|
63
|
+
import RobotSiteFloors from "./SiteFloors.vue";
|
|
64
|
+
import RobotSiteFleetCore from "./SiteFleetCore.vue";
|
|
57
65
|
import useRobotBuilding from "../../composables/useRobotBuilding";
|
|
58
66
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
59
67
|
import { robotSetupSteps, setupProgress, STATUS_TEXT, type TSetupStatus, type TSetupTarget } from "../../utils/robot-setup";
|
|
@@ -72,6 +80,8 @@ const emit = defineEmits<{
|
|
|
72
80
|
/** A step's button: a site view (fleet / map / lifts), the client's details, or Add robot. */
|
|
73
81
|
go: [target: TSetupTarget];
|
|
74
82
|
network: [];
|
|
83
|
+
/** "Setup 5/8" once worked out, so the host can show it beside its site views. */
|
|
84
|
+
progress: [progress: { done: number; total: number }];
|
|
75
85
|
}>();
|
|
76
86
|
|
|
77
87
|
const TONE: Record<TSetupStatus, string> = { done: "success", action: "warning", waiting: "info", check: "neutral" };
|
|
@@ -143,14 +153,28 @@ const steps = computed(() =>
|
|
|
143
153
|
lifts: lifts.value,
|
|
144
154
|
fleetCore: fleetCore.value,
|
|
145
155
|
roles: roles.value,
|
|
156
|
+
siteFloors: siteFloors.value,
|
|
146
157
|
}),
|
|
147
158
|
);
|
|
148
159
|
const progress = computed(() => setupProgress(steps.value));
|
|
160
|
+
watch([loaded, progress], () => loaded.value && emit("progress", progress.value), { immediate: true });
|
|
149
161
|
const next = computed(() => (loaded.value ? steps.value.find((s) => !s.optional && s.key !== "ready" && s.status !== "done") ?? null : null));
|
|
150
162
|
|
|
151
163
|
function go(target: TSetupTarget) {
|
|
164
|
+
if (target === "floors") return void floorsEl.value?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
152
165
|
if (target) emit("go", target);
|
|
153
166
|
}
|
|
167
|
+
|
|
168
|
+
// Floor names: the list the panel loaded or saved; a save re-reads the robots so the checklist shows the new floors.
|
|
169
|
+
const floorsEl = ref<HTMLElement | null>(null);
|
|
170
|
+
const siteFloors = ref<Array<{ key: number; name: string }>>([]);
|
|
171
|
+
const mapFloors = computed(() => [...new Set<number>(((saved.value?.levels ?? []) as any[]).map((l) => Number(l?.floorNo)).filter(Number.isInteger))]);
|
|
172
|
+
let firstFloors = true;
|
|
173
|
+
function floorsSaved(list: Array<{ key: number; name: string }>) {
|
|
174
|
+
siteFloors.value = list;
|
|
175
|
+
if (firstFloors) return void (firstFloors = false);
|
|
176
|
+
void refresh();
|
|
177
|
+
}
|
|
154
178
|
</script>
|
|
155
179
|
|
|
156
180
|
<style scoped>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
HERO FLEET CORE FOR THIS SITE (Seven365 Super Admin, owner 2026-10-15):
|
|
4
|
+
on/off, the site's short name on the one shared Fleet Core server, which
|
|
5
|
+
robots Fleet Core may use (the per-robot switch), and the site's own relay
|
|
6
|
+
key - shown once, like a lift gateway code. Server:
|
|
7
|
+
/api/robots/sites/:site/fleet-core.
|
|
8
|
+
-->
|
|
9
|
+
<section class="fcs" aria-labelledby="fcs-title">
|
|
10
|
+
<h3 id="fcs-title" class="hero-title fcs__title">HERO Fleet Core for this site</h3>
|
|
11
|
+
<div v-if="loadError" class="hero-notice hero-notice--error" role="alert">{{ loadError }}</div>
|
|
12
|
+
<template v-else-if="loaded">
|
|
13
|
+
<v-switch v-model="enabled" color="primary" density="compact" hide-details inset label="Use HERO Fleet Core at this site" />
|
|
14
|
+
<v-text-field
|
|
15
|
+
v-model="slug"
|
|
16
|
+
density="compact"
|
|
17
|
+
variant="outlined"
|
|
18
|
+
label="Short name on the shared server"
|
|
19
|
+
hint="2-10 lowercase letters or numbers, starting with a letter (e.g. oth)"
|
|
20
|
+
persistent-hint
|
|
21
|
+
maxlength="10"
|
|
22
|
+
/>
|
|
23
|
+
<div class="hero-kicker">Robots HERO Fleet Core may use</div>
|
|
24
|
+
<v-checkbox
|
|
25
|
+
v-for="r in robots"
|
|
26
|
+
:key="r.id"
|
|
27
|
+
v-model="robotIds"
|
|
28
|
+
:value="r.id"
|
|
29
|
+
:label="r.name"
|
|
30
|
+
density="compact"
|
|
31
|
+
hide-details
|
|
32
|
+
/>
|
|
33
|
+
<div v-if="!robots.length" class="hero-muted">No robots at this site yet.</div>
|
|
34
|
+
<div v-if="problem" class="hero-notice hero-notice--error" role="alert">{{ problem }}</div>
|
|
35
|
+
<div class="fcs__act">
|
|
36
|
+
<v-btn size="small" color="primary" variant="flat" :loading="busy === 'save'" :disabled="!!problem || !!busy" @click="save">Save</v-btn>
|
|
37
|
+
<v-btn
|
|
38
|
+
v-if="keyVersion !== null"
|
|
39
|
+
size="small"
|
|
40
|
+
variant="outlined"
|
|
41
|
+
:loading="busy === 'key'"
|
|
42
|
+
:disabled="!!busy || !relayKeysAvailable"
|
|
43
|
+
@click="createKey"
|
|
44
|
+
>
|
|
45
|
+
Create relay key
|
|
46
|
+
</v-btn>
|
|
47
|
+
</div>
|
|
48
|
+
<div v-if="keyVersion !== null && !relayKeysAvailable" class="hero-muted fcs__note">
|
|
49
|
+
Per-site relay keys are not switched on on the server yet. Fleet Core keeps using its current connection.
|
|
50
|
+
</div>
|
|
51
|
+
<div v-if="newKey" class="hero-notice hero-notice--panel" role="status">
|
|
52
|
+
<div class="hero-notice__title">Copy this key now - it is shown only once</div>
|
|
53
|
+
<code class="fcs__key">{{ newKey }}</code>
|
|
54
|
+
<div class="fcs__act">
|
|
55
|
+
<v-btn size="small" variant="outlined" @click="copy">{{ copied ? "Copied" : "Copy" }}</v-btn>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="hero-notice__body hero-muted">Every older key for this site has stopped working.</div>
|
|
58
|
+
</div>
|
|
59
|
+
<div v-if="message" class="hero-muted" role="status">{{ message }}</div>
|
|
60
|
+
<div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
|
|
61
|
+
</template>
|
|
62
|
+
<div v-else class="hero-muted">Checking…</div>
|
|
63
|
+
</section>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<script setup lang="ts">
|
|
67
|
+
import { computed, ref, watch } from "vue";
|
|
68
|
+
import { robotErrorMessage } from "../../utils/robot-display";
|
|
69
|
+
|
|
70
|
+
const props = defineProps<{ site: string }>();
|
|
71
|
+
const { getSiteFleetCore, saveSiteFleetCore, createSiteRelayKey } = useRobots();
|
|
72
|
+
|
|
73
|
+
const loaded = ref(false);
|
|
74
|
+
const loadError = ref("");
|
|
75
|
+
const error = ref("");
|
|
76
|
+
const message = ref("");
|
|
77
|
+
const busy = ref<"" | "save" | "key">("");
|
|
78
|
+
const version = ref(0);
|
|
79
|
+
const enabled = ref(false);
|
|
80
|
+
const slug = ref("");
|
|
81
|
+
const robotIds = ref<string[]>([]);
|
|
82
|
+
const robots = ref<Array<{ id: string; name: string }>>([]);
|
|
83
|
+
const keyVersion = ref<number | null>(null);
|
|
84
|
+
const relayKeysAvailable = ref(false);
|
|
85
|
+
const newKey = ref("");
|
|
86
|
+
const copied = ref(false);
|
|
87
|
+
|
|
88
|
+
async function load() {
|
|
89
|
+
loaded.value = false;
|
|
90
|
+
loadError.value = "";
|
|
91
|
+
try {
|
|
92
|
+
const res = await getSiteFleetCore(props.site);
|
|
93
|
+
version.value = res.version ?? 0;
|
|
94
|
+
robots.value = res.robots ?? [];
|
|
95
|
+
enabled.value = res.fleetCore?.enabled === true;
|
|
96
|
+
slug.value = res.fleetCore?.slug ?? "";
|
|
97
|
+
robotIds.value = [...(res.fleetCore?.robotIds ?? [])];
|
|
98
|
+
keyVersion.value = res.fleetCore ? res.fleetCore.keyVersion ?? 0 : null;
|
|
99
|
+
relayKeysAvailable.value = res.relayKeysAvailable === true;
|
|
100
|
+
loaded.value = true;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
loadError.value = robotErrorMessage(e, "Could not read the HERO Fleet Core settings.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
watch(() => props.site, load, { immediate: true });
|
|
106
|
+
|
|
107
|
+
const problem = computed(() => (/^[a-z][a-z0-9]{1,9}$/.test(slug.value.trim()) ? null : "Short name: 2-10 lowercase letters or numbers, starting with a letter."));
|
|
108
|
+
|
|
109
|
+
async function save() {
|
|
110
|
+
error.value = message.value = "";
|
|
111
|
+
busy.value = "save";
|
|
112
|
+
try {
|
|
113
|
+
const res = await saveSiteFleetCore(props.site, { enabled: enabled.value, slug: slug.value.trim(), robotIds: robotIds.value, version: version.value });
|
|
114
|
+
version.value = res.version;
|
|
115
|
+
if (keyVersion.value === null) keyVersion.value = 0;
|
|
116
|
+
message.value = "Saved.";
|
|
117
|
+
} catch (e) {
|
|
118
|
+
error.value = robotErrorMessage(e, "Could not save the HERO Fleet Core settings.");
|
|
119
|
+
} finally {
|
|
120
|
+
busy.value = "";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function createKey() {
|
|
125
|
+
error.value = message.value = "";
|
|
126
|
+
busy.value = "key";
|
|
127
|
+
copied.value = false;
|
|
128
|
+
try {
|
|
129
|
+
const res = await createSiteRelayKey(props.site);
|
|
130
|
+
newKey.value = res.key;
|
|
131
|
+
keyVersion.value = res.keyVersion;
|
|
132
|
+
version.value += 1;
|
|
133
|
+
} catch (e) {
|
|
134
|
+
error.value = robotErrorMessage(e, "Could not create a relay key.");
|
|
135
|
+
} finally {
|
|
136
|
+
busy.value = "";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function copy() {
|
|
141
|
+
try {
|
|
142
|
+
await navigator.clipboard.writeText(newKey.value);
|
|
143
|
+
copied.value = true;
|
|
144
|
+
} catch {
|
|
145
|
+
copied.value = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<style scoped>
|
|
151
|
+
.fcs { margin-top: 16px; padding: 12px; background: var(--hero-surface); box-shadow: var(--hero-shadow-sm); display: grid; gap: 8px; }
|
|
152
|
+
.fcs__title { font-size: 16px; }
|
|
153
|
+
.fcs__act { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
154
|
+
.fcs__note { font-size: 12px; }
|
|
155
|
+
.fcs__key { display: block; overflow-wrap: anywhere; font-size: 12px; padding: 6px; background: var(--hero-divider); }
|
|
156
|
+
</style>
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
FLOOR NAMES (Seven365 Super Admin, owner 2026-10-15): each floor is named
|
|
4
|
+
as the lift car display shows it, and each robot's own floor number is
|
|
5
|
+
linked to the floor it really is (the PadBot at OTH says "3" on the 34th
|
|
6
|
+
floor). Preview shows what every robot will read before anything is saved;
|
|
7
|
+
Undo puts the last list back. Server: /api/robots/sites/:site/floors.
|
|
8
|
+
-->
|
|
9
|
+
<section class="floors" aria-labelledby="floors-title">
|
|
10
|
+
<div class="floors__head">
|
|
11
|
+
<h3 id="floors-title" class="hero-title floors__title">Floor names</h3>
|
|
12
|
+
<span class="hero-muted">As the lift car display shows them</span>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div v-if="loadError" class="hero-notice hero-notice--error" role="alert">{{ loadError }}</div>
|
|
16
|
+
<template v-else>
|
|
17
|
+
<div class="floors__rows" role="table" aria-label="Floors">
|
|
18
|
+
<div class="floors__row floors__row--head" role="row">
|
|
19
|
+
<span role="columnheader">Lift display</span>
|
|
20
|
+
<span role="columnheader">Map floor no.</span>
|
|
21
|
+
<span />
|
|
22
|
+
</div>
|
|
23
|
+
<div v-for="(f, i) in floors" :key="i" class="floors__row" role="row">
|
|
24
|
+
<v-text-field v-model="f.name" density="compact" hide-details variant="outlined" :aria-label="`Floor ${i + 1} lift display name`" maxlength="12" @update:model-value="changed" />
|
|
25
|
+
<v-text-field v-model.number="f.key" type="number" density="compact" hide-details variant="outlined" :aria-label="`Floor ${i + 1} map floor number`" @update:model-value="changed" />
|
|
26
|
+
<v-btn icon="mdi-close" size="small" variant="text" :aria-label="`Remove floor ${f.name}`" @click="remove(i)" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
<v-btn size="small" variant="text" prepend-icon="mdi-plus" @click="add">Add floor</v-btn>
|
|
30
|
+
|
|
31
|
+
<div v-if="robotRows.length" class="floors__robots">
|
|
32
|
+
<div class="hero-kicker">Which floor is each robot on?</div>
|
|
33
|
+
<div v-for="r in robotRows" :key="r.id" class="floors__robot">
|
|
34
|
+
<span class="floors__robot-name">{{ r.name }}</span>
|
|
35
|
+
<span class="hero-muted">{{ r.floorNo === null ? "not reporting a floor now" : `reports floor ${r.floorNo}` }}</span>
|
|
36
|
+
<v-select
|
|
37
|
+
v-if="r.floorNo !== null"
|
|
38
|
+
:model-value="r.key"
|
|
39
|
+
:items="pickItems"
|
|
40
|
+
density="compact"
|
|
41
|
+
hide-details
|
|
42
|
+
variant="outlined"
|
|
43
|
+
:aria-label="`${r.name} is on floor`"
|
|
44
|
+
class="floors__pick"
|
|
45
|
+
@update:model-value="(k) => pick(r.id, r.floorNo as number, k)"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div v-if="problem" class="hero-notice hero-notice--error" role="alert">{{ problem }}</div>
|
|
51
|
+
<div class="floors__act">
|
|
52
|
+
<v-btn size="small" variant="outlined" :loading="busy === 'preview'" :disabled="!!problem || !!busy" @click="preview">Preview</v-btn>
|
|
53
|
+
<v-btn size="small" color="primary" variant="flat" :loading="busy === 'save'" :disabled="!!problem || !!busy || !previewed" @click="save">Save floor names</v-btn>
|
|
54
|
+
<v-btn v-if="canUndo" size="small" variant="text" :loading="busy === 'undo'" :disabled="!!busy" @click="undo">Undo last save</v-btn>
|
|
55
|
+
</div>
|
|
56
|
+
<div v-if="!previewed && !problem" class="hero-muted floors__hint">Preview first - nothing changes until you save.</div>
|
|
57
|
+
|
|
58
|
+
<div v-if="result" class="floors__result" role="status">
|
|
59
|
+
<div class="hero-notice__title">{{ result.title }}</div>
|
|
60
|
+
<table v-if="result.robots?.length" class="floors__table">
|
|
61
|
+
<thead><tr><th scope="col">Robot</th><th scope="col">Robot says</th><th scope="col">Shown now</th><th scope="col">After</th></tr></thead>
|
|
62
|
+
<tbody>
|
|
63
|
+
<tr v-for="r in result.robots" :key="r.id">
|
|
64
|
+
<td>{{ r.name }}</td>
|
|
65
|
+
<td>{{ r.robotFloor ?? "-" }}</td>
|
|
66
|
+
<td>{{ r.now ?? "-" }}</td>
|
|
67
|
+
<td :class="{ 'floors__diff': r.now !== r.after }">{{ r.after ?? "-" }}</td>
|
|
68
|
+
</tr>
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
<div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
|
|
73
|
+
</template>
|
|
74
|
+
</section>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<script setup lang="ts">
|
|
78
|
+
import { computed, ref, watch } from "vue";
|
|
79
|
+
import { floorKeyFor, floorsBody, floorsProblem, linkRobotFloor, startingFloors, type TSiteFloor } from "../../utils/robot-floors";
|
|
80
|
+
import { robotErrorMessage } from "../../utils/robot-display";
|
|
81
|
+
|
|
82
|
+
const props = withDefaults(
|
|
83
|
+
defineProps<{
|
|
84
|
+
site: string;
|
|
85
|
+
/** Every robot at the site. */
|
|
86
|
+
robots?: Array<Record<string, any>>;
|
|
87
|
+
/** Live states by robot id: the floor each robot reports now. */
|
|
88
|
+
states?: Record<string, Record<string, any>>;
|
|
89
|
+
/** The floors the saved building map has (the starting list when none is saved). */
|
|
90
|
+
mapFloors?: number[];
|
|
91
|
+
}>(),
|
|
92
|
+
{ robots: () => [], states: () => ({}), mapFloors: () => [] },
|
|
93
|
+
);
|
|
94
|
+
const emit = defineEmits<{ saved: [floors: TSiteFloor[]] }>();
|
|
95
|
+
|
|
96
|
+
const { getSiteFloors, saveSiteFloors, undoSiteFloors } = useRobots();
|
|
97
|
+
const floors = ref<TSiteFloor[]>([]);
|
|
98
|
+
const version = ref(0);
|
|
99
|
+
const canUndo = ref(false);
|
|
100
|
+
const loadError = ref("");
|
|
101
|
+
const error = ref("");
|
|
102
|
+
const busy = ref<"" | "preview" | "save" | "undo">("");
|
|
103
|
+
const previewed = ref(false);
|
|
104
|
+
const result = ref<{ title: string; robots?: Array<Record<string, any>> } | null>(null);
|
|
105
|
+
|
|
106
|
+
async function load() {
|
|
107
|
+
loadError.value = "";
|
|
108
|
+
try {
|
|
109
|
+
const res = await getSiteFloors(props.site);
|
|
110
|
+
version.value = res.version ?? 0;
|
|
111
|
+
canUndo.value = res.canUndo === true;
|
|
112
|
+
floors.value = startingFloors(res.floors, props.mapFloors);
|
|
113
|
+
emit("saved", res.floors ?? []);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
loadError.value = robotErrorMessage(e, "Could not read the floor names.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
watch(() => props.site, load, { immediate: true });
|
|
119
|
+
// A map that loads after the (empty) list still seeds it.
|
|
120
|
+
watch(() => props.mapFloors.join(","), () => {
|
|
121
|
+
if (!version.value && !floors.value.length) floors.value = startingFloors(null, props.mapFloors);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
function changed() {
|
|
125
|
+
previewed.value = false;
|
|
126
|
+
result.value = null;
|
|
127
|
+
}
|
|
128
|
+
function add() {
|
|
129
|
+
const used = new Set(floors.value.map((f) => f.key));
|
|
130
|
+
let key = 1;
|
|
131
|
+
while (used.has(key)) key++;
|
|
132
|
+
floors.value = [...floors.value, { key, name: String(key), vendor: [] }];
|
|
133
|
+
changed();
|
|
134
|
+
}
|
|
135
|
+
function remove(i: number) {
|
|
136
|
+
floors.value = floors.value.filter((_, j) => j !== i);
|
|
137
|
+
changed();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const robotRows = computed(() =>
|
|
141
|
+
props.robots.map((r) => {
|
|
142
|
+
const id = String(r._id);
|
|
143
|
+
const s = props.states[id];
|
|
144
|
+
const floorNo = s?.ok && s.floorKnown === true && Number.isInteger(s.floorNo) ? (s.floorNo as number) : null;
|
|
145
|
+
return { id, name: String(r.name || "Robot"), floorNo, key: floorKeyFor(floors.value, id, floorNo) };
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
const pickItems = computed(() => [
|
|
149
|
+
{ title: "Not linked", value: null },
|
|
150
|
+
...floors.value.map((f) => ({ title: f.name || "(blank)", value: f.key })),
|
|
151
|
+
]);
|
|
152
|
+
function pick(robotId: string, floorNo: number, key: number | null) {
|
|
153
|
+
floors.value = linkRobotFloor(floors.value, robotId, floorNo, key);
|
|
154
|
+
changed();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const problem = computed(() => floorsProblem(floors.value));
|
|
158
|
+
|
|
159
|
+
async function send(dryRun: boolean) {
|
|
160
|
+
error.value = "";
|
|
161
|
+
busy.value = dryRun ? "preview" : "save";
|
|
162
|
+
try {
|
|
163
|
+
const res = await saveSiteFloors(props.site, { floors: floorsBody(floors.value), version: version.value, dryRun });
|
|
164
|
+
if (dryRun) {
|
|
165
|
+
previewed.value = true;
|
|
166
|
+
result.value = { title: `Preview: ${res.robotsChanged ?? 0} robot(s) would show a different floor. Nothing is saved yet.`, robots: res.robots };
|
|
167
|
+
} else {
|
|
168
|
+
version.value = res.version;
|
|
169
|
+
canUndo.value = true;
|
|
170
|
+
previewed.value = false;
|
|
171
|
+
result.value = { title: "Floor names saved.", robots: res.robots };
|
|
172
|
+
emit("saved", floorsBody(floors.value));
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
error.value = robotErrorMessage(e, dryRun ? "Could not preview the floor names." : "Could not save the floor names.");
|
|
176
|
+
} finally {
|
|
177
|
+
busy.value = "";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const preview = () => send(true);
|
|
181
|
+
const save = () => send(false);
|
|
182
|
+
|
|
183
|
+
async function undo() {
|
|
184
|
+
error.value = "";
|
|
185
|
+
busy.value = "undo";
|
|
186
|
+
try {
|
|
187
|
+
const res = await undoSiteFloors(props.site, version.value);
|
|
188
|
+
version.value = res.version;
|
|
189
|
+
canUndo.value = false;
|
|
190
|
+
floors.value = startingFloors(res.floors, props.mapFloors);
|
|
191
|
+
result.value = { title: "The previous floor names are back." };
|
|
192
|
+
emit("saved", res.floors ?? []);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
error.value = robotErrorMessage(e, "Could not undo.");
|
|
195
|
+
} finally {
|
|
196
|
+
busy.value = "";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
</script>
|
|
200
|
+
|
|
201
|
+
<style scoped>
|
|
202
|
+
.floors { margin-top: 16px; padding: 12px; background: var(--hero-surface); box-shadow: var(--hero-shadow-sm); display: grid; gap: 10px; }
|
|
203
|
+
.floors__title { font-size: 16px; }
|
|
204
|
+
.floors__head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
|
|
205
|
+
.floors__rows { display: grid; gap: 6px; }
|
|
206
|
+
.floors__row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 36px; gap: 8px; align-items: center; }
|
|
207
|
+
.floors__row--head { font-size: 12px; font-weight: 700; opacity: 0.7; }
|
|
208
|
+
.floors__robots { display: grid; gap: 8px; }
|
|
209
|
+
.floors__robot { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(120px, 180px); gap: 8px; align-items: center; }
|
|
210
|
+
.floors__robot-name { font-weight: 600; overflow-wrap: anywhere; }
|
|
211
|
+
.floors__act { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
212
|
+
.floors__hint { font-size: 12px; }
|
|
213
|
+
.floors__table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 6px; }
|
|
214
|
+
.floors__table th, .floors__table td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--hero-divider); }
|
|
215
|
+
.floors__diff { font-weight: 700; }
|
|
216
|
+
@media (max-width: 480px) {
|
|
217
|
+
.floors__robot { grid-template-columns: 1fr; }
|
|
218
|
+
.floors__table { display: block; overflow-x: auto; }
|
|
219
|
+
}
|
|
220
|
+
</style>
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
:back-to-network="backToNetwork"
|
|
47
47
|
@go="onSetupGo"
|
|
48
48
|
@network="emit('network')"
|
|
49
|
+
@progress="(p) => emit('setupProgress', p)"
|
|
49
50
|
/>
|
|
50
51
|
<RobotBuildingMap
|
|
51
52
|
v-else-if="view === 'map'"
|
|
@@ -200,6 +201,8 @@ const emit = defineEmits<{
|
|
|
200
201
|
* (fleet / map / lifts / setup) or the client's details page ("client").
|
|
201
202
|
*/
|
|
202
203
|
navigate: [target: string];
|
|
204
|
+
/** Super Admin setup: required steps done of total, once the setup view has worked it out. */
|
|
205
|
+
setupProgress: [progress: { done: number; total: number }];
|
|
203
206
|
/** Live feed health + open faults, for a host drawing its own header. */
|
|
204
207
|
status: [status: { connected: boolean; faults: number }];
|
|
205
208
|
}>();
|
package/composables/useRobots.ts
CHANGED
|
@@ -621,7 +621,40 @@ export default function useRobots() {
|
|
|
621
621
|
});
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
+
/*
|
|
625
|
+
* One site's robot settings (`robot-sites`): floor names as the lift car
|
|
626
|
+
* display shows them (read by anyone at the site; saved by Seven365 Super
|
|
627
|
+
* Admin, with a dry run and one-step undo), and the site's HERO Fleet Core
|
|
628
|
+
* record (Super Admin). The server refuses anyone else.
|
|
629
|
+
*/
|
|
630
|
+
type TFloor = { key: number; name: string; vendor?: Array<{ robotId: string; floorNo: number }>; fleetCoreLevel?: string };
|
|
631
|
+
const sitePath = (site: string) => `/api/robots/sites/${site}`;
|
|
632
|
+
function getSiteFloors(site: string) {
|
|
633
|
+
return useNuxtApp().$api<{ floors: TFloor[]; version: number; canUndo: boolean }>(`${sitePath(site)}/floors`, { query: { appSlug } });
|
|
634
|
+
}
|
|
635
|
+
function saveSiteFloors(site: string, body: { floors: TFloor[]; version: number; dryRun?: boolean }) {
|
|
636
|
+
return useNuxtApp().$api<Record<string, any>>(`${sitePath(site)}/floors`, { method: "PUT", query: { appSlug }, body });
|
|
637
|
+
}
|
|
638
|
+
function undoSiteFloors(site: string, version: number) {
|
|
639
|
+
return useNuxtApp().$api<Record<string, any>>(`${sitePath(site)}/floors/undo`, { method: "POST", query: { appSlug }, body: { version } });
|
|
640
|
+
}
|
|
641
|
+
function getSiteFleetCore(site: string) {
|
|
642
|
+
return useNuxtApp().$api<Record<string, any>>(`${sitePath(site)}/fleet-core`, { query: { appSlug } });
|
|
643
|
+
}
|
|
644
|
+
function saveSiteFleetCore(site: string, body: { enabled: boolean; slug: string; robotIds: string[]; version: number }) {
|
|
645
|
+
return useNuxtApp().$api<Record<string, any>>(`${sitePath(site)}/fleet-core`, { method: "PUT", query: { appSlug }, body });
|
|
646
|
+
}
|
|
647
|
+
function createSiteRelayKey(site: string) {
|
|
648
|
+
return useNuxtApp().$api<{ key: string; keyVersion: number }>(`${sitePath(site)}/fleet-core/relay-key`, { method: "POST", query: { appSlug } });
|
|
649
|
+
}
|
|
650
|
+
|
|
624
651
|
return {
|
|
652
|
+
getSiteFloors,
|
|
653
|
+
saveSiteFloors,
|
|
654
|
+
undoSiteFloors,
|
|
655
|
+
getSiteFleetCore,
|
|
656
|
+
saveSiteFleetCore,
|
|
657
|
+
createSiteRelayKey,
|
|
625
658
|
emergencyStopRobot,
|
|
626
659
|
getRobotSetup,
|
|
627
660
|
applyRobotSetup,
|
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.125.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-display.ts
CHANGED
|
@@ -101,8 +101,11 @@ export function canRecall(state: { online?: unknown } | null | undefined): boole
|
|
|
101
101
|
export function floorLabel(state: {
|
|
102
102
|
floorNo?: unknown;
|
|
103
103
|
floorKnown?: unknown;
|
|
104
|
+
/** The site's name for the floor (what the lift car display shows), from the server. Wins when present. */
|
|
105
|
+
floorName?: unknown;
|
|
104
106
|
} | null | undefined): string {
|
|
105
107
|
if (!state || state.floorKnown !== true) return "Floor unknown";
|
|
108
|
+
if (typeof state.floorName === "string" && state.floorName.trim()) return state.floorName.trim();
|
|
106
109
|
return typeof state.floorNo === "number" && Number.isInteger(state.floorNo) ? floorName(state.floorNo) : "Floor unknown";
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -1473,7 +1476,7 @@ export function robotFacts(input: {
|
|
|
1473
1476
|
const battery = !reports("battery") ? nr("Battery") : fact("Battery", batteryReading(live?.batteryPercent)?.text);
|
|
1474
1477
|
const floor = !reports("floorReporting")
|
|
1475
1478
|
? nr("Floor")
|
|
1476
|
-
: fact("Floor", live?.ok ? floorLabel({ floorNo: live.floorNo, floorKnown: live.floorKnown }) : "");
|
|
1479
|
+
: fact("Floor", live?.ok ? floorLabel({ floorNo: live.floorNo, floorKnown: live.floorKnown, floorName: (live as any).floorName }) : "");
|
|
1477
1480
|
const position = !reports("readLocation")
|
|
1478
1481
|
? nr("Live position")
|
|
1479
1482
|
: fact("Live position", live ? positionText(live, caps ?? { readLocation: true }) : "");
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floor names (owner, 2026-10-15): a floor is called what the lift car display
|
|
3
|
+
* shows ("34", "B1", "G"). Robots report their own floor number (the PadBot at
|
|
4
|
+
* OTH says 3 on the 34th floor), so each site keeps one list (`robot-sites`):
|
|
5
|
+
* each floor's lift display name, the number the building map uses for it
|
|
6
|
+
* (`key`), and which robot floor numbers mean that floor. The server applies it
|
|
7
|
+
* (`siteFloorNo` / `floorName` on every robot state); this file is the editor's
|
|
8
|
+
* arithmetic only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type TFloorLink = { robotId: string; floorNo: number };
|
|
12
|
+
export type TSiteFloor = { key: number; name: string; vendor?: TFloorLink[]; fleetCoreLevel?: string };
|
|
13
|
+
|
|
14
|
+
export const FLOOR_NAME_RE = /^[A-Za-z0-9-]{1,12}$/;
|
|
15
|
+
|
|
16
|
+
/** The old label (before floor names): 1 = L1, -1 = B1, 0 = G. */
|
|
17
|
+
export function defaultFloorName(key: number): string {
|
|
18
|
+
return key > 0 ? `L${key}` : key < 0 ? `B${-key}` : "G";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a map floor number most likely shows on the lift display: 34 -> "34", -1 -> "B1", 0 -> "G". */
|
|
22
|
+
export function suggestFloorName(key: number): string {
|
|
23
|
+
return key > 0 ? String(key) : key < 0 ? `B${-key}` : "G";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The map floor number for a lift display name: "34" -> 34, "B1" -> -1, "G" -> 0; null when it cannot tell. */
|
|
27
|
+
export function suggestFloorKey(name: string): number | null {
|
|
28
|
+
const s = String(name ?? "").trim().toUpperCase();
|
|
29
|
+
if (/^\d{1,3}$/.test(s)) return Number(s);
|
|
30
|
+
if (/^L\d{1,3}$/.test(s)) return Number(s.slice(1));
|
|
31
|
+
if (/^B\d{1,2}$/.test(s)) return -Number(s.slice(1));
|
|
32
|
+
if (s === "G" || s === "GF") return 0;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The rows to start editing from: the saved list, else one row per floor the
|
|
38
|
+
* saved building map has, named as the lift most likely shows it.
|
|
39
|
+
*/
|
|
40
|
+
export function startingFloors(saved: TSiteFloor[] | null | undefined, mapFloors: number[]): TSiteFloor[] {
|
|
41
|
+
if (saved?.length) return saved.map((f) => ({ ...f, vendor: [...(f.vendor ?? [])] }));
|
|
42
|
+
return [...new Set(mapFloors)].sort((a, b) => b - a).map((key) => ({ key, name: suggestFloorName(key), vendor: [] }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Which floor (key) a robot reporting `floorNo` is on, by this list: its link, else the same number, else null. */
|
|
46
|
+
export function floorKeyFor(floors: TSiteFloor[], robotId: string, floorNo: number | null | undefined): number | null {
|
|
47
|
+
if (typeof floorNo !== "number") return null;
|
|
48
|
+
const link = floors.find((f) => (f.vendor ?? []).some((v) => v.robotId === robotId && v.floorNo === floorNo));
|
|
49
|
+
if (link) return link.key;
|
|
50
|
+
return floors.some((f) => f.key === floorNo) ? floorNo : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Put robot `robotId`'s floor `floorNo` on floor `key` (null = no link). A
|
|
55
|
+
* robot that already reports the floor's own number needs no link, so none is
|
|
56
|
+
* stored. Returns a new list.
|
|
57
|
+
*/
|
|
58
|
+
export function linkRobotFloor(floors: TSiteFloor[], robotId: string, floorNo: number, key: number | null): TSiteFloor[] {
|
|
59
|
+
return floors.map((f) => {
|
|
60
|
+
const vendor = (f.vendor ?? []).filter((v) => !(v.robotId === robotId && v.floorNo === floorNo));
|
|
61
|
+
if (key !== null && f.key === key && key !== floorNo) vendor.push({ robotId, floorNo });
|
|
62
|
+
return { ...f, vendor };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Why this list cannot be saved, in plain words; null when it can. */
|
|
67
|
+
export function floorsProblem(floors: TSiteFloor[]): string | null {
|
|
68
|
+
const names = new Set<string>();
|
|
69
|
+
const keys = new Set<number>();
|
|
70
|
+
for (const f of floors) {
|
|
71
|
+
const name = String(f.name ?? "").trim();
|
|
72
|
+
if (!FLOOR_NAME_RE.test(name)) return `"${name || "(blank)"}" - a floor name is 1-12 letters, numbers or "-", as the lift display shows it.`;
|
|
73
|
+
if (!Number.isInteger(f.key) || f.key < -20 || f.key > 200) return `${name}: the map floor number must be a whole number from -20 to 200.`;
|
|
74
|
+
if (names.has(name.toUpperCase())) return `Floor ${name} is listed twice.`;
|
|
75
|
+
if (keys.has(f.key)) return `Two floors use map floor number ${f.key}.`;
|
|
76
|
+
names.add(name.toUpperCase());
|
|
77
|
+
keys.add(f.key);
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The body the server takes: names trimmed, nothing else. */
|
|
83
|
+
export function floorsBody(floors: TSiteFloor[]): TSiteFloor[] {
|
|
84
|
+
return floors.map((f) => ({
|
|
85
|
+
key: f.key,
|
|
86
|
+
name: String(f.name).trim(),
|
|
87
|
+
vendor: (f.vendor ?? []).map((v) => ({ robotId: v.robotId, floorNo: v.floorNo })),
|
|
88
|
+
...(f.fleetCoreLevel ? { fleetCoreLevel: f.fleetCoreLevel } : {}),
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A floor's name by map number: the site's name, else the old label. */
|
|
93
|
+
export function floorNameByKey(floors: TSiteFloor[] | null | undefined, key: number): string {
|
|
94
|
+
return floors?.find((f) => f.key === key)?.name ?? defaultFloorName(key);
|
|
95
|
+
}
|
package/utils/robot-setup.ts
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* Every input may be `undefined` = "could not read it": that step then says
|
|
8
8
|
* "Check manually" with its link, never a guess.
|
|
9
9
|
*/
|
|
10
|
-
import { floorName } from "./robot-display.ts";
|
|
10
|
+
import { floorName as defaultFloorLabel } from "./robot-display.ts";
|
|
11
11
|
|
|
12
12
|
export type TSetupStatus = "done" | "action" | "waiting" | "check";
|
|
13
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" | "";
|
|
14
|
+
export type TSetupTarget = "client" | "add" | "fleet" | "map" | "lifts" | "fleetcore" | "floors" | "";
|
|
15
15
|
|
|
16
16
|
export type TSetupStep = {
|
|
17
17
|
key: string;
|
|
@@ -43,6 +43,8 @@ export type TSetupInput = {
|
|
|
43
43
|
fleetCore?: true | number;
|
|
44
44
|
/** The client's roles that apply at this site. */
|
|
45
45
|
roles?: Array<Record<string, any>>;
|
|
46
|
+
/** The site's floor names (`GET /robots/sites/:site/floors`): what the lift display calls each map floor. */
|
|
47
|
+
siteFloors?: Array<{ key: number; name: string }>;
|
|
46
48
|
};
|
|
47
49
|
|
|
48
50
|
export const STATUS_TEXT: Record<TSetupStatus, string> = {
|
|
@@ -184,14 +186,18 @@ export function robotSetupSteps(input: TSetupInput): TSetupStep[] {
|
|
|
184
186
|
});
|
|
185
187
|
}
|
|
186
188
|
|
|
187
|
-
// 5. Floors present: one per floor the robots report
|
|
189
|
+
// 5. Floors present: one per floor the robots report - the SITE floor (the
|
|
190
|
+
// server applies the site's floor names: the PadBot's "3" is the 34th floor).
|
|
188
191
|
const floors = floorsOf(map);
|
|
192
|
+
const siteFloor = (s: Record<string, any>) => Number(Number.isInteger(s.siteFloorNo) ? s.siteFloorNo : s.floorNo);
|
|
189
193
|
const onFloor = (robots ?? [])
|
|
190
194
|
.map((r) => ({ name: String(r?.name || "A robot"), s: input.states?.[String(r?._id)] }))
|
|
191
195
|
.filter((x) => x.s?.ok && x.s.floorKnown === true && Number.isInteger(x.s.floorNo));
|
|
192
|
-
const reported = [...new Set<number>(onFloor.map((x) =>
|
|
196
|
+
const reported = [...new Set<number>(onFloor.map((x) => siteFloor(x.s!)))];
|
|
193
197
|
const missing = reported.filter((f) => !floors.includes(f));
|
|
194
|
-
const who = (f: number) => onFloor.filter((x) =>
|
|
198
|
+
const who = (f: number) => onFloor.filter((x) => siteFloor(x.s!) === f).map((x) => x.name).join(", ");
|
|
199
|
+
const named = input.siteFloors ?? [];
|
|
200
|
+
const floorName = (f: number) => named.find((x) => x.key === f)?.name ?? defaultFloorLabel(f);
|
|
195
201
|
if (map === undefined) {
|
|
196
202
|
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" } });
|
|
197
203
|
} else if (!map) {
|
|
@@ -204,9 +210,9 @@ export function robotSetupSteps(input: TSetupInput): TSetupStep[] {
|
|
|
204
210
|
reason: !floors.length
|
|
205
211
|
? "The saved map has no floors. Use Edit map → Add floor."
|
|
206
212
|
: missing.length
|
|
207
|
-
? `${missing.map((f) => `${who(f)} report${who(f).includes(",") ? "" : "s"} ${floorName(f)}`).join("; ")} - the map has no such floor (it has ${floors.map(floorName).join(", ")}).
|
|
208
|
-
: `${floors.map(floorName).join(", ")}${reported.length ? " - every floor the robots report." : "."}`,
|
|
209
|
-
button: { label: "Edit floors", target: "map" },
|
|
213
|
+
? `${missing.map((f) => `${who(f)} report${who(f).includes(",") ? "" : "s"} ${floorName(f)}`).join("; ")} - the map has no such floor (it has ${floors.map(floorName).join(", ")}). If it is really one of those, say which in Floor names below; otherwise add it in Edit map → Add floor.`
|
|
214
|
+
: `${floors.map(floorName).join(", ")}${reported.length ? " - every floor the robots report." : "."}${named.length ? " Names as the lift display shows them." : ""}`,
|
|
215
|
+
button: missing.length ? { label: "Floor names", target: "floors" } : { label: "Edit floors", target: "map" },
|
|
210
216
|
});
|
|
211
217
|
}
|
|
212
218
|
|