@7365admin1/layer-common 4.97.1-staging.573 → 4.97.1-staging.574

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.
@@ -56,6 +56,7 @@
56
56
  </div>
57
57
  <RobotSiteFleetCore :site="site" />
58
58
  <RobotSiteSharedSpace :site="site" editable />
59
+ <RobotSiteOps :site="site" />
59
60
  </template>
60
61
 
61
62
  <script setup lang="ts">
@@ -64,6 +65,7 @@ import RobotFleetCoreButton from "./FleetCoreButton.vue";
64
65
  import RobotSiteFloors from "./SiteFloors.vue";
65
66
  import RobotSiteFleetCore from "./SiteFleetCore.vue";
66
67
  import RobotSiteSharedSpace from "./SiteSharedSpace.vue";
68
+ import RobotSiteOps from "./SiteOps.vue";
67
69
  import useRobotBuilding from "../../composables/useRobotBuilding";
68
70
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
69
71
  import { robotSetupSteps, setupProgress, STATUS_TEXT, type TSetupStatus, type TSetupTarget } from "../../utils/robot-setup";
@@ -15,6 +15,11 @@
15
15
  <span class="hero-head__spacer" />
16
16
  <span class="hero-note">Each occurrence notified once</span>
17
17
  </div>
18
+ <!-- A security robot's alarm: the site's cameras next to the robot's own live view (open the robot). -->
19
+ <div v-if="camerasPath && hasAlarm" class="hero-notice hero-notice--panel" role="status">
20
+ A robot raised an alarm. Open the robot for its photo and live view, and
21
+ <NuxtLink :to="camerasPath">the site's camera wall</NuxtLink> for the cameras nearby.
22
+ </div>
18
23
 
19
24
  <div v-if="error && !items.length" class="hero-notice" role="alert">{{ error }}</div>
20
25
  <div v-else-if="error" class="hero-notice" role="status">Reconnecting… the alerts show their last reading.</div>
@@ -46,8 +51,10 @@ const props = withDefaults(
46
51
  backToNetwork?: boolean;
47
52
  limit?: number;
48
53
  pollMs?: number;
54
+ /** The host app's camera wall for this site (empty = no link). */
55
+ camerasPath?: string;
49
56
  }>(),
50
- { siteName: "", clientName: "", backToNetwork: false, limit: 25, pollMs: 10000 },
57
+ { siteName: "", clientName: "", backToNetwork: false, limit: 25, pollMs: 10000, camerasPath: "" },
51
58
  );
