@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 +16 -4
- package/components/Robot/FleetCoreButton.vue +48 -0
- package/components/Robot/SiteHeader.vue +2 -0
- package/components/VehicleManagement.vue +26 -3
- package/composables/useRobots.ts +13 -0
- package/composables/useVehicle.ts +2 -1
- package/package.json +1 -1
- package/types/people.d.ts +1 -1
- package/utils/robot-fleet-core.ts +38 -0
- package/utils/vehicle-qr-blank.ts +9 -0
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
|
-
|
|
3084
|
-
|
|
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
|
-
|
|
3087
|
-
|
|
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;
|
package/composables/useRobots.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|