@7365admin1/layer-common 4.110.2 → 4.112.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 +111 -1
- package/components/VehicleQrStickerDialog.vue +61 -9
- package/composables/useRobots.ts +13 -0
- package/composables/useVehicle.ts +24 -0
- package/package.json +1 -1
- package/utils/robot-fleet-core.ts +38 -0
- package/utils/vehicle-qr-blank.ts +54 -0
- package/utils/vehicle-qr-sticker.ts +18 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.112.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## 4.111.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 0ba99c0: Vehicle QR blank (pre-generated) stickers: the Print QR stickers dialog gets "Blank stickers (link later)" (count 1-500, same designs, shapes, sizes, print / save sheet / PNG-JPEG; the printed code sits in the plate slot with a cut-off "not linked yet" strip), and each vehicle row gets "Link QR sticker" (type the printed code - case, spaces and dashes ignored; replace confirm when the vehicle already has a sticker) and "Remove QR sticker". useVehicle: getBlankVehicleQrStickers, linkVehicleQr, unlinkVehicleQr.
|
|
14
|
+
|
|
3
15
|
## 4.110.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -3074,11 +3086,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3074
3086
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3075
3087
|
own rule from two endpoints the console already calls, unprojected:
|
|
3076
3088
|
|
|
3077
|
-
|
|
3078
|
-
|
|
3089
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3090
|
+
GET /api/roles/id/:role that membership's role document
|
|
3079
3091
|
|
|
3080
|
-
|
|
3081
|
-
|
|
3092
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3093
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3082
3094
|
|
|
3083
3095
|
`role.default` is the marker because it is the only property of a platform
|
|
3084
3096
|
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(
|
|
@@ -101,6 +101,10 @@
|
|
|
101
101
|
</v-list-subheader>
|
|
102
102
|
<v-list-item v-if="qrStickersOn" title="Re-issue sticker" prepend-icon="mdi-qrcode-edit"
|
|
103
103
|
@click="askReissue(plate as TPlateNumber)" />
|
|
104
|
+
<v-list-item v-if="qrStickersOn" title="Link QR sticker" prepend-icon="mdi-link-variant"
|
|
105
|
+
@click="askLink(plate as TPlateNumber)" />
|
|
106
|
+
<v-list-item v-if="qrStickersOn" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
|
|
107
|
+
@click="askUnlink(plate as TPlateNumber)" />
|
|
104
108
|
<v-list-item v-for="action in vehiclePlateActions(plate, vehiclePermissions)" :key="action.key"
|
|
105
109
|
:title="action.title" :prepend-icon="action.icon" :base-color="action.color"
|
|
106
110
|
@click="runVehicleAction(action.key, plate as TPlateNumber)" />
|
|
@@ -227,6 +231,10 @@
|
|
|
227
231
|
<v-list density="compact">
|
|
228
232
|
<v-list-item v-if="qrStickersOn" title="Re-issue sticker" prepend-icon="mdi-qrcode-edit"
|
|
229
233
|
@click="askReissue(plateNumberItem as TPlateNumber)" />
|
|
234
|
+
<v-list-item v-if="qrStickersOn" title="Link QR sticker" prepend-icon="mdi-link-variant"
|
|
235
|
+
@click="askLink(plateNumberItem as TPlateNumber)" />
|
|
236
|
+
<v-list-item v-if="qrStickersOn" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
|
|
237
|
+
@click="askUnlink(plateNumberItem as TPlateNumber)" />
|
|
230
238
|
<v-list-item
|
|
231
239
|
v-for="action in vehiclePlateActions(plateNumberItem, vehiclePermissions)"
|
|
232
240
|
:key="action.key" :title="action.title" :prepend-icon="action.icon"
|
|
@@ -371,6 +379,48 @@
|
|
|
371
379
|
</v-card-actions>
|
|
372
380
|
</v-card>
|
|
373
381
|
</v-dialog>
|
|
382
|
+
|
|
383
|
+
<v-dialog v-model="dialog.qrLink" width="460">
|
|
384
|
+
<v-card data-test="vehicle-qr-link-dialog">
|
|
385
|
+
<v-card-title>Link QR sticker</v-card-title>
|
|
386
|
+
<v-card-text>
|
|
387
|
+
Type the code printed on the sticker. It will show {{ qrPlate?.plateNumber }} when scanned.
|
|
388
|
+
<v-text-field v-model="qrLinkCode" label="Sticker code" placeholder="ABCD-EFGH-JKMN-PQRS-TVWX-YZ01" density="compact"
|
|
389
|
+
variant="outlined" class="mt-3" autofocus :disabled="qrBusy" :hint="qrLinkHint" persistent-hint
|
|
390
|
+
data-test="vehicle-qr-link-code" @keyup.enter="confirmLink(false)" />
|
|
391
|
+
<v-alert v-if="qrLinkAskReplace" type="warning" variant="tonal" density="compact" class="mt-3">
|
|
392
|
+
{{ qrError }} Link it anyway?
|
|
393
|
+
</v-alert>
|
|
394
|
+
<v-alert v-else-if="qrError" type="error" variant="tonal" density="compact" class="mt-3">{{ qrError }}</v-alert>
|
|
395
|
+
</v-card-text>
|
|
396
|
+
<v-card-actions>
|
|
397
|
+
<v-spacer />
|
|
398
|
+
<v-btn variant="text" class="text-none" @click="dialog.qrLink = false">Cancel</v-btn>
|
|
399
|
+
<v-btn color="primary" variant="flat" class="text-none" :loading="qrBusy" :disabled="!qrLinkCode.trim()"
|
|
400
|
+
data-test="vehicle-qr-link-go" @click="confirmLink(qrLinkAskReplace)">
|
|
401
|
+
{{ qrLinkAskReplace ? "Link and stop the old sticker" : "Link" }}
|
|
402
|
+
</v-btn>
|
|
403
|
+
</v-card-actions>
|
|
404
|
+
</v-card>
|
|
405
|
+
</v-dialog>
|
|
406
|
+
|
|
407
|
+
<v-dialog v-model="dialog.qrUnlink" width="460">
|
|
408
|
+
<v-card data-test="vehicle-qr-unlink-dialog">
|
|
409
|
+
<v-card-title>Remove QR sticker?</v-card-title>
|
|
410
|
+
<v-card-text>
|
|
411
|
+
The QR sticker on {{ qrPlate?.plateNumber }} will stop working. Scanning it will no longer show this vehicle.
|
|
412
|
+
<v-alert v-if="qrError" type="error" variant="tonal" density="compact" class="mt-3">{{ qrError }}</v-alert>
|
|
413
|
+
</v-card-text>
|
|
414
|
+
<v-card-actions>
|
|
415
|
+
<v-spacer />
|
|
416
|
+
<v-btn variant="text" class="text-none" @click="dialog.qrUnlink = false">Cancel</v-btn>
|
|
417
|
+
<v-btn color="error" variant="flat" class="text-none" :loading="qrBusy" data-test="vehicle-qr-unlink-go"
|
|
418
|
+
@click="confirmUnlink">
|
|
419
|
+
Remove
|
|
420
|
+
</v-btn>
|
|
421
|
+
</v-card-actions>
|
|
422
|
+
</v-card>
|
|
423
|
+
</v-dialog>
|
|
374
424
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
375
425
|
</v-row>
|
|
376
426
|
</template>
|
|
@@ -389,6 +439,7 @@ import {
|
|
|
389
439
|
// `error.message` put ofetch's own developer string in the delete dialog:
|
|
390
440
|
// `[PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502`.
|
|
391
441
|
import { errorConverter } from '../utils/data';
|
|
442
|
+
import { stickerCodeHint } from '../utils/vehicle-qr-blank';
|
|
392
443
|
import { isPermissionRefusalError } from '../utils/permission-refusal';
|
|
393
444
|
|
|
394
445
|
|
|
@@ -490,7 +541,7 @@ const activeTab = ref<TVehicleTab>("residents");
|
|
|
490
541
|
|
|
491
542
|
|
|
492
543
|
const { formatCamelCaseToWords, formatDate, debounce, maskNRIC } = useUtils();
|
|
493
|
-
const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr } = useVehicle();
|
|
544
|
+
const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr, linkVehicleQr, unlinkVehicleQr } = useVehicle();
|
|
494
545
|
|
|
495
546
|
// Vehicle QR stickers: the site's switch (Settings) decides whether the print
|
|
496
547
|
// and re-issue actions exist; only people who may update vehicles get them.
|
|
@@ -510,6 +561,63 @@ async function loadQrSetting() {
|
|
|
510
561
|
}
|
|
511
562
|
onMounted(loadQrSetting);
|
|
512
563
|
|
|
564
|
+
// Link a blank (pre-printed) sticker to a vehicle, or take a sticker off one.
|
|
565
|
+
const qrPlate = ref<TPlateNumber | null>(null);
|
|
566
|
+
const qrLinkCode = ref("");
|
|
567
|
+
const qrLinkAskReplace = ref(false);
|
|
568
|
+
const qrBusy = ref(false);
|
|
569
|
+
const qrError = ref("");
|
|
570
|
+
// a gentle hint once something is typed, never a block - the server has the final say
|
|
571
|
+
const qrLinkHint = computed(() => (qrLinkCode.value.trim() ? stickerCodeHint(qrLinkCode.value) : ""));
|
|
572
|
+
|
|
573
|
+
function askLink(plate: TPlateNumber) {
|
|
574
|
+
qrPlate.value = plate || null;
|
|
575
|
+
qrLinkCode.value = "";
|
|
576
|
+
qrLinkAskReplace.value = false;
|
|
577
|
+
qrError.value = "";
|
|
578
|
+
dialog.qrLink = true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function confirmLink(replace: boolean) {
|
|
582
|
+
const id = qrPlate.value?._id;
|
|
583
|
+
if (!id || !qrLinkCode.value.trim()) return;
|
|
584
|
+
qrBusy.value = true;
|
|
585
|
+
qrError.value = "";
|
|
586
|
+
try {
|
|
587
|
+
await linkVehicleQr(qrLinkCode.value.trim(), String(id), replace);
|
|
588
|
+
dialog.qrLink = false;
|
|
589
|
+
showMessage(`QR sticker linked to ${qrPlate.value?.plateNumber}`, "success");
|
|
590
|
+
} catch (e: any) {
|
|
591
|
+
qrError.value = errorConverter(e);
|
|
592
|
+
// the vehicle already has a sticker: ask, then resend with replace
|
|
593
|
+
qrLinkAskReplace.value = !replace && /already has a QR sticker/i.test(qrError.value);
|
|
594
|
+
} finally {
|
|
595
|
+
qrBusy.value = false;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function askUnlink(plate: TPlateNumber) {
|
|
600
|
+
qrPlate.value = plate || null;
|
|
601
|
+
qrError.value = "";
|
|
602
|
+
dialog.qrUnlink = true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function confirmUnlink() {
|
|
606
|
+
const id = qrPlate.value?._id;
|
|
607
|
+
if (!id) return;
|
|
608
|
+
qrBusy.value = true;
|
|
609
|
+
qrError.value = "";
|
|
610
|
+
try {
|
|
611
|
+
await unlinkVehicleQr(String(id));
|
|
612
|
+
dialog.qrUnlink = false;
|
|
613
|
+
showMessage(`QR sticker removed from ${qrPlate.value?.plateNumber}`, "success");
|
|
614
|
+
} catch (e: any) {
|
|
615
|
+
qrError.value = errorConverter(e);
|
|
616
|
+
} finally {
|
|
617
|
+
qrBusy.value = false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
513
621
|
function openStickerDialog(ids: string[] = []) {
|
|
514
622
|
qrVehicleIds.value = ids;
|
|
515
623
|
dialog.qrStickers = true;
|
|
@@ -622,6 +730,8 @@ const dialog = reactive({
|
|
|
622
730
|
bulkVehicle: false,
|
|
623
731
|
qrStickers: false,
|
|
624
732
|
qrReissue: false,
|
|
733
|
+
qrLink: false,
|
|
734
|
+
qrUnlink: false,
|
|
625
735
|
});
|
|
626
736
|
|
|
627
737
|
|
|
@@ -13,7 +13,22 @@
|
|
|
13
13
|
|
|
14
14
|
<!-- Step 1: choose vehicles -->
|
|
15
15
|
<template v-if="step === 'select'">
|
|
16
|
-
<
|
|
16
|
+
<v-btn-toggle v-model="mode" mandatory density="compact" color="primary" variant="outlined" divided class="mb-3"
|
|
17
|
+
data-test="vehicle-qr-mode">
|
|
18
|
+
<v-btn value="vehicles" class="text-none" data-test="vehicle-qr-mode-vehicles">For vehicles</v-btn>
|
|
19
|
+
<v-btn value="blank" class="text-none" data-test="vehicle-qr-mode-blank">Blank stickers (link later)</v-btn>
|
|
20
|
+
</v-btn-toggle>
|
|
21
|
+
<div v-if="mode === 'blank'" data-test="vehicle-qr-blank">
|
|
22
|
+
<p class="text-body-2 mb-3">
|
|
23
|
+
Make QR stickers before you know which car each is for. Each sticker has its own code printed on it. Paste it
|
|
24
|
+
on a car, then link it to that vehicle in Vehicles > Link QR sticker - or scan it with iMA365 / iSecure365
|
|
25
|
+
and pick the vehicle there. Until it is linked, scanning it shows "not linked yet".
|
|
26
|
+
</p>
|
|
27
|
+
<v-text-field v-model.number="blankQty" type="number" :min="1" :max="BLANK_MAX_COUNT" label="How many stickers?"
|
|
28
|
+
density="compact" variant="outlined" style="max-width: 240px" :hint="`1 to ${BLANK_MAX_COUNT} at a time`"
|
|
29
|
+
persistent-hint data-test="vehicle-qr-blank-count" />
|
|
30
|
+
</div>
|
|
31
|
+
<div v-if="mode === 'vehicles'" class="d-flex flex-wrap align-center ga-2 mb-2">
|
|
17
32
|
<v-checkbox-btn :model-value="allOnPageTicked" :indeterminate="someOnPageTicked && !allOnPageTicked"
|
|
18
33
|
aria-label="Select all on this page" @update:model-value="(v: any) => tickGroup(plates.map((p) => p.id), !!v)" />
|
|
19
34
|
<span class="text-body-2">Select all on this page</span>
|
|
@@ -22,7 +37,7 @@
|
|
|
22
37
|
Select all matching the current tab and search
|
|
23
38
|
</v-btn>
|
|
24
39
|
</div>
|
|
25
|
-
<v-table density="compact" fixed-header height="360">
|
|
40
|
+
<v-table v-if="mode === 'vehicles'" density="compact" fixed-header height="360">
|
|
26
41
|
<thead>
|
|
27
42
|
<tr><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Status</th></tr>
|
|
28
43
|
</thead>
|
|
@@ -38,7 +53,7 @@
|
|
|
38
53
|
<tr v-if="!plates.length"><td colspan="5" class="text-center text-medium-emphasis py-6">No vehicles to list</td></tr>
|
|
39
54
|
</tbody>
|
|
40
55
|
</v-table>
|
|
41
|
-
<p class="text-caption text-medium-emphasis mt-2 mb-0">
|
|
56
|
+
<p v-if="mode === 'vehicles'" class="text-caption text-medium-emphasis mt-2 mb-0">
|
|
42
57
|
Up to {{ STICKER_MAX_PER_REQUEST }} stickers at a time. Ticked: {{ selected.length }}.
|
|
43
58
|
</p>
|
|
44
59
|
</template>
|
|
@@ -53,12 +68,18 @@
|
|
|
53
68
|
</v-alert>
|
|
54
69
|
<v-table density="compact" fixed-header height="220">
|
|
55
70
|
<thead>
|
|
56
|
-
<tr><th style="width: 48px" /><th>
|
|
71
|
+
<tr v-if="isBlank"><th style="width: 48px" /><th>Code</th><th>Note</th></tr>
|
|
72
|
+
<tr v-else><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Check</th></tr>
|
|
57
73
|
</thead>
|
|
58
74
|
<tbody>
|
|
59
75
|
<tr v-for="it in items" :key="it.vehicleId">
|
|
60
76
|
<td><v-checkbox-btn :model-value="ticked.includes(it.vehicleId)" :aria-label="`Print ${it.plateNumber}`"
|
|
61
77
|
@update:model-value="(v: any) => (ticked = setIds(ticked, [it.vehicleId], !!v))" /></td>
|
|
78
|
+
<template v-if="isBlank">
|
|
79
|
+
<td class="font-weight-bold">{{ it.plateNumber }}</td>
|
|
80
|
+
<td class="text-medium-emphasis">{{ BLANK_STRIP_NOTE }}</td>
|
|
81
|
+
</template>
|
|
82
|
+
<template v-else>
|
|
62
83
|
<td>{{ installerLine(it).place || "-" }}</td>
|
|
63
84
|
<td>{{ installerLine(it).owner || "-" }}</td>
|
|
64
85
|
<td class="font-weight-bold">{{ it.plateNumber }}</td>
|
|
@@ -69,10 +90,20 @@
|
|
|
69
90
|
</v-chip>
|
|
70
91
|
<span v-else class="text-medium-emphasis">OK</span>
|
|
71
92
|
</td>
|
|
93
|
+
</template>
|
|
72
94
|
</tr>
|
|
73
95
|
</tbody>
|
|
74
96
|
</v-table>
|
|
75
|
-
<
|
|
97
|
+
<v-alert v-if="isBlank && design.shape !== 'square' && design.text === 'none'" type="warning" variant="tonal"
|
|
98
|
+
density="compact" class="my-2" data-test="vehicle-qr-blank-notext">
|
|
99
|
+
With "No text" the sticker's code is not printed on it, so these stickers can only be linked by scanning them with
|
|
100
|
+
iMA365 / iSecure365 - Vehicles > Link QR sticker needs the printed code. Pick another Text option to print it.
|
|
101
|
+
</v-alert>
|
|
102
|
+
<p v-if="isBlank" class="text-caption text-medium-emphasis my-2">
|
|
103
|
+
Each sticker has a cut-off strip under the dashed line saying it is not linked yet - cut it off before pasting
|
|
104
|
+
the sticker on the car. The code on the sticker is what you type in Vehicles > Link QR sticker.
|
|
105
|
+
</p>
|
|
106
|
+
<p v-else class="text-caption text-medium-emphasis my-2">
|
|
76
107
|
Expired, blocklisted and inactive vehicles start unticked. Each sticker has a cut-off strip under the
|
|
77
108
|
dashed line saying which unit and owner it is for - cut it off before pasting the sticker on the car.
|
|
78
109
|
</p>
|
|
@@ -157,9 +188,10 @@
|
|
|
157
188
|
<v-btn v-if="step === 'review' && !fromReissue" variant="text" class="text-none" @click="step = 'select'">Back</v-btn>
|
|
158
189
|
<v-spacer />
|
|
159
190
|
<v-btn variant="text" class="text-none" @click="close(false)">Cancel</v-btn>
|
|
160
|
-
<v-btn v-if="step === 'select'" color="primary" variant="flat" class="text-none"
|
|
161
|
-
:loading="loading" @click="goReview">
|
|
162
|
-
Preview {{
|
|
191
|
+
<v-btn v-if="step === 'select'" color="primary" variant="flat" class="text-none"
|
|
192
|
+
:disabled="mode === 'blank' ? !blankCount(blankQty) : !selected.length" :loading="loading" @click="goReview">
|
|
193
|
+
<template v-if="mode === 'blank'">Preview {{ blankCount(blankQty) }} blank sticker{{ blankCount(blankQty) === 1 ? "" : "s" }}</template>
|
|
194
|
+
<template v-else>Preview {{ selected.length }} sticker{{ selected.length === 1 ? "" : "s" }}</template>
|
|
163
195
|
</v-btn>
|
|
164
196
|
<template v-if="step === 'review'">
|
|
165
197
|
<v-btn variant="tonal" class="text-none" prepend-icon="mdi-content-save" :disabled="!ticked.length || !docHtml"
|
|
@@ -192,6 +224,7 @@
|
|
|
192
224
|
<script lang="ts" setup>
|
|
193
225
|
import QRCode from "qrcode";
|
|
194
226
|
import useVehicle from "../composables/useVehicle";
|
|
227
|
+
import { BLANK_DEFAULT_COUNT, BLANK_MAX_COUNT, BLANK_STRIP_NOTE, blankCount, blankStickerItems } from "../utils/vehicle-qr-blank";
|
|
195
228
|
import { errorConverter } from "../utils/data";
|
|
196
229
|
import { saveFile, stickerBundleName, stickerFileNames, svgToImage, zipFiles, type ImageFormat } from "../utils/vehicle-qr-export";
|
|
197
230
|
import {
|
|
@@ -240,7 +273,12 @@ const props = defineProps({
|
|
|
240
273
|
});
|
|
241
274
|
const emit = defineEmits(["update:modelValue"]);
|
|
242
275
|
|
|
243
|
-
const { getVehicles, getVehicleQrStickers } = useVehicle();
|
|
276
|
+
const { getVehicles, getVehicleQrStickers, getBlankVehicleQrStickers } = useVehicle();
|
|
277
|
+
|
|
278
|
+
// "vehicles" = stickers for chosen vehicles; "blank" = codes made in advance, linked to a vehicle later.
|
|
279
|
+
const mode = ref<"vehicles" | "blank">("vehicles");
|
|
280
|
+
const blankQty = ref<number | string>(BLANK_DEFAULT_COUNT);
|
|
281
|
+
const isBlank = ref(false); // the mode the shown review was made in
|
|
244
282
|
|
|
245
283
|
const step = ref<"select" | "review">("select");
|
|
246
284
|
const plates = ref<StickerPlateRow[]>([]);
|
|
@@ -339,6 +377,8 @@ watch(
|
|
|
339
377
|
items.value = [];
|
|
340
378
|
docHtml.value = "";
|
|
341
379
|
step.value = "select";
|
|
380
|
+
mode.value = "vehicles";
|
|
381
|
+
isBlank.value = false;
|
|
342
382
|
plates.value = stickerPlateRows(props.rows);
|
|
343
383
|
selected.value = defaultPlateIds(plates.value);
|
|
344
384
|
if (props.vehicleIds.length) {
|
|
@@ -376,6 +416,18 @@ async function goReview() {
|
|
|
376
416
|
loading.value = true;
|
|
377
417
|
error.value = "";
|
|
378
418
|
try {
|
|
419
|
+
if (mode.value === "blank" && !props.vehicleIds.length) {
|
|
420
|
+
const blank = await getBlankVehicleQrStickers(props.site, blankCount(blankQty.value));
|
|
421
|
+
items.value = blankStickerItems(blank?.items ?? []);
|
|
422
|
+
siteName.value = String(blank?.site?.name ?? "");
|
|
423
|
+
missing.value = [];
|
|
424
|
+
dropped.value = 0;
|
|
425
|
+
ticked.value = items.value.map((i) => i.vehicleId);
|
|
426
|
+
isBlank.value = true;
|
|
427
|
+
step.value = "review";
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
isBlank.value = false;
|
|
379
431
|
const { ids, dropped: over } = idsForRequest(selected.value);
|
|
380
432
|
const res = await getVehicleQrStickers(props.site, ids);
|
|
381
433
|
items.value = (res?.items ?? []) as StickerItem[];
|
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,
|
|
@@ -306,6 +306,27 @@ function getSpecificVehicleById(id: string){
|
|
|
306
306
|
);
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// Blank (pre-generated) stickers: print codes first, link them to vehicles later.
|
|
310
|
+
function getBlankVehicleQrStickers(site: string, count: number) {
|
|
311
|
+
return useNuxtApp().$api<{
|
|
312
|
+
site: { _id: string; name: string };
|
|
313
|
+
items: Array<{ token: string; qr: string; code: string }>;
|
|
314
|
+
}>("/api/vehicles/qr/blank-stickers", { method: "POST", body: { site, count } });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// `token` is sent as typed - the server ignores case, spaces and dashes.
|
|
318
|
+
function linkVehicleQr(token: string, vehicleId: string, replace?: boolean) {
|
|
319
|
+
return useNuxtApp().$api<Record<string, any>>("/api/vehicles/qr/link", {
|
|
320
|
+
method: "POST",
|
|
321
|
+
body: { token, vehicleId, ...(replace ? { replace: true } : {}) },
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function unlinkVehicleQr(id: string) {
|
|
326
|
+
return useNuxtApp().$api<{ vehicleId: string; linked: boolean }>(`/api/vehicles/qr/unlink/id/${id}`, {
|
|
327
|
+
method: "PUT",
|
|
328
|
+
});
|
|
329
|
+
}
|
|
309
330
|
|
|
310
331
|
return {
|
|
311
332
|
getVehicles,
|
|
@@ -330,5 +351,8 @@ function getSpecificVehicleById(id: string){
|
|
|
330
351
|
setVehicleQrSettings,
|
|
331
352
|
getVehicleQrStickers,
|
|
332
353
|
regenerateVehicleQr,
|
|
354
|
+
getBlankVehicleQrStickers,
|
|
355
|
+
linkVehicleQr,
|
|
356
|
+
unlinkVehicleQr,
|
|
333
357
|
};
|
|
334
358
|
}
|
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.112.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.",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blank (pre-generated) vehicle QR stickers - the pure parts: turn the
|
|
3
|
+
* server's blank codes into sticker items, and check a code typed by staff.
|
|
4
|
+
* Kept free of Vue so `node --test` can run it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { StickerItem } from "./vehicle-qr-sticker.ts";
|
|
8
|
+
|
|
9
|
+
/** Most blank stickers per request (server: POST /api/vehicles/qr/blank-stickers). */
|
|
10
|
+
export const BLANK_MAX_COUNT = 500;
|
|
11
|
+
export const BLANK_DEFAULT_COUNT = 20;
|
|
12
|
+
/** Characters in a blank sticker token (Crockford base32, 120 bits). */
|
|
13
|
+
export const BLANK_TOKEN_LENGTH = 24;
|
|
14
|
+
|
|
15
|
+
/** What the installer strip says under a blank sticker. */
|
|
16
|
+
export const BLANK_STRIP_NOTE = "Not linked yet - link it in Vehicles > Link QR sticker, or scan it with iMA365 / iSecure365";
|
|
17
|
+
|
|
18
|
+
export type BlankItem = { token: string; qr: string; code: string };
|
|
19
|
+
|
|
20
|
+
/** A whole number 1..500, or 0 when the field is empty or not a number. */
|
|
21
|
+
export function blankCount(value: unknown): number {
|
|
22
|
+
const n = Math.floor(Number(value));
|
|
23
|
+
return Number.isFinite(n) && n >= 1 ? Math.min(n, BLANK_MAX_COUNT) : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Sticker items for the normal sticker flow: token = the unique key, the code is printed in the plate slot. */
|
|
27
|
+
export function blankStickerItems(items: BlankItem[]): StickerItem[] {
|
|
28
|
+
return items.map((i) => ({ vehicleId: i.token, qr: i.qr, token: i.token, plateNumber: i.code, status: "active", blank: true }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What staff type, as the server reads it: no spaces or dashes, capitals, I/L as 1, O as 0. A pasted "S365V:" is ignored. */
|
|
32
|
+
export function normalizeStickerCode(input: unknown): string {
|
|
33
|
+
return String(input ?? "")
|
|
34
|
+
.replace(/^\s*S365V:/i, "")
|
|
35
|
+
.replace(/[^0-9A-Za-z]/g, "")
|
|
36
|
+
.toUpperCase()
|
|
37
|
+
.replace(/[IL]/g, "1")
|
|
38
|
+
.replace(/O/g, "0");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "" when the typed code looks right, else a gentle hint (the server has the final say). */
|
|
42
|
+
export function stickerCodeHint(input: unknown): string {
|
|
43
|
+
const code = normalizeStickerCode(input);
|
|
44
|
+
if (code.length === BLANK_TOKEN_LENGTH && /^[0-9A-HJKMNP-TV-Z]+$/.test(code)) return "";
|
|
45
|
+
return "Sticker codes look like ABCD-EFGH-JKMN-PQRS-TVWX-YZ01 (24 letters and numbers, printed under the QR code).";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A blank sticker's printed code, "ABCD-EFGH-JKMN-PQRS-TVWX-YZ01"? */
|
|
49
|
+
export const isBlankCode = (plate: unknown): boolean => /^([0-9A-Z]{4}-){5}[0-9A-Z]{4}$/.test(String(plate ?? ""));
|
|
50
|
+
|
|
51
|
+
/** Two lines for a blank code so the letters can be big enough to read; anything else is one line. */
|
|
52
|
+
export function plateLines(plate: string): string[] {
|
|
53
|
+
return isBlankCode(plate) ? [plate.slice(0, 14), plate.slice(15)] : [plate];
|
|
54
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
stickerTheme,
|
|
19
19
|
type StickerDesign,
|
|
20
20
|
} from "./vehicle-qr-design.ts";
|
|
21
|
+
import { BLANK_STRIP_NOTE, plateLines } from "./vehicle-qr-blank.ts";
|
|
21
22
|
|
|
22
23
|
/** The server's most vehicles per request (POST /api/vehicles/qr/stickers). */
|
|
23
24
|
export const STICKER_MAX_PER_REQUEST = 500;
|
|
@@ -170,6 +171,8 @@ export type StickerItem = {
|
|
|
170
171
|
plateNumber?: string;
|
|
171
172
|
ownerName?: string;
|
|
172
173
|
status?: string;
|
|
174
|
+
/** A blank (pre-generated) sticker: `plateNumber` holds its printed code and it is not linked to a vehicle yet. */
|
|
175
|
+
blank?: boolean;
|
|
173
176
|
};
|
|
174
177
|
|
|
175
178
|
export type StickerPlateRow = {
|
|
@@ -304,6 +307,7 @@ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family:
|
|
|
304
307
|
.mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm; color: #fff; font-weight: 800; font-size: 5pt; line-height: 3.2mm; text-align: center; }
|
|
305
308
|
.cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top: 0.8mm; font-size: 6.5pt; line-height: 1.2; color: #111; height: ${l.stripH - 1.2}mm; overflow: hidden; }
|
|
306
309
|
.cut.free { margin-top: 1.6mm; border-top: 0; padding-top: 0; height: ${l.stripH - 1.6}mm; }
|
|
310
|
+
.cut.blank { font-size: 5pt; line-height: 1.15; }
|
|
307
311
|
.cut .owner { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
308
312
|
.info { position: absolute; left: 0; right: 0; bottom: 0; height: ${INFO_H}mm; border-top: 0.2mm solid #bbb; padding-top: 1mm; font-size: 6.5pt; line-height: 1.3; color: #333; display: flex; gap: 3mm; align-items: flex-start; }
|
|
309
313
|
.info .sizes { flex: 1; }
|
|
@@ -342,7 +346,7 @@ function plateSizeMm(plate: string, boxW: number, max = 4.6): number {
|
|
|
342
346
|
* `items` (each sticker's frame + QR, already drawn for the chosen design).
|
|
343
347
|
* Every value is HTML-escaped - an owner or site name is typed by a person.
|
|
344
348
|
*/
|
|
345
|
-
type CardContext = { ink: string; label: string; layout: SheetLayout; k: number; shaped: boolean; bare: boolean; wordsTop: number; tag: { x: number; y: number; w: number; h: number } | null };
|
|
349
|
+
type CardContext = { cardW: number; ink: string; label: string; layout: SheetLayout; k: number; shaped: boolean; bare: boolean; wordsTop: number; tag: { x: number; y: number; w: number; h: number } | null };
|
|
346
350
|
|
|
347
351
|
function cardContext(design: StickerDesign, siteName: string | undefined, n: number): CardContext {
|
|
348
352
|
const g = stickerGeometry(design, n);
|
|
@@ -350,6 +354,7 @@ function cardContext(design: StickerDesign, siteName: string | undefined, n: num
|
|
|
350
354
|
// Shaped codes: plate (and words) in ink on the paper tab under the shape - or no text at all.
|
|
351
355
|
const shaped = !!g.pts;
|
|
352
356
|
return {
|
|
357
|
+
cardW: sheetLayout(design).cellW,
|
|
353
358
|
ink: stickerTheme(design.theme).ink,
|
|
354
359
|
label: stickerLabel(design, siteName),
|
|
355
360
|
layout: sheetLayout(design),
|
|
@@ -363,17 +368,20 @@ function cardContext(design: StickerDesign, siteName: string | undefined, n: num
|
|
|
363
368
|
|
|
364
369
|
/** The sticker itself (frame + QR + plate words) - what goes on the car. */
|
|
365
370
|
function cardHtml(item: StickerItem, face: string, c: CardContext): string {
|
|
366
|
-
const
|
|
367
|
-
const
|
|
371
|
+
const lines = plateLines(text(item.plateNumber));
|
|
372
|
+
const plate = lines.map(escapeHtml).join("<br>");
|
|
373
|
+
const longest = lines.reduce((a, l) => (l.length > a.length ? l : a), "");
|
|
374
|
+
// a two-line code has no room for the words under it inside the tag
|
|
375
|
+
const label = c.label && !(c.tag && lines.length > 1) ? `<div class="hint">${escapeHtml(c.label)}</div>` : "";
|
|
368
376
|
const words = c.shaped && !c.tag
|
|
369
377
|
? ""
|
|
370
378
|
: c.tag
|
|
371
379
|
? `<div class="words band" style="left:${c.tag.x}mm;right:auto;top:${c.tag.y}mm;width:${c.tag.w}mm;height:${c.tag.h}mm">` +
|
|
372
|
-
`<div class="plate" style="font-size:${plateSizeMm(
|
|
380
|
+
`<div class="plate" style="font-size:${plateSizeMm(longest, c.tag.w, 4.6 * c.k)}mm">${plate}</div>` +
|
|
373
381
|
label +
|
|
374
382
|
`</div>`
|
|
375
383
|
: `<div class="words" style="top:${c.wordsTop}mm">` +
|
|
376
|
-
`<div class="plate">${plate}</div>` +
|
|
384
|
+
`<div class="plate"${lines.length > 1 ? ` style="font-size:${plateSizeMm(longest, c.cardW - 6, 4.6)}mm"` : ""}>${plate}</div>` +
|
|
377
385
|
label +
|
|
378
386
|
`<div class="brand"><span class="mark" style="background:${c.ink}">7</span>Powered by Seven365</div>` +
|
|
379
387
|
`</div>`;
|
|
@@ -395,10 +403,13 @@ export function buildStickerDocument(
|
|
|
395
403
|
const c = cardContext(design, opts.siteName, n);
|
|
396
404
|
const cells = items.map((item, i) => {
|
|
397
405
|
const { place, owner } = installerLine(item);
|
|
406
|
+
const [noteHead, noteRest] = BLANK_STRIP_NOTE.split(" - ");
|
|
407
|
+
const strip = item.blank
|
|
408
|
+
? `<div class="cut${c.bare ? " free" : ""} blank"><div><b>${escapeHtml(noteHead)}</b></div><div>${escapeHtml(noteRest)}</div></div>`
|
|
409
|
+
: `<div class="cut${c.bare ? " free" : ""}"><div><b>${escapeHtml(place || "-")}</b></div><div class="owner">Owner: ${escapeHtml(owner || "-")}</div></div>`;
|
|
398
410
|
return (
|
|
399
411
|
`<div class="cell">${cardHtml(item, faceSvgs[i] ?? "", c)}` +
|
|
400
|
-
|
|
401
|
-
`<div class="owner">Owner: ${escapeHtml(owner || "-")}</div></div></div>`
|
|
412
|
+
`${strip}</div>`
|
|
402
413
|
);
|
|
403
414
|
});
|
|
404
415
|
const marks = cropMarksSvg(c.layout);
|