@7365admin1/layer-common 4.113.0 → 4.114.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,11 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.114.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Vehicle Mgmt: the Link QR sticker dialog now offers a searchable list of the blank stickers still available, a Scan sticker camera button and typing as fallback; a count of blank stickers available (printed / linked) shows on Vehicle Mgmt and in the Blank stickers tab of the print dialog
|
|
8
|
+
|
|
3
9
|
## 4.113.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
subtitle="The register of residents' and visitors' vehicles. Works with or without number-plate (LPR) cameras - cameras only add automatic plate matching at the gate."
|
|
12
12
|
>
|
|
13
13
|
<template #actions>
|
|
14
|
+
<span v-if="qrStickersOn && qrSummary" class="text-caption text-medium-emphasis mr-2" data-test="vehicle-qr-summary">
|
|
15
|
+
{{ blankSummaryText(qrSummary) }}
|
|
16
|
+
</span>
|
|
14
17
|
<AppButton v-if="qrStickersOn" icon="mdi-qrcode" variant="ghost" @click="openStickerDialog()">
|
|
15
18
|
Print QR stickers
|
|
16
19
|
</AppButton>
|
|
@@ -393,10 +396,15 @@
|
|
|
393
396
|
<v-card data-test="vehicle-qr-link-dialog">
|
|
394
397
|
<v-card-title>Link QR sticker</v-card-title>
|
|
395
398
|
<v-card-text>
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
399
|
+
Pick an available sticker code, scan the sticker, or type the code printed on it. It will show
|
|
400
|
+
{{ qrPlate?.plateNumber }} when scanned.
|
|
401
|
+
<v-combobox v-model="qrLinkCode" v-model:search="qrSearch" :items="qrCodes" no-filter clearable label="Sticker code"
|
|
402
|
+
placeholder="Pick or type ABCD-EFGH-JKMN-PQRS-TVWX-YZ01" density="compact" variant="outlined" class="mt-3"
|
|
403
|
+
:disabled="qrBusy" :hint="qrLinkHint" persistent-hint hide-no-data data-test="vehicle-qr-link-code"
|
|
404
|
+
@keyup.enter="confirmLink(false)" />
|
|
405
|
+
<p v-if="qrSummary" class="text-caption mt-2" data-test="vehicle-qr-link-summary">{{ blankSummaryText(qrSummary) }}</p>
|
|
406
|
+
<p class="text-caption text-medium-emphasis">{{ BLANK_LIST_NOTE }}</p>
|
|
407
|
+
<VehicleQrCodeScanner class="mt-2" :disabled="qrBusy" @scanned="onQrScanned" />
|
|
400
408
|
<v-alert v-if="qrLinkAskReplace" type="warning" variant="tonal" density="compact" class="mt-3">
|
|
401
409
|
{{ qrError }} Link it anyway?
|
|
402
410
|
</v-alert>
|
|
@@ -405,7 +413,7 @@
|
|
|
405
413
|
<v-card-actions>
|
|
406
414
|
<v-spacer />
|
|
407
415
|
<v-btn variant="text" class="text-none" @click="dialog.qrLink = false">Cancel</v-btn>
|
|
408
|
-
<v-btn color="primary" variant="flat" class="text-none" :loading="qrBusy" :disabled="!
|
|
416
|
+
<v-btn color="primary" variant="flat" class="text-none" :loading="qrBusy" :disabled="!qrCodeToLink"
|
|
409
417
|
data-test="vehicle-qr-link-go" @click="confirmLink(qrLinkAskReplace)">
|
|
410
418
|
{{ qrLinkAskReplace ? "Link and stop the old sticker" : "Link" }}
|
|
411
419
|
</v-btn>
|
|
@@ -448,7 +456,7 @@ import {
|
|
|
448
456
|
// `error.message` put ofetch's own developer string in the delete dialog:
|
|
449
457
|
// `[PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502`.
|
|
450
458
|
import { errorConverter } from '../utils/data';
|
|
451
|
-
import { qrStickerSummary, stickerCodeHint } from '../utils/vehicle-qr-blank';
|
|
459
|
+
import { BLANK_LIST_NOTE, blankSummaryText, qrStickerSummary, stickerCodeHint, type BlankSummary } from '../utils/vehicle-qr-blank';
|
|
452
460
|
import { isPermissionRefusalError } from '../utils/permission-refusal';
|
|
453
461
|
|
|
454
462
|
|
|
@@ -552,7 +560,7 @@ const activeTab = ref<TVehicleTab>("residents");
|
|
|
552
560
|
|
|
553
561
|
|
|
554
562
|
const { formatCamelCaseToWords, formatDate, debounce, maskNRIC } = useUtils();
|
|
555
|
-
const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr, linkVehicleQr, unlinkVehicleQr } = useVehicle();
|
|
563
|
+
const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr, linkVehicleQr, unlinkVehicleQr, getAvailableBlankVehicleQr } = useVehicle();
|
|
556
564
|
|
|
557
565
|
// Vehicle QR stickers: the site's switch (Settings) decides whether the print
|
|
558
566
|
// and re-issue actions exist; only people who may update vehicles get them.
|
|
@@ -585,24 +593,56 @@ const qrLinkAskReplace = ref(false);
|
|
|
585
593
|
const qrBusy = ref(false);
|
|
586
594
|
const qrError = ref("");
|
|
587
595
|
// a gentle hint once something is typed, never a block - the server has the final say
|
|
588
|
-
const qrLinkHint = computed(() => (
|
|
596
|
+
const qrLinkHint = computed(() => (qrCodeToLink.value ? stickerCodeHint(qrCodeToLink.value) : ""));
|
|
597
|
+
// the combobox keeps a typed-but-not-committed code in its search text
|
|
598
|
+
const qrSearch = ref("");
|
|
599
|
+
const qrCodes = ref<string[]>([]);
|
|
600
|
+
const qrSummary = ref<BlankSummary | null>(null);
|
|
601
|
+
const qrCodeToLink = computed(() => String(qrLinkCode.value || qrSearch.value || "").trim());
|
|
602
|
+
|
|
603
|
+
// Saved blank codes still free to link + the site's printed / linked / available count.
|
|
604
|
+
async function loadQrAvailable(search = "") {
|
|
605
|
+
if (!qrStickersOn.value) return;
|
|
606
|
+
try {
|
|
607
|
+
const res = await getAvailableBlankVehicleQr(props.site, { search, limit: 50 });
|
|
608
|
+
qrSummary.value = res?.summary ?? null;
|
|
609
|
+
qrCodes.value = (res?.items ?? []).map((i) => i.code);
|
|
610
|
+
} catch {
|
|
611
|
+
qrSummary.value = null;
|
|
612
|
+
qrCodes.value = [];
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
let qrSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
616
|
+
watch(qrSearch, (v) => {
|
|
617
|
+
clearTimeout(qrSearchTimer);
|
|
618
|
+
if (dialog.qrLink) qrSearchTimer = setTimeout(() => loadQrAvailable(String(v ?? "")), 250);
|
|
619
|
+
});
|
|
620
|
+
watch(qrStickersOn, (on) => on && loadQrAvailable());
|
|
621
|
+
watch(() => dialog.qrStickers, (open) => !open && loadQrAvailable());
|
|
622
|
+
function onQrScanned(code: string) {
|
|
623
|
+
qrLinkCode.value = code;
|
|
624
|
+
qrSearch.value = code;
|
|
625
|
+
}
|
|
589
626
|
|
|
590
627
|
function askLink(plate: TPlateNumber) {
|
|
591
628
|
qrPlate.value = plate || null;
|
|
592
629
|
qrLinkCode.value = "";
|
|
630
|
+
qrSearch.value = "";
|
|
593
631
|
qrLinkAskReplace.value = false;
|
|
594
632
|
qrError.value = "";
|
|
595
633
|
dialog.qrLink = true;
|
|
634
|
+
loadQrAvailable();
|
|
596
635
|
}
|
|
597
636
|
|
|
598
637
|
async function confirmLink(replace: boolean) {
|
|
599
638
|
const id = qrPlate.value?._id;
|
|
600
|
-
if (!id || !
|
|
639
|
+
if (!id || !qrCodeToLink.value) return;
|
|
601
640
|
qrBusy.value = true;
|
|
602
641
|
qrError.value = "";
|
|
603
642
|
try {
|
|
604
|
-
await linkVehicleQr(
|
|
643
|
+
await linkVehicleQr(qrCodeToLink.value, String(id), replace);
|
|
605
644
|
dialog.qrLink = false;
|
|
645
|
+
loadQrAvailable();
|
|
606
646
|
showMessage(`QR sticker linked to ${qrPlate.value?.plateNumber}`, "success");
|
|
607
647
|
} catch (e: any) {
|
|
608
648
|
qrError.value = errorConverter(e);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<v-btn variant="tonal" size="small" class="text-none" prepend-icon="mdi-qrcode-scan" data-test="vehicle-qr-scan-btn"
|
|
4
|
+
:disabled="disabled" @click="on ? stop() : start()">
|
|
5
|
+
{{ on ? "Stop scanning" : "Scan sticker" }}
|
|
6
|
+
</v-btn>
|
|
7
|
+
<div v-show="on" :id="readerId" class="mt-2" style="width: 100%; max-width: 320px" />
|
|
8
|
+
<p v-if="err" class="text-caption text-error mt-1">{{ err }}</p>
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
<script setup lang="ts">
|
|
12
|
+
// Camera scan of a blank sticker: emits the code the QR carries ("S365V:" is trimmed).
|
|
13
|
+
defineProps({ disabled: { type: Boolean, default: false } });
|
|
14
|
+
const emit = defineEmits<{ (e: "scanned", code: string): void }>();
|
|
15
|
+
const { $Html5Qrcode }: any = useNuxtApp();
|
|
16
|
+
const readerId = `vqr-reader-${Math.random().toString(36).slice(2, 8)}`;
|
|
17
|
+
const on = ref(false);
|
|
18
|
+
const err = ref("");
|
|
19
|
+
let scanner: any = null;
|
|
20
|
+
|
|
21
|
+
async function start() {
|
|
22
|
+
err.value = "";
|
|
23
|
+
on.value = true;
|
|
24
|
+
await nextTick();
|
|
25
|
+
try {
|
|
26
|
+
scanner = new $Html5Qrcode(readerId);
|
|
27
|
+
await scanner.start(
|
|
28
|
+
{ facingMode: "environment" },
|
|
29
|
+
{ fps: 10, qrbox: { width: 220, height: 220 } },
|
|
30
|
+
async (text: string) => {
|
|
31
|
+
emit("scanned", String(text).replace(/^\s*S365V:/i, "").trim());
|
|
32
|
+
await stop();
|
|
33
|
+
},
|
|
34
|
+
() => {},
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
err.value = "Could not start the camera. Allow camera access, or type the code instead.";
|
|
38
|
+
on.value = false;
|
|
39
|
+
scanner = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function stop() {
|
|
44
|
+
const s = scanner;
|
|
45
|
+
scanner = null;
|
|
46
|
+
on.value = false;
|
|
47
|
+
try {
|
|
48
|
+
await s?.stop();
|
|
49
|
+
s?.clear?.();
|
|
50
|
+
} catch {
|
|
51
|
+
/* already stopped */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onBeforeUnmount(stop);
|
|
56
|
+
</script>
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
on a car, then link it to that vehicle in Vehicles > Link QR sticker - or scan it with iMA365 / iSecure365
|
|
25
25
|
and pick the vehicle there. Until it is linked, scanning it shows "not linked yet".
|
|
26
26
|
</p>
|
|
27
|
+
<p v-if="blankSummary" class="text-body-2 font-weight-medium mb-3" data-test="vehicle-qr-blank-summary">
|
|
28
|
+
{{ blankSummaryText(blankSummary) }}
|
|
29
|
+
</p>
|
|
27
30
|
<v-text-field v-model.number="blankQty" type="number" :min="1" :max="BLANK_MAX_COUNT" label="How many stickers?"
|
|
28
31
|
density="compact" variant="outlined" style="max-width: 240px" :hint="`1 to ${BLANK_MAX_COUNT} at a time`"
|
|
29
32
|
persistent-hint data-test="vehicle-qr-blank-count" />
|
|
@@ -224,7 +227,7 @@
|
|
|
224
227
|
<script lang="ts" setup>
|
|
225
228
|
import QRCode from "qrcode";
|
|
226
229
|
import useVehicle from "../composables/useVehicle";
|
|
227
|
-
import { BLANK_DEFAULT_COUNT, BLANK_MAX_COUNT, BLANK_STRIP_NOTE, blankCount, blankStickerItems } from "../utils/vehicle-qr-blank";
|
|
230
|
+
import { BLANK_DEFAULT_COUNT, BLANK_MAX_COUNT, BLANK_STRIP_NOTE, blankCount, blankStickerItems, blankSummaryText, type BlankSummary } from "../utils/vehicle-qr-blank";
|
|
228
231
|
import { errorConverter } from "../utils/data";
|
|
229
232
|
import { saveFile, stickerBundleName, stickerFileNames, svgToImage, zipFiles, type ImageFormat } from "../utils/vehicle-qr-export";
|
|
230
233
|
import {
|
|
@@ -273,7 +276,16 @@ const props = defineProps({
|
|
|
273
276
|
});
|
|
274
277
|
const emit = defineEmits(["update:modelValue"]);
|
|
275
278
|
|
|
276
|
-
const { getVehicles, getVehicleQrStickers, getBlankVehicleQrStickers } = useVehicle();
|
|
279
|
+
const { getVehicles, getVehicleQrStickers, getBlankVehicleQrStickers, getAvailableBlankVehicleQr } = useVehicle();
|
|
280
|
+
|
|
281
|
+
const blankSummary = ref<BlankSummary | null>(null);
|
|
282
|
+
async function loadBlankSummary() {
|
|
283
|
+
try {
|
|
284
|
+
blankSummary.value = (await getAvailableBlankVehicleQr(props.site, { limit: 1 }))?.summary ?? null;
|
|
285
|
+
} catch {
|
|
286
|
+
blankSummary.value = null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
277
289
|
|
|
278
290
|
// "vehicles" = stickers for chosen vehicles; "blank" = codes made in advance, linked to a vehicle later.
|
|
279
291
|
const mode = ref<"vehicles" | "blank">("vehicles");
|
|
@@ -373,6 +385,7 @@ watch(
|
|
|
373
385
|
() => props.modelValue,
|
|
374
386
|
async (open) => {
|
|
375
387
|
if (!open) return;
|
|
388
|
+
loadBlankSummary();
|
|
376
389
|
error.value = "";
|
|
377
390
|
items.value = [];
|
|
378
391
|
docHtml.value = "";
|
|
@@ -425,6 +438,7 @@ async function goReview() {
|
|
|
425
438
|
ticked.value = items.value.map((i) => i.vehicleId);
|
|
426
439
|
isBlank.value = true;
|
|
427
440
|
step.value = "review";
|
|
441
|
+
loadBlankSummary();
|
|
428
442
|
return;
|
|
429
443
|
}
|
|
430
444
|
isBlank.value = false;
|
|
@@ -315,6 +315,17 @@ function getSpecificVehicleById(id: string){
|
|
|
315
315
|
}>("/api/vehicles/qr/blank-stickers", { method: "POST", body: { site, count } });
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
// Blank codes saved when printed and still free to link, plus {printed, linked, available}.
|
|
319
|
+
function getAvailableBlankVehicleQr(site: string, query: { search?: string; page?: number; limit?: number } = {}) {
|
|
320
|
+
return useNuxtApp().$api<{
|
|
321
|
+
summary: { printed: number; linked: number; available: number };
|
|
322
|
+
total: number;
|
|
323
|
+
page: number;
|
|
324
|
+
limit: number;
|
|
325
|
+
items: Array<{ token: string; code: string }>;
|
|
326
|
+
}>(`/api/vehicles/qr/blank-stickers/site/${site}`, { query });
|
|
327
|
+
}
|
|
328
|
+
|
|
318
329
|
// `token` is sent as typed - the server ignores case, spaces and dashes.
|
|
319
330
|
function linkVehicleQr(token: string, vehicleId: string, replace?: boolean) {
|
|
320
331
|
return useNuxtApp().$api<Record<string, any>>("/api/vehicles/qr/link", {
|
|
@@ -353,6 +364,7 @@ function getSpecificVehicleById(id: string){
|
|
|
353
364
|
getVehicleQrStickers,
|
|
354
365
|
regenerateVehicleQr,
|
|
355
366
|
getBlankVehicleQrStickers,
|
|
367
|
+
getAvailableBlankVehicleQr,
|
|
356
368
|
linkVehicleQr,
|
|
357
369
|
unlinkVehicleQr,
|
|
358
370
|
};
|
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.114.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.",
|
|
@@ -61,3 +61,16 @@ export function qrStickerSummary(plates: Array<{ hasQrSticker?: boolean }>): { l
|
|
|
61
61
|
if (linked === total) return { label: "Linked", color: "success" };
|
|
62
62
|
return { label: `${linked} of ${total}`, color: "warning" };
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
export type BlankSummary = { printed: number; linked: number; available: number };
|
|
66
|
+
|
|
67
|
+
/** "12 blank stickers available to link (20 printed, 8 linked)" - or what to do when none were printed yet. */
|
|
68
|
+
export function blankSummaryText(s: Partial<BlankSummary> | null | undefined): string {
|
|
69
|
+
if (!s) return "";
|
|
70
|
+
const { printed = 0, linked = 0, available = 0 } = s;
|
|
71
|
+
if (!printed) return "No blank stickers printed yet. Print some under Print QR stickers > Blank stickers.";
|
|
72
|
+
return `${available} blank sticker${available === 1 ? "" : "s"} available to link (${printed} printed, ${linked} linked)`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shown beside the pick-list: stickers printed before the list existed were never saved. */
|
|
76
|
+
export const BLANK_LIST_NOTE = "Stickers printed before 9 Oct 2026 are not in this list - scan them or type their code.";
|