@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 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
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1912
- GET /api/roles/id/:role that membership's role document
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
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1915
- staff = member.type === "admin" && role.type === "admin"
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 security robot's own maps (PadBot map images), read once per robot. */
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) => {
@@ -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.75.1",
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.",
@@ -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 ?? {};
@@ -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
- * [] for brands whose maps are not vendor images (Ounitech keeps its own view).
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
+ }