@7365admin1/layer-common 4.64.1 → 4.64.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.64.3
4
+
5
+ ### Patch Changes
6
+
7
+ - ed34f76: Invite member: "Create more" keeps the same app and site, so the Role list after an invitation is the same as before it (it used to switch to the organisation-type roles). When no role exists for the site, the list says so and links to Roles & Permissions.
8
+
9
+ ## 4.64.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 5f0c221: Robot detail: after a command the line follows the robot ("Leaving the charger…", "On the way to P4…", "Arrived at P4", "Docking…", "Back on charger") instead of a false "didn't start"; says up front that undocking takes about 10 s, with a spinner while it follows; reads the robot every 1 s for a minute after a command (then every 4 s) so the status and map pin keep up; a note under the live view says the video is about 8 s behind and the map is real time.
14
+
3
15
  ## 4.64.1
4
16
 
5
17
  ### Patch Changes
@@ -1703,11 +1715,11 @@
1703
1715
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1704
1716
  own rule from two endpoints the console already calls, unprojected:
1705
1717
 
1706
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1707
- GET /api/roles/id/:role that membership's role document
1718
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1719
+ GET /api/roles/id/:role that membership's role document
1708
1720
 
1709
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1710
- staff = member.type === "admin" && role.type === "admin"
1721
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1722
+ staff = member.type === "admin" && role.type === "admin"
1711
1723
 
1712
1724
  `role.default` is the marker because it is the only property of a platform
1713
1725
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -1284,6 +1284,7 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
1284
1284
 
1285
1285
  /* PadBot security extras: live video button row under the facts. */
1286
1286
  .hero-detail__video { margin: 10px 0 4px; }
1287
+ .hero-detail__lag { margin: 6px 0 0; font-size: 12px; }
1287
1288
 
1288
1289
  /* A kept (stale) reading: faded, no ping - never drawn as live (owner 2026-09-26). */
1289
1290
  .hero-bot--stale, .hero-me--stale { opacity: 0.45; }
@@ -53,7 +53,18 @@
53
53
  :rules="[requiredRule]"
54
54
  class="filter-field"
55
55
  density="comfortable"
56
- ></v-autocomplete>
56
+ >
57
+ <template #no-data>
58
+ <v-list-item data-test="invite-no-roles">
59
+ <v-list-item-title class="text-wrap">
60
+ {{ noRolesText }}
61
+ </v-list-item-title>
62
+ <NuxtLink v-if="rolesPageLink" :to="rolesPageLink">
63
+ Open Roles &amp; Permissions
64
+ </NuxtLink>
65
+ </v-list-item>
66
+ </template>
67
+ </v-autocomplete>
57
68
  </v-col>
58
69
  </v-row>
59
70
  </v-col>
@@ -248,6 +259,17 @@ const loading = reactive({
248
259
  verifyingEmail: false,
249
260
  });
250
261
 
