@7365admin1/layer-common 4.97.1-staging.567 → 4.97.1-staging.569

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.
@@ -681,6 +681,8 @@
681
681
  .hero-plan__safe { fill: rgba(67, 160, 71, 0.18); stroke: #43a047; }
682
682
  .hero-plan__slow { fill: rgba(251, 140, 0, 0.18); stroke: #fb8c00; }
683
683
  .hero-plan__taught { fill: none; stroke: #1e88e5; stroke-dasharray: 4 3; }
684
+ .hero-plan__pass { fill: rgba(0, 172, 193, 0.14); stroke: #00acc1; stroke-dasharray: 2 2; }
685
+ .hero-plan__track { fill: none; stroke: #8e24aa; stroke-dasharray: 3 2; }
684
686
  .hero-rec__obstacles { display: block; max-width: 100%; max-height: 320px; margin-top: 4px; border: 1px solid var(--hero-line, rgba(127, 127, 127, 0.3)); image-rendering: pixelated; }
685
687
  .hero-plan__area { position: absolute; transform: translate(-50%, -50%); font-size: 12px; 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); }
686
688
  .hero-rec { margin-top: 16px; }
@@ -22,12 +22,22 @@
22
22
  <div v-else class="hero-rec__wrap">
23
23
  <table class="hero-rec__table">
24
24
  <thead>
25
- <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>
25
+ <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><th /></tr>
26
26
  </thead>
27
27
  <tbody>
28
- <tr v-for="j in shownJobs" :key="j.key">
29
- <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>
30
- </tr>
28
+ <template v-for="j in shownJobs" :key="j.key">
29
+ <tr>
30
+ <td>{{ j.when }}</td><td>{{ j.area }}</td><td>{{ j.job }}<span v-if="j.settings" class="hero-muted"><br />{{ j.settings }}</span></td><td>{{ j.duration }}</td><td>{{ j.size }}</td><td>{{ j.done }}</td><td>{{ j.result }}</td><td>{{ j.by }}</td>
31
+ <td><v-btn v-if="j.sourceKey" variant="text" size="small" :loading="detailLoading === j.sourceKey" @click="toggleDetail(j.sourceKey)">{{ openDetail === j.sourceKey ? "Hide" : "Detail" }}</v-btn></td>
32
+ </tr>
33
+ <tr v-if="openDetail && openDetail === j.sourceKey">
34
+ <td colspan="9">
35
+ <div v-if="detailError" class="hero-notice hero-notice--error" role="status">{{ detailError }}</div>
36
+ <div v-else-if="!detailRows.length" class="hero-sched__empty">{{ makerApp }} sent no floor or area runs for this job.</div>
37
+ <div v-for="d in detailRows" :key="d.key" class="hero-sched__row"><b>{{ d.where }}</b><span>{{ d.when }} · {{ d.duration }} · {{ d.size }} · {{ d.done }}</span><span>{{ d.result }}</span></div>
38
+ </td>
39
+ </tr>
40
+ </template>
31
41
  </tbody>
32
42
  </table>
33
43
  <v-btn v-if="jobs.length > 5" variant="text" size="small" @click="allJobs = !allJobs">{{ allJobs ? "Show fewer" : `See all ${jobs.length}` }}</v-btn>
@@ -149,10 +159,11 @@ import {
149
159
  settingGroups,
150
160
  statusLogRows,
151
161
  zoneRows,
162
+ jobDetailRows,
152
163
  } from "../../utils/robot-records";
153
164
 
154
165
  const props = defineProps<{ robotId: string; makerApp: string; brand?: string }>();
155
- const { getRobotRecords } = useRobots();
166
+ const { getRobotRecords, getRobotJobDetail } = useRobots();
156
167
 
157
168
  const records = ref<Record<string, any> | null>(null);
158
169
  const loading = ref(false);
@@ -186,6 +197,26 @@ watch(
186
197
  { immediate: true },
187
198
  );
188
199
 
200
+ /* One job's floor / area runs, read when asked (one open at a time). */
201
+ const openDetail = ref<string | null>(null);
202
+ const detailLoading = ref<string | null>(null);
203
+ const detail = ref<Record<string, any> | null>(null);
204
+ const detailError = ref("");
205
+ const detailRows = computed(() => jobDetailRows(detail.value));
206
+ async function toggleDetail(source: string) {
207
+ if (openDetail.value === source) return void (openDetail.value = null);
208
+ detailLoading.value = source;
209
+ detailError.value = "";
210
+ try {
211
+ detail.value = await getRobotJobDetail(props.robotId, source);
212
+ } catch (e: any) {
213
+ detail.value = null;
214
+ detailError.value = `Couldn't read the job detail: ${robotErrorMessage(e)}`;
215
+ } finally {
216
+ detailLoading.value = null;
217
+ openDetail.value = source;
218
+ }
219
+ }
189
220
  const jobs = computed(() => jobRows(records.value));
190
221
  const shownJobs = computed(() => (allJobs.value ? jobs.value : jobs.value.slice(0, 5)));
191
222
  const alerts = computed(() => alertHistoryRows(records.value));
@@ -30,8 +30,14 @@
30
30
  </div>
31
31
  </div>
32
32
 
33
- <!-- Emergency stop: anyone allowed to command this robot, one tap, no confirm (PadBot today). -->
34
- <RobotEmergencyStop v-if="cmdOk && caps?.emergencyStop === true" :robot-id="robotId" :name="record?.name" @stopped="fastUntil = Date.now() + COMMAND_FOLLOW_MS" />
33
+ <!-- Emergency stop: anyone allowed to command this robot, one tap, no confirm (PadBot, OneWay). OneWay's is a software stop: Release lifts it. -->
34
+ <RobotEmergencyStop
35
+ v-if="cmdOk && caps?.emergencyStop === true"
36
+ :robot-id="robotId"
37
+ :name="record?.name"
38
+ :releasable="caps?.emergencyRelease === true && fault?.code === 'E-STOP'"
39
+ @stopped="fastUntil = Date.now() + COMMAND_FOLLOW_MS"
40
+ />
35
41
 
36
42
  <div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
37
43
  <div v-else-if="live && !live.ok && live.reason" class="hero-notice" role="status">Can't read this robot right now: {{ live.reason }}</div>
@@ -242,7 +248,16 @@
242
248
  class="mb-2"
243
249
  @update:model-value="pickRoute"
244
250
  />
251
+ <!-- Documented job options (Ounitech clean mode / power / repeats; PadBot patrol rounds), only where the brand declares them. -->
252
+ <template v-if="offered.clean">
253
+ <v-select v-model="jobOpts.modeFlag" :items="CLEAN_MODE_CHOICES" label="Cleaning mode" density="compact" hide-details class="mb-2" />
254
+ <v-checkbox v-if="jobOpts.modeFlag === 2" v-model="jobOpts.subModeFlag" :true-value="1" :false-value="0" label="Also vacuum while washing" density="compact" hide-details />
255
+ <v-select v-model="jobOpts.levelFlag" :items="CLEAN_POWER_CHOICES" label="Power" density="compact" hide-details class="mb-2" />
256
+ <v-select v-model="jobOpts.cleanTimes" :items="CLEAN_TIMES_CHOICES" label="Times to clean" density="compact" hide-details class="mb-2" />
257
+ </template>
258
+ <v-select v-if="offered.rounds" v-model="jobOpts.rounds" :items="PATROL_ROUND_CHOICES" label="Rounds" density="compact" hide-details class="mb-2" />
245
259
  <div class="hero-dialog__body">{{ confirming.text.body }}</div>
260
+ <div v-if="optionsLine" class="hero-dialog__body">{{ optionsLine }}</div>
246
261
  <div class="hero-dialog__actions">
247
262
  <v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
248
263
  <!-- Cancel sends the robot home (core 2026-10-15); this keeps it where it stops. -->
@@ -368,6 +383,15 @@ import {
368
383
  type TRobotCommandKind,
369
384
  type TSavedPoint,
370
385
  type TSavedPointGroup,
386
+ CLEAN_MODE_CHOICES,
387
+ CLEAN_POWER_CHOICES,
388
+ CLEAN_TIMES_CHOICES,
389
+ JOB_OPTION_DEFAULTS,
390
+ PATROL_ROUND_CHOICES,
391
+ jobOptionsLine,
392
+ jobOptionsOffered,
393
+ withJobOptions,
394
+ type TJobOptions,
371
395
  } from "../../utils/robot-display";
372
396
 
373
397
  const props = withDefaults(
@@ -798,10 +822,19 @@ function pickRoute(id: unknown) {
798
822
  c.what = /^patrol/i.test(name) ? name : `patrolling ${name}`;
799
823
  c.text = commandConfirm("area", { type: "security", name: record.value?.name, route: { name, stops }, map });
800
824
  }
825
+ /* Job options on a start: reset each time the confirm opens. */
826
+ const jobOpts = ref<TJobOptions>({ ...JOB_OPTION_DEFAULTS });
827
+ const offered = computed(() => jobOptionsOffered(confirming.value?.kind, robotType.value, caps.value));
828
+ const optionsLine = computed(() => jobOptionsLine(offered.value, jobOpts.value));
829
+ watch(confirmOpen, (open) => {
830
+ if (open) jobOpts.value = { ...JOB_OPTION_DEFAULTS };
831
+ });
801
832
  function confirmSend(stayHere = false) {
802
833
  const c = confirming.value;
803
834
  confirmOpen.value = false;
804
- if (c) run(c.kind, stayHere === true ? { ...c.body, stayHere: true } : c.body, c.what);
835
+ if (!c) return;
836
+ const body = withJobOptions(c.body, offered.value, jobOpts.value);
837
+ run(c.kind, stayHere === true ? { ...body, stayHere: true } : body, c.what);
805
838
  }
806
839
 
807
840
  /** The robot's plan image size for its floor, for turning plan clicks into pixels. */
@@ -4,12 +4,15 @@
4
4
  command this robot. One tap, NO confirm - a stop must never wait on a
5
5
  dialog or on a state read. The server sends PadBot's "full machine stop"
6
6
  over the live link AND its stop command, and says which reached the robot.
7
+ OneWay's stop is a software one that stays on: while the robot reports it
8
+ (`releasable`), Release lifts it - after a confirm, since the robot may move again.
7
9
  -->
8
10
  <div class="hero-estop" role="group" aria-label="Emergency stop">
9
11
  <button type="button" class="hero-estop__btn" :disabled="busy" @click="stop">
10
12
  <span class="hero-estop__icon" aria-hidden="true">■</span>
11
13
  {{ busy ? "Stopping…" : "Emergency stop" }}
12
14
  </button>
15
+ <v-btn v-if="releasable" variant="outlined" :loading="releasing" :disabled="busy" @click="release">Release emergency stop</v-btn>
13
16
  <div v-if="result" class="hero-estop__msg" :class="{ 'hero-estop__msg--error': failed }" role="status" aria-live="assertive">{{ result }}</div>
14
17
  </div>
15
18
  </template>
@@ -18,11 +21,12 @@
18
21
  import { ref } from "vue";
19
22
  import { estopResultText } from "../../utils/robot-config";
20
23
 
21
- const props = defineProps<{ robotId: string; name?: string }>();
24
+ const props = defineProps<{ robotId: string; name?: string; releasable?: boolean }>();
22
25
  const emit = defineEmits<{ stopped: [] }>();
23
- const { emergencyStopRobot } = useRobots();
26
+ const { emergencyStopRobot, releaseEmergencyStopRobot } = useRobots();
24
27
 
25
28
  const busy = ref(false);
29
+ const releasing = ref(false);
26
30
  const result = ref("");
27
31
  const failed = ref(false);
28
32
 
@@ -42,6 +46,23 @@ async function stop() {
42
46
  busy.value = false;
43
47
  }
44
48
  }
49
+
50
+ async function release() {
51
+ if (!window.confirm(`Release the emergency stop on ${props.name || "this robot"}? It can take commands and move again.`)) return;
52
+ releasing.value = true;
53
+ failed.value = false;
54
+ result.value = "";
55
+ try {
56
+ const r = await releaseEmergencyStopRobot(props.robotId);
57
+ result.value = r?.message || "Emergency stop released.";
58
+ emit("stopped");
59
+ } catch (e: any) {
60
+ failed.value = true;
61
+ result.value = e?.data?.message || e?.message || "The release did not reach the robot. The emergency stop is still on.";
62
+ } finally {
63
+ releasing.value = false;
64
+ }
65
+ }
45
66
  </script>
46
67
 
47
68
  <style scoped>
@@ -67,7 +67,7 @@
67
67
  :id="`${uid}-token`"
68
68
  v-model="appToken"
69
69
  class="hero-input"
70
- type="password"
70
+ :type="tokenSecret ? 'password' : 'text'"
71
71
  autocomplete="new-password"
72
72
  spellcheck="false"
73
73
  :placeholder="credentialPlaceholder(editing)"
@@ -108,7 +108,14 @@ import {
108
108
  type TRobotType,
109
109
  } from "../../utils/robot-display";
110
110
  import { credentialsNote } from "../../utils/robot-floorplan";
111
- import { credentialLabels, credentialMissingMessage, discoveredRobotOptions } from "../../utils/robot-credential-form";
111
+ import {
112
+ credentialDiscoverable,
113
+ credentialLabels,
114
+ credentialMissingMessage,
115
+ credentialTokenDefault,
116
+ credentialTokenIsSecret,
117
+ discoveredRobotOptions,
118
+ } from "../../utils/robot-credential-form";
112
119
 
113
120
  const props = withDefaults(
114
121
  defineProps<{
@@ -154,9 +161,17 @@ const robotSn = ref(savedSn);
154
161
  const found = ref<Array<{ value: string; label: string; disabled: boolean }>>([]);
155
162
  const finding = ref(false);
156
163
  const labels = computed(() => credentialLabels(brand.value));
157
- const typed = computed(() => !!appKey.value.trim() && !!appToken.value.trim());
158
- // Ounitech / PadBot: one credential pair per company account, so the robot is picked from its list.
159
- const discoverable = computed(() => brand.value === "ounitech" || brand.value === "padbot");
164
+ // A prefilled server address with no key typed is not a typed pair (Find robots then uses the client's saved one).
165
+ const tokenValue = computed(() => (!appKey.value.trim() && appToken.value.trim() === credentialTokenDefault(brand.value) ? "" : appToken.value.trim()));
166
+ const typed = computed(() => !!appKey.value.trim() && !!tokenValue.value);
167
+ // Ounitech / PadBot / OneWay: one company credential lists its robots, so the robot is picked from that list.
168
+ const discoverable = computed(() => credentialDiscoverable(brand.value));
169
+ // OneWay's second field is its server's address: plain text, prefilled with OneWay's cloud when adding.
170
+ const tokenSecret = computed(() => credentialTokenIsSecret(brand.value));
171
+ watch(brand, (b, was) => {
172
+ if (editing.value) return;
173
+ if (!appToken.value.trim() || appToken.value === credentialTokenDefault(was)) appToken.value = credentialTokenDefault(b);
174
+ });
160
175
  const findHint = computed(() =>
161
176
  editing.value || typed.value
162
177
  ? "Press Find robots"
@@ -219,12 +234,12 @@ onMounted(async () => {
219
234
 
220
235
  async function find() {
221
236
  error.value = "";
222
- if (!!appKey.value.trim() !== !!appToken.value.trim()) {
237
+ if (!!appKey.value.trim() !== !!tokenValue.value) {
223
238
  return void (error.value = `Enter both the ${labels.value.key} and the ${labels.value.token}.`);
224
239
  }
225
240
  finding.value = true;
226
241
  try {
227
- const pair = typed.value ? { appKey: appKey.value.trim(), appToken: appToken.value.trim() } : null;
242
+ const pair = typed.value ? { appKey: appKey.value.trim(), appToken: tokenValue.value } : null;
228
243
  const body = pair
229
244
  ? { site: site.value, brand: brand.value, ...pair }
230
245
  : editing.value
@@ -247,11 +262,11 @@ async function submit() {
247
262
  error.value = "";
248
263
  if (!name.value.trim()) return void (error.value = "Give the robot a name.");
249
264
  // Adding with no pair typed = reuse this client's saved one; needs the picked robot.
250
- const useSaved = !editing.value && discoverable.value && !appKey.value.trim() && !appToken.value.trim();
265
+ const useSaved = !editing.value && discoverable.value && !appKey.value.trim() && !tokenValue.value;
251
266
  if (useSaved && !robotSn.value) {
252
267
  return void (error.value = `Enter the ${labels.value.key} and ${labels.value.token}, or press Find robots to use this client's saved ones and pick the robot.`);
253
268
  }
254
- const creds = useSaved ? ({ ok: true, body: {} } as const) : credentialPatch(appKey.value, appToken.value, editing.value);
269
+ const creds = useSaved ? ({ ok: true, body: {} } as const) : credentialPatch(appKey.value, tokenValue.value, editing.value);
255
270
  if (!creds.ok) return void (error.value = credentialMissingMessage(labels.value, editing.value));
256
271
  const serial = robotSn.value && robotSn.value !== savedSn ? { robotSn: robotSn.value } : {};
257
272
  saving.value = true;
@@ -41,7 +41,10 @@
41
41
  <div v-if="l.gateway || (canEditMap && setupMapId)" class="hero-lift__gw" :data-status="l.gateway?.status ?? 'none'">
42
42
  <template v-if="l.gateway">
43
43
  <span class="hero-chip" :class="`hero-tone--${l.gateway.tone}`" role="status">{{ l.gateway.text }}</span>
44
+ <span v-if="l.gateway.fault" class="hero-chip hero-tone--error" role="alert">{{ l.gateway.fault }}</span>
44
45
  <span v-if="l.gateway.decoded" class="hero-lift__gwread">{{ l.gateway.decoded }}</span>
46
+ <span v-if="l.gateway.facts" class="hero-muted hero-lift__gwread">{{ l.gateway.facts }}</span>
47
+ <span v-if="l.gateway.calls" class="hero-muted hero-lift__gwread">{{ l.gateway.calls }}</span>
45
48
  <span v-else-if="l.gateway.undecoded" class="hero-muted hero-lift__gwread">Real lift answering · floor/door not decoded yet (fill [decode] in the gateway settings)</span>
46
49
  <span v-if="l.gateway.help" class="hero-muted hero-lift__gwhelp">{{ l.gateway.help }}</span>
47
50
  <details v-if="l.gateway.registers.length" class="hero-lift__gwraw">
@@ -12,8 +12,8 @@
12
12
  <img v-if="image && overlay" class="hero-plan__overlay" :src="overlay" alt="" aria-hidden="true" draggable="false" referrerpolicy="no-referrer" />
13
13
  <svg v-if="image && (zones.length || taughtRoute.length > 1)" class="hero-plan__zones" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
14
14
  <template v-for="(z, i) in zones" :key="`${z.kind}-${i}`">
15
- <polyline v-if="z.kind === 'wall'" class="hero-plan__wall" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polyline>
16
- <polygon v-else :class="z.kind === 'safe' ? 'hero-plan__safe' : 'hero-plan__slow'" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polygon>
15
+ <polyline v-if="z.kind === 'wall' || z.kind === 'track'" :class="z.kind === 'wall' ? 'hero-plan__wall' : 'hero-plan__track'" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polyline>
16
+ <polygon v-else :class="`hero-plan__${z.kind}`" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polygon>
17
17
  </template>
18
18
  <polyline v-if="taughtRoute.length > 1" class="hero-plan__taught" :points="svgPoints(taughtRoute)" />
19
19
  </svg>
@@ -69,7 +69,7 @@ const props = withDefaults(
69
69
  overlay?: string | null;
70
70
  areas?: Array<{ name: string; at: { x: number; y: number } | null }>;
71
71
  /** Virtual walls / safe zones / slow zones and the taught route on the map shown (0-100 shares). */
72
- zones?: Array<{ kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> }>;
72
+ zones?: Array<{ kind: "wall" | "safe" | "slow" | "pass" | "track"; name: string; points: Array<{ x: number; y: number }> }>;
73
73
  taughtRoute?: Array<{ x: number; y: number }>;
74
74
  /** Saved points on the map shown (0-100 shares). */
75
75
  points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
@@ -134,6 +134,43 @@
134
134
  </span>
135
135
  </div>
136
136
  <v-btn v-if="settings.length" size="small" color="primary" variant="flat" :disabled="busy || !Object.keys(changedSettings).length" @click="ask({ action: 'settings', values: changedSettings })">Save settings</v-btn>
137
+
138
+ <div class="hero-section__title">Parts replaced</div>
139
+ <p class="hero-muted">After a brush, mop, filter or bag is replaced, reset its hours so the wear count starts again.</p>
140
+ <div v-if="setup.parts?.error" class="hero-notice hero-notice--error">{{ setup.parts.error }}</div>
141
+ <div v-else-if="!parts.length" class="hero-sched__empty">The robot reports no parts.</div>
142
+ <div v-for="p in parts" :key="p.code" class="hero-sched__row hero-setup__row">
143
+ <b>{{ p.part }}</b>
144
+ <span>{{ p.hours != null ? `${p.hours} h` : "hours not reported" }}{{ p.days != null ? ` · ${p.days} days` : "" }}</span>
145
+ <span class="hero-setup__actions">
146
+ <v-btn size="small" variant="outlined" :disabled="busy" @click="ask({ action: 'parts-reset', codes: [p.code] }, { parts: [p.part] })">Part replaced · reset hours</v-btn>
147
+ </span>
148
+ </div>
149
+ </section>
150
+
151
+ <!-- Area settings (Ounitech overwrite-area-list): name, path, location mode, edge cleaning -->
152
+ <section v-if="caps.setupZones === true && areas.length" class="hero-setup__section" aria-label="Area settings">
153
+ <div class="hero-section__title">Areas on {{ currentMap?.name ?? "this map" }}</div>
154
+ <p class="hero-muted">How the robot cleans each area. The area shapes themselves are drawn in the Ounitech app.</p>
155
+ <div v-for="a in areas" :key="a.name" class="hero-setup__form">
156
+ <input v-model="areaEdits[a.name]!.name" class="hero-input" maxlength="40" :aria-label="`Name of area ${a.name}`" />
157
+ <label>Path
158
+ <select v-model.number="areaEdits[a.name]!.pathType" class="hero-input hero-select hero-select--sm">
159
+ <option v-for="c in AREA_PATH_CHOICES" :key="c.value" :value="c.value">{{ c.title }}</option>
160
+ </select>
161
+ </label>
162
+ <label>Place
163
+ <select v-model="areaEdits[a.name]!.locationMode" class="hero-input hero-select hero-select--sm">
164
+ <option v-for="c in AREA_LOCATION_CHOICES" :key="c.value" :value="c.value">{{ c.title }}</option>
165
+ </select>
166
+ </label>
167
+ <label>Edges
168
+ <select v-model.number="areaEdits[a.name]!.alongWallMode" class="hero-input hero-select hero-select--sm">
169
+ <option v-for="c in AREA_EDGE_CHOICES" :key="c.value" :value="c.value">{{ c.title }}</option>
170
+ </select>
171
+ </label>
172
+ <v-btn size="small" variant="outlined" :disabled="busy || !areaChange(currentMap?.mapId, a, areaEdits[a.name]!)" @click="ask(areaChange(currentMap?.mapId, a, areaEdits[a.name]!)!)">Save area</v-btn>
173
+ </div>
137
174
  </section>
138
175
  </template>
139
176
 
@@ -154,7 +191,17 @@
154
191
  import { computed, reactive, ref, watch } from "vue";
155
192
  import RobotSetupZones from "./SetupZones.vue";
156
193
  import RobotSetupPoints from "./SetupPoints.vue";
157
- import { DAY_CHOICES, daysText, scheduleProblem, settingsDiff, setupConfirmText } from "../../utils/robot-config";
194
+ import {
195
+ AREA_EDGE_CHOICES,
196
+ AREA_LOCATION_CHOICES,
197
+ AREA_PATH_CHOICES,
198
+ DAY_CHOICES,
199
+ areaChange,
200
+ daysText,
201
+ scheduleProblem,
202
+ settingsDiff,
203
+ setupConfirmText,
204
+ } from "../../utils/robot-config";
158
205
 
159
206
  const props = defineProps<{ robotId: string; name?: string; brand?: string; capabilities?: Record<string, unknown> | null }>();
160
207
  const emit = defineEmits<{ changed: [] }>();
@@ -198,8 +245,9 @@ const confirmOpen = ref(false);
198
245
  const confirming = ref<{ change: Record<string, any>; text: ReturnType<typeof setupConfirmText> } | null>(null);
199
246
  const currentMap = computed(() => (setup.value?.maps ?? []).find((m: any) => m.mapId === (setup.value?.mapId ?? mapId.value)) ?? null);
200
247
 
201
- function ask(change: Record<string, any>, ctx: { map?: string } = {}) {
202
- confirming.value = { change, text: setupConfirmText(change, { robot: props.name, map: ctx.map ?? currentMap.value?.name }) };
248
+ function ask(change: Record<string, any>, ctx: { map?: string; parts?: string[] } = {}) {
249
+ // `parts` is for the sentence only; the server gets the codes.
250
+ confirming.value = { change, text: setupConfirmText({ ...change, ...(ctx.parts ? { parts: ctx.parts } : {}) }, { robot: props.name, map: ctx.map ?? currentMap.value?.name }) };
203
251
  confirmOpen.value = true;
204
252
  }
205
253
  async function send() {
@@ -266,6 +314,15 @@ watch(setup, (s) => {
266
314
  if (!planForm.planId && planForm.floorNo == null) planForm.floorNo = s?.floorNo ?? s?.floors?.[0] ?? null;
267
315
  });
268
316
  const changedSettings = computed(() => settingsDiff(settings.value, edited));
317
+
318
+ /* ---- parts replaced, area settings (Ounitech) ---- */
319
+ const parts = computed<any[]>(() => setup.value?.parts?.value ?? []);
320
+ const areas = computed<any[]>(() => (currentMap.value?.areas ?? []).filter((a: any) => a?.name && a.name !== "default"));
321
+ const areaEdits = reactive<Record<string, { name: string; pathType: number | null; locationMode: string | null; alongWallMode: number | null }>>({});
322
+ watch(areas, (list) => {
323
+ for (const k of Object.keys(areaEdits)) delete areaEdits[k];
324
+ for (const a of list) areaEdits[a.name] = { name: a.name, pathType: a.pathType ?? null, locationMode: a.locationMode ?? null, alongWallMode: a.alongWallMode ?? null };
325
+ }, { immediate: true });
269
326
  </script>
270
327
 
271
328
  <style>
@@ -18,10 +18,12 @@
18
18
  :image="map.imageUrl"
19
19
  :aspect="map.width && map.height ? map.width / map.height : null"
20
20
  :points="placed"
21
+ :zones="map.zones ?? []"
21
22
  :spot="picked ? [picked] : []"
22
23
  :pick="picking !== null"
23
24
  @pick="(p) => (picked = p)"
24
25
  />
26
+ <p v-if="map.imageUrl && (map.zones ?? []).length" class="hero-muted">{{ mapLayerLegend({ zones: map.zones, taughtRoute: [] }) }}. Set in the PadBot app; shown here so new points stay out of them.</p>
25
27
  <div v-if="picking" class="hero-spotbar" role="group" aria-label="Pick a spot">
26
28
  <span>{{ picking === "add" ? "Click the map where the new point goes." : `Click the map where "${moving?.name}" goes.` }}</span>
27
29
  <v-btn size="small" color="primary" variant="flat" :disabled="!picked || busy" @click="donePick">Use this spot</v-btn>
@@ -60,6 +62,7 @@
60
62
  import { computed, reactive, ref, watch } from "vue";
61
63
  import RobotLivePlan from "./LivePlan.vue";
62
64
  import type { TShare } from "../../utils/robot-config";
65
+ import { mapLayerLegend } from "../../utils/robot-maps";
63
66
 
64
67
  const props = defineProps<{ setup: Record<string, any>; busy?: boolean }>();
65
68
  const emit = defineEmits<{ apply: [change: Record<string, any>, ctx?: { map?: string }] }>();
@@ -155,6 +155,11 @@ export default function useRobots() {
155
155
  return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}/records`, { method: "GET", query: { appSlug } });
156
156
  }
157
157
 
158
+ /** One past cleaning job's floor / area runs, by the record's `sourceKey` (read-only). */
159
+ function getRobotJobDetail(id: string, source: string) {
160
+ return useNuxtApp().$api<{ items?: Array<Record<string, any>> }>(`/api/robots/${id}/records/job`, { method: "GET", query: { appSlug, source } });
161
+ }
162
+
158
163
  /**
159
164
  * Signed image URLs for a map's layers.
160
165
  *
@@ -358,6 +363,15 @@ export default function useRobots() {
358
363
  );
359
364
  }
360
365
 
366
+ /** `emergencyRelease` (OneWay): lift a software emergency stop so the robot takes commands again. */
367
+ function releaseEmergencyStopRobot(id: string) {
368
+ return useNuxtApp().$api<{ ok: boolean; message: string }>(`/api/robots/${id}/emergency-stop`, {
369
+ method: "POST",
370
+ query: { appSlug },
371
+ body: { release: true },
372
+ });
373
+ }
374
+
361
375
  /** Super Admin setup: what can be changed now, read fresh from the maker's cloud (`mapId` picks the map). */
362
376
  function getRobotSetup(id: string, mapId?: string | null) {
363
377
  return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}/setup`, {
@@ -656,6 +670,7 @@ export default function useRobots() {
656
670
  saveSiteFleetCore,
657
671
  createSiteRelayKey,
658
672
  emergencyStopRobot,
673
+ releaseEmergencyStopRobot,
659
674
  getRobotSetup,
660
675
  applyRobotSetup,
661
676
  listBuildingMaps,
@@ -696,6 +711,7 @@ export default function useRobots() {
696
711
  getRobotMaps,
697
712
  getRobotMapImages,
698
713
  getRobotRecords,
714
+ getRobotJobDetail,
699
715
  getRobotPlans,
700
716
  sendRobotCommand,
701
717
  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.97.1-staging.567",
5
+ "version": "4.97.1-staging.569",
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.",
@@ -1535,6 +1535,29 @@ const MODE_TEXT = ["Mode unknown", "Normal", "Robot mode", "FIRE mode", "Out of
1535
1535
  * one status line, a tone, and the decoded floor/door/motion/mode once the
1536
1536
  * gateway's [decode] settings are filled in. `null` = no gateway: the car is simulated.
1537
1537
  */
1538
+ /** The B0S details (`gateway.details`) as the lift card's lines: the fault, then facts, then registered calls. */
1539
+ export function liftBoardLines(d: Record<string, any> | null | undefined) {
1540
+ if (!d) return { fault: "", facts: "", calls: "" };
1541
+ const num = (v: unknown) => typeof v === "number" && Number.isFinite(v);
1542
+ const fault = d.fault ? String(d.fault.text || "Lift fault") : "";
1543
+ const facts = [
1544
+ d.detailedMode && d.detailedMode !== d.status ? `Mode: ${d.detailedMode}` : "",
1545
+ d.load ? `Load: ${d.load}` : "",
1546
+ num(d.bottomFloor) && num(d.topFloor) ? `Floors ${d.bottomFloor}-${d.topFloor}` : "",
1547
+ d.boardLink === "Offline" ? "Board has lost the lift controller" : "",
1548
+ num(d.speedMps) && d.speedMps > 0 ? `${d.speedMps} m/s` : "",
1549
+ num(d.runHours) ? `${d.runHours} run hours` : "",
1550
+ num(d.runCount) ? `${Number(d.runCount).toLocaleString("en-GB")} runs` : "",
1551
+ ].filter(Boolean).join(" · ");
1552
+ const list = (a: unknown) => (Array.isArray(a) && a.length ? a.join(", ") : "");
1553
+ const calls = [
1554
+ list(d.carCalls) ? `Car calls: ${list(d.carCalls)}` : "",
1555
+ list(d.hallUp) ? `Hall up: ${list(d.hallUp)}` : "",
1556
+ list(d.hallDown) ? `Hall down: ${list(d.hallDown)}` : "",
1557
+ ].filter(Boolean).join(" · ");
1558
+ return { fault, facts, calls };
1559
+ }
1560
+
1538
1561
  export function liftGatewayLine(g: Record<string, any> | null) {
1539
1562
  if (!g) return null;
1540
1563
  const ago = g.ageS == null ? "" : agoText(0, Number(g.ageS) * 1000);
@@ -1558,12 +1581,15 @@ export function liftGatewayLine(g: Record<string, any> | null) {
1558
1581
  };
1559
1582
  const line = byStatus[g.status] ?? byStatus.offline!;
1560
1583
  const st = g.status === "connected" ? g.state : null;
1584
+ // The board's own words (Inovance B0S registers, decoded by the API): a lift in fault says Fault, not Offline.
1585
+ const d = g.status === "connected" && g.details && typeof g.details === "object" ? (g.details as Record<string, any>) : null;
1586
+ const boardMode = d?.status && d.status !== "Normal" ? String(d.status) : "";
1561
1587
  const decoded = st
1562
1588
  ? [
1563
1589
  st.currentFloor ? `Floor ${st.currentFloor}` : "",
1564
1590
  st.doorState != null ? DOOR_TEXT[st.doorState] ?? "" : "",
1565
1591
  st.motionState != null ? MOTION_TEXT[st.motionState] ?? "" : "",
1566
- st.currentMode != null ? MODE_TEXT[st.currentMode] ?? "" : "",
1592
+ boardMode || (st.currentMode != null ? MODE_TEXT[st.currentMode] ?? "" : ""),
1567
1593
  ].filter(Boolean).join(" · ")
1568
1594
  : "";
1569
1595
  const registers = Object.entries((g.registers ?? {}) as Record<string, number>)
@@ -1574,6 +1600,7 @@ export function liftGatewayLine(g: Record<string, any> | null) {
1574
1600
  status: String(g.status ?? "offline"),
1575
1601
  connected: g.status === "connected",
1576
1602
  decoded,
1603
+ ...liftBoardLines(d),
1577
1604
  // Until the Inovance register list is in the gateway's settings, only raw numbers exist.
1578
1605
  undecoded: g.status === "connected" && !decoded,
1579
1606
  registers,
@@ -145,11 +145,51 @@ export function setupConfirmText(change: Record<string, any>, ctx: { robot?: str
145
145
  return { title: "Change this point?", body: `Patrol routes and buttons that use it go to the new place / name from now on.`, action: "Save point" };
146
146
  case "point-delete":
147
147
  return { title: "Delete this point?", body: `It is removed from ${robot}'s map. Patrol routes that use it may stop working; check them in the PadBot app.`, action: "Delete point" };
148
+ case "parts-reset": {
149
+ const parts = Array.isArray(change.parts) && change.parts.length ? change.parts.join(", ") : "this part";
150
+ return {
151
+ title: `Reset the hours of ${parts}?`,
152
+ body: `Only after the part has been replaced: its hours on ${robot} restart from 0 and the old count can't be brought back.`,
153
+ action: "Reset hours",
154
+ };
155
+ }
156
+ case "area-settings":
157
+ return {
158
+ title: `Change area "${change.areaName}"?`,
159
+ body: `${robot} cleans area "${change.areaName}" on ${map} with the new settings from its next job${change.name && change.name !== change.areaName ? `, and the area is called "${change.name}" here and in the Ounitech app (schedules keep working; Clean area uses the new name)` : ""}. Every other area is sent back unchanged.`,
160
+ action: "Save area",
161
+ };
148
162
  default:
149
163
  return { title: "Send this change?", body: "", action: "Send" };
150
164
  }
151
165
  }
152
166
 
167
+ /** overwrite-area-list values (Ounitech 2025.10): how the robot covers an area, how it keeps its place, how it cleans the edges. */
168
+ export const AREA_PATH_CHOICES = [
169
+ { title: "Loop (outside in)", value: 0 },
170
+ { title: "Zigzag (back and forth)", value: 1 },
171
+ { title: "Taught route", value: 2 },
172
+ ] as const;
173
+ export const AREA_LOCATION_CHOICES = [
174
+ { title: "Open area", value: "open_area" },
175
+ { title: "Narrow corridor", value: "narrow_tunnel" },
176
+ { title: "Factory / warehouse", value: "factory" },
177
+ ] as const;
178
+ export const AREA_EDGE_CHOICES = [
179
+ { title: "Off", value: 0 },
180
+ { title: "Standard", value: 1 },
181
+ { title: "Deep", value: 2 },
182
+ ] as const;
183
+
184
+ /** The area change to send: only fields that differ from what the robot has (null when nothing changed). */
185
+ export function areaChange(mapId: unknown, was: Record<string, any>, now: Record<string, any>) {
186
+ const out: Record<string, unknown> = {};
187
+ const name = String(now.name ?? "").trim();
188
+ if (name && name !== was.name) out.name = name;
189
+ for (const k of ["pathType", "locationMode", "alongWallMode"]) if (now[k] != null && now[k] !== was[k]) out[k] = now[k];
190
+ return Object.keys(out).length ? { action: "area-settings", mapId, areaName: was.name, ...out } : null;
191
+ }
192
+
153
193
  /** The emergency stop's answer in plain words (never "accepted" when nothing reached the robot). */
154
194
  export function estopResultText(r: { live?: boolean; rest?: boolean; message?: string } | null | undefined): string {
155
195
  if (!r) return "";
@@ -14,9 +14,29 @@ export function credentialLabels(brand: unknown): { key: string; token: string }
14
14
  // PadBot issues an AccessKey ID + AccessKey Secret per account (API v4.0 §2.1).
15
15
  if (brand === "padbot") return { key: "AccessKey ID", token: "AccessKey Secret" };
16
16
  if (brand === "demo") return { key: "App key (any value)", token: "Token (any value)" };
17
+ // OneWay (Anbot RMS): one API key per company; the second field is the RMS server's address.
18
+ if (brand === "oneway") return { key: "API key", token: "Server address" };
17
19
  return { key: "App key", token: "Token" };
18
20
  }
19
21
 
22
+ /** OneWay's cloud, prefilled in "Server address" when adding (change it only for a company's own RMS). */
23
+ export const ONEWAY_DEFAULT_SERVER = "https://www.anbotcloud.cn";
24
+
25
+ /** What the second field starts with when adding a robot of this brand ("" = empty). */
26
+ export function credentialTokenDefault(brand: unknown): string {
27
+ return brand === "oneway" ? ONEWAY_DEFAULT_SERVER : "";
28
+ }
29
+
30
+ /** False when the second field is not a secret (OneWay's server address): shown as plain text. */
31
+ export function credentialTokenIsSecret(brand: unknown): boolean {
32
+ return brand !== "oneway";
33
+ }
34
+
35
+ /** Brands whose one company credential lists its robots, so Add robot picks the serial (Find robots). */
36
+ export function credentialDiscoverable(brand: unknown): boolean {
37
+ return brand === "ounitech" || brand === "padbot" || brand === "oneway";
38
+ }
39
+
20
40
  /** "Enter the AppID and the Secret ..." - `credentialPatch`'s refusal, in the brand's words. */
21
41
  export function credentialMissingMessage(labels: { key: string; token: string }, editing: boolean): string {
22
42
  return editing
@@ -1948,3 +1948,59 @@ export function nameTagSide(
1948
1948
  /** The bigger tags: robot detail's "name + status" (11px 800, 14px from the dot, 26px above) and the building map's name (11px 800). */
1949
1949
  export const DETAIL_TAG = { charW: 7.4, pad: 12, gap: 14, above: 30 };
1950
1950
  export const BUILDING_TAG = { charW: 7.4, pad: 13, gap: 6, above: 0 };
1951
+
1952
+ /* ------------------------------------------------ job options (2026-10-19) */
1953
+
1954
+ /** Ounitech start-*-cleaning modeFlag / levelFlag, and PadBot §6.2 patrolCount (0 = until stopped). */
1955
+ export const CLEAN_MODE_CHOICES = [
1956
+ { title: "Vacuum", value: 1 },
1957
+ { title: "Wash floor", value: 2 },
1958
+ { title: "Dust mop", value: 3 },
1959
+ ] as const;
1960
+ export const CLEAN_POWER_CHOICES = [
1961
+ { title: "Standard", value: 1 },
1962
+ { title: "Strong", value: 2 },
1963
+ { title: "Silent", value: 3 },
1964
+ ] as const;
1965
+ export const CLEAN_TIMES_CHOICES = [1, 2, 3, 4, 5].map((n) => ({ title: n === 1 ? "Once" : `${n} times`, value: n }));
1966
+ export const PATROL_ROUND_CHOICES = [
1967
+ ...[1, 2, 3, 5, 10].map((n) => ({ title: n === 1 ? "Once" : `${n} rounds`, value: n })),
1968
+ { title: "Until stopped", value: 0 },
1969
+ ];
1970
+
1971
+ export type TJobOptions = { modeFlag: number; subModeFlag: number; levelFlag: number; cleanTimes: number; rounds: number };
1972
+ export const JOB_OPTION_DEFAULTS: TJobOptions = { modeFlag: 1, subModeFlag: 0, levelFlag: 1, cleanTimes: 1, rounds: 1 };
1973
+
1974
+ /**
1975
+ * Which job options the confirm offers: clean options for a cleaning robot whose
1976
+ * brand declares `cleanOptions`, rounds for a security robot whose brand declares
1977
+ * `patrolRounds` - only on a start (full / area), never on any other command.
1978
+ */
1979
+ export function jobOptionsOffered(kind: unknown, type: unknown, capabilities: Record<string, unknown> | null | undefined) {
1980
+ const start = kind === "full" || kind === "area";
1981
+ return {
1982
+ clean: start && type !== "security" && capabilities?.cleanOptions === true,
1983
+ rounds: start && type === "security" && capabilities?.patrolRounds === true,
1984
+ };
1985
+ }
1986
+
1987
+ /** The command body with the picked options (only the offered ones; vacuum-while-washing only for wash). */
1988
+ export function withJobOptions(body: Record<string, unknown>, offered: { clean: boolean; rounds: boolean }, o: TJobOptions) {
1989
+ if (offered.clean) {
1990
+ return { ...body, modeFlag: o.modeFlag, subModeFlag: o.modeFlag === 2 ? o.subModeFlag : 0, levelFlag: o.levelFlag, cleanTimes: o.cleanTimes };
1991
+ }
1992
+ if (offered.rounds) return { ...body, cleanTimes: o.rounds };
1993
+ return body;
1994
+ }
1995
+
1996
+ /** One line under the confirm saying what the options mean for this job. */
1997
+ export function jobOptionsLine(offered: { clean: boolean; rounds: boolean }, o: TJobOptions): string {
1998
+ if (offered.clean) {
1999
+ const mode = CLEAN_MODE_CHOICES.find((c) => c.value === o.modeFlag)?.title ?? "Vacuum";
2000
+ const power = CLEAN_POWER_CHOICES.find((c) => c.value === o.levelFlag)?.title ?? "Standard";
2001
+ const wet = o.modeFlag === 2 ? (o.subModeFlag === 1 ? ", vacuuming while it washes" : "") + ". Wash mode needs water in the clean-water tank" : "";
2002
+ return `${mode}, ${power.toLowerCase()} power, ${o.cleanTimes === 1 ? "once" : `${o.cleanTimes} times`}${wet}.`;
2003
+ }
2004
+ if (offered.rounds) return o.rounds === 0 ? "It keeps patrolling until someone presses Cancel." : `It patrols the route ${o.rounds === 1 ? "once" : `${o.rounds} times`}.`;
2005
+ return "";
2006
+ }
@@ -34,13 +34,14 @@ export type TRobotMapView = {
34
34
  taughtRoute: Array<{ x: number; y: number }>;
35
35
  };
36
36
 
37
- export type TRobotMapZone = { kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> };
37
+ export type TRobotMapZone = { kind: "wall" | "safe" | "slow" | "pass" | "track"; name: string; points: Array<{ x: number; y: number }> };
38
+ const ZONE_KINDS: readonly string[] = ["wall", "safe", "slow", "pass", "track"];
38
39
 
39
40
  const share = (p: any) => isNum(p?.x) && isNum(p?.y) && p.x >= 0 && p.x <= 100 && p.y >= 0 && p.y <= 100;
40
41
  function mapZones(list: unknown): TRobotMapZone[] {
41
42
  return (Array.isArray(list) ? list : []).flatMap((z: any) => {
42
43
  const points = (Array.isArray(z?.points) ? z.points : []).filter(share).map((p: any) => ({ x: p.x, y: p.y }));
43
- return (z?.kind === "wall" || z?.kind === "safe" || z?.kind === "slow") && points.length >= 2 ? [{ kind: z.kind, name: String(z.name ?? ""), points }] : [];
44
+ return ZONE_KINDS.includes(z?.kind) && points.length >= 2 ? [{ kind: z.kind, name: String(z.name ?? ""), points }] : [];
44
45
  });
45
46
  }
46
47
 
@@ -53,6 +54,8 @@ export function mapLayerLegend(view: Pick<TRobotMapView, "zones" | "taughtRoute"
53
54
  part(n("wall"), "virtual wall", "virtual walls", "red"),
54
55
  part(n("safe"), "safe zone", "safe zones", "green"),
55
56
  part(n("slow"), "slow zone", "slow zones", "amber"),
57
+ part(n("pass"), "passable area", "passable areas", "teal dashes"),
58
+ part(n("track"), "virtual track", "virtual tracks", "purple dashes"),
56
59
  view.taughtRoute.length >= 2 ? "taught route (blue dashes)" : "",
57
60
  ].filter(Boolean);
58
61
  return parts.length ? `Drawn on the map: ${parts.join(" · ")}` : "";
@@ -32,7 +32,20 @@ export function whenText(iso: unknown, timeZone?: string): string {
32
32
  return `${parts.day} ${MONTHS[Number(parts.month) - 1]}, ${parts.hour}:${parts.minute}`;
33
33
  }
34
34
 
35
- export type TJobRow = { key: string; when: string; area: string; job: string; duration: string; size: string; done: string; result: string; by: string };
35
+ export type TJobRow = {
36
+ key: string; when: string; area: string; job: string; duration: string; size: string; done: string; result: string; by: string;
37
+ /** The settings it ran with ("Wash floor · strong · 2 times"), "" when not reported. */
38
+ settings: string;
39
+ /** The key for its floor / area detail (`/records/job?source=`), null when the brand has none. */
40
+ sourceKey: string | null;
41
+ };
42
+
43
+ /** "Wash floor · strong · 2 times · vacuum while washing" from a job's settings (get-source-list). */
44
+ export function jobSettingsText(s: Record<string, any> | null | undefined): string {
45
+ if (!s || typeof s !== "object") return "";
46
+ const times = s.repeats === "Until stopped" ? "until stopped" : s.repeats && s.repeats !== "1" ? `${s.repeats} times` : "";
47
+ return [s.mode, s.power ? `${String(s.power).toLowerCase()} power` : "", times, s.vacuumWhileWashing ? "vacuum while washing" : ""].filter(Boolean).join(" · ");
48
+ }
36
49
 
37
50
  /** Cleaning jobs, newest first as the API sends them. */
38
51
  export function jobRows(records: { tasks?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TJobRow[] {
@@ -49,7 +62,25 @@ export function jobRows(records: { tasks?: TPart<Record<string, any>> } | null |
49
62
  size,
50
63
  done: isNum(t.percent) ? `${Math.round(t.percent)}%` : "—",
51
64
  result: t.result || "—",
52
- by: t.source || "—",
65
+ by: (t.startedBy ? `${t.startedBy}${t.scheduleId ? ` #${t.scheduleId}` : ""}` : t.source) || "—",
66
+ settings: jobSettingsText(t.settings),
67
+ sourceKey: typeof t.sourceKey === "string" && t.sourceKey ? t.sourceKey : null,
68
+ };
69
+ });
70
+ }
71
+
72
+ /** One job's floor / area runs (`/records/job`), in the order the robot did them. */
73
+ export function jobDetailRows(detail: { items?: Array<Record<string, any>> } | null | undefined, timeZone?: string) {
74
+ return (Array.isArray(detail?.items) ? detail!.items! : []).map((t, i) => {
75
+ const floor = isNum(t.floorNo) ? (t.floorNo > 0 ? `L${t.floorNo}` : t.floorNo < 0 ? `B${-t.floorNo}` : "G") : "";
76
+ return {
77
+ key: `${t.id ?? ""}-${i}`,
78
+ where: [t.map, floor, isNum(t.areaNo) ? `area ${t.areaNo}` : ""].filter(Boolean).join(" · ") || "—",
79
+ when: whenText(t.start, timeZone),
80
+ duration: durationText(t.durationSec),
81
+ size: isNum(t.areaM2) ? (isNum(t.plannedM2) && t.plannedM2 > 0 ? `${t.areaM2} of ${t.plannedM2} m²` : `${t.areaM2} m²`) : "—",
82
+ done: isNum(t.percent) ? `${Math.round(t.percent)}%` : "—",
83
+ result: t.status || t.result || "—",
53
84
  };
54
85
  });
55
86
  }