@7365admin1/layer-common 4.44.0 → 4.46.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,18 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.46.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4cf067f: Robot building map editor: floor plans per floor (upload / replace / remove, stored privately in the map; Set scale by measuring a known length; Fit), HERO Fleet Core import into the saved map, and downloads of the building file and route maps. The 3D building map draws saved floor plans. Edit map is also offered at HERO Fleet Core sites (it edits the site's saved copy). The Add robot note says "stored encrypted" only when the server reports it.
8
+
9
+ ## 4.45.0
10
+
11
+ ### Minor Changes
12
+
13
+ - c6a8606: Invite member: from inside a site the Site field is fixed to that site (read-only), and the Role list shows only the organisation's own active roles for the invited app and that site, each labelled "Site role" or "Organisation role". Org-less template roles are no longer offered on an invitation into an organisation.
14
+ - 1c0aea4: Settings → Site Information is read-only on a site the organisation serves but does not own: a note says "This site belongs to <Client>. Only they can change its details.", cover photo, description and documents are inert and Save is hidden. Blocks and guard posts stay editable. Driven by `detailsEditable`/`ownerOrgName` on the site read (core); absent on an older API = unchanged.
15
+
3
16
  ## 4.44.0
4
17
 
5
18
  ### Minor Changes
@@ -1502,11 +1515,11 @@
1502
1515
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1503
1516
  own rule from two endpoints the console already calls, unprojected:
1504
1517
 
1505
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1506
- GET /api/roles/id/:role that membership's role document
1518
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1519
+ GET /api/roles/id/:role that membership's role document
1507
1520
 
1508
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1509
- staff = member.type === "admin" && role.type === "admin"
1521
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1522
+ staff = member.type === "admin" && role.type === "admin"
1510
1523
 
1511
1524
  `role.default` is the marker because it is the only property of a platform
1512
1525
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -1043,6 +1043,12 @@
1043
1043
  }
1044
1044
  .hero-editor__plan:focus-visible { outline: 2px solid var(--hero-accent); outline-offset: 0; }
1045
1045
  .hero-editor__room { position: absolute; border: 2px solid var(--hero-text); opacity: 0.4; pointer-events: none; }
1046
+ /* Floor plan image (stored privately in the map) under everything drawn; scale by measurement. */
1047
+ .hero-editor__planimg, .hero-bmap__planimg { position: absolute; max-width: none; pointer-events: none; user-select: none; opacity: 0.55; object-fit: fill; }
1048
+ .hero-editor__measure { stroke: var(--hero-accent); stroke-width: 2; stroke-dasharray: 4 3; }
1049
+ .hero-editor__mpoint { position: absolute; width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; background: var(--hero-accent); pointer-events: none; }
1050
+ .hero-editor__measurebox { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px dashed var(--hero-accent); font-size: 12px; }
1051
+ .hero-editor__measurein { width: 110px; min-height: 32px; }
1046
1052
  .hero-editor__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
1047
1053
  .hero-editor__wall { stroke: var(--hero-text); stroke-width: 1.5; }
1048
1054
  .hero-editor__zone-shape { fill: color-mix(in srgb, var(--hero-text) 10%, transparent); stroke: var(--hero-text); stroke-width: 1; stroke-dasharray: 4 3; }
@@ -46,9 +46,10 @@
46
46
  <v-col cols="12">
47
47
  <v-autocomplete
48
48
  v-model="invite.role"
49
- :items="roles"
50
- item-title="name"
49
+ :items="roleOptions"
50
+ item-title="title"
51
51
  item-value="_id"
52
+ :item-props="(item: any) => ({ subtitle: item.subtitle })"
52
53
  :rules="[requiredRule]"
53
54
  class="filter-field"
54
55
  density="comfortable"
@@ -61,7 +62,23 @@
61
62
  <v-row no-gutters>
62
63
  <InputLabel class="text-capitalize" title="Site" />
63
64
  <v-col cols="12">
65
+ <!--
66
+ Inviting from inside a site invites INTO that site. The owner
67
+ (2026-09-25): other sites in this list were confusing, so the
68
+ site is shown, not chosen.
69
+ -->
70
+ <v-text-field
71
+ v-if="fixedSite"
72
+ :model-value="invite.siteName || 'This site'"
73
+ class="filter-field"
74
+ density="comfortable"
75
+ readonly
76
+ hint="Invitations from this site are for this site."
77
+ persistent-hint
78
+ data-test="invite-fixed-site"
79
+ ></v-text-field>
64
80
  <v-autocomplete
81
+ v-else
65
82
  v-model="invite.site"
66
83
  :items="sites"
67
84
  class="filter-field"
@@ -133,6 +150,8 @@ import useCustomerSite from '../composables/useCustomerSite';
133
150
  import useLocal from '../composables/useLocal';
134
151
  import { useLocalSetup } from '../composables/useLocalSetup';
135
152
  import useRole from '../composables/useRole';
153
+ import useSite from '../composables/useSite';
154
+ import { inviteRoleOptions } from '../utils/invite-role-options';
136
155
  import useUser from '../composables/useUser';
137
156
  import useUtils from '../composables/useUtils';
138
157
 