262
+ /*
263
+ * The app this form invites into when it opens. "Create more" resets to THIS,
264
+ * never to a literal: it used to reset to "organization", so after the first
265
+ * invitation the Role list switched to the organisation-type roles ("Site
266
+ * Admin", "Org Admin") and this site's own roles vanished (owner report
267
+ * 2026-09-28, Wing Tai / The LakeGarden Residences).
268
+ */
269
+ function initialApp() {
270
+ return props.adminState ? (props.app || "") : "property_management_agency"
271
+ }
272
+
251
273
  const invite = ref<Record<string, any>>({
252
274
  email: "",
253
275
  app: "",
@@ -274,9 +296,7 @@ watch(
274
296
  * ORGANISATION-admin invitation, which is a separate flow, and changing it
275
297
  * here would move where those members land.
276
298
  */
277
- invite.value.app = props.adminState
278
- ? (props.app || "")
279
- : "property_management_agency"
299
+ invite.value.app = initialApp()
280
300
  },
281
301
  { immediate: true }
282
302
  )
@@ -481,6 +501,25 @@ watchEffect(() => {
481
501
 
482
502
  const roleOptions = computed(() => inviteRoleOptions(roles.value));
483
503
 
504
+ const noRolesText = computed(() =>
505
+ fixedSite.value
506
+ ? "No roles have been created for this site yet. Create one in Roles & Permissions first."
507
+ : "No roles have been created yet. Create one in Roles & Permissions first."
508
+ );
509
+
510
+ /* This screen's own Roles & Permissions page, when the app has one. */
511
+ const route = useRoute();
512
+ const router = useRouter();
513
+ const rolesPageLink = computed(() => {
514
+ const path = route.path.replace(/\/invitations(\/.*)?$/, "/roles-permissions");
515
+ if (path === route.path) return "";
516
+ try {
517
+ return router.resolve(path).matched.length ? path : "";
518
+ } catch {
519
+ return "";
520
+ }
521
+ });
522
+
484
523
  async function handleUpdateApp(value: string) {
485
524
  invite.value.role = "";
486
525
  if (!fixedSite.value) {
@@ -499,8 +538,11 @@ const message = ref("");
499
538
 
500
539
  function resetInvite() {
501
540
  invite.value.email = "";
502
- invite.value.app = "organization";
541
+ invite.value.app = initialApp();
503
542
  invite.value.role = "";
543
+ // Same site as before: a fixed site stays fixed, a picked one is cleared.
544
+ invite.value.site = fixedSite.value;
545
+ if (!invite.value.site) invite.value.siteName = "";
504
546
  message.value = "";
505
547
  }
506
548
 
@@ -60,6 +60,7 @@
60
60
  <div v-if="!noInterface" class="hero-detail__main" :class="{ 'hero-detail__main--single': !hasCamera }">
61
61
  <section v-if="hasCamera" class="hero-detail__cam" aria-label="Camera">
62
62
  <RobotLiveView v-if="caps?.liveVideo === true" ref="livePanel" panel :robot-id="robotId" :name="record?.name" :offline="!online" />
63
+ <p v-if="caps?.liveVideo === true && reportsPosition" class="hero-muted hero-detail__lag">{{ VIDEO_LAG_NOTE }}</p>
63
64
  <div v-if="canDrive" class="hero-detail__video">
64
65
  <v-btn variant="outlined" @click="driveOpen = true">Manual control</v-btn>
65
66
  </div>
@@ -126,6 +127,7 @@
126
127
  @command="send"
127
128
  />
128
129
  <div v-if="commandResult" class="hero-notice" :class="{ 'hero-notice--error': commandResult.warn }" role="status" aria-live="polite">
130
+ <v-progress-circular v-if="watching" indeterminate size="14" width="2" class="mr-2" aria-hidden="true" />
129
131
  {{ commandResult.text }}
130
132
  </div>
131
133
  </div>
@@ -274,7 +276,11 @@ import {
274
276
  capabilityLabel,
275
277
  cleaningScheduleRows,
276
278
  commandBody,
279
+ COMMAND_FOLLOW_MS,
277
280
  COMMAND_WATCH_MS,
281
+ FAST_POLL_MS,
282
+ VIDEO_LAG_NOTE,
283
+ sentText,
278
284
  commandConfirm,
279
285
  commandOutcome,
280
286
  patrolResumable,
@@ -366,7 +372,9 @@ const brand = computed(() => brandName(record.value?.brand, brands.value));
366
372
 
367
373
  /* ---- live: the 4 s poll (swappable feed) + the per-robot position stream ---- */
368
374
  const ids = computed(() => (props.robotId ? [props.robotId] : []));
369
- const { states, connected, lastAt } = useRobotLive(ids, siteStatesFeed());
375
+ // Every 4 s; every 1 s for a minute after a command, so the status and the map pin keep up with the robot.
376
+ const fastUntil = ref(0);
377
+ const { states, connected, lastAt } = useRobotLive(ids, siteStatesFeed(() => (Date.now() < fastUntil.value ? FAST_POLL_MS : 4000)));
370
378
  const polled = computed<TLiveRobotState | null>(() => states.value[props.robotId] ?? null);
371
379
 
372
380
  const lastPose = ref<{ event: any; at: number } | null>(null);
@@ -590,7 +598,8 @@ watch([lastAt, now], () => {
590
598
  if (!w || at <= w.at + 300) return;
591
599
  const out = commandOutcome(w.kind, polled.value?.ok ? polled.value : null, Date.now() - w.at, { name: record.value?.name, what: w.what });
592
600
  if (!out) return;
593
- watching.value = null;
601
+ // Keep following (Leaving the charger… -> On the way to P4… -> Arrived at P4) until it is done.
602
+ if (out.done) watching.value = null;
594
603
  commandResult.value = { text: out.text, warn: !out.ok };
595
604
  if (!out.ok) optimistic.value = null;
596
605
  });
@@ -604,6 +613,7 @@ watch(
604
613
  async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknown>, what?: string) {
605
614
  const name = record.value?.name ?? "the robot";
606
615
  const before = statusMode.value;
616
+ const docked = before === "CHARGED" || before === "CHARGING_CYCLE" || /At charger/.test(String(live.value?.ok ? live.value.statusName ?? "" : ""));
607
617
  busy.value = kind;
608
618
  optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
609
619
  const label = (kind === "full" || kind === "area") && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
@@ -621,7 +631,8 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
621
631
  const outcomeKind = body.kind === "go-to-point" ? "go-to-point" : kind;
622
632
  if (commandOutcome(outcomeKind, { mode: "" }, COMMAND_WATCH_MS) !== null) {
623
633
  watching.value = { kind: String(outcomeKind), at: Date.now(), what };
624
- commandResult.value = { text: `${label} → ${name} · accepted. Waiting for the robot to move…`, warn: false };
634
+ fastUntil.value = Date.now() + COMMAND_FOLLOW_MS;
635
+ commandResult.value = { text: sentText(docked), warn: false };
625
636
  } else commandResult.value = { text: `${label} → ${name} · accepted.`, warn: false };
626
637
  return true;
627
638
  } catch (e) {
@@ -36,16 +36,25 @@ export type TRobotLiveFeed = {
36
36
  */
37
37
  export function robotPollingFeed(
38
38
  fetchStates: (ids: string[]) => Promise<{ items?: Array<Partial<TLiveRobotState> & { id: string }> }>,
39
- intervalMs = 4000,
39
+ /** A number, or a function read before every wait (robot detail: 1 s for a minute after a command). */
40
+ intervalMs: number | (() => number) = 4000,
40
41
  ): TRobotLiveFeed {
42
+ const every = typeof intervalMs === "function" ? intervalMs : () => intervalMs;
41
43
  return {
42
44
  watch(ids, push, connected) {
43
45
  let stopped = false;
44
46
  let timer: ReturnType<typeof setTimeout> | undefined;
47
+ let lastTick = 0;
45
48
  const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
49
+ // Waits in 250 ms steps, so a shorter interval (a command just sent) starts at once.
50
+ const wait = () => {
51
+ if (stopped) return;
52
+ timer = setTimeout(() => (Date.now() - lastTick >= every() ? tick() : wait()), 250);
53
+ };
46
54
 
47
55
  async function tick() {
48
56
  if (stopped) return;
57
+ lastTick = Date.now();
49
58
  if (!hidden() && ids.length) {
50
59
  try {
51
60
  const res = await fetchStates(ids);
@@ -58,7 +67,7 @@ export function robotPollingFeed(
58
67
  connected(false);
59
68
  }
60
69
  }
61
- if (!stopped) timer = setTimeout(tick, intervalMs);
70
+ wait();
62
71
  }
63
72
  // Coming back to the tab refreshes at once instead of waiting a tick.
64
73
  const onVisible = () => {
@@ -95,7 +104,7 @@ export function networkStatesFeed(intervalMs = 4000): TRobotLiveFeed {
95
104
  * The API checks every robot against its own site and narrows by the app's
96
105
  * `appSlug`, so a robot this screen may not see simply never arrives.
97
106
  */
98
- export function siteStatesFeed(intervalMs = 4000): TRobotLiveFeed {
107
+ export function siteStatesFeed(intervalMs: number | (() => number) = 4000): TRobotLiveFeed {
99
108
  const appSlug = useRuntimeConfig().public?.appSlug as string | undefined;
100
109
  return robotPollingFeed(
101
110
  (ids) =>
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.64.1",
5
+ "version": "4.64.3",
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.",
@@ -1713,34 +1713,60 @@ const STARTED: Readonly<Record<string, ReadonlySet<string>>> = {
1713
1713
  return: new Set([...GOING, "CHARGING_CYCLE", "CHARGED"]),
1714
1714
  };
1715
1715
  const RESTING = /^(Standing by|Charging|Charged|Working on a task)$/;
1716
+ /** The robot got there: the line stops following. */
1717
+ const ARRIVED = /^(Arrived at|Back on charger)/;
1718
+ /** After a start, the line follows the robot's own words this long (then keeps the last). */
1719
+ export const COMMAND_FOLLOW_MS = 60_000;
1720
+ /** Robot detail reads the robot this often for COMMAND_FOLLOW_MS after a command (else every 4 s). */
1721
+ export const FAST_POLL_MS = 1000;
1722
+ /**
1723
+ * Under the live view. Measured on prod 2026-09-27: the PadBot/EZVIZ picture
1724
+ * is 7-10 s behind (4 s HLS segments upstream); the map pin comes from the
1725
+ * position read / MQTT push and leads it.
1726
+ */
1727
+ export const VIDEO_LAG_NOTE = "Video is about 8 s behind live; the map shows the robot's position in real time.";
1728
+ /** The line the moment the robot cloud accepts a moving command (the S2 takes 8-13 s to undock). */
1729
+ export function sentText(docked: boolean): string {
1730
+ return docked ? "Sent to robot — it usually takes about 10 s to leave the charger." : "Sent to robot — waiting for it to move…";
1731
+ }
1716
1732
 
1717
1733
  /**
1718
1734
  * What an accepted command actually did, from a reading taken AFTER it was
1719
1735
  * sent (never the optimistic status). The robot cloud's "accepted" is not
1720
1736
  * "done": PadBot answers code 0 and the robot may still not move.
1721
- * - started: the robot shows it acted -> `${who} started ${what}.`
1737
+ * - started: the robot shows it acted -> what it is doing in its own words
1738
+ * (`${who}: Leaving the charger…`, `${who}: On the way to P4…`), else
1739
+ * `${who} started ${what}.`; `done` once it arrives ("Arrived at P4",
1740
+ * "Back on charger") or COMMAND_FOLLOW_MS has passed.
1722
1741
  * - still resting after COMMAND_WATCH_MS -> `${who} didn't start — <reason>`
1723
1742
  * (the robot's own words when it gave any, e.g. "Could not reach P2 (...)").
1724
1743
  * - otherwise: keep watching (null).
1725
1744
  * Commands without a visible outcome (cancel, set position, pause) -> null.
1745
+ * The PadBot S2 drives with its navigation state idle; the API reads the
1746
+ * charging state and the position instead, so "started" is real movement.
1726
1747
  */
1727
1748
  export function commandOutcome(
1728
1749
  kind: unknown,
1729
1750
  reading: { online?: unknown; mode?: unknown; statusName?: unknown; fault?: { message?: unknown } | null } | null | undefined,
1730
1751
  elapsedMs: number,
1731
1752
  input: { name?: unknown; what?: unknown } = {},
1732
- ): { ok: boolean; text: string } | null {
1753
+ ): { ok: boolean; text: string; done: boolean } | null {
1733
1754
  const started = STARTED[String(kind)];
1734
1755
  if (!started || !reading) return null;
1735
1756
  const who = (typeof input.name === "string" && input.name) || "The robot";
1736
1757
  const what = typeof input.what === "string" && input.what ? input.what : kind === "return" ? "going back to its charger" : "moving";
1737
- if (typeof reading.mode === "string" && started.has(reading.mode)) return { ok: true, text: `${who} started ${what}.` };
1738
- if (elapsedMs < COMMAND_WATCH_MS) return null;
1739
1758
  const said = typeof reading.statusName === "string" ? reading.statusName.split(" · ")[0]!.trim() : "";
1759
+ if (ARRIVED.test(said)) return { ok: true, text: `${who}: ${said}.`, done: true };
1760
+ if (typeof reading.mode === "string" && started.has(reading.mode)) {
1761
+ const doing = said && !RESTING.test(said) ? said : "";
1762
+ return { ok: true, text: doing ? `${who}: ${doing}…` : `${who} started ${what}.`, done: elapsedMs >= COMMAND_FOLLOW_MS };
1763
+ }
1764
+ if (elapsedMs < COMMAND_WATCH_MS) return null;
1740
1765
  const fault = typeof reading.fault?.message === "string" ? reading.fault.message : "";
1741
1766
  const reason = reading.online === false ? "it stopped reporting" : fault || (said && !RESTING.test(said) ? said : "");
1742
1767
  return {
1743
1768
  ok: false,
1769
+ done: true,
1744
1770
  text: reason
1745
1771
  ? `${who} didn't start — ${reason}.`
1746
1772
  : `${who} didn't start — the robot cloud accepted the command, but the robot is still ${said ? said.toLowerCase() : "idle"}. Check the robot or its maker's app.`,