52
59
  const emit = defineEmits<{
53
60
  open: [robotId: string];
@@ -57,6 +64,7 @@ const emit = defineEmits<{
57
64
 
58
65
  const { getSiteFaults } = useRobots();
59
66
  const items = ref<any[]>([]);
67
+ const hasAlarm = computed(() => items.value.some((f) => String(f?.code ?? "").startsWith("ALARM-")));
60
68
  const total = ref(0);
61
69
  const faults = ref(0);
62
70
  const page = ref(1);
@@ -0,0 +1,83 @@
1
+ <template>
2
+ <!--
3
+ "SEND THE BEST ROBOT" (fleet operations 2.2): for people who may command robots at the site. A patrol or a clean
4
+ -> the server picks the right kind of robot that is online, free, fault-free and charged (the most charged wins)
5
+ and says why every other robot was not chosen. Sending needs the site's Dispatch switch (Seven365); without it the
6
+ choice is shown and nothing starts. Server: POST /api/robots/sites/:site/dispatch.
7
+ -->
8
+ <div v-if="canCommand" class="sdp">
9
+ <v-btn v-for="j in jobs" :key="j" size="small" variant="outlined" @click="ask(j)">Best robot for a {{ j }}</v-btn>
10
+ <v-dialog v-model="open" width="520">
11
+ <v-card>
12
+ <v-card-title class="hero-title">Best robot for a {{ job }}</v-card-title>
13
+ <v-card-text class="sdp__body">
14
+ <div v-if="busy === 'ask'" class="hero-muted">Checking the robots…</div>
15
+ <div v-else-if="answer" role="status" data-test="dispatch-line">{{ dispatchLine(answer, job) }}</div>
16
+ <ul v-if="answer?.reasons?.length" class="sdp__list">
17
+ <li v-for="r in answer.reasons" :key="r.id" class="hero-muted">{{ r.name }} {{ r.reason }}</li>
18
+ </ul>
19
+ <div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
20
+ </v-card-text>
21
+ <v-card-actions>
22
+ <v-spacer />
23
+ <v-btn variant="text" @click="open = false">Close</v-btn>
24
+ <v-btn v-if="answer?.chosen && !answer?.sent" color="primary" variant="flat" :loading="busy === 'send'" :disabled="!!busy" @click="send">
25
+ Send {{ answer.chosen.name }}
26
+ </v-btn>
27
+ </v-card-actions>
28
+ </v-card>
29
+ </v-dialog>
30
+ </div>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { computed, ref } from "vue";
35
+ import { robotErrorMessage } from "../../utils/robot-display";
36
+ import { dispatchLine } from "../../utils/robot-fleet-ops";
37
+
38
+ const props = withDefaults(defineProps<{ site: string; type?: string; canCommand?: boolean }>(), { type: "", canCommand: false });
39
+ const emit = defineEmits<{ sent: [] }>();
40
+ const { dispatchSiteRobot } = useRobots();
41
+
42
+ // The page shows one robot type at a time: offer that type's job (both when the page does not say).
43
+ const jobs = computed<Array<"patrol" | "clean">>(() => (props.type === "security" ? ["patrol"] : props.type === "cleaning" ? ["clean"] : ["patrol", "clean"]));
44
+ const open = ref(false);
45
+ const job = ref<"patrol" | "clean">("patrol");
46
+ const answer = ref<any>(null);
47
+ const busy = ref<"" | "ask" | "send">("");
48
+ const error = ref("");
49
+
50
+ async function ask(j: "patrol" | "clean") {
51
+ job.value = j;
52
+ answer.value = null;
53
+ error.value = "";
54
+ open.value = true;
55
+ busy.value = "ask";
56
+ try {
57
+ answer.value = await dispatchSiteRobot(props.site, { job: j, dryRun: true });
58
+ } catch (e) {
59
+ error.value = robotErrorMessage(e, "Could not check the robots.");
60
+ } finally {
61
+ busy.value = "";
62
+ }
63
+ }
64
+
65
+ async function send() {
66
+ error.value = "";
67
+ busy.value = "send";
68
+ try {
69
+ answer.value = await dispatchSiteRobot(props.site, { job: job.value, dryRun: false });
70
+ emit("sent");
71
+ } catch (e) {
72
+ error.value = robotErrorMessage(e, "The robot was not sent.");
73
+ } finally {
74
+ busy.value = "";
75
+ }
76
+ }
77
+ </script>
78
+
79
+ <style scoped>
80
+ .sdp { display: flex; flex-wrap: wrap; gap: 6px; }
81
+ .sdp__body { display: grid; gap: 8px; }
82
+ .sdp__list { margin: 0; padding-left: 18px; font-size: 13px; }
83
+ </style>
@@ -0,0 +1,164 @@
1
+ <template>
2
+ <!--
3
+ ROBOT EMERGENCY for one site (fleet operations 1.5, 2026-10-19). Everyone at the site sees the red line while
4
+ it is on. People who may command robots here get the Emergency button: a preview of what each robot will do,
5
+ then Start; and End. Stop needs no switch; "send every robot to its charger" only when Seven365 switched it on.
6
+ Server: GET/POST/DELETE /api/robots/sites/:site/emergency (starts audited; refused starts while on).
7
+ -->
8
+ <section class="sem" aria-label="Robot emergency">
9
+ <div v-if="banner" class="sem__banner" role="alert" data-test="emergency-banner">
10
+ <span>{{ banner }}</span>
11
+ <v-btn v-if="canCommand" size="small" variant="flat" color="white" :loading="busy === 'end'" :disabled="!!busy" @click="end">End emergency</v-btn>
12
+ </div>
13
+ <v-btn v-else-if="canCommand" size="small" color="error" variant="flat" prepend-icon="mdi-alert-octagon" data-test="emergency-open" @click="openDialog">
14
+ Emergency
15
+ </v-btn>
16
+ <div v-if="message" class="hero-muted" role="status">{{ message }}</div>
17
+
18
+ <v-dialog v-model="open" width="560">
19
+ <v-card>
20
+ <v-card-title class="hero-title">Robot emergency - this site</v-card-title>
21
+ <v-card-text class="sem__body">
22
+ <v-radio-group v-model="mode" hide-details density="compact" @update:model-value="preview">
23
+ <v-radio value="stop" label="Stop every robot where it is" />
24
+ <v-radio value="return" :disabled="!canReturn" :label="canReturn ? 'Send every robot to its charger' : 'Send every robot to its charger (switched off for this site)'" />
25
+ </v-radio-group>
26
+ <v-text-field v-model="reason" density="compact" variant="outlined" label="Reason (optional)" maxlength="200" hide-details />
27
+ <div class="hero-kicker">{{ result ? "What happened" : "What will happen" }}</div>
28
+ <div v-if="busy === 'preview'" class="hero-muted">Checking the robots…</div>
29
+ <ul class="sem__list">
30
+ <li v-for="(l, i) in lines" :key="i" :class="`hero-tone--${TONE[l.tone]}`">{{ l.text }}</li>
31
+ </ul>
32
+ <p class="hero-muted sem__note">
33
+ While emergency mode is on no robot at this site may start - from iService365, HERO Fleet Core or a schedule
34
+ (a robot that starts by itself is stopped again). Stop, return and the robot's own emergency stop always work.
35
+ A robot iService365 cannot stop must be stopped by hand.
36
+ </p>
37
+ <div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
38
+ </v-card-text>
39
+ <v-card-actions>
40
+ <v-spacer />
41
+ <v-btn variant="text" @click="open = false">{{ result ? "Close" : "Cancel" }}</v-btn>
42
+ <v-btn v-if="!result" color="error" variant="flat" :loading="busy === 'start'" :disabled="!!busy" data-test="emergency-start" @click="start">
43
+ Start emergency
44
+ </v-btn>
45
+ </v-card-actions>
46
+ </v-card>
47
+ </v-dialog>
48
+ </section>
49
+ </template>
50
+
51
+ <script setup lang="ts">
52
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
53
+ import { robotErrorMessage } from "../../utils/robot-display";
54
+ import { emergencyBanner, emergencyStepLine, type TSiteEmergency } from "../../utils/robot-fleet-ops";
55
+
56
+ const props = withDefaults(defineProps<{ site: string; canCommand?: boolean }>(), { canCommand: false });
57
+ const { getSiteEmergency, startSiteEmergency, endSiteEmergency } = useRobots();
58
+ const TONE = { ok: "success", warn: "warning", neutral: "neutral" } as const;
59
+
60
+ const emergency = ref<TSiteEmergency>(null);
61
+ const canReturn = ref(false);
62
+ const open = ref(false);
63
+ const mode = ref<"stop" | "return">("stop");
64
+ const reason = ref("");
65
+ const steps = ref<any[]>([]);
66
+ const result = ref(false);
67
+ const busy = ref<"" | "preview" | "start" | "end">("");
68
+ const error = ref("");
69
+ const message = ref("");
70
+ const now = ref(Date.now());
71
+
72
+ const banner = computed(() => emergencyBanner(emergency.value, now.value));
73
+ const lines = computed(() => steps.value.map((s) => emergencyStepLine(s, result.value)));
74
+
75
+ async function load() {
76
+ now.value = Date.now();
77
+ try {
78
+ const res = await getSiteEmergency(props.site);
79
+ emergency.value = res.emergency;
80
+ canReturn.value = res.canReturn === true;
81
+ } catch {
82
+ // An older server has no emergency route: show nothing rather than a wrong state.
83
+ }
84
+ }
85
+
86
+ async function preview() {
87
+ error.value = "";
88
+ result.value = false;
89
+ busy.value = "preview";
90
+ try {
91
+ steps.value = (await startSiteEmergency(props.site, { mode: mode.value, dryRun: true })).robots ?? [];
92
+ } catch (e) {
93
+ steps.value = [];
94
+ error.value = robotErrorMessage(e, "Could not check the robots.");
95
+ } finally {
96
+ busy.value = "";
97
+ }
98
+ }
99
+
100
+ function openDialog() {
101
+ mode.value = "stop";
102
+ reason.value = message.value = "";
103
+ open.value = true;
104
+ preview();
105
+ }
106
+
107
+ async function start() {
108
+ error.value = "";
109
+ busy.value = "start";
110
+ try {
111
+ const res = await startSiteEmergency(props.site, { mode: mode.value, reason: reason.value.trim() });
112
+ steps.value = res.robots ?? [];
113
+ result.value = true;
114
+ await load();
115
+ } catch (e) {
116
+ error.value = robotErrorMessage(e, "Could not start emergency mode.");
117
+ } finally {
118
+ busy.value = "";
119
+ }
120
+ }
121
+
122
+ async function end() {
123
+ busy.value = "end";
124
+ message.value = "";
125
+ try {
126
+ await endSiteEmergency(props.site);
127
+ message.value = "Emergency mode ended - robots may be started again.";
128
+ await load();
129
+ } catch (e) {
130
+ message.value = robotErrorMessage(e, "Could not end emergency mode.");
131
+ } finally {
132
+ busy.value = "";
133
+ }
134
+ }
135
+
136
+ let timer: ReturnType<typeof setInterval> | undefined;
137
+ onMounted(() => {
138
+ timer = setInterval(load, 10_000);
139
+ load();
140
+ });
141
+ onBeforeUnmount(() => clearInterval(timer));
142
+ watch(() => props.site, load);
143
+ </script>
144
+
145
+ <style scoped>
146
+ .sem { margin: 8px 0; display: grid; gap: 6px; justify-items: start; }
147
+ .sem__banner {
148
+ width: 100%;
149
+ display: flex;
150
+ flex-wrap: wrap;
151
+ gap: 8px;
152
+ align-items: center;
153
+ justify-content: space-between;
154
+ padding: 8px 12px;
155
+ border-radius: 6px;
156
+ background: rgb(var(--v-theme-error));
157
+ color: rgb(var(--v-theme-on-error));
158
+ font-weight: 600;
159
+ overflow-wrap: anywhere;
160
+ }
161
+ .sem__body { display: grid; gap: 10px; }
162
+ .sem__list { margin: 0; padding-left: 18px; font-size: 13px; }
163
+ .sem__note { font-size: 12px; margin: 0; }
164
+ </style>
@@ -16,6 +16,10 @@
16
16
 
17
17
  <RobotSummaryStrip :key="`${site}:${type}`" class="hero-summary--six" :cells="cells" label="Site summary" />
18
18
  <RobotSiteSharedSpace :key="`ss:${site}`" :site="site" />
19
+ <div class="hero-fleetops">
20
+ <RobotSiteEmergency :key="`sos:${site}`" :site="site" :can-command="canCommand" />
21
+ <RobotSiteDispatch :site="site" :type="type" :can-command="canCommand" />
22
+ </div>
19
23
 
20
24
  <div class="hero-split">
21
25
  <div class="hero-split__main hero-split__main--fleet">
@@ -81,12 +85,16 @@
81
85
  />
82
86
  </aside>
83
87
  </div>
88
+ <RobotSiteKpis :key="`kpi:${site}`" :site="site" />
84
89
  </template>
85
90
 
86
91
  <script setup lang="ts">
87
92
  import { computed, onBeforeUnmount, ref, watch } from "vue";
88
93
  import RobotSummaryStrip from "./SummaryStrip.vue";
89
94
  import RobotSiteSharedSpace from "./SiteSharedSpace.vue";
95
+ import RobotSiteEmergency from "./SiteEmergency.vue";
96
+ import RobotSiteDispatch from "./SiteDispatch.vue";
97
+ import RobotSiteKpis from "./SiteKpis.vue";
90
98
  import RobotTable from "./Table.vue";
91
99
  import RobotStatusChip from "./StatusChip.vue";
92
100
  import RobotBattery from "./Battery.vue";
@@ -120,11 +128,13 @@ const props = withDefaults(
120
128
  canSetup?: boolean;
121
129
  /** Show "‹ Network" (Super Admin drilled in). */
122
130
  backToNetwork?: boolean;
131
+ /** May command robots here: the Emergency and "Best robot" buttons (the server judges again at the site). */
132
+ canCommand?: boolean;
123
133
  /** Bumped by the parent to reload (after add / edit / remove). */
124
134
  reloadKey?: number;
125
135
  limit?: number;
126
136
  }>(),
127
- { siteName: "", clientName: "", type: "cleaning", canSetup: false, backToNetwork: false, reloadKey: 0, limit: 25 },
137
+ { siteName: "", clientName: "", type: "cleaning", canSetup: false, backToNetwork: false, canCommand: false, reloadKey: 0, limit: 25 },
128
138
  );
129
139
  const emit = defineEmits<{
130
140
  open: [robot: any];
@@ -309,3 +319,7 @@ watch(
309
319
  { immediate: true },
310
320
  );
311
321
  </script>
322
+
323
+ <style scoped>
324
+ .hero-fleetops { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; }
325
+ </style>
@@ -0,0 +1,117 @@
1
+ <template>
2
+ <!--
3
+ SITE ROBOT PERFORMANCE (fleet operations 3.1 / 3.3): availability, faults, work hours and utilisation, m² cleaned,
4
+ patrol rounds - per robot and for the site - from the robots' own records, plus a CSV report for the client.
5
+ A dash = no record (never shown as 0). Server: GET /api/robots/sites/:site/kpis?days=&format=csv.
6
+ -->
7
+ <details class="skp" @toggle="onToggle">
8
+ <summary class="hero-kicker skp__sum">Performance &amp; report</summary>
9
+ <div class="skp__bar">
10
+ <v-btn-toggle v-model="days" density="compact" mandatory variant="outlined" @update:model-value="load">
11
+ <v-btn :value="1" size="small">1 day</v-btn>
12
+ <v-btn :value="7" size="small">7 days</v-btn>
13
+ <v-btn :value="30" size="small">30 days</v-btn>
14
+ </v-btn-toggle>
15
+ <v-btn size="small" variant="outlined" prepend-icon="mdi-download" :loading="downloading" @click="download">CSV report</v-btn>
16
+ </div>
17
+ <div v-if="loading" class="hero-muted">Reading the robots' records…</div>
18
+ <div v-else-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
19
+ <template v-else-if="data">
20
+ <div class="skp__cells">
21
+ <div><b>{{ kpi(data.site.availabilityPct, "%") }}</b><span>Availability</span></div>
22
+ <div><b>{{ kpi(data.site.utilisationPct, "%") }}</b><span>Utilisation</span></div>
23
+ <div><b>{{ kpi(data.site.faults) }}</b><span>Machine faults</span></div>
24
+ <div><b>{{ kpi(data.site.alarms) }}</b><span>Alarms</span></div>
25
+ <div><b>{{ kpi(data.site.m2Cleaned, " m²") }}</b><span>Cleaned</span></div>
26
+ <div><b>{{ kpi(data.site.patrolRounds) }}</b><span>Patrol rounds</span></div>
27
+ </div>
28
+ <div class="skp__scroll">
29
+ <table class="skp__table">
30
+ <thead>
31
+ <tr><th>Robot</th><th>Availability</th><th>Faults / 100 h</th><th>Work h</th><th>Utilisation</th><th>m² · jobs</th><th>Patrols done</th></tr>
32
+ </thead>
33
+ <tbody>
34
+ <tr v-for="r in data.robots" :key="r.id">
35
+ <td>{{ r.name }}</td>
36
+ <td>{{ kpi(r.availabilityPct, "%") }}</td>
37
+ <td>{{ kpi(r.faultsPer100h) }}</td>
38
+ <td>{{ kpi(r.workHours) }}</td>
39
+ <td>{{ kpi(r.utilisationPct, "%") }}</td>
40
+ <td>{{ kpi(r.m2Cleaned) }} · {{ kpi(r.jobs) }}</td>
41
+ <td>{{ r.patrolRounds === null ? "-" : `${r.patrolsCompleted}/${r.patrolRounds}` }}</td>
42
+ </tr>
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ <p v-for="(n, i) in data.notes" :key="i" class="hero-muted skp__note">{{ n }}</p>
47
+ </template>
48
+ </details>
49
+ </template>
50
+
51
+ <script setup lang="ts">
52
+ import { ref, watch } from "vue";
53
+ import { robotErrorMessage } from "../../utils/robot-display";
54
+ import { kpi } from "../../utils/robot-fleet-ops";
55
+
56
+ const props = defineProps<{ site: string }>();
57
+ const { getSiteKpis, getSiteKpisCsv } = useRobots();
58
+
59
+ const days = ref(7);
60
+ const data = ref<any>(null);
61
+ const loading = ref(false);
62
+ const downloading = ref(false);
63
+ const error = ref("");
64
+ const shown = ref(false);
65
+
66
+ async function load() {
67
+ if (!shown.value) return;
68
+ loading.value = true;
69
+ error.value = "";
70
+ try {
71
+ data.value = await getSiteKpis(props.site, days.value);
72
+ } catch (e) {
73
+ error.value = robotErrorMessage(e, "Could not read the robots' records.");
74
+ } finally {
75
+ loading.value = false;
76
+ }
77
+ }
78
+
79
+ // Read only when opened: the figures come from each robot's own cloud records.
80
+ function onToggle(e: Event) {
81
+ shown.value = (e.target as HTMLDetailsElement).open;
82
+ if (shown.value && !data.value) load();
83
+ }
84
+
85
+ async function download() {
86
+ downloading.value = true;
87
+ try {
88
+ const blob = await getSiteKpisCsv(props.site, days.value);
89
+ const url = URL.createObjectURL(blob);
90
+ const a = document.createElement("a");
91
+ a.href = url;
92
+ a.download = `robot-report-${days.value}d.csv`;
93
+ a.click();
94
+ URL.revokeObjectURL(url);
95
+ } catch (e) {
96
+ error.value = robotErrorMessage(e, "Could not make the report.");
97
+ } finally {
98
+ downloading.value = false;
99
+ }
100
+ }
101
+
102
+ watch(() => props.site, () => { data.value = null; load(); });
103
+ </script>
104
+
105
+ <style scoped>
106
+ .skp { margin: 12px 0; padding: 8px 12px; background: var(--hero-surface); box-shadow: var(--hero-shadow-sm); }
107
+ .skp__sum { cursor: pointer; }
108
+ .skp__bar { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
109
+ .skp__cells { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; margin-bottom: 8px; }
110
+ .skp__cells div { display: grid; }
111
+ .skp__cells b { font-size: 18px; }
112
+ .skp__cells span { font-size: 12px; opacity: 0.75; }
113
+ .skp__scroll { overflow-x: auto; }
114
+ .skp__table { width: 100%; border-collapse: collapse; font-size: 13px; }
115
+ .skp__table th, .skp__table td { text-align: left; padding: 4px 6px; border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); white-space: nowrap; }
116
+ .skp__note { font-size: 12px; margin: 4px 0 0; }
117
+ </style>
@@ -0,0 +1,117 @@
1
+ <template>
2
+ <!--
3
+ FLEET OPERATIONS FOR THIS SITE (Seven365 Super Admin, Setup; market benchmark 2026-10-19). The five switches -
4
+ all OFF by default = today's behaviour; the ones that drive a robot say so - and the site timetable: every
5
+ robot's own schedule side by side, with the times two robots would move together.
6
+ Server: GET/PUT /api/robots/sites/:site/ops, GET /api/robots/sites/:site/timetable (audited saves).
7
+ -->
8
+ <section class="sop" aria-labelledby="sop-title">
9
+ <h3 id="sop-title" class="hero-title sop__title">Fleet operations</h3>
10
+ <div v-if="loadError" class="hero-notice hero-notice--error" role="alert">{{ loadError }}</div>
11
+ <template v-else-if="loaded">
12
+ <div v-for="s in FLEET_OPS_SWITCHES" :key="s.key" class="sop__row">
13
+ <v-switch v-model="ops[s.key]" color="primary" density="compact" hide-details inset :label="s.label" />
14
+ <p class="hero-muted sop__help">
15
+ <b v-if="s.moves">Drives a robot - switch on only after the on-site test.</b>
16
+ {{ s.help }}
17
+ </p>
18
+ </div>
19
+ <div class="sop__act">
20
+ <v-btn size="small" color="primary" variant="flat" :loading="saving" :disabled="saving || !changed" @click="save">Save</v-btn>
21
+ </div>
22
+ <div v-if="message" class="hero-muted" role="status">{{ message }}</div>
23
+ <div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
24
+
25
+ <div class="hero-kicker sop__tt">Site timetable (the robots' own schedules)</div>
26
+ <v-btn size="small" variant="outlined" :loading="ttBusy" @click="loadTimetable">{{ timetable ? "Read again" : "Read the schedules" }}</v-btn>
27
+ <template v-if="timetable">
28
+ <div v-for="(o, i) in timetable.overlaps" :key="`o${i}`" class="hero-notice" role="alert">{{ o.message }}</div>
29
+ <div v-if="!timetable.overlaps.length && timetable.slots.length" class="hero-muted">No two robots are scheduled at the same time.</div>
30
+ <ul class="sop__list">
31
+ <li v-for="(s, i) in timetable.slots" :key="i">{{ slotLine(s) }}</li>
32
+ </ul>
33
+ <div v-if="!timetable.slots.length" class="hero-muted">No schedule found (cleaning schedules in the robot's cloud, patrols run at the same time on two or more days).</div>
34
+ <div v-for="u in timetable.unread" :key="u.name" class="hero-muted">{{ u.name }}: could not be read ({{ u.reason }})</div>
35
+ <div class="hero-muted sop__help">Times in {{ timetable.timezone }}. A schedule started by the robot's own cloud is not refused by iService365 - the guard alerts (and, when switched on above, stops the security robot) if two robots move at once.</div>
36
+ </template>
37
+ <div v-if="ttError" class="hero-notice hero-notice--error" role="alert">{{ ttError }}</div>
38
+ </template>
39
+ <div v-else class="hero-muted">Checking…</div>
40
+ </section>
41
+ </template>
42
+
43
+ <script setup lang="ts">
44
+ import { computed, reactive, ref, watch } from "vue";
45
+ import { robotErrorMessage } from "../../utils/robot-display";
46
+ import { FLEET_OPS_SWITCHES, slotLine } from "../../utils/robot-fleet-ops";
47
+
48
+ const props = defineProps<{ site: string }>();
49
+ const { getSiteOps, saveSiteOps, getSiteTimetable } = useRobots();
50
+
51
+ const loaded = ref(false);
52
+ const loadError = ref("");
53
+ const version = ref(0);
54
+ const ops = reactive<Record<string, boolean>>({});
55
+ const saved = ref<Record<string, boolean>>({});
56
+ const saving = ref(false);
57
+ const message = ref("");
58
+ const error = ref("");
59
+ const timetable = ref<any>(null);
60
+ const ttBusy = ref(false);
61
+ const ttError = ref("");
62
+
63
+ const changed = computed(() => FLEET_OPS_SWITCHES.some((s) => !!ops[s.key] !== !!saved.value[s.key]));
64
+
65
+ async function load() {
66
+ loadError.value = "";
67
+ try {
68
+ const res = await getSiteOps(props.site);
69
+ version.value = res.version;
70
+ saved.value = { ...res.ops };
71
+ for (const s of FLEET_OPS_SWITCHES) ops[s.key] = res.ops?.[s.key] === true;
72
+ loaded.value = true;
73
+ } catch (e) {
74
+ loadError.value = robotErrorMessage(e, "Could not read the fleet operations settings.");
75
+ }
76
+ }
77
+
78
+ async function save() {
79
+ error.value = message.value = "";
80
+ saving.value = true;
81
+ try {
82
+ const body: Record<string, boolean | number> = { version: version.value };
83
+ for (const s of FLEET_OPS_SWITCHES) body[s.key] = !!ops[s.key];
84
+ await saveSiteOps(props.site, body);
85
+ message.value = "Saved.";
86
+ await load();
87
+ } catch (e) {
88
+ error.value = robotErrorMessage(e, "Could not save.");
89
+ } finally {
90
+ saving.value = false;
91
+ }
92
+ }
93
+
94
+ async function loadTimetable() {
95
+ ttError.value = "";
96
+ ttBusy.value = true;
97
+ try {
98
+ timetable.value = await getSiteTimetable(props.site);
99
+ } catch (e) {
100
+ ttError.value = robotErrorMessage(e, "Could not read the schedules.");
101
+ } finally {
102
+ ttBusy.value = false;
103
+ }
104
+ }
105
+
106
+ watch(() => props.site, () => { loaded.value = false; timetable.value = null; load(); }, { immediate: true });
107
+ </script>
108
+
109
+ <style scoped>
110
+ .sop { margin-top: 16px; padding: 12px; background: var(--hero-surface); box-shadow: var(--hero-shadow-sm); display: grid; gap: 6px; }
111
+ .sop__title { font-size: 16px; }
112
+ .sop__row { display: grid; gap: 2px; }
113
+ .sop__help { font-size: 12px; margin: 0 0 4px; }
114
+ .sop__act { display: flex; gap: 6px; }
115
+ .sop__tt { margin-top: 10px; }
116
+ .sop__list { margin: 0; padding-left: 18px; font-size: 13px; }
117
+ </style>
@@ -81,6 +81,7 @@
81
81
  v-else-if="view === 'alerts'"
82
82
  :key="`alerts:${site}`"
83
83
  :site="site"
84
+ :cameras-path="camerasPath"
84
85
  :site-name="siteName || names.siteName"
85
86
  :client-name="clientName || names.clientName"
86
87
  :back-to-network="backToNetwork"
@@ -105,6 +106,7 @@
105
106
  :client-name="clientName"
106
107
  :type="type"
107
108
  :can-setup="canSetup && superAdmin"
109
+ :can-command="canCommand"
108
110
  :back-to-network="backToNetwork"
109
111
  :reload-key="reloadKey"
110
112
  @open="(r) => emit('open', String(r._id))"
@@ -171,6 +173,8 @@ const props = withDefaults(
171
173
  superAdmin?: boolean;
172
174
  hideHeader?: boolean;
173
175
  backToNetwork?: boolean;
176
+ /** The host app's camera wall for this site: an alarm on the Alerts page links to it (fleet operations 2.6). */
177
+ camerasPath?: string;
174
178
  }>(),
175
179
  {
176
180
  siteName: "",
@@ -187,6 +191,7 @@ const props = withDefaults(
187
191
  canDelete: false,
188
192
  superAdmin: false,
189
193
  hideHeader: false,
194
+ camerasPath: "",
190
195
  backToNetwork: false,
191
196
  },
192
197
  );
@@ -669,6 +669,42 @@ export default function useRobots() {
669
669
  return useNuxtApp().$api<{ key: string; keyVersion: number }>(`${sitePath(site)}/fleet-core/relay-key`, { method: "POST", query: { appSlug } });
670
670
  }
671
671
 
672
+ /*
673
+ * Fleet operations (market benchmark, 2026-10-19): the site's switches and
674
+ * timetable (Super Admin), its emergency (status: anyone at the site; start /
675
+ * end: robot command right at the site), best-robot dispatch and KPIs.
676
+ */
677
+ function getSiteOps(site: string) {
678
+ return useNuxtApp().$api<{ version: number; ops: Record<string, boolean>; emergency: any }>(`${sitePath(site)}/ops`, { query: { appSlug } });
679
+ }
680
+ function saveSiteOps(site: string, body: Record<string, boolean | number>) {
681
+ return useNuxtApp().$api<{ version: number }>(`${sitePath(site)}/ops`, { method: "PUT", query: { appSlug }, body });
682
+ }
683
+ function getSiteTimetable(site: string) {
684
+ return useNuxtApp().$api<Record<string, any>>(`${sitePath(site)}/timetable`, { query: { appSlug } });
685
+ }
686
+ function getSiteEmergency(site: string) {
687
+ return useNuxtApp().$api<{ emergency: { active: boolean; mode: string; reason: string; at: string } | null; canReturn: boolean }>(
688
+ `${sitePath(site)}/emergency`,
689
+ { query: { appSlug } },
690
+ );
691
+ }
692
+ function startSiteEmergency(site: string, body: { mode: "stop" | "return"; reason?: string; dryRun?: boolean }) {
693
+ return useNuxtApp().$api<Record<string, any>>(`${sitePath(site)}/emergency`, { method: "POST", query: { appSlug }, body });
694
+ }
695
+ function endSiteEmergency(site: string) {
696
+ return useNuxtApp().$api<Record<string, any>>(`${sitePath(site)}/emergency`, { method: "DELETE", query: { appSlug } });
697
+ }
698
+ function dispatchSiteRobot(site: string, body: { job: "patrol" | "clean"; dryRun: boolean }) {
699
+ return useNuxtApp().$api<Record<string, any>>(`${sitePath(site)}/dispatch`, { method: "POST", query: { appSlug }, body });
700
+ }
701
+ function getSiteKpis(site: string, days: number) {
702
+ return useNuxtApp().$api<Record<string, any>>(`${sitePath(site)}/kpis`, { query: { appSlug, days } });
703
+ }
704
+ function getSiteKpisCsv(site: string, days: number) {
705
+ return useNuxtApp().$api<Blob>(`${sitePath(site)}/kpis`, { query: { appSlug, days, format: "csv" }, responseType: "blob" } as any);
706
+ }
707
+
672
708
  return {
673
709
  getSiteFloors,
674
710
  saveSiteFloors,
@@ -677,6 +713,15 @@ export default function useRobots() {
677
713
  getSiteFleetCore,
678
714
  saveSiteFleetCore,
679
715
  createSiteRelayKey,
716
+ getSiteOps,
717
+ saveSiteOps,
718
+ getSiteTimetable,
719
+ getSiteEmergency,
720
+ startSiteEmergency,
721
+ endSiteEmergency,
722
+ dispatchSiteRobot,
723
+ getSiteKpis,
724
+ getSiteKpisCsv,
680
725
  emergencyStopRobot,
681
726
  releaseEmergencyStopRobot,
682
727
  getRobotSetup,
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.573",
5
+ "version": "4.97.1-staging.574",
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.",
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Robots - fleet operations (core `/api/robots/sites/:site/{ops,emergency,dispatch,kpis,timetable}`,
3
+ * market benchmark 2026-10-19). The words the screens show, as pure functions.
4
+ */
5
+
6
+ /** The site switches (Super Admin, Setup). Every one is OFF by default = today's behaviour. */
7
+ export const FLEET_OPS_SWITCHES = [
8
+ {
9
+ key: "vendorStartHold",
10
+ label: "Stop a robot started outside iService365",
11
+ help: "When two robots move at once (one started from its own app, schedule or button), stop the security robot where it is. Staff and the site are always told, switch on or off.",
12
+ moves: false,
13
+ },
14
+ {
15
+ key: "emergencyReturn",
16
+ label: "Emergency may send every robot to its charger",
17
+ help: "Adds \"Send every robot to its charger\" to the site's Emergency button. This DRIVES robots - switch on only after the on-site test. Emergency stop never needs a switch.",
18
+ moves: true,
19
+ },
20
+ {
21
+ key: "alarmIncidents",
22
+ label: "Robot alarms open an incident report",
23
+ help: "A security robot's alarm (smoke, intrusion, door open) opens one pending incident report for the guards to check and complete.",
24
+ moves: false,
25
+ },
26
+ {
27
+ key: "dispatch",
28
+ label: "\"Send the best robot\" may start the robot",
29
+ help: "Without it the site sees which robot would be chosen and why, but nothing is started.",
30
+ moves: true,
31
+ },
32
+ {
33
+ key: "lowBatteryReturn",
34
+ label: "Send a low-battery robot to its charger",
35
+ help: "An idle robot at or below the low-battery mark is driven to its charger (never during an emergency). The alert is sent either way.",
36
+ moves: true,
37
+ },
38
+ ] as const;
39
+
40
+ export type TFleetOpsKey = (typeof FLEET_OPS_SWITCHES)[number]["key"];
41
+
42
+ export type TSiteEmergency = { active: boolean; mode: string; reason?: string; at?: string } | null | undefined;
43
+
44
+ /** The red line at the top of the site page while an emergency is on; null when there is none. */
45
+ export function emergencyBanner(e: TSiteEmergency, now: number = Date.now()): string | null {
46
+ if (!e?.active) return null;
47
+ const what = e.mode === "return" ? "every robot was sent to its charger" : "every robot was stopped";
48
+ const t = e.at ? Date.parse(e.at) : NaN;
49
+ const ago = Number.isFinite(t) ? ` ${Math.max(0, Math.round((now - t) / 60_000))} min ago` : "";
50
+ return `EMERGENCY MODE${e.reason ? ` - ${e.reason}` : ""}: ${what}${ago}. No robot may start until it is ended.`;
51
+ }
52
+
53
+ export type TEmergencyStep = { name: string; kind?: string | null; sent?: boolean; note?: string; error?: string };
54
+
55
+ /** One robot's line in the preview / result list. */
56
+ export function emergencyStepLine(s: TEmergencyStep, done: boolean): { text: string; tone: "ok" | "warn" | "neutral" } {
57
+ const needsPerson = (s.kind && done && !s.sent) || (!s.kind && /cannot be stopped|offline|refused/.test(s.note ?? ""));
58
+ const verb = !done && s.kind ? { cancel: "will be stopped", pause: "will be paused", return: "will be sent to its charger" }[s.kind] : "";
59
+ const text = `${s.name}: ${verb || s.note || ""}${s.error ? ` (${s.error})` : ""}`;
60
+ return { text, tone: needsPerson ? "warn" : s.kind && (s.sent || !done) ? "ok" : "neutral" };
61
+ }
62
+
63
+ /** A KPI figure: blank stays a dash, never 0 (no record is not "zero"). */
64
+ export function kpi(v: unknown, unit = ""): string {
65
+ return typeof v === "number" && Number.isFinite(v) ? `${v}${unit}` : "-";
66
+ }
67
+
68
+ /** The dispatch answer in one sentence. */
69
+ export function dispatchLine(r: { chosen?: { name: string; battery: number } | null; sent?: boolean } | null | undefined, job: "patrol" | "clean"): string {
70
+ if (!r) return "";
71
+ if (!r.chosen) return `No robot can take a ${job} now - see the reasons below.`;
72
+ return r.sent ? `${r.chosen.name} was sent on a ${job}.` : `${r.chosen.name} would be sent (battery ${r.chosen.battery}%).`;
73
+ }
74
+
75
+ /** Timetable slot in words: "Mon, Wed 10:00-11:30 · Q3 · Cleaning schedule in the Ounitech cloud". */
76
+ const DAYS = ["", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
77
+ export function slotLine(s: { robotName: string; days: number[]; start: string; end: string; source: string; enabled?: boolean }): string {
78
+ const days = s.days.length === 7 ? "Every day" : s.days.map((d) => DAYS[d] ?? String(d)).join(", ");
79
+ return `${days} ${s.start}-${s.end} · ${s.robotName} · ${s.source}${s.enabled === false ? " (switched off)" : ""}`;
80
+ }