@7365admin1/layer-common 4.111.0 → 4.113.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.113.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 92f08bca: Vehicle Mgmt: new "QR sticker" column (Linked / None / n of m) and an All / With sticker / Without sticker filter, shown only while the site's QR switch is on
8
+
9
+ ## 4.112.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 9350b17: Robots: "Open HERO Fleet Core" button (`RobotFleetCoreButton`, in the site robots header) for Super Admin and Property Management. Shown only when `GET /api/robots/fleet-core/session?check=1` answers 200; on click it opens a new tab, fetches the one-hour sign-in pass and points the tab at the console (`https://…/#token=…` only). Web only.
14
+
3
15
  ## 4.111.0
4
16
 
5
17
  ### Minor Changes
@@ -3080,11 +3092,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3080
3092
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3081
3093
  own rule from two endpoints the console already calls, unprojected:
3082
3094
 
3083
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3084
- GET /api/roles/id/:role that membership's role document
3095
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3096
+ GET /api/roles/id/:role that membership's role document
3085
3097
 
3086
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3087
- staff = member.type === "admin" && role.type === "admin"
3098
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3099
+ staff = member.type === "admin" && role.type === "admin"
3088
3100
 
3089
3101
  `role.default` is the marker because it is the only property of a platform
3090
3102
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -0,0 +1,48 @@
1
+ <template>
2
+ <!--
3
+ "Open HERO Fleet Core" (web only, owner 2026-10-09: Super Admin + Property
4
+ Management). Shown only when the server says this account may open the
5
+ console (`?check=1` answered 200) - 404 where Fleet Core is not set up and
6
+ 403 for everyone else keep it hidden. The pass is fetched on click only.
7
+ -->
8
+ <v-btn
9
+ v-if="available"
10
+ size="small"
11
+ variant="tonal"
12
+ color="primary"
13
+ prepend-icon="mdi-open-in-new"
14
+ :loading="opening"
15
+ @click="open"
16
+ >
17
+ Open HERO Fleet Core
18
+ </v-btn>
19
+ <span v-if="failed" class="hero-tag hero-tag--neutral" role="status">Could not open HERO Fleet Core. Try again.</span>
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ import { onMounted, ref } from "vue";
24
+ import { openFleetCoreConsole } from "../../utils/robot-fleet-core";
25
+
26
+ const { getFleetCoreSession } = useRobots();
27
+ const available = ref(false);
28
+ const opening = ref(false);
29
+ const failed = ref(false);
30
+
31
+ onMounted(async () => {
32
+ try {
33
+ available.value = (await getFleetCoreSession({ check: true }))?.available === true;
34
+ } catch {
35
+ available.value = false;
36
+ }
37
+ });
38
+
39
+ async function open() {
40
+ opening.value = true;
41
+ failed.value = false;
42
+ failed.value = !(await openFleetCoreConsole(
43
+ () => getFleetCoreSession(),
44
+ () => window.open("", "_blank") as any,
45
+ ));
46
+ opening.value = false;
47
+ }
48
+ </script>
@@ -35,6 +35,7 @@
35
35
  </div>
36
36
  <span class="hero-head__spacer" />
37
37
  <div class="hero-header__right">
38
+ <RobotFleetCoreButton />
38
39
  <RobotLiveIndicator :connected="connected" />
39
40
  <span class="hero-tag hero-tag--neutral">{{ role }}</span>
40
41
  </div>
@@ -45,6 +46,7 @@
45
46
  <script setup lang="ts">
46
47
  import RobotMark from "./Mark.vue";
47
48
  import RobotLiveIndicator from "./LiveIndicator.vue";
49
+ import RobotFleetCoreButton from "./FleetCoreButton.vue";
48
50
  import { typeLabel, type TRobotType } from "../../utils/robot-display";
49
51
 
