@7365admin1/layer-common 4.75.1 → 4.77.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 +10 -0
- package/components/Robot/CleaningRecords.vue +122 -0
- package/components/Robot/Detail.vue +68 -1
- package/components/Robot/LivePlan.vue +7 -1
- package/components/Robot/SiteFleet.vue +6 -5
- package/composables/useRobots.ts +6 -0
- package/package.json +1 -1
- package/utils/robot-display.ts +69 -0
- package/utils/robot-maps.ts +13 -1
- package/utils/robot-records.ts +135 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.77.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e3682f3: Robot detail: an Ounitech robot shows its real floor map with its area layer, area names and live pin ("position approximate" until Ounitech confirms the map corner); Clean area picks the areas to clean; new records card: recent cleaning jobs, alert history, every robot and base-station reading, parts wear, timed switch on/off and lifts. The site mini-map shows cleaning robots on their own map.
|
|
8
|
+
|
|
9
|
+
## 4.76.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 5fafd9a: Robot detail: a cleaning robot whose brand reports them (Ounitech) now shows Clean water and Dirty water (Low / Medium / High with the %), a "Tanks and covers" line ("Tanks and covers OK", or which cover is open / tank not fitted) and, while it cleans, "This job" (% done and m² cleaned of planned). Warning tags for clean water low, dirty water full and any open cover or missing tank. Nothing is shown for brands that do not report them. The rule (`cleaningReadings`) is part of the robot rules synced to the mobile apps; "What this robot can do" lists "water levels and tanks".
|
|
14
|
+
|
|
3
15
|
## 4.75.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1908,11 +1920,11 @@
|
|
|
1908
1920
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1909
1921
|
own rule from two endpoints the console already calls, unprojected:
|
|
1910
1922
|
|
|
1911
|
-
|
|
1912
|
-
|
|
1923
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1924
|
+
GET /api/roles/id/:role that membership's role document
|
|
1913
1925
|
|
|
1914
|
-
|
|
1915
|
-
|
|
1926
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1927
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1916
1928
|
|
|
1917
1929
|
`role.default` is the marker because it is the only property of a platform
|
|
1918
1930
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -670,6 +670,15 @@
|
|
|
670
670
|
.hero-mapsel__hint { font-size: 12px; opacity: 0.75; }
|
|
671
671
|
.hero-plan__pt { position: absolute; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: var(--hero-accent); pointer-events: none; }
|
|
672
672
|
.hero-plan__ptname { position: absolute; transform: translate(6px, -50%); font-size: 11px; white-space: nowrap; pointer-events: none; color: var(--hero-text); }
|
|
673
|
+
.hero-plan__overlay { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; object-fit: fill; pointer-events: none; user-select: none; opacity: 0.35; }
|
|
674
|
+
.hero-plan__area { position: absolute; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; padding: 0 4px; white-space: nowrap; pointer-events: none; color: var(--hero-text); background: var(--hero-bg); border: 1px solid var(--hero-accent); }
|
|
675
|
+
.hero-rec { margin-top: 16px; }
|
|
676
|
+
.hero-rec__table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
677
|
+
.hero-rec__table th { text-align: left; font-weight: 600; font-size: 12px; opacity: 0.75; padding: 4px 6px; border-bottom: 1px solid var(--hero-divider); }
|
|
678
|
+
.hero-rec__table td { padding: 4px 6px; border-bottom: 1px solid var(--hero-divider); vertical-align: top; }
|
|
679
|
+
.hero-rec__wrap { overflow-x: auto; }
|
|
680
|
+
.hero-rec__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 2px 16px; }
|
|
681
|
+
.hero-rec__attn { color: var(--hero-accent); font-weight: 600; }
|
|
673
682
|
.hero-plan:fullscreen { background-color: var(--hero-bg); }
|
|
674
683
|
.hero-plan__corner { position: absolute; width: 14px; height: 14px; }
|
|
675
684
|
.hero-plan__corner--tl { left: -1px; top: -1px; border-top: 2px solid var(--hero-accent); border-left: 2px solid var(--hero-accent); }
|
|
@@ -806,6 +815,7 @@
|
|
|
806
815
|
.hero-strip { grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); }
|
|
807
816
|
.hero-strip .hero-fact { padding: 8px 12px; }
|
|
808
817
|
.hero-strip .hero-fact__value { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
818
|
+
.hero-cleaning-warnings { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
|
809
819
|
.hero-detail__main { display: grid; grid-template-columns: minmax(0, 11fr) minmax(0, 9fr); gap: 20px; align-items: start; }
|
|
810
820
|
.hero-detail__main--single { grid-template-columns: minmax(0, 1fr); }
|
|
811
821
|
.hero-detail__cam, .hero-detail__map { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Robot detail's records (`/robots/:id/records`, read-only): recent cleaning
|
|
4
|
+
jobs, alert history, then - folded - every reading the robot sends, parts
|
|
5
|
+
wear, the power schedule and lifts. Brands without records render nothing.
|
|
6
|
+
A part the maker refused is said in words, never left blank.
|
|
7
|
+
-->
|
|
8
|
+
<section v-if="records?.supported" class="hero-rec" aria-label="Cleaning records">
|
|
9
|
+
<div class="hero-aside__head">
|
|
10
|
+
<span class="hero-section__title">Recent cleaning jobs</span>
|
|
11
|
+
<span class="hero-aside__hint">last 14 days · from {{ makerApp }}</span>
|
|
12
|
+
<span class="hero-head__spacer" />
|
|
13
|
+
<v-btn variant="text" size="small" :loading="loading" @click="load(true)">Refresh</v-btn>
|
|
14
|
+
</div>
|
|
15
|
+
<div v-for="e in errors" :key="e" class="hero-notice hero-notice--error" role="status">{{ e }}</div>
|
|
16
|
+
<div v-if="!jobs.length" class="hero-sched__empty">No cleaning jobs in the last 14 days.</div>
|
|
17
|
+
<div v-else class="hero-rec__wrap">
|
|
18
|
+
<table class="hero-rec__table">
|
|
19
|
+
<thead>
|
|
20
|
+
<tr><th>Started</th><th>Area</th><th>Job</th><th>Took</th><th>Cleaned</th><th>Done</th><th>Result</th><th>Started by</th></tr>
|
|
21
|
+
</thead>
|
|
22
|
+
<tbody>
|
|
23
|
+
<tr v-for="j in shownJobs" :key="j.key">
|
|
24
|
+
<td>{{ j.when }}</td><td>{{ j.area }}</td><td>{{ j.job }}</td><td>{{ j.duration }}</td><td>{{ j.size }}</td><td>{{ j.done }}</td><td>{{ j.result }}</td><td>{{ j.by }}</td>
|
|
25
|
+
</tr>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
<v-btn v-if="jobs.length > 5" variant="text" size="small" @click="allJobs = !allJobs">{{ allJobs ? "Show fewer" : `See all ${jobs.length}` }}</v-btn>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="hero-section__title mt-4">Alert history</div>
|
|
32
|
+
<div class="hero-sched__note">What the robot reported in the last 30 days (open alerts are listed above).</div>
|
|
33
|
+
<div v-if="!alerts.length" class="hero-sched__empty">No alerts in the last 30 days.</div>
|
|
34
|
+
<div v-for="a in shownAlerts" :key="a.key" class="hero-sched__row hero-sched__row--alert" :title="a.vendorText">
|
|
35
|
+
<b class="hero-detail__alertcode">{{ a.code }}</b>
|
|
36
|
+
<span>{{ a.level ? `${a.level} · ` : "" }}{{ a.message }}</span>
|
|
37
|
+
<span>{{ a.when }} · {{ a.state }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<v-btn v-if="alerts.length > 5" variant="text" size="small" @click="allAlerts = !allAlerts">{{ allAlerts ? "Show fewer" : `See all ${alerts.length}` }}</v-btn>
|
|
40
|
+
|
|
41
|
+
<details class="hero-can hero-can--fold" :open="attention.length > 0">
|
|
42
|
+
<summary class="hero-can__title">
|
|
43
|
+
Robot readings{{ attention.length ? ` · needs attention: ${attention.join(", ")}` : "" }}
|
|
44
|
+
</summary>
|
|
45
|
+
<div v-if="!groups.length" class="hero-sched__empty">The robot sent no readings.</div>
|
|
46
|
+
<div v-for="g in groups" :key="g.group" class="mb-2">
|
|
47
|
+
<div class="hero-sched__note">{{ g.group }}</div>
|
|
48
|
+
<div class="hero-rec__grid">
|
|
49
|
+
<div v-for="r in g.rows" :key="r.key" class="hero-can__row">
|
|
50
|
+
<span class="hero-can__label">{{ r.label }}</span>
|
|
51
|
+
<span :class="{ 'hero-rec__attn': r.attention }">{{ r.value }}</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</details>
|
|
56
|
+
|
|
57
|
+
<details class="hero-can hero-can--fold">
|
|
58
|
+
<summary class="hero-can__title">Parts wear, power schedule and lifts</summary>
|
|
59
|
+
<div class="hero-sched__note">Parts wear (hours worked since last reset in {{ makerApp }})</div>
|
|
60
|
+
<div v-if="!parts.length" class="hero-sched__empty">No parts reported.</div>
|
|
61
|
+
<div v-for="p in parts" :key="p.key" class="hero-sched__row"><b>{{ p.part }}</b><span>{{ p.worked }}</span><span>{{ p.days }}</span></div>
|
|
62
|
+
<div class="hero-sched__note mt-2">Timed switch on / off</div>
|
|
63
|
+
<div v-if="!power.length" class="hero-sched__empty">No timed switch on / off.</div>
|
|
64
|
+
<div v-for="p in power" :key="p.key" class="hero-sched__row">
|
|
65
|
+
<b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.action }}{{ p.enabled ? "" : " · off" }}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="hero-sched__note mt-2">Lifts linked in {{ makerApp }}</div>
|
|
68
|
+
<div v-if="!lifts.length" class="hero-sched__empty">No lifts linked to this robot's building.</div>
|
|
69
|
+
<div v-for="l in lifts" :key="l.key" class="hero-sched__row"><b>{{ l.name }}</b><span>{{ l.state }}</span></div>
|
|
70
|
+
</details>
|
|
71
|
+
</section>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<script setup lang="ts">
|
|
75
|
+
import { computed, ref, watch } from "vue";
|
|
76
|
+
import { robotErrorMessage } from "../../utils/robot-display";
|
|
77
|
+
import { alertHistoryRows, attentionReadings, jobRows, liftRows, partRows, powerRows, readingGroups, recordErrors } from "../../utils/robot-records";
|
|
78
|
+
|
|
79
|
+
const props = defineProps<{ robotId: string; makerApp: string }>();
|
|
80
|
+
const { getRobotRecords } = useRobots();
|
|
81
|
+
|
|
82
|
+
const records = ref<Record<string, any> | null>(null);
|
|
83
|
+
const loading = ref(false);
|
|
84
|
+
const allJobs = ref(false);
|
|
85
|
+
const allAlerts = ref(false);
|
|
86
|
+
|
|
87
|
+
// ponytail: read on open and on Refresh only - the maker rate-limits these reads (the API caches 2 min).
|
|
88
|
+
async function load(force = false) {
|
|
89
|
+
if (loading.value && !force) return;
|
|
90
|
+
loading.value = true;
|
|
91
|
+
try {
|
|
92
|
+
records.value = await getRobotRecords(props.robotId);
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
// An API without the records route (404) shows no card; any other failure is said.
|
|
95
|
+
const status = e?.statusCode ?? e?.status ?? e?.response?.status;
|
|
96
|
+
records.value = status === 404 ? null : { supported: true, error: `Couldn't read the records: ${robotErrorMessage(e)}` };
|
|
97
|
+
} finally {
|
|
98
|
+
loading.value = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
watch(
|
|
102
|
+
() => props.robotId,
|
|
103
|
+
() => {
|
|
104
|
+
records.value = null;
|
|
105
|
+
allJobs.value = false;
|
|
106
|
+
allAlerts.value = false;
|
|
107
|
+
load(true);
|
|
108
|
+
},
|
|
109
|
+
{ immediate: true },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const jobs = computed(() => jobRows(records.value));
|
|
113
|
+
const shownJobs = computed(() => (allJobs.value ? jobs.value : jobs.value.slice(0, 5)));
|
|
114
|
+
const alerts = computed(() => alertHistoryRows(records.value));
|
|
115
|
+
const shownAlerts = computed(() => (allAlerts.value ? alerts.value : alerts.value.slice(0, 5)));
|
|
116
|
+
const groups = computed(() => readingGroups(records.value));
|
|
117
|
+
const attention = computed(() => attentionReadings(records.value));
|
|
118
|
+
const parts = computed(() => partRows(records.value));
|
|
119
|
+
const power = computed(() => powerRows(records.value));
|
|
120
|
+
const lifts = computed(() => liftRows(records.value));
|
|
121
|
+
const errors = computed(() => recordErrors(records.value));
|
|
122
|
+
</script>
|
|
@@ -56,6 +56,17 @@
|
|
|
56
56
|
</div>
|
|
57
57
|
</div>
|
|
58
58
|
|
|
59
|
+
<!-- A scrubber's water, tanks and current job (brands that report them); warnings as tags. -->
|
|
60
|
+
<div v-if="cleaning.warnings.length" class="hero-cleaning-warnings" role="status" aria-label="Needs attention">
|
|
61
|
+
<span v-for="w in cleaning.warnings" :key="w" class="hero-tag hero-tag--outline">{{ w }}</span>
|
|
62
|
+
</div>
|
|
63
|
+
<div v-if="cleaning.facts.length" class="hero-facts hero-strip" role="list" aria-label="Water and tanks">
|
|
64
|
+
<div v-for="f in cleaning.facts" :key="f.label" class="hero-fact" role="listitem">
|
|
65
|
+
<div class="hero-fact__label">{{ f.label }}</div>
|
|
66
|
+
<div class="hero-fact__value" :title="f.value">{{ f.value }}</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
59
70
|
<!-- Above the fold: camera | map, with the commands under the map they act on. -->
|
|
60
71
|
<div v-if="!noInterface" class="hero-detail__main" :class="{ 'hero-detail__main--single': !hasCamera }">
|
|
61
72
|
<section v-if="hasCamera" class="hero-detail__cam" aria-label="Camera">
|
|
@@ -100,12 +111,17 @@
|
|
|
100
111
|
:image="selectedMap?.imageUrl ?? null"
|
|
101
112
|
:aspect="selectedMap?.width && selectedMap?.height ? selectedMap.width / selectedMap.height : null"
|
|
102
113
|
:points="selectedMap?.points ?? []"
|
|
114
|
+
:overlay="selectedMap?.overlay ?? null"
|
|
115
|
+
:areas="selectedMap?.areas ?? []"
|
|
103
116
|
:route-moving="statusMode === 'WORKING' && robotOnShownMap"
|
|
104
117
|
:spot="spot"
|
|
105
118
|
:pick="spotOn"
|
|
106
119
|
:tick-ms="tickMs"
|
|
107
120
|
@pick="(p) => spot.push(p)"
|
|
108
121
|
/>
|
|
122
|
+
<p v-if="selectedMap?.approximate" class="hero-muted hero-detail__lag">
|
|
123
|
+
Robot position is approximate: {{ brand }} has not yet confirmed which corner their map is measured from.
|
|
124
|
+
</p>
|
|
109
125
|
<div v-if="spotOn" class="hero-spotbar">
|
|
110
126
|
<v-btn color="primary" variant="flat" :disabled="!spot.length || busy === 'spot'" :loading="busy === 'spot'" @click="sendSpot">
|
|
111
127
|
Send spot clean · {{ spot.length }} points
|
|
@@ -176,6 +192,9 @@
|
|
|
176
192
|
</div>
|
|
177
193
|
</section>
|
|
178
194
|
|
|
195
|
+
<!-- Cleaning robots: recent jobs, alert history, every reading, parts wear, power schedule, lifts (read-only). -->
|
|
196
|
+
<RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" />
|
|
197
|
+
|
|
179
198
|
<!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
|
|
180
199
|
<details v-if="abilities.length" class="hero-can hero-can--fold">
|
|
181
200
|
<summary class="hero-can__title">What this robot can do</summary>
|
|
@@ -188,6 +207,19 @@
|
|
|
188
207
|
<v-dialog v-model="confirmOpen" width="440">
|
|
189
208
|
<div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
|
|
190
209
|
<div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
|
|
210
|
+
<v-select
|
|
211
|
+
v-if="confirming.zones?.length"
|
|
212
|
+
v-model="confirming.zoneNames"
|
|
213
|
+
:items="confirming.zones"
|
|
214
|
+
label="Areas to clean"
|
|
215
|
+
multiple
|
|
216
|
+
chips
|
|
217
|
+
closable-chips
|
|
218
|
+
density="compact"
|
|
219
|
+
hide-details
|
|
220
|
+
class="mb-2"
|
|
221
|
+
@update:model-value="pickZones"
|
|
222
|
+
/>
|
|
191
223
|
<v-select
|
|
192
224
|
v-if="confirming.routes?.length"
|
|
193
225
|
v-model="confirming.routeId"
|
|
@@ -203,7 +235,7 @@
|
|
|
203
235
|
<div class="hero-dialog__body">{{ confirming.text.body }}</div>
|
|
204
236
|
<div class="hero-dialog__actions">
|
|
205
237
|
<v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
|
|
206
|
-
<v-btn color="primary" variant="flat" @click="confirmSend">{{ confirming.text.action }}</v-btn>
|
|
238
|
+
<v-btn color="primary" variant="flat" :disabled="!!confirming.zones && !confirming.zoneNames?.length" @click="confirmSend">{{ confirming.text.action }}</v-btn>
|
|
207
239
|
</div>
|
|
208
240
|
</div>
|
|
209
241
|
</v-dialog>
|
|
@@ -261,6 +293,7 @@ import RobotNoMonitoring from "./NoMonitoring.vue";
|
|
|
261
293
|
import RobotCommandBar from "./CommandBar.vue";
|
|
262
294
|
import RobotViewOnlyNotice from "./ViewOnlyNotice.vue";
|
|
263
295
|
import RobotLivePlan from "./LivePlan.vue";
|
|
296
|
+
import RobotCleaningRecords from "./CleaningRecords.vue";
|
|
264
297
|
import RobotToast from "./Toast.vue";
|
|
265
298
|
import RobotForm from "./Form.vue";
|
|
266
299
|
import RobotPatrolHistory from "./PatrolHistory.vue";
|
|
@@ -305,6 +338,7 @@ import {
|
|
|
305
338
|
robotAbilities,
|
|
306
339
|
robotErrorMessage,
|
|
307
340
|
robotFacts,
|
|
341
|
+
cleaningReadings,
|
|
308
342
|
robotStatus,
|
|
309
343
|
showSpotClean,
|
|
310
344
|
sinceText,
|
|
@@ -420,6 +454,7 @@ const stripFacts = computed(() =>
|
|
|
420
454
|
.filter((f): f is NonNullable<typeof f> => !!f)
|
|
421
455
|
.map((f) => (f.label === "Robot reports" ? { ...f, label: "Doing now" } : f)),
|
|
422
456
|
);
|
|
457
|
+
const cleaning = computed(() => cleaningReadings(live.value as any));
|
|
423
458
|
const identity = computed(() => {
|
|
424
459
|
const model = facts.value.find((f) => f.label === "Model")?.value;
|
|
425
460
|
const serial = facts.value.find((f) => f.label === "Serial")?.value;
|
|
@@ -482,6 +517,10 @@ const planTitle = computed(() => {
|
|
|
482
517
|
const planHint = computed(() => {
|
|
483
518
|
if (spotOn.value) return "Click points on the plan, then send";
|
|
484
519
|
if (noInterface.value) return "No position feed for this model";
|
|
520
|
+
if (selectedMap.value && robotType.value !== "security") {
|
|
521
|
+
const n = selectedMap.value.areas.length;
|
|
522
|
+
return `${n} cleaning area${n === 1 ? "" : "s"}`;
|
|
523
|
+
}
|
|
485
524
|
if (selectedMap.value) return `${selectedMap.value.points.length} saved points · ${mapRoutes.value.length} routes`;
|
|
486
525
|
if (route.value) return `${route.value.checkpoints.length} checkpoints · ${floorText.value}`;
|
|
487
526
|
return ""; // the Floor cell above already says it
|
|
@@ -676,6 +715,19 @@ async function send(kind: TRobotCommandKind) {
|
|
|
676
715
|
return;
|
|
677
716
|
}
|
|
678
717
|
}
|
|
718
|
+
// Clean area (cleaning robots): the API cleans named areas, so pick them from the robot's map.
|
|
719
|
+
if (kind === "area" && robotType.value !== "security") {
|
|
720
|
+
const map = mapViews.value.find((m) => m.current) ?? selectedMap.value;
|
|
721
|
+
const zones = map?.areas.map((a) => a.name) ?? [];
|
|
722
|
+
if (!zones.length) {
|
|
723
|
+
commandResult.value = { text: `There are no cleaning areas on ${map ? map.label : "this robot's map"} yet. Areas are drawn in ${makerApp.value}.`, warn: true };
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, zones, zoneNames: [zones[0]!] };
|
|
727
|
+
pickZones([zones[0]!]);
|
|
728
|
+
confirmOpen.value = true;
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
679
731
|
const body = commandBody(kind, live.value);
|
|
680
732
|
if (!body) {
|
|
681
733
|
toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
|
|
@@ -692,8 +744,23 @@ const confirming = ref<{
|
|
|
692
744
|
text: { title: string; body: string; action: string };
|
|
693
745
|
routes?: TPatrolRouteView[];
|
|
694
746
|
routeId?: string;
|
|
747
|
+
/** Clean area: the map's area names, and the ones picked. */
|
|
748
|
+
zones?: string[];
|
|
749
|
+
zoneNames?: string[];
|
|
695
750
|
what?: string;
|
|
696
751
|
} | null>(null);
|
|
752
|
+
/** The areas picked in Clean area's confirm: the body names them and the sentence says them. */
|
|
753
|
+
function pickZones(names: unknown) {
|
|
754
|
+
const c = confirming.value;
|
|
755
|
+
if (!c?.zones) return;
|
|
756
|
+
const picked = (Array.isArray(names) ? names : []).filter((n): n is string => typeof n === "string" && c.zones!.includes(n));
|
|
757
|
+
const list = picked.length > 2 ? `${picked.slice(0, -1).join(", ")} and ${picked.at(-1)}` : picked.join(" and ");
|
|
758
|
+
c.zoneNames = picked;
|
|
759
|
+
c.body = { kind: "area", areas: picked };
|
|
760
|
+
c.what = picked.length ? `cleaning ${list}` : undefined;
|
|
761
|
+
const text = commandConfirm("area", { type: robotType.value, name: record.value?.name });
|
|
762
|
+
c.text = { ...text, body: picked.length ? `${record.value?.name ?? "The robot"} starts cleaning ${list}, one pass.` : "Pick at least one area." };
|
|
763
|
+
}
|
|
697
764
|
/** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
|
|
698
765
|
function pickRoute(id: unknown) {
|
|
699
766
|
const c = confirming.value;
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
is lost - never a frozen dot.
|
|
10
10
|
-->
|
|
11
11
|
<RobotFloorPlan detail :pick="pick" :image="image" :aspect="aspect" @pick="emit('pick', $event)">
|
|
12
|
+
<img v-if="image && overlay" class="hero-plan__overlay" :src="overlay" alt="" aria-hidden="true" draggable="false" referrerpolicy="no-referrer" />
|
|
13
|
+
<span v-for="a in placedAreas" :key="a.name" class="hero-plan__area" :style="{ left: `${a.at!.x}%`, top: `${a.at!.y}%` }">{{ a.name }}</span>
|
|
12
14
|
<template v-for="p in points" :key="p.targetPointId">
|
|
13
15
|
<span class="hero-plan__pt" :style="{ left: `${p.x}%`, top: `${p.y}%` }" aria-hidden="true" />
|
|
14
16
|
<span class="hero-plan__ptname" :style="{ left: `${p.x}%`, top: `${p.y}%` }">{{ p.name }}</span>
|
|
@@ -56,6 +58,9 @@ const props = withDefaults(
|
|
|
56
58
|
/** The map shown: the robot's own image and its width / height. */
|
|
57
59
|
image?: string | null;
|
|
58
60
|
aspect?: number | null;
|
|
61
|
+
/** The maker's coloured area layer over the map image (Ounitech), and its areas' names + label spots. */
|
|
62
|
+
overlay?: string | null;
|
|
63
|
+
areas?: Array<{ name: string; at: { x: number; y: number } | null }>;
|
|
59
64
|
/** Saved points on the map shown (0-100 shares). */
|
|
60
65
|
points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
|
|
61
66
|
/** The route's dash walks only while the robot patrols. */
|
|
@@ -67,8 +72,9 @@ const props = withDefaults(
|
|
|
67
72
|
/** The last reading kept through a short gap: drawn faded, never as live. */
|
|
68
73
|
stale?: boolean;
|
|
69
74
|
}>(),
|
|
70
|
-
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
75
|
+
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, overlay: null, areas: () => [], points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
71
76
|
);
|
|
77
|
+
const placedAreas = computed(() => (props.image ? props.areas.filter((a) => a.at) : []));
|
|
72
78
|
const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
|
|
73
79
|
// The tag stays inside the plan (flipped left near the right edge, below near the top) - never clipped.
|
|
74
80
|
const tagClass = computed(() => {
|
|
@@ -246,19 +246,20 @@ function seenText(row: any) {
|
|
|
246
246
|
|
|
247
247
|
/* ---- patrol routes for the security robots on screen ---- */
|
|
248
248
|
const routes = ref<Record<string, TPatrolRouteView[]>>({});
|
|
249
|
-
/** Each
|
|
249
|
+
/** Each robot's own maps (PadBot / Ounitech map images), read once per robot. */
|
|
250
|
+
// ponytail: read once; the maker signs image URLs for ~1 h, so a page left open longer needs a reload.
|
|
250
251
|
const robotMaps = ref<Record<string, Array<Record<string, any>>>>({});
|
|
251
252
|
async function loadRoutes() {
|
|
252
|
-
if (props.type !== "security") {
|
|
253
|
-
routes.value = {};
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
253
|
const missing = ids.value.filter((id) => !(id in robotMaps.value));
|
|
257
254
|
if (missing.length) {
|
|
258
255
|
const got: Record<string, Array<Record<string, any>>> = {};
|
|
259
256
|
await Promise.allSettled(missing.map(async (id) => (got[id] = (await getRobotMaps(id))?.items ?? [])));
|
|
260
257
|
robotMaps.value = { ...robotMaps.value, ...Object.fromEntries(missing.map((id) => [id, got[id] ?? []])) };
|
|
261
258
|
}
|
|
259
|
+
if (props.type !== "security") {
|
|
260
|
+
routes.value = {};
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
262
263
|
const next: Record<string, TPatrolRouteView[]> = {};
|
|
263
264
|
await Promise.allSettled(
|
|
264
265
|
ids.value.map(async (id) => {
|
package/composables/useRobots.ts
CHANGED
|
@@ -150,6 +150,11 @@ export default function useRobots() {
|
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** Cleaning jobs, alert history, consumables, power schedule, lifts and every reading (read-only; `supported: false` for brands without). */
|
|
154
|
+
function getRobotRecords(id: string) {
|
|
155
|
+
return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}/records`, { method: "GET", query: { appSlug } });
|
|
156
|
+
}
|
|
157
|
+
|
|
153
158
|
/**
|
|
154
159
|
* Signed image URLs for a map's layers.
|
|
155
160
|
*
|
|
@@ -592,6 +597,7 @@ export default function useRobots() {
|
|
|
592
597
|
getRobotState,
|
|
593
598
|
getRobotMaps,
|
|
594
599
|
getRobotMapImages,
|
|
600
|
+
getRobotRecords,
|
|
595
601
|
getRobotPlans,
|
|
596
602
|
sendRobotCommand,
|
|
597
603
|
getLiveVideo,
|
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.77.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
|
@@ -626,6 +626,8 @@ export type TLiveRobotState = {
|
|
|
626
626
|
capabilities?: Record<string, unknown>;
|
|
627
627
|
/** Plain-words facts for this brand + model (core `robotManifest`). */
|
|
628
628
|
profile?: TRobotProfile;
|
|
629
|
+
/** A scrubber's water, tank and job readings (core `TRobotCleaning`, brands with `waterTanks`). */
|
|
630
|
+
cleaning?: TRobotCleaning | null;
|
|
629
631
|
lastSeenAt?: string | null;
|
|
630
632
|
/** Epoch ms of the last answer that was read (set by `mergeLiveStates`). */
|
|
631
633
|
readAt?: number;
|
|
@@ -1358,6 +1360,71 @@ export function lastKnownLabel(at: unknown, now: number = Date.now()): string {
|
|
|
1358
1360
|
|
|
1359
1361
|
export type TRobotFact = { label: string; value: string; dim: boolean };
|
|
1360
1362
|
|
|
1363
|
+
/** Core's `TRobotCleaning`: levels are "low" | "medium" | "high"; parts ok=false = open / not fitted. */
|
|
1364
|
+
export type TRobotCleaning = {
|
|
1365
|
+
cleanWater?: { level?: string; percent?: number | null } | null;
|
|
1366
|
+
dirtyWater?: { level?: string; percent?: number | null } | null;
|
|
1367
|
+
parts?: Array<{ part?: string; ok?: boolean }> | null;
|
|
1368
|
+
job?: { percent?: number | null; areaDone?: number | null; areaPlanned?: number | null } | null;
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
export type TCleaningReadings = {
|
|
1372
|
+
/** Clean water · Dirty water · Tanks and covers · Job - only the ones the robot reported. */
|
|
1373
|
+
facts: TRobotFact[];
|
|
1374
|
+
/** Warning chips: "Clean water low", "Dirty water full", "Top cover open". */
|
|
1375
|
+
warnings: string[];
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
const LEVEL_TEXT: Readonly<Record<string, string>> = { low: "Low", medium: "Medium", high: "High" };
|
|
1379
|
+
const PART_PROBLEM: Readonly<Record<string, string>> = {
|
|
1380
|
+
"top cover": "Top cover open",
|
|
1381
|
+
"clean-water tank": "Clean-water tank not fitted",
|
|
1382
|
+
"dirty-water tank": "Dirty-water tank not fitted",
|
|
1383
|
+
"dust box": "Dust box not fitted",
|
|
1384
|
+
};
|
|
1385
|
+
const m2 = (n: number) => `${Number.isInteger(n) ? n : n.toFixed(1)} m²`;
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* A cleaning robot's water, tanks and current job in plain words, for the
|
|
1389
|
+
* detail screen (web and phones). Capability-driven: nothing unless the brand +
|
|
1390
|
+
* model reports `waterTanks` and the robot answered online; a reading the robot
|
|
1391
|
+
* did not send is left out (hide, never grey).
|
|
1392
|
+
*
|
|
1393
|
+
* Clean water low and dirty water full (high) are warnings, as is any open
|
|
1394
|
+
* cover or missing tank. The job line shows only while it cleans.
|
|
1395
|
+
*/
|
|
1396
|
+
export function cleaningReadings(live: TLiveRobotState | null | undefined): TCleaningReadings {
|
|
1397
|
+
const none: TCleaningReadings = { facts: [], warnings: [] };
|
|
1398
|
+
if (!live?.ok || live.online === false || live.capabilities?.waterTanks !== true) return none;
|
|
1399
|
+
const c = live.cleaning;
|
|
1400
|
+
if (!c || typeof c !== "object") return none;
|
|
1401
|
+
const facts: TRobotFact[] = [];
|
|
1402
|
+
const warnings: string[] = [];
|
|
1403
|
+
const water = (label: string, w: TRobotCleaning["cleanWater"]) => {
|
|
1404
|
+
const text = LEVEL_TEXT[String(w?.level ?? "")];
|
|
1405
|
+
if (!text) return "";
|
|
1406
|
+
const pct = finite(w?.percent) ? ` · ${Math.round(w!.percent as number)}%` : "";
|
|
1407
|
+
facts.push({ label, value: `${text}${pct}`, dim: false });
|
|
1408
|
+
return String(w!.level);
|
|
1409
|
+
};
|
|
1410
|
+
if (water("Clean water", c.cleanWater) === "low") warnings.push("Clean water low");
|
|
1411
|
+
if (water("Dirty water", c.dirtyWater) === "high") warnings.push("Dirty water full");
|
|
1412
|
+
const parts = (Array.isArray(c.parts) ? c.parts : []).filter((p) => p && typeof p.part === "string" && typeof p.ok === "boolean");
|
|
1413
|
+
if (parts.length) {
|
|
1414
|
+
const problems = parts.filter((p) => !p.ok).map((p) => PART_PROBLEM[p.part as string] ?? `${firstUp(String(p.part))} not fitted`);
|
|
1415
|
+
facts.push({ label: "Tanks and covers", value: problems.length ? problems.join(" · ") : "Tanks and covers OK", dim: false });
|
|
1416
|
+
warnings.push(...problems);
|
|
1417
|
+
}
|
|
1418
|
+
const j = c.job;
|
|
1419
|
+
if (j && typeof j === "object") {
|
|
1420
|
+
const bits: string[] = [];
|
|
1421
|
+
if (finite(j.percent)) bits.push(`${Math.round(j.percent as number)}% done`);
|
|
1422
|
+
if (finite(j.areaDone)) bits.push(finite(j.areaPlanned) ? `${m2(j.areaDone as number)} of ${m2(j.areaPlanned as number)}` : `${m2(j.areaDone as number)} cleaned`);
|
|
1423
|
+
if (bits.length) facts.push({ label: "This job", value: bits.join(" · "), dim: false });
|
|
1424
|
+
}
|
|
1425
|
+
return { facts, warnings };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1361
1428
|
/**
|
|
1362
1429
|
* The detail screen's facts grid (README §3): Connection · Battery · Floor ·
|
|
1363
1430
|
* Live position · Robot reports · Model · Serial · Last seen. A fact the brand
|
|
@@ -1625,6 +1692,8 @@ export function robotAbilities(input: {
|
|
|
1625
1692
|
on("floorReporting") && "floor",
|
|
1626
1693
|
on("readLocation") && "live position",
|
|
1627
1694
|
on("thermalCamera") && "heat readings",
|
|
1695
|
+
on("waterTanks") && "water levels",
|
|
1696
|
+
on("waterTanks") && "tank checks",
|
|
1628
1697
|
on("patrolHistory") && (on("thermalCamera") ? "past patrols with photos and temperatures" : "past patrols with photos"),
|
|
1629
1698
|
].filter(Boolean) as string[];
|
|
1630
1699
|
const p = input?.profile ?? {};
|
package/utils/robot-maps.ts
CHANGED
|
@@ -22,6 +22,12 @@ export type TRobotMapView = {
|
|
|
22
22
|
routeNames: string[];
|
|
23
23
|
/** Saved points placed on this map's image (0-100 from its top-left). */
|
|
24
24
|
points: TRobotMapPoint[];
|
|
25
|
+
/** The maker's coloured area layer (Ounitech partition image), drawn over the map; null when none. */
|
|
26
|
+
overlay: string | null;
|
|
27
|
+
/** The map's cleaning areas by name (Clean area picks from these); `at` = label spot, null when unknown. */
|
|
28
|
+
areas: Array<{ name: string; at: { x: number; y: number } | null }>;
|
|
29
|
+
/** The maker has not confirmed the map's corner, so the robot pin may be off: say "position approximate". */
|
|
30
|
+
approximate: boolean;
|
|
25
31
|
};
|
|
26
32
|
|
|
27
33
|
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
@@ -47,7 +53,8 @@ export function robotMapShare(map: Record<string, any> | null | undefined, wx: u
|
|
|
47
53
|
* `/robots/:id/maps` items (PadBot: `mapId` + `imageUrl`) as selector entries,
|
|
48
54
|
* in PadBot's order. Named by PadBot's display name; without one, "Map N"
|
|
49
55
|
* with its floor, else its first route's name, as the hint ("Map 1 (B1)").
|
|
50
|
-
*
|
|
56
|
+
* Ounitech maps arrive in the same shape (the API adds mapId / imageUrl / size),
|
|
57
|
+
* plus their area layer, area names and "position approximate".
|
|
51
58
|
*/
|
|
52
59
|
export function robotMapViews(
|
|
53
60
|
items: Array<Record<string, any>> | null | undefined,
|
|
@@ -78,6 +85,11 @@ export function robotMapViews(
|
|
|
78
85
|
current: !!currentMapId && m.mapId === currentMapId,
|
|
79
86
|
routeNames,
|
|
80
87
|
points,
|
|
88
|
+
overlay: typeof m.partitionImageUrl === "string" && /^https?:\/\//i.test(m.partitionImageUrl) ? m.partitionImageUrl : null,
|
|
89
|
+
areas: (Array.isArray(m.areas) ? m.areas : [])
|
|
90
|
+
.filter((a: any) => typeof a?.name === "string" && a.name.trim())
|
|
91
|
+
.map((a: any) => ({ name: String(a.name).trim(), at: a.at && isNum(a.at.x) && isNum(a.at.y) ? { x: a.at.x, y: a.at.y } : null })),
|
|
92
|
+
approximate: m.positionApproximate === true,
|
|
81
93
|
};
|
|
82
94
|
});
|
|
83
95
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robot detail's records card (`/robots/:id/records`): cleaning jobs, alert
|
|
3
|
+
* history, parts wear, power schedule, lifts and every reading the robot
|
|
4
|
+
* sends, as display rows. Pure helpers - no Vue, no Node globals.
|
|
5
|
+
*/
|
|
6
|
+
import { dayRange } from "./robot-display.ts";
|
|
7
|
+
|
|
8
|
+
type TPart<T> = { items?: T[]; error?: string } | null | undefined;
|
|
9
|
+
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
10
|
+
const items = <T>(p: TPart<T>): T[] => (Array.isArray(p?.items) ? p!.items! : []);
|
|
11
|
+
|
|
12
|
+
/** "45 s", "16 min", "1 h 5 min"; "—" when unknown. */
|
|
13
|
+
export function durationText(sec: unknown): string {
|
|
14
|
+
if (!isNum(sec) || sec < 0) return "—";
|
|
15
|
+
if (sec < 60) return `${Math.round(sec)} s`;
|
|
16
|
+
const min = Math.round(sec / 60);
|
|
17
|
+
return min < 60 ? `${min} min` : `${Math.floor(min / 60)} h${min % 60 ? ` ${min % 60} min` : ""}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
21
|
+
|
|
22
|
+
/** An ISO time as "30 Sep, 08:15" in the viewer's zone (or `timeZone`); "—" when unknown. */
|
|
23
|
+
export function whenText(iso: unknown, timeZone?: string): string {
|
|
24
|
+
const t = typeof iso === "string" ? Date.parse(iso) : NaN;
|
|
25
|
+
if (!Number.isFinite(t)) return "—";
|
|
26
|
+
const parts = Object.fromEntries(
|
|
27
|
+
new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", ...(timeZone ? { timeZone } : {}) })
|
|
28
|
+
.formatToParts(t)
|
|
29
|
+
.map((p) => [p.type, p.value]),
|
|
30
|
+
);
|
|
31
|
+
return `${parts.day} ${MONTHS[Number(parts.month) - 1]}, ${parts.hour}:${parts.minute}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type TJobRow = { key: string; when: string; area: string; job: string; duration: string; size: string; done: string; result: string; by: string };
|
|
35
|
+
|
|
36
|
+
/** Cleaning jobs, newest first as the API sends them. */
|
|
37
|
+
export function jobRows(records: { tasks?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TJobRow[] {
|
|
38
|
+
return items(records?.tasks).map((t, i) => {
|
|
39
|
+
const floor = isNum(t.floorNo) ? (t.floorNo > 0 ? `L${t.floorNo}` : t.floorNo < 0 ? `B${-t.floorNo}` : "G") : "";
|
|
40
|
+
const area = [t.map, floor].filter(Boolean).join(" · ") || "—";
|
|
41
|
+
const size = isNum(t.areaM2) ? (isNum(t.plannedM2) && t.plannedM2 > 0 ? `${t.areaM2} of ${t.plannedM2} m²` : `${t.areaM2} m²`) : "—";
|
|
42
|
+
return {
|
|
43
|
+
key: `${t.id ?? ""}-${i}`,
|
|
44
|
+
when: whenText(t.start, timeZone),
|
|
45
|
+
area,
|
|
46
|
+
job: [t.job, t.mode].filter(Boolean).join(" · ") || "—",
|
|
47
|
+
duration: durationText(t.durationSec),
|
|
48
|
+
size,
|
|
49
|
+
done: isNum(t.percent) ? `${Math.round(t.percent)}%` : "—",
|
|
50
|
+
result: t.result || "—",
|
|
51
|
+
by: t.source || "—",
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type TAlertRow = { key: string; when: string; code: string; level: string; message: string; state: string; vendorText: string };
|
|
57
|
+
|
|
58
|
+
export function alertHistoryRows(records: { alerts?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TAlertRow[] {
|
|
59
|
+
return items(records?.alerts).map((a, i) => ({
|
|
60
|
+
key: `${a.code ?? ""}-${a.at ?? ""}-${i}`,
|
|
61
|
+
when: whenText(a.at, timeZone),
|
|
62
|
+
code: a.code ? String(a.code) : "—",
|
|
63
|
+
level: a.level || "",
|
|
64
|
+
message: a.message || a.vendorText || "—",
|
|
65
|
+
state: a.resolved ? "Resolved" : "Reported",
|
|
66
|
+
vendorText: a.vendorText && a.vendorText !== a.message ? String(a.vendorText) : "",
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Readings grouped in the API's order ("Battery", "Water", ...), base station last. */
|
|
71
|
+
export function readingGroups(records: { readings?: TPart<Record<string, any>>; station?: TPart<Record<string, any>> } | null | undefined) {
|
|
72
|
+
const rows = [...items(records?.readings), ...items(records?.station).flatMap((s) => (Array.isArray(s?.readings) ? s.readings : []))];
|
|
73
|
+
const status = items(records?.station)[0]?.status;
|
|
74
|
+
if (status) rows.push({ key: "station-status", group: "Base station", label: "Station status", value: String(status), attention: false });
|
|
75
|
+
const groups: Array<{ group: string; rows: Array<{ key: string; label: string; value: string; attention: boolean }> }> = [];
|
|
76
|
+
for (const r of rows) {
|
|
77
|
+
const g = String(r?.group || "Other readings");
|
|
78
|
+
let hit = groups.find((x) => x.group === g);
|
|
79
|
+
if (!hit) groups.push((hit = { group: g, rows: [] }));
|
|
80
|
+
hit.rows.push({ key: String(r.key ?? r.label), label: String(r.label ?? r.key), value: String(r.value ?? ""), attention: r.attention === true });
|
|
81
|
+
}
|
|
82
|
+
return groups;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Readings that need a person ("Dust box fitted: No"), for the card's one-line summary. */
|
|
86
|
+
export function attentionReadings(records: Parameters<typeof readingGroups>[0]): string[] {
|
|
87
|
+
return readingGroups(records).flatMap((g) => g.rows.filter((r) => r.attention).map((r) => `${r.label}: ${r.value}`));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function partRows(records: { consumables?: TPart<Record<string, any>> } | null | undefined) {
|
|
91
|
+
return items(records?.consumables).map((c, i) => ({
|
|
92
|
+
key: `${c.part}-${i}`,
|
|
93
|
+
part: String(c.part ?? "Part"),
|
|
94
|
+
worked: isNum(c.hours) ? `${c.hours} h` : "—",
|
|
95
|
+
days: isNum(c.days) ? `${c.days} days` : "—",
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function powerRows(records: { power?: TPart<Record<string, any>> } | null | undefined) {
|
|
100
|
+
return items(records?.power).map((p, i) => ({
|
|
101
|
+
key: `${p.time}-${i}`,
|
|
102
|
+
days: dayRange(p.days),
|
|
103
|
+
time: p.time || "—",
|
|
104
|
+
action: p.action === "on" ? "Switch on" : "Switch off",
|
|
105
|
+
enabled: p.enabled !== false,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function liftRows(records: { lifts?: TPart<Record<string, any>> } | null | undefined) {
|
|
110
|
+
return items(records?.lifts).map((l, i) => ({
|
|
111
|
+
key: `${l.name}-${i}`,
|
|
112
|
+
name: String(l.name ?? "Lift"),
|
|
113
|
+
state: `${l.online ? "Online" : "Offline"}${l.enabled === false ? " · disabled" : ""}${isNum(l.floorNo) ? ` · at L${l.floorNo}` : ""}`,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** "Couldn't read the cleaning records: …" per refused part, so a gap is said, never silent. */
|
|
118
|
+
export function recordErrors(records: Record<string, any> | null | undefined): string[] {
|
|
119
|
+
const names: Record<string, string> = {
|
|
120
|
+
tasks: "cleaning jobs",
|
|
121
|
+
alerts: "alert history",
|
|
122
|
+
consumables: "parts wear",
|
|
123
|
+
power: "power schedule",
|
|
124
|
+
lifts: "lifts",
|
|
125
|
+
readings: "robot readings",
|
|
126
|
+
station: "base station",
|
|
127
|
+
};
|
|
128
|
+
if (!records) return [];
|
|
129
|
+
const out = typeof records.error === "string" && records.error ? [records.error] : [];
|
|
130
|
+
for (const [k, name] of Object.entries(names)) {
|
|
131
|
+
const e = records[k]?.error;
|
|
132
|
+
if (typeof e === "string" && e) out.push(`Couldn't read the ${name}: ${e}`);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|