@@ -169,6 +188,16 @@ const props = defineProps({
169
188
  * which is about the site created in step 4) had no way to say so. It is
170
189
  * wired to `invite` below.
171
190
  */
191
+ /*
192
+ * The site the invitation is being made FROM (every site-level Invitations
193
+ * page passes it through InvitationMain). When set, the Site field is fixed
194
+ * to it and the Role list is that site's roles plus the organisation-wide
195
+ * ones.
196
+ */
197
+ siteId: {
198
+ type: String,
199
+ default: "",
200
+ },
172
201
  site: {
173
202
  type: Object as PropType<TCustomerSite>,
174
203
  default: () => ({
@@ -262,6 +291,18 @@ watch(
262
291
  },
263
292
  { immediate: true, deep: true }
264
293
  )
294
+
295
+ /* The site this form is fixed to, if any: the page's site, or onboarding's. */
296
+ const fixedSite = computed(() => props.siteId || props.site?.site || "")
297
+
298
+ watch(
299
+ () => props.siteId,
300
+ (siteId) => {
301
+ if (!siteId) return
302
+ invite.value.site = siteId
303
+ },
304
+ { immediate: true }
305
+ )
265
306
  // invite.value.app = props.app;
266
307
  // invite.value.org = props.org ?? "";
267
308
 
@@ -357,6 +398,27 @@ watchEffect(() => {
357
398
  }
358
399
  });
359
400
 
401
+ /* Name the fixed site: from the organisation's site list, else read it. */
402
+ const { getSiteById } = useSite();
403
+ watch(
404
+ [fixedSite, sites],
405
+ async ([siteId]) => {
406
+ if (!siteId || invite.value.siteName) return
407
+ const listed = sites.value.find((x) => x?.value === siteId)
408
+ if (listed?.title) {
409
+ invite.value.siteName = listed.title
410
+ return
411
+ }
412
+ try {
413
+ const site: any = await getSiteById(siteId)
414
+ invite.value.siteName = site?.name || site?.data?.name || ""
415
+ } catch {
416
+ // The field still reads "This site"; the invitation carries the id.
417
+ }
418
+ },
419
+ { immediate: true }
420
+ )
421
+
360
422
  watchEffect(() => {
361
423
  if (hasSite.value) {
362
424
  refreshSiteData();
@@ -384,26 +446,47 @@ const roles = ref<Array<Record<string, any>>>([]);
384
446
  */
385
447
  const { getInvitableRoles } = useRole();
386
448
 
449
+ /*
450
+ * 2026-09-25 (owner report, Wing Tai / Winsland House I): the list now asks for
451
+ * the app actually being invited into (`invite.app`, which the App field can
452
+ * change - it used to keep asking for `props.app`) and for the chosen site, so
453
+ * it holds only this organisation's roles for this app and site. The org-less
454
+ * templates are gone from any invitation that names an organisation - the
455
+ * server refuses them there (`core roleInvitable`).
456
+ */
387
457
  const { data: RolesData, refresh: refreshRoles } = await useLazyAsyncData(
388
- "get-roles-by-type-" + props.org + "-" + props.app,
458
+ "get-roles-by-type-" + props.org + "-" + props.app + "-" + fixedSite.value,
389
459
  () =>
390
460
  getInvitableRoles({
391
461
  org: props.org,
392
- type: props.app,
393
- limit: 50,
462
+ type: invite.value.app || props.app,
463
+ site: invite.value.site,
464
+ limit: 100,
394
465
  }),
395
- { watch: [() => props.app] }
466
+ { watch: [() => props.app, () => invite.value.app, () => invite.value.site] }
396
467
  );
397
468
 
398
469
  watchEffect(() => {
399
470
  if (RolesData.value) {
400
471
  roles.value = RolesData.value.items;
472
+ // A role picked for a different site is not valid for this one.
473
+ if (
474
+ invite.value.role &&
475
+ !roles.value.some((r: any) => String(r._id) === String(invite.value.role))
476
+ ) {
477
+ invite.value.role = "";
478
+ }
401
479
  }
402
480
  });
403
481
 
482
+ const roleOptions = computed(() => inviteRoleOptions(roles.value));
483
+
404
484
  async function handleUpdateApp(value: string) {
405
485
  invite.value.role = "";
406
- invite.value.site = "";
486
+ if (!fixedSite.value) {
487
+ invite.value.site = "";
488
+ invite.value.siteName = "";
489
+ }
407
490
  await refreshRoles()
408
491
  }
409
492
 
@@ -124,6 +124,7 @@
124
124
  <InvitationForm
125
125
  title="Invite member"
126
126
  :org="org"
127
+ :site-id="props.siteId"
127
128
  :invite-member="true"
128
129
  @cancel="dialogMember = false"
129
130
  @success="handleSuccess"
@@ -18,7 +18,7 @@
18
18
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" /></svg>
19
19
  Full screen
20
20
  </v-btn>
21
- <v-btn v-if="canEditMap && !fleet" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
21
+ <v-btn v-if="canEditMap" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
22
22
  {{ editing ? "Exit edit mode" : "Edit map" }}
23
23
  </v-btn>
24
24
  </div>
@@ -26,12 +26,15 @@
26
26
  <div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
27
27
  <div v-else-if="!loading && emptyNote" class="hero-notice">{{ emptyNote }}</div>
28
28
 
29
- <div v-if="fleet" class="hero-notice" role="status">Live building from HERO Fleet Core - doors and lifts update every few seconds. It is managed by the fleet, so it cannot be edited here.</div>
29
+ <div v-if="fleet && !editing" class="hero-notice" role="status">Live building from HERO Fleet Core - doors and lifts update every few seconds. The fleet manages it; Edit map changes this site's saved copy (floor plans, import, downloads).</div>
30
+ <div v-else-if="fleet && editing" class="hero-notice" role="status">Editing this site's saved building map. The live HERO Fleet Core view is unchanged until you import.</div>
30
31
 
31
32
  <RobotMapEditor
32
- v-if="editing && canEditMap && !fleet"
33
+ v-if="editing && canEditMap"
33
34
  ref="editor"
34
- :map="map"
35
+ :map="saved"
36
+ :site="site"
37
+ :fleet-site="fleet"
35
38
  :floors="floors"
36
39
  :site-name="siteLabel"
37
40
  :owner-robot-id="ownerRobotId"
@@ -107,7 +110,8 @@
107
110
  class="hero-bmap__floor"
108
111
  :style="{ transform: `translateZ(${i * FLOOR_GAP_PX}px)`, opacity: f.dim, '--hero-fill': look.fill }"
109
112
  >
110
- <template v-if="!fleet">
113
+ <img v-if="f.plan" class="hero-bmap__planimg" :src="f.plan.url" :style="f.plan.style" alt="" aria-hidden="true" draggable="false" />
114
+ <template v-if="!fleet && !f.plan">
111
115
  <span class="hero-bmap__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
112
116
  <span class="hero-bmap__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
113
117
  <span class="hero-bmap__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
@@ -201,6 +205,8 @@ import { computed, onBeforeUnmount, ref, toRef, watch } from "vue";
201
205
  import RobotMapEditor from "./MapEditor.vue";
202
206
  import RobotReconnecting from "./Reconnecting.vue";
203
207
  import useRobotBuilding from "../../composables/useRobotBuilding";
208
+ import useFloorPlanImages from "../../composables/useFloorPlanImages";
209
+ import { floorPlanFor, planBox } from "../../utils/robot-floorplan";
204
210
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
205
211
  import { openDoorNames } from "../../utils/robot-fleet-core";
206
212
  import {
@@ -264,7 +270,7 @@ const emit = defineEmits<{
264
270
  status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
265
271
  }>();
266
272
 
267
- const { rows, map, fleet, names, loading, error, reload } = useRobotBuilding(
273
+ const { rows, map, saved, fleet, names, loading, error, reload } = useRobotBuilding(
268
274
  toRef(props, "organization"),
269
275
  toRef(props, "site"),
270
276
  toRef(props, "type"),
@@ -307,9 +313,19 @@ const floorRoutes = (floorNo: number) => {
307
313
  }
308
314
  return [...seen.values()];
309
315
  };
316
+ /** Each floor's saved plan image (kept privately in the map), drawn under the shapes. */
317
+ const { urls: planUrls } = useFloorPlanImages(saved);
318
+ function floorImage(floorNo: number) {
319
+ const p = floorPlanFor(saved.value, floorNo);
320
+ const url = planUrls.value[floorNo];
321
+ if (!p || !url) return null;
322
+ const b = planBox(p);
323
+ return { url, style: { left: `${b.left}%`, top: `${b.top}%`, width: `${b.width}%`, height: `${b.height}%` } };
324
+ }
310
325
  const floors3d = computed(() =>
311
326
  floors.value.map((floorNo) => ({
312
327
  floorNo,
328
+ plan: floorImage(floorNo),
313
329
  dim: floorOpacity(cam.value.tilt, focus.value, floorNo),
314
330
  shapes: shapes.value.filter((s) => s.kind !== "lift" && s.floorNo === floorNo),
315
331
  routes: props.type === "security" ? floorRoutes(floorNo) : [],
@@ -388,11 +404,11 @@ function fullscreen(el?: HTMLElement | null) {
388
404
  else el?.requestFullscreen?.();
389
405
  }
390
406
  const editing = ref(false);
391
- watch([() => props.canEditMap, fleet], ([can, isFleet]) => {
392
- if (!can || isFleet) editing.value = false;
407
+ watch(() => props.canEditMap, (can) => {
408
+ if (!can) editing.value = false;
393
409
  });
394
410
  /** The robot a first save is attached to (maps are stored per robot): one of this site's. */
395
- const ownerRobotId = computed(() => String(map.value?.robotId ?? rows.value[0]?._id ?? ""));
411
+ const ownerRobotId = computed(() => String(saved.value?.robotId ?? rows.value[0]?._id ?? ""));
396
412
  function onSaved() {
397
413
  reload();
398
414
  }
@@ -72,7 +72,7 @@
72
72
  />
73
73
  </div>
74
74
  </div>
75
- <div class="hero-dialog__note">{{ CREDENTIALS_NOTE }} Capability: {{ note }}</div>
75
+ <div class="hero-dialog__note">{{ credentialsNote(credentialsEncrypted) }} Capability: {{ note }}</div>
76
76
  <div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
77
77
  <div class="hero-dialog__actions">
78
78
  <v-btn variant="outlined" :disabled="saving" @click="emit('cancel')">Cancel</v-btn>
@@ -88,11 +88,11 @@ import {
88
88
  capabilityNote,
89
89
  credentialPatch,
90
90
  credentialPlaceholder,
91
- CREDENTIALS_NOTE,
92
91
  robotErrorMessage,
93
92
  typeLabel,
94
93
  type TRobotType,
95
94
  } from "../../utils/robot-display";
95
+ import { credentialsNote } from "../../utils/robot-floorplan";
96
96
 
97
97
  const props = withDefaults(
98
98
  defineProps<{
@@ -137,6 +137,8 @@ const saving = ref(false);
137
137
  const error = ref("");
138
138
 
139
139
  const brands = ref<Array<Record<string, any>>>([]);
140
+ /** The server says it stores credentials encrypted; only then does the note say so. */
141
+ const credentialsEncrypted = ref(false);
140
142
  const loadingBrands = ref(false);
141
143
  const options = computed(() => {
142
144
  const list = brandOptions(brands.value, type.value);
@@ -172,7 +174,9 @@ function onClient() {
172
174
  onMounted(async () => {
173
175
  loadingBrands.value = true;
174
176
  try {
175
- brands.value = (await getRobotBrands())?.items ?? [];
177
+ const res = await getRobotBrands();
178
+ brands.value = res?.items ?? [];
179
+ credentialsEncrypted.value = res?.credentialsEncrypted === true;
176
180
  } catch {
177
181
  brands.value = [];
178
182
  } finally {
@@ -11,6 +11,10 @@
11
11
  selects, Delete removes, Esc clears, Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z or
12
12
  Ctrl+Y redo. Every drag is one undo step. Save checks in plain words
13
13
  first, then writes the building map through the API (versioned).
14
+ Floor plan (per floor): upload / replace / remove an image kept privately
15
+ in the map, Set scale by measuring a known length, Fit. Building file:
16
+ import the HERO Fleet Core building, download the building file or a
17
+ route map.
14
18
  -->
15
19
  <div ref="root" class="hero-editor" :class="{ 'hero-editor--fs': isFs }">
16
20
  <div class="hero-editor__bar">
@@ -59,9 +63,12 @@
59
63
  @keydown="onKey"
60
64
  @blur="keyboard = false"
61
65
  >
62
- <span class="hero-editor__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
63
- <span class="hero-editor__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
64
- <span class="hero-editor__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
66
+ <img v-if="floorPlan && planUrl" class="hero-editor__planimg" :src="planUrl" :style="planImgStyle" alt="" aria-hidden="true" draggable="false" />
67
+ <template v-else-if="!floorPlan">
68
+ <span class="hero-editor__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
69
+ <span class="hero-editor__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
70
+ <span class="hero-editor__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
71
+ </template>
65
72
  <svg class="hero-editor__svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
66
73
  <template v-for="s in visible" :key="s.id">
67
74
  <polygon v-if="s.kind === 'zone'" class="hero-editor__zone-shape" :class="{ 'hero-editor__sel': isSel(s.id) }" :points="pts(s.points)" vector-effect="non-scaling-stroke" />
@@ -72,7 +79,9 @@
72
79
  </template>
73
80
  </template>
74
81
  <line v-if="rotateLine" class="hero-editor__rotline" :x1="rotateLine.x1" :y1="rotateLine.y1" :x2="rotateLine.x2" :y2="rotateLine.y2" vector-effect="non-scaling-stroke" />
82
+ <line v-if="measure.a && measure.b" class="hero-editor__measure" :x1="measure.a.x" :y1="measure.a.y" :x2="measure.b.x" :y2="measure.b.y" vector-effect="non-scaling-stroke" />
75
83
  </svg>
84
+ <span v-for="(m, i) in [measure.a, measure.b].filter(Boolean)" :key="`m${i}`" class="hero-editor__mpoint" :style="{ left: `${m!.x}%`, top: `${m!.y}%` }" aria-hidden="true" />
76
85
  <template v-for="s in visible" :key="`l-${s.id}`">
77
86
  <div v-if="s.kind === 'zone'" class="hero-editor__label" :style="boxStyle(s)" :title="shapeTitle(s)">{{ s.name || "Unnamed zone" }}</div>
78
87
  <div v-else-if="s.kind === 'lift'" class="hero-editor__lift" :class="{ 'hero-editor__sel': isSel(s.id) }" :style="boxStyle(s)" :title="shapeTitle(s)">Lift</div>
@@ -102,6 +111,35 @@
102
111
  <span>{{ shapeTitle(s) }}</span><span class="hero-editor__pos">{{ shapeAnchor(s).x }}, {{ shapeAnchor(s).y }}</span>
103
112
  </button>
104
113
  <div v-if="!visible.length" class="hero-muted">Nothing drawn yet. Pick a tool and click the plan.</div>
114
+
115
+ <div class="hero-aside__title hero-editor__asidetitle">Floor plan · {{ floorName(floor) }}</div>
116
+ <div class="hero-editor__actions">
117
+ <button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" @click="pickPlan">{{ planBusy ? "Saving…" : floorPlan ? "Replace plan" : "Upload plan" }}</button>
118
+ <template v-if="floorPlan">
119
+ <button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" :aria-pressed="measuring" @click="startMeasure">Set scale</button>
120
+ <button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" @click="fitPlanNow">Fit</button>
121
+ <button type="button" class="hero-editor__act hero-editor__act--del" :disabled="planLocked" :title="planLockText" @click="removePlanNow">Remove plan</button>
122
+ </template>
123
+ </div>
124
+ <input ref="planInput" type="file" accept="image/png,image/jpeg,image/webp" hidden @change="onPlanFile" />
125
+ <div v-if="planLockText && !planBusy" class="hero-muted">{{ planLockText }}</div>
126
+ <div v-if="floorPlan" class="hero-muted">{{ scaleText(floorPlan) }}</div>
127
+ <div v-if="measuring" class="hero-editor__measurebox" role="group" aria-label="Set scale">
128
+ <span>{{ measure.b ? "Real length between the two points, in metres:" : measure.a ? "Click the other end." : "Click one end of a length you know on the plan (a wall, a doorway)." }}</span>
129
+ <div class="hero-editor__actions">
130
+ <input v-if="measure.b" v-model.number="measureM" type="number" min="0.01" step="0.01" class="hero-input hero-editor__measurein" aria-label="Real length in metres" placeholder="e.g. 12.5" @keydown.enter.prevent="applyMeasure" />
131
+ <button v-if="measure.b" type="button" class="hero-editor__act" @click="applyMeasure">Apply</button>
132
+ <button type="button" class="hero-editor__act" @click="cancelMeasure">Cancel</button>
133
+ </div>
134
+ </div>
135
+
136
+ <div class="hero-aside__title hero-editor__asidetitle">Building file · HERO Fleet Core</div>
137
+ <div v-if="map?.rmf" class="hero-muted">{{ rmfLine(map.rmf) }}</div>
138
+ <div class="hero-editor__actions">
139
+ <button v-if="fleetSite" type="button" class="hero-editor__act" :disabled="planBusy || saving || state.dirty" :title="state.dirty ? 'Save your drawing first.' : ''" @click="importFleet">Import from HERO Fleet Core</button>
140
+ <button type="button" class="hero-editor__act" :disabled="!map?._id" @click="download('building')">Download building file</button>
141
+ <button v-for="g in graphs" :key="`g${g}`" type="button" class="hero-editor__act" @click="download('nav', g)">Download route map{{ graphs.length > 1 ? ` ${g}` : "" }}</button>
142
+ </div>
105
143
  <div class="hero-editor__note">The plan image comes from the robot; everything here is drawn on top of it. Keyboard: Tab to the plan; arrow keys move the cursor (or nudge what is selected, Shift for 1 m); Enter places or selects; Delete removes; Esc clears the selection.</div>
106
144
  </aside>
107
145
  </div>
@@ -111,6 +149,23 @@
111
149
  <script setup lang="ts">
112
150
  import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
113
151
  import { floorName, MAP_TOOLS } from "../../utils/robot-display";
152
+ import useFloorPlanImages from "../../composables/useFloorPlanImages";
153
+ import {
154
+ compressSize,
155
+ exportFileName,
156
+ fitPlan,
157
+ fleetImportBody,
158
+ floorPlanFileProblem,
159
+ floorPlanFor,
160
+ FLOOR_PLAN_MAX_BYTES,
161
+ planBox,
162
+ replacePlacement,
163
+ rescaleByMeasure,
164
+ rmfGraphs,
165
+ rmfLine,
166
+ scaleText,
167
+ type TPlanPoint,
168
+ } from "../../utils/robot-floorplan";
114
169
  import {
115
170
  DEFAULT_GRID_M,
116
171
  doorSwing,
@@ -155,11 +210,15 @@ const props = withDefaults(
155
210
  ownerRobotId?: string;
156
211
  /** The floor to open on. */
157
212
  startFloor?: number | null;
213
+ /** The site, for the HERO Fleet Core import. */
214
+ site?: string;
215
+ /** The site has HERO Fleet Core robots: offer "Import from HERO Fleet Core". */
216
+ fleetSite?: boolean;
158
217
  }>(),
159
- { map: null, siteName: "", ownerRobotId: "", startFloor: null },
218
+ { map: null, siteName: "", ownerRobotId: "", startFloor: null, site: "", fleetSite: false },
160
219
  );
161
220
  const emit = defineEmits<{ saved: [] }>();
162
- const { createBuildingMap, updateBuildingMap } = useRobots();
221
+ const { createBuildingMap, updateBuildingMap, saveFloorPlan, removeFloorPlan, exportBuildingMap, getFleetCoreBuildingData } = useRobots();
163
222
 
164
223
  const state = ref<TEditorState>(emptyEditor(shapesFromMap(props.map)));
165
224
  const dispatch = (a: TEditorAction) => (state.value = editorReduce(state.value, a));
@@ -240,6 +299,8 @@ function onPlanDown(e: PointerEvent) {
240
299
  plan.value?.focus({ preventScroll: true });
241
300
  keyboard.value = false;
242
301
  const p = at(e);
302
+ // Set scale: the two clicks are the measured length, not drawing.
303
+ if (measuring.value) return void measureClick(p);
243
304
  const target = hitShape(visible.value, p, tolerance(e.pointerType));
244
305
  const t = tool.value;
245
306
  if (t === "delete") {
@@ -464,6 +525,182 @@ async function save() {
464
525
  }
465
526
  }
466
527
 
528
+ /* ---- floor plan: stored in the map (not the public file store), per floor ---- */
529
+ const { urls: planUrls } = useFloorPlanImages(computed(() => props.map));
530
+ const floorPlan = computed(() => floorPlanFor(props.map, floor.value));
531
+ // (`plan` is the plan ELEMENT, for pointer maths; this floor's image record is `floorPlan`.)
532
+ const planUrl = computed(() => planUrls.value[floor.value] ?? "");
533
+ const planImgStyle = computed(() => {
534
+ const b = planBox(floorPlan.value!);
535
+ return { left: `${b.left}%`, top: `${b.top}%`, width: `${b.width}%`, height: `${b.height}%` };
536
+ });
537
+ const planBusy = ref(false);
538
+ const planLockText = computed(() =>
539
+ !props.map?._id ? "Save the map once, then add a floor plan." : state.value.dirty ? "Save your drawing first, then change the floor plan." : "",
540
+ );
541
+ const planLocked = computed(() => planBusy.value || saving.value || Boolean(planLockText.value));
542
+ const planInput = ref<HTMLInputElement | null>(null);
543
+ const note = (text: string, ok = false) => dispatch({ type: "notice", text, ok });
544
+ const errText = (e: any, fallback: string) => e?.data?.message || e?.message || fallback;
545
+
546
+ function pickPlan() {
547
+ if (!planLocked.value) planInput.value?.click();
548
+ }
549
+
550
+ const blobToDataUrl = (b: Blob) =>
551
+ new Promise<string>((ok, no) => {
552
+ const r = new FileReader();
553
+ r.onload = () => ok(String(r.result));
554
+ r.onerror = () => no(r.error);
555
+ r.readAsDataURL(b);
556
+ });
557
+
558
+ /** Shrink in the browser until it fits the 1 MB the server keeps: WebP (JPEG where WebP cannot be written), then smaller. */
559
+ async function compressPlan(file: File) {
560
+ const bmp = await createImageBitmap(file);
561
+ let { width, height } = compressSize(bmp.width, bmp.height);
562
+ const canvas = document.createElement("canvas");
563
+ try {
564
+ for (let attempt = 0; attempt < 8; attempt++) {
565
+ canvas.width = width;
566
+ canvas.height = height;
567
+ const ctx = canvas.getContext("2d")!;
568
+ ctx.fillStyle = "#fff";
569
+ ctx.fillRect(0, 0, width, height);
570
+ ctx.drawImage(bmp, 0, 0, width, height);
571
+ for (const q of [0.85, 0.7, 0.55]) {
572
+ let out = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/webp", q));
573
+ if (out?.type !== "image/webp") out = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", q));
574
+ if (out && out.size <= FLOOR_PLAN_MAX_BYTES) return { image: await blobToDataUrl(out), widthPx: width, heightPx: height };
575
+ }
576
+ width = Math.max(1, Math.round(width * 0.8));
577
+ height = Math.max(1, Math.round(height * 0.8));
578
+ }
579
+ } finally {
580
+ bmp.close?.();
581
+ }
582
+ throw new Error("This floor plan cannot be made small enough. Try a simpler or smaller image.");
583
+ }
584
+
585
+ async function onPlanFile(e: Event) {
586
+ const input = e.target as HTMLInputElement;
587
+ const file = input.files?.[0];
588
+ input.value = "";
589
+ if (!file || !props.map?._id) return;
590
+ const problem = floorPlanFileProblem(file);
591
+ if (problem) return void note(problem);
592
+ planBusy.value = true;
593
+ try {
594
+ const img = await compressPlan(file);
595
+ await saveFloorPlan(String(props.map._id), floor.value, {
596
+ version: Number(props.map.version) || 1,
597
+ ...img,
598
+ ...replacePlacement(floorPlan.value, img.widthPx, img.heightPx),
599
+ });
600
+ note(`Floor plan saved for ${floorName(floor.value)}. Use Set scale to measure a known length.`, true);
601
+ emit("saved");
602
+ } catch (err) {
603
+ note(errText(err, "Could not save the floor plan."));
604
+ } finally {
605
+ planBusy.value = false;
606
+ }
607
+ }
608
+
609
+ async function placePlan(next: { metersPerPixel: number; tx: number; ty: number }, done: string) {
610
+ const p = floorPlan.value;
611
+ if (!p || !props.map?._id) return;
612
+ planBusy.value = true;
613
+ try {
614
+ await saveFloorPlan(String(props.map._id), floor.value, { version: Number(props.map.version) || 1, widthPx: p.widthPx, heightPx: p.heightPx, ...next });
615
+ note(done, true);
616
+ emit("saved");
617
+ } catch (err) {
618
+ note(errText(err, "Could not save the floor plan scale."));
619
+ } finally {
620
+ planBusy.value = false;
621
+ }
622
+ }
623
+
624
+ const fitPlanNow = () => floorPlan.value && placePlan(fitPlan(floorPlan.value.widthPx, floorPlan.value.heightPx), "Floor plan fitted to the drawing area.");
625
+
626
+ async function removePlanNow() {
627
+ if (!props.map?._id || !floorPlan.value) return;
628
+ if (!window.confirm(`Remove the floor plan of ${floorName(floor.value)}? Walls, doors and zones stay.`)) return;
629
+ planBusy.value = true;
630
+ try {
631
+ await removeFloorPlan(String(props.map._id), floor.value, Number(props.map.version) || 1);
632
+ note(`Floor plan removed from ${floorName(floor.value)}.`, true);
633
+ emit("saved");
634
+ } catch (err) {
635
+ note(errText(err, "Could not remove the floor plan."));
636
+ } finally {
637
+ planBusy.value = false;
638
+ }
639
+ }
640
+
641
+ /* ---- Set scale: two clicks on a known length, then its real length ---- */
642
+ const measuring = ref(false);
643
+ const measure = ref<{ a: TPlanPoint | null; b: TPlanPoint | null }>({ a: null, b: null });
644
+ const measureM = ref<number | null>(null);
645
+ function startMeasure() {
646
+ measuring.value = !measuring.value;
647
+ measure.value = { a: null, b: null };
648
+ measureM.value = null;
649
+ }
650
+ function measureClick(p: TPlanPoint) {
651
+ measure.value = !measure.value.a || measure.value.b ? { a: p, b: null } : { ...measure.value, b: p };
652
+ }
653
+ function cancelMeasure() {
654
+ measuring.value = false;
655
+ measure.value = { a: null, b: null };
656
+ }
657
+ async function applyMeasure() {
658
+ const { a, b } = measure.value;
659
+ if (!a || !b || !floorPlan.value) return;
660
+ const r = rescaleByMeasure(floorPlan.value, a, b, Number(measureM.value));
661
+ if ("error" in r) return void note(r.error);
662
+ cancelMeasure();
663
+ await placePlan(r.plan, `Scale set: ${scaleText(r.plan)}.`);
664
+ }
665
+ watch(floor, cancelMeasure);
666
+
667
+ /* ---- building file: HERO Fleet Core import, downloads ---- */
668
+ const graphs = computed(() => rmfGraphs(props.map?.rmf));
669
+
670
+ async function importFleet() {
671
+ if (!props.site) return;
672
+ if (props.map?._id && !window.confirm("Replace this building's walls, doors, lifts and routes with the HERO Fleet Core building? Floor plans stay.")) return;
673
+ if (!props.map?._id && !props.ownerRobotId) return void note("Can't import yet: this site has no robot to keep the map with.");
674
+ planBusy.value = true;
675
+ try {
676
+ const res = await getFleetCoreBuildingData(props.site);
677
+ const body = fleetImportBody(props.map, res?.rmf ?? {}, props.siteName);
678
+ if (props.map?._id) await updateBuildingMap(String(props.map._id), { ...body, version: Number(props.map.version) || 1 });
679
+ else await createBuildingMap({ ...body, robotId: props.ownerRobotId });
680
+ note(`Imported from HERO Fleet Core: ${rmfLine(res?.rmf)}.`, true);
681
+ emit("saved");
682
+ } catch (err) {
683
+ note(errText(err, "Could not import the HERO Fleet Core building."));
684
+ } finally {
685
+ planBusy.value = false;
686
+ }
687
+ }
688
+
689
+ async function download(format: "building" | "nav", graph = 0) {
690
+ if (!props.map?._id) return;
691
+ try {
692
+ const blob = await exportBuildingMap(String(props.map._id), format, format === "nav" ? graph : undefined);
693
+ const url = URL.createObjectURL(blob);
694
+ const a = document.createElement("a");
695
+ a.href = url;
696
+ a.download = exportFileName(String(props.map.name ?? ""), format, graph);
697
+ a.click();
698
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
699
+ } catch (err) {
700
+ note(errText(err, "Could not download the file."));
701
+ }
702
+ }
703
+
467
704
  /* ---- drawing helpers + full screen ---- */
468
705
  const pts = (list: Array<{ x: number; y: number }>) => list.map((p) => `${p.x},${p.y}`).join(" ");
469
706
  function boxStyle(s: TEditorShape) {
@@ -28,7 +28,17 @@
28
28
  <v-expansion-panel-text>
29
29
  <v-row no-gutters class="d-flex justify-center py-10">
30
30
  <v-form class="w-100">
31
- <v-row>
31
+ <!--
32
+ Owner decision 2026-09-25: a site's details are its owning
33
+ client's. A provider that serves the site sees them read-only;
34
+ the server refuses the save too. Blocks and guard posts below
35
+ stay editable - they are the provider's operational settings.
36
+ -->
37
+ <v-alert v-if="siteDetailsLocked" type="info" variant="tonal" density="compact" class="mb-4"
38
+ data-test="site-info-owner-only">
39
+ {{ siteOwnerNote }}
40
+ </v-alert>
41
+ <v-row :inert="siteDetailsLocked" :class="{ 'opacity-60': siteDetailsLocked }">
32
42
  <!-- Cover Photo + Preview -->
33
43
  <v-col cols="12" lg="5" class="pt-0">
34
44
  <InputLabel title="Cover Photo (displayed in resident app)" class="d-block mb-2" />
@@ -58,10 +68,12 @@
58
68
  </v-col>
59
69
 
60
70
  <!-- Submit -->
61
- <v-col cols="12" class="pt-0 d-flex justify-end">
71
+ <v-col v-if="!siteDetailsLocked" cols="12" class="pt-0 d-flex justify-end">
62
72
  <v-btn color="primary-button" class="text-none" size="large" variant="flat" text="Save"
63
73
  :loading="savingSiteInfo" @click="saveSiteInfo" />
64
74
  </v-col>
75
+ </v-row>
76
+ <v-row>
65
77
 
66
78
  <!-- Divider -->
67
79
  <v-col cols="12" class="my-2">
@@ -290,6 +302,13 @@ const orgId = computed(
290
302
  () => props.orgId || String((siteData.value as TSite | null)?.orgId ?? "")
291
303
  );
292
304
 
305
+ // The site read says whether this caller may change the site's details (core
306
+ // 3.103). Absent on an older API = unknown, so nothing is locked here.
307
+ const siteDetailsLocked = computed(
308
+ () => (siteData.value as any)?.detailsEditable === false
309
+ );
310
+ const siteOwnerNote = computed(() => siteOwnerOnlyNote((siteData.value as any)?.ownerOrgName));
311
+
293
312
  watch(
294
313
  siteData,
295
314
  (val: TSite) => {
@@ -0,0 +1,52 @@
1
+ import { onBeforeUnmount, ref, watch, type Ref } from "vue";
2
+
3
+ /**
4
+ * The floor-plan images of one building map, as object URLs by floor.
5
+ *
6
+ * The images sit behind the session (stored in the map record, not the public
7
+ * file store), so each is fetched with the session header and shown from a
8
+ * local object URL. A plan is fetched again only when its `updatedAt` changes;
9
+ * old URLs are released.
10
+ */
11
+ export default function useFloorPlanImages(map: Ref<Record<string, any> | null | undefined>) {
12
+ const { getFloorPlanImage } = useRobots();
13
+ const urls = ref<Record<number, string>>({});
14
+ const keys = new Map<number, string>();
15
+
16
+ function release(floorNo: number) {
17
+ const u = urls.value[floorNo];
18
+ if (u) URL.revokeObjectURL(u);
19
+ const next = { ...urls.value };
20
+ delete next[floorNo];
21
+ urls.value = next;
22
+ keys.delete(floorNo);
23
+ }
24
+
25
+ watch(
26
+ () => [map.value?._id, (map.value?.floorPlans ?? []).map((p: any) => `${p.floorNo}:${p.updatedAt}`).join("|")],
27
+ async () => {
28
+ const id = map.value?._id ? String(map.value._id) : "";
29
+ const plans: Array<Record<string, any>> = id ? map.value?.floorPlans ?? [] : [];
30
+ const wanted = new Map(plans.map((p) => [Number(p.floorNo), `${id}:${p.updatedAt}`]));
31
+ for (const f of [...keys.keys()]) if (wanted.get(f) !== keys.get(f)) release(f);
32
+ await Promise.all(
33
+ [...wanted].map(async ([floorNo, key]) => {
34
+ if (keys.get(floorNo) === key) return;
35
+ keys.set(floorNo, key);
36
+ try {
37
+ const blob = await getFloorPlanImage(id, floorNo);
38
+ if (keys.get(floorNo) !== key) return;
39
+ urls.value = { ...urls.value, [floorNo]: URL.createObjectURL(blob) };
40
+ } catch {
41
+ // No image drawn for that floor; the next change of the map tries again.
42
+ keys.delete(floorNo);
43
+ }
44
+ }),
45
+ );
46
+ },
47
+ { immediate: true },
48
+ );
49
+
50
+ onBeforeUnmount(() => [...keys.keys()].forEach(release));
51
+ return { urls };
52
+ }
@@ -24,6 +24,12 @@ export default function useRobotBuilding(
24
24
  /** Every robot at the site this app may see, both types where the app shows both. */
25
25
  const all = ref<Array<Record<string, any>>>([]);
26
26
  const map = ref<Record<string, any> | null>(null);
27
+ /**
28
+ * The site's SAVED map - what the editor edits (drawn walls, floor plans, an
29
+ * imported HERO Fleet Core building). At a fleet site `map` is the live fleet
30
+ * building instead, and this is the stored copy beside it.
31
+ */
32
+ const saved = ref<Record<string, any> | null>(null);
27
33
  const names = ref({ siteName: "", clientName: "" });
28
34
  const loading = ref(true);
29
35
  const error = ref("");
@@ -31,13 +37,17 @@ export default function useRobotBuilding(
31
37
  /** The robots of the type on screen (scope isolation: the other type is never drawn). */
32
38
  const rows = computed(() => all.value.filter((r) => r.type === type.value));
33
39
 
34
- async function loadMap() {
40
+ async function loadSaved() {
41
+ const newest = (await listSiteBuildingMaps(site.value, { limit: 1 }))?.items?.[0];
42
+ return newest ? await getBuildingMap(String(newest._id)) : null;
43
+ }
44
+
45
+ async function loadMap(stored: Record<string, any> | null) {
35
46
  if (hasFleetRobots(all.value)) {
36
47
  const building = await getFleetCoreBuilding(site.value).catch(() => null);
37
48
  if (building) return building;
38
49
  }
39
- const newest = (await listSiteBuildingMaps(site.value, { limit: 1 }))?.items?.[0];
40
- return newest ? await getBuildingMap(String(newest._id)) : null;
50
+ return stored;
41
51
  }
42
52
 
43
53
  /** Live doors and lifts of the fleet building; a failed refresh keeps the last reading. */
@@ -63,13 +73,16 @@ export default function useRobotBuilding(
63
73
  if (mine !== run) return;
64
74
  all.value = fleet?.items ?? [];
65
75
  names.value = { siteName: summary?.site?.name ?? "", clientName: summary?.client?.name ?? "" };
66
- const found = await loadMap();
76
+ const stored = await loadSaved();
77
+ const found = await loadMap(stored);
67
78
  if (mine !== run) return;
79
+ saved.value = stored;
68
80
  map.value = found;
69
81
  } catch (e: any) {
70
82
  if (mine !== run) return;
71
83
  all.value = [];
72
84
  map.value = null;
85
+ saved.value = null;
73
86
  error.value = e?.data?.message || "Could not load this building.";
74
87
  } finally {
75
88
  if (mine === run) loading.value = false;
@@ -78,5 +91,5 @@ export default function useRobotBuilding(
78
91
 
79
92
  watch([organization, site], load, { immediate: true });
80
93
 
81
- return { all, rows, map, fleet, names, loading, error, reload: load };
94
+ return { all, rows, map, saved, fleet, names, loading, error, reload: load };
82
95
  }
@@ -364,6 +364,57 @@ export default function useRobots() {
364
364
  });
365
365
  }
366
366
 
367
+ /**
368
+ * The HERO Fleet Core building in stored form (lanes, waypoints, door types,
369
+ * lift cabin doors) for the editor's import. Nothing is saved until the
370
+ * editor saves it.
371
+ */
372
+ function getFleetCoreBuildingData(site: string) {
373
+ return useNuxtApp().$api<Record<string, any>>("/api/robots/fleet-core/building", {
374
+ method: "GET",
375
+ query: { site, appSlug, format: "rmf" },
376
+ });
377
+ }
378
+
379
+ /** The map as a building file or one route graph, as a Blob to download. */
380
+ function exportBuildingMap(mapId: string, format: "building" | "nav", graph?: number) {
381
+ return useNuxtApp().$api<Blob>(`/api/robots/building-maps/${mapId}/export`, {
382
+ method: "GET",
383
+ query: { format, graph, appSlug },
384
+ responseType: "blob",
385
+ });
386
+ }
387
+
388
+ /**
389
+ * One floor's plan image. A Blob, not a URL: the image sits behind the
390
+ * session like the map itself, and an <img src> cannot carry the header.
391
+ */
392
+ function getFloorPlanImage(mapId: string, floorNo: number) {
393
+ return useNuxtApp().$api<Blob>(`/api/robots/building-maps/${mapId}/floor-plans/${floorNo}`, {
394
+ method: "GET",
395
+ responseType: "blob",
396
+ });
397
+ }
398
+
399
+ /** Add / replace (with `image`) or re-scale (without) one floor's plan. Versioned like a map save. */
400
+ function saveFloorPlan(
401
+ mapId: string,
402
+ floorNo: number,
403
+ body: { version: number; image?: string; widthPx: number; heightPx: number; metersPerPixel: number; tx: number; ty: number },
404
+ ) {
405
+ return useNuxtApp().$api<Record<string, any>>(`/api/robots/building-maps/${mapId}/floor-plans/${floorNo}`, {
406
+ method: "PUT",
407
+ body,
408
+ });
409
+ }
410
+
411
+ function removeFloorPlan(mapId: string, floorNo: number, version: number) {
412
+ return useNuxtApp().$api<Record<string, any>>(`/api/robots/building-maps/${mapId}/floor-plans/${floorNo}`, {
413
+ method: "DELETE",
414
+ query: { version },
415
+ });
416
+ }
417
+
367
418
  function deleteBuildingMap(mapId: string) {
368
419
  return useNuxtApp().$api<Record<string, any>>(`/api/robots/building-maps/${mapId}`, {
369
420
  method: "DELETE",
@@ -414,6 +465,11 @@ export default function useRobots() {
414
465
  createBuildingMap,
415
466
  updateBuildingMap,
416
467
  deleteBuildingMap,
468
+ getFleetCoreBuildingData,
469
+ exportBuildingMap,
470
+ getFloorPlanImage,
471
+ saveFloorPlan,
472
+ removeFloorPlan,
417
473
  getLiftQueue,
418
474
  requestLift,
419
475
  releaseLiftSession,
@@ -73,17 +73,34 @@ export default function useRole() {
73
73
  limit?: number;
74
74
  }
75
75
  ) {
76
+ /*
77
+ * An invitation INTO an organisation offers that organisation's own roles
78
+ * only (owner, 2026-09-25: the org-less templates "Org Owner", "Org - Admin
79
+ * Doan623"... in a site's Invite member list were never this client's).
80
+ * The server refuses a template on such an invitation (`core
81
+ * roleInvitable`). Templates are still what an invitation naming NO
82
+ * organisation offers, e.g. the console's new-client flows.
83
+ *
84
+ * A role pinned to a site is offered only for THAT site: the server's site
85
+ * filter returns this site's roles plus the org-wide ones, and with no site
86
+ * chosen yet only the org-wide ones are valid.
87
+ */
76
88
  const [tenant, templates] = await Promise.all([
77
89
  org
78
90
  ? getRoles({ search, org, site, type, limit })
79
91
  : Promise.resolve({ items: [] } as Record<string, any>),
80
- getRoles({ search, type: "organization", limit }),
92
+ org
93
+ ? Promise.resolve({ items: [] } as Record<string, any>)
94
+ : getRoles({ search, type: "organization", limit }),
81
95
  ]);
82
96
 
83
97
  const seen = new Set<string>();
84
98
  const items = [...(tenant?.items ?? []), ...(templates?.items ?? [])].filter(
85
99
  (role: Record<string, any>) => {
86
100
  if (role?.type === "admin") return false;
101
+ if (role?.status && role.status !== "active") return false;
102
+ const roleSite = String(role?.site ?? "");
103
+ if (roleSite && roleSite !== String(site ?? "")) return false;
87
104
  const id = String(role?._id ?? "");
88
105
  if (!id || seen.has(id)) return false;
89
106
  seen.add(id);
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.44.0",
5
+ "version": "4.46.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.",
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The Invite member Role list, labelled so an inviter can tell a role made for
3
+ * this site from one that applies across the organisation (owner report
4
+ * 2026-09-25). The roles themselves are already scoped by
5
+ * `useRole.getInvitableRoles`.
6
+ */
7
+ export type TInviteRoleOption = { _id: string; title: string; subtitle: string };
8
+
9
+ export const SITE_ROLE_LABEL = "Site role";
10
+ export const ORG_ROLE_LABEL = "Organisation role";
11
+
12
+ export function inviteRoleOptions(
13
+ roles: Array<Record<string, any>> | null | undefined,
14
+ ): TInviteRoleOption[] {
15
+ return (roles ?? [])
16
+ .filter((role) => role && role._id)
17
+ .map((role) => ({
18
+ _id: String(role._id),
19
+ title: String(role.name ?? ""),
20
+ subtitle: role.site ? SITE_ROLE_LABEL : ORG_ROLE_LABEL,
21
+ }));
22
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Floor plans and HERO Fleet Core building files in the map editor - the pure
3
+ * rules (placement, scale by measurement, import body, file names). The
4
+ * components only draw what these return.
5
+ *
6
+ * A floor plan is an image stored privately in the building map (never the
7
+ * public file store) and placed in the HERO plan frame (40 x 26 m, the same
8
+ * frame every drawn wall, door and lift uses): its top-left corner at
9
+ * (`tx`, `ty`) metres, `metersPerPixel` from a measured distance.
10
+ */
11
+
12
+ /** The HERO plan frame - `utils/robot-building.ts` PLAN_W_M / PLAN_H_M (decision 30). */
13
+ export const FRAME_W_M = 40;
14
+ export const FRAME_H_M = 26;
15
+ /** What the API stores per image, after compression here. */
16
+ export const FLOOR_PLAN_MAX_BYTES = 1_000_000;
17
+ /** Longest side the browser compresses a plan to. */
18
+ export const FLOOR_PLAN_MAX_PX = 2400;
19
+ export const FLOOR_PLAN_TYPES = ["image/png", "image/jpeg", "image/webp"];
20
+
21
+ export type TFloorPlan = { floorNo: number; widthPx: number; heightPx: number; metersPerPixel: number; tx: number; ty: number; updatedAt?: string };
22
+ export type TPlanPoint = { x: number; y: number };
23
+
24
+ const r6 = (v: number) => Math.round(v * 1e6) / 1e6;
25
+
26
+ export const floorPlanFor = (map: Record<string, any> | null | undefined, floorNo: number): TFloorPlan | null =>
27
+ ((map?.floorPlans ?? []) as TFloorPlan[]).find((p) => p.floorNo === floorNo) ?? null;
28
+
29
+ /** A new plan fitted inside the frame, top-left aligned, aspect kept. */
30
+ export function fitPlan(widthPx: number, heightPx: number): Pick<TFloorPlan, "metersPerPixel" | "tx" | "ty"> {
31
+ return { metersPerPixel: r6(Math.min(FRAME_W_M / widthPx, FRAME_H_M / heightPx)), tx: 0, ty: 0 };
32
+ }
33
+
34
+ /**
35
+ * A replacement image keeps the building's real size and place: same width in
36
+ * metres, same corner. (A re-drawn plan of the same floor should not need
37
+ * measuring again.)
38
+ */
39
+ export function replacePlacement(old: TFloorPlan | null, widthPx: number, heightPx: number) {
40
+ if (!old) return fitPlan(widthPx, heightPx);
41
+ return { metersPerPixel: r6((old.widthPx * old.metersPerPixel) / widthPx), tx: old.tx, ty: old.ty };
42
+ }
43
+
44
+ /** Where the image sits over the plan, as percentages of the frame (the editor draws everything in %). */
45
+ export function planBox(p: Pick<TFloorPlan, "widthPx" | "heightPx" | "metersPerPixel" | "tx" | "ty">) {
46
+ return {
47
+ left: (p.tx / FRAME_W_M) * 100,
48
+ top: (p.ty / FRAME_H_M) * 100,
49
+ width: ((p.widthPx * p.metersPerPixel) / FRAME_W_M) * 100,
50
+ height: ((p.heightPx * p.metersPerPixel) / FRAME_H_M) * 100,
51
+ };
52
+ }
53
+
54
+ /** Metres between two points given in plan percentages. */
55
+ export function planDistanceM(a: TPlanPoint, b: TPlanPoint) {
56
+ return Math.hypot(((b.x - a.x) / 100) * FRAME_W_M, ((b.y - a.y) / 100) * FRAME_H_M);
57
+ }
58
+
59
+ /**
60
+ * Scale by measurement: two points the person clicked on the plan (in plan %)
61
+ * and the real distance between them. The image grows or shrinks about its
62
+ * top-left corner. Null (with a reason) when it cannot be done.
63
+ */
64
+ export function rescaleByMeasure(p: TFloorPlan, a: TPlanPoint, b: TPlanPoint, realM: number): { plan: TFloorPlan } | { error: string } {
65
+ const drawn = planDistanceM(a, b);
66
+ if (!(realM > 0) || !Number.isFinite(realM)) return { error: "Enter the real distance in metres (more than 0)." };
67
+ if (drawn < 0.05) return { error: "Pick two points further apart." };
68
+ const metersPerPixel = r6(p.metersPerPixel * (realM / drawn));
69
+ if (metersPerPixel < 0.0001 || metersPerPixel > 10) return { error: "That distance gives an impossible scale. Check the two points and the number." };
70
+ return { plan: { ...p, metersPerPixel } };
71
+ }
72
+
73
+ /** "1 px = 4.2 cm · plan 33.6 x 25.2 m" */
74
+ export function scaleText(p: TFloorPlan | null) {
75
+ if (!p) return "";
76
+ const cm = p.metersPerPixel * 100;
77
+ const w = p.widthPx * p.metersPerPixel;
78
+ const h = p.heightPx * p.metersPerPixel;
79
+ const big = w > FRAME_W_M + 0.05 || h > FRAME_H_M + 0.05;
80
+ return `1 px = ${cm >= 10 ? cm.toFixed(0) : cm.toFixed(1)} cm · plan ${w.toFixed(1)} × ${h.toFixed(1)} m${big ? " (larger than the 40 × 26 m drawing area - the edge is cut off)" : ""}`;
81
+ }
82
+
83
+ /** Plain-language reason a picked file cannot be used, or null. */
84
+ export function floorPlanFileProblem(file: { type: string; size: number }) {
85
+ if (!FLOOR_PLAN_TYPES.includes(file.type)) return "Choose a PNG, JPEG or WebP image of the floor plan.";
86
+ if (file.size > 40_000_000) return "That image is too large to open (40 MB at most).";
87
+ return null;
88
+ }
89
+
90
+ /** The size to compress to: longest side at most FLOOR_PLAN_MAX_PX, never upscaled. */
91
+ export function compressSize(widthPx: number, heightPx: number, maxPx = FLOOR_PLAN_MAX_PX) {
92
+ const s = Math.min(1, maxPx / Math.max(widthPx, heightPx));
93
+ return { width: Math.max(1, Math.round(widthPx * s)), height: Math.max(1, Math.round(heightPx * s)) };
94
+ }
95
+
96
+ /**
97
+ * The save body for an import from HERO Fleet Core: the fleet's building is the
98
+ * truth (`rmf`) and the server derives the drawn walls, doors and lifts from
99
+ * it. Floor plans and the map's name stay.
100
+ */
101
+ export function fleetImportBody(map: Record<string, any> | null | undefined, rmf: Record<string, any>, siteName = "") {
102
+ return { name: String(map?.name || `${siteName || "Site"} building`).slice(0, 60), levels: [], doors: [], lifts: [], rmf };
103
+ }
104
+
105
+ /** "Tower B" -> "tower-b.building.yaml" / "tower-b-nav-0.yaml" - the names the API sends too. */
106
+ export function exportFileName(name: string, format: "building" | "nav", graph = 0) {
107
+ const slug = (name || "building").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "building";
108
+ return format === "building" ? `${slug}.building.yaml` : `${slug}-nav-${graph}.yaml`;
109
+ }
110
+
111
+ /** "3 floors · 42 lanes · 18 named points · 19 doors · 2 lifts" for a stored rmf part. */
112
+ export function rmfLine(rmf: Record<string, any> | null | undefined) {
113
+ if (!rmf?.levels) return "";
114
+ const levels = rmf.levels as Array<Record<string, any>>;
115
+ const n = (f: (l: Record<string, any>) => number) => levels.reduce((s, l) => s + f(l), 0);
116
+ const parts = [
117
+ `${levels.length} floor${levels.length === 1 ? "" : "s"}`,
118
+ `${n((l) => l.lanes?.length ?? 0)} lanes`,
119
+ `${n((l) => (l.vertices ?? []).filter((v: any) => v.name).length)} named points`,
120
+ `${n((l) => l.doors?.length ?? 0)} doors`,
121
+ `${rmf.lifts?.length ?? 0} lifts`,
122
+ ];
123
+ return parts.join(" · ");
124
+ }
125
+
126
+ /** The route graphs a download can offer. */
127
+ export const rmfGraphs = (rmf: Record<string, any> | null | undefined): number[] =>
128
+ ((rmf?.graphs ?? []) as Array<{ idx: number }>).map((g) => g.idx).sort((a, b) => a - b);
129
+
130
+ /** The Add robot dialog's credentials note - it says "encrypted" only when this server really encrypts. */
131
+ export const credentialsNote = (encrypted: boolean) =>
132
+ encrypted ? "Credentials are stored encrypted and never shown again after saving." : "Credentials are never shown again after saving.";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * What a service provider is told on a site it serves but does not own
3
+ * (owner decision 2026-09-25: only the owning client changes a site's
4
+ * details). Same words as the server's refusal.
5
+ */
6
+ export function siteOwnerOnlyNote(ownerName?: string | null): string {
7
+ const name = String(ownerName ?? "").trim() || "another organisation";
8
+ return `This site belongs to ${name}. Only they can change its details.`;
9
+ }