50
52
  withDefaults(
@@ -48,9 +48,18 @@
48
48
 
49
49
  <!-- Search joins the toolbar row rather than stacking a second one. -->
50
50
  <template #prepend-additional>
51
+ <v-select v-if="qrEnabled" v-model="qrStickerFilter" :items="qrStickerFilterItems" label="QR sticker"
52
+ density="compact" variant="outlined" hide-details class="vehicle-qr-filter" style="max-width: 190px"
53
+ data-test="vehicle-qr-filter" />
51
54
  <AppField v-model="searchInput" search compact placeholder="Search" klass="app-toolbar__search" />
52
55
  </template>
53
56
 
57
+ <template #item.qrSticker="{ item }">
58
+ <v-chip size="small" variant="tonal" :color="qrStickerSummary(rowPlates(item)).color" data-test="vehicle-qr-cell">
59
+ {{ qrStickerSummary(rowPlates(item)).label }}
60
+ </v-chip>
61
+ </template>
62
+
54
63
  <template #item.category="{ value }">
55
64
  {{ formatValues("category", value) }}
56
65
  </template>
@@ -439,7 +448,7 @@ import {
439
448
  // `error.message` put ofetch's own developer string in the delete dialog:
440
449
  // `[PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502`.
441
450
  import { errorConverter } from '../utils/data';
442
- import { stickerCodeHint } from '../utils/vehicle-qr-blank';
451
+ import { qrStickerSummary, stickerCodeHint } from '../utils/vehicle-qr-blank';
443
452
  import { isPermissionRefusalError } from '../utils/permission-refusal';
444
453
 
445
454
 
@@ -456,9 +465,11 @@ const props = defineProps({
456
465
  });
457
466
 
458
467
 
459
- const headers = [
468
+ const headers = computed(() => [
460
469
  { title: "Name", value: "name" },
461
470
  { title: "Vehicle Numbers", value: "plates" },
471
+ // Only while the site's QR switch is on (like the print button).
472
+ ...(qrEnabled.value ? [{ title: "QR sticker", value: "qrSticker", sortable: false }] : []),
462
473
  { title: "NRIC", value: "nric" },
463
474
  { title: "Block", value: "block.name" },
464
475
  { title: "Floor", value: "level.name" },
@@ -467,7 +478,7 @@ const headers = [
467
478
  // { title: "Type", value: "type" },
468
479
  // { title: "Status", value: "status" },
469
480
  { title: "", value: "action" },
470
- ]
481
+ ])
471
482
 
472
483
  const plateHeaders = [
473
484
  { title: "Plate Number", value: "plateNumber" },
@@ -546,6 +557,12 @@ const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVe
546
557
  // Vehicle QR stickers: the site's switch (Settings) decides whether the print
547
558
  // and re-issue actions exist; only people who may update vehicles get them.
548
559
  const qrEnabled = ref(false);
560
+ const qrStickerFilter = ref("");
561
+ const qrStickerFilterItems = [
562
+ { title: "All", value: "" },
563
+ { title: "With sticker", value: "with" },
564
+ { title: "Without sticker", value: "without" },
565
+ ];
549
566
  const qrStickersOn = computed(() => qrEnabled.value && props.canUpdateVehicle);
550
567
  const qrVehicleIds = ref<string[]>([]);
551
568
  const qrReissuePlate = ref<TPlateNumber | null>(null);
@@ -838,6 +855,7 @@ const { data: getVehiclesReq, refresh: getVehiclesRefresh, pending: getVehiclesP
838
855
  search: searchInput.value,
839
856
  view: activeTab.value,
840
857
  site: props.site,
858
+ qrSticker: qrEnabled.value ? qrStickerFilter.value : "",
841
859
  }),
842
860
  {
843
861
  watch: [page],
@@ -1321,6 +1339,11 @@ watch(searchInput, () => {
1321
1339
  debouncedSearch()
1322
1340
  })
1323
1341
 
1342
+ watch(qrStickerFilter, () => {
1343
+ page.value = 1;
1344
+ getVehiclesRefresh();
1345
+ })
1346
+
1324
1347
  // A new tab starts from its first page and loads straight away.
1325
1348
  watch(activeTab, () => {
1326
1349
  page.value = 1;
@@ -400,6 +400,18 @@ export default function useRobots() {
400
400
  });
401
401
  }
402
402
 
403
+ /**
404
+ * HERO Fleet Core sign-in pass (web only): `{ url: "<console>/#token=...", expiresAt }`.
405
+ * `check: true` only asks whether this account may open the console (mints nothing).
406
+ * 404 where Fleet Core is not set up, 403 for anyone but Super Admin / Property Management.
407
+ */
408
+ function getFleetCoreSession({ check = false }: { check?: boolean } = {}) {
409
+ return useNuxtApp().$api<{ url?: string; expiresAt?: string; available?: boolean }>("/api/robots/fleet-core/session", {
410
+ method: "GET",
411
+ query: check ? { check: "1" } : {},
412
+ });
413
+ }
414
+
403
415
  /* ------------------------------------------ alerts + activity log */
404
416
 
405
417
  /**
@@ -566,6 +578,7 @@ export default function useRobots() {
566
578
  listBuildingMaps,
567
579
  listSiteBuildingMaps,
568
580
  getFleetCoreBuilding,
581
+ getFleetCoreSession,
569
582
  getSiteFaults,
570
583
  getSiteActivity,
571
584
  getNetworkActivity,
@@ -9,12 +9,13 @@ export default function useVehicle() {
9
9
  site = "",
10
10
  status = "",
11
11
  view = "",
12
+ qrSticker = "",
12
13
  } = {}) {
13
14
  // `view` is the screen's tab: residents, season-passes or block-listed.
14
15
  // Every status comes back unless `status` narrows it.
15
16
  return await useNuxtApp().$api<Record<string, any>>("/api/vehicles", {
16
17
  method: "GET",
17
- query: { search, page, limit, order, type, category, site, status, view },
18
+ query: { search, page, limit, order, type, category, site, status, view, qrSticker },
18
19
  });
19
20
  }
20
21
 
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.111.0",
5
+ "version": "4.113.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.",
package/types/people.d.ts CHANGED
@@ -48,7 +48,7 @@ declare type TOccupancyRoleOption = {
48
48
  description: string;
49
49
  };
50
50
 
51
- declare type TPlateNumber = { plateNumber: string, recNo?: string, _id?: string, status: TVehicleStatus, type: TVehicleType, anprCameras?: TVehicleAnprCamera[] };
51
+ declare type TPlateNumber = { plateNumber: string, recNo?: string, _id?: string, status: TVehicleStatus, type: TVehicleType, anprCameras?: TVehicleAnprCamera[], hasQrSticker?: boolean };
52
52
 
53
53
 
54
54
  // `""` is the FORM's "nothing picked yet" - it is stripped from the payload
@@ -55,3 +55,41 @@ export function fleetLiftItems(map: Record<string, any> | null | undefined, rows
55
55
  };
56
56
  });
57
57
  }
58
+
59
+ /* ---- "Open HERO Fleet Core" (web only, owner 2026-10-09) ---------------- */
60
+
61
+ /**
62
+ * Is this a console URL we may open? https, and the pass in the fragment
63
+ * (`#token=`) - the only form the console reads, and one no server ever logs.
64
+ */
65
+ export function isFleetCoreConsoleUrl(url: unknown): url is string {
66
+ if (typeof url !== "string") return false;
67
+ try {
68
+ const u = new URL(url);
69
+ return u.protocol === "https:" && /^#token=[\w-]+\.[\w-]+\.[\w-]+$/.test(u.hash);
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Open the console in a new tab. The tab is opened BEFORE the pass is fetched
77
+ * (inside the click), so popup blockers allow it; it is then pointed at the
78
+ * console, or closed if no pass came back. Returns false when it failed.
79
+ */
80
+ export async function openFleetCoreConsole(
81
+ getSession: () => Promise<{ url?: unknown } | null | undefined>,
82
+ openTab: () => { location: { href: string }; opener: unknown; close: () => void } | null,
83
+ ): Promise<boolean> {
84
+ const tab = openTab();
85
+ if (tab) tab.opener = null; // the console never gets a handle on this app
86
+ try {
87
+ const session = await getSession();
88
+ if (!tab || !isFleetCoreConsoleUrl(session?.url)) throw new Error("no pass");
89
+ tab.location.href = session!.url as string;
90
+ return true;
91
+ } catch {
92
+ tab?.close();
93
+ return false;
94
+ }
95
+ }
@@ -52,3 +52,12 @@ export const isBlankCode = (plate: unknown): boolean => /^([0-9A-Z]{4}-){5}[0-9A
52
52
  export function plateLines(plate: string): string[] {
53
53
  return isBlankCode(plate) ? [plate.slice(0, 14), plate.slice(15)] : [plate];
54
54
  }
55
+
56
+ /** The list's "QR sticker" cell for one row: how many of its plates already hold a sticker. */
57
+ export function qrStickerSummary(plates: Array<{ hasQrSticker?: boolean }>): { label: string; color: string } {
58
+ const total = plates.length;
59
+ const linked = plates.filter((p) => p?.hasQrSticker === true).length;
60
+ if (!total || !linked) return { label: "None", color: "grey" };
61
+ if (linked === total) return { label: "Linked", color: "success" };
62
+ return { label: `${linked} of ${total}`, color: "warning" };
63
+ }