@7365admin1/layer-common 4.112.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 +12 -0
- package/components/VehicleManagement.vue +75 -12
- package/components/VehicleQrCodeScanner.vue +56 -0
- package/components/VehicleQrStickerDialog.vue +16 -2
- package/composables/useVehicle.ts +14 -1
- package/package.json +1 -1
- package/types/people.d.ts +1 -1
- package/utils/vehicle-qr-blank.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 4.113.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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
|
|
14
|
+
|
|
3
15
|
## 4.112.0
|
|
4
16
|
|
|
5
17
|
### 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>
|
|
@@ -48,9 +51,18 @@
|
|
|
48
51
|
|
|
49
52
|
<!-- Search joins the toolbar row rather than stacking a second one. -->
|
|
50
53
|
<template #prepend-additional>
|
|
54
|
+
<v-select v-if="qrEnabled" v-model="qrStickerFilter" :items="qrStickerFilterItems" label="QR sticker"
|
|
55
|
+
density="compact" variant="outlined" hide-details class="vehicle-qr-filter" style="max-width: 190px"
|
|
56
|
+
data-test="vehicle-qr-filter" />
|
|
51
57
|
<AppField v-model="searchInput" search compact placeholder="Search" klass="app-toolbar__search" />
|
|
52
58
|
</template>
|
|
53
59
|
|
|
60
|
+
<template #item.qrSticker="{ item }">
|
|
61
|
+
<v-chip size="small" variant="tonal" :color="qrStickerSummary(rowPlates(item)).color" data-test="vehicle-qr-cell">
|
|
62
|
+
{{ qrStickerSummary(rowPlates(item)).label }}
|
|
63
|
+
</v-chip>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
54
66
|
<template #item.category="{ value }">
|
|
55
67
|
{{ formatValues("category", value) }}
|
|
56
68
|
</template>
|
|
@@ -384,10 +396,15 @@
|
|
|
384
396
|
<v-card data-test="vehicle-qr-link-dialog">
|
|
385
397
|
<v-card-title>Link QR sticker</v-card-title>
|
|
386
398
|
<v-card-text>
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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" />
|
|
391
408
|
<v-alert v-if="qrLinkAskReplace" type="warning" variant="tonal" density="compact" class="mt-3">
|
|
392
409
|
{{ qrError }} Link it anyway?
|
|
393
410
|
</v-alert>
|
|
@@ -396,7 +413,7 @@
|
|
|
396
413
|
<v-card-actions>
|
|
397
414
|
<v-spacer />
|
|
398
415
|
<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="!
|
|
416
|
+
<v-btn color="primary" variant="flat" class="text-none" :loading="qrBusy" :disabled="!qrCodeToLink"
|
|
400
417
|
data-test="vehicle-qr-link-go" @click="confirmLink(qrLinkAskReplace)">
|
|
401
418
|
{{ qrLinkAskReplace ? "Link and stop the old sticker" : "Link" }}
|
|
402
419
|
</v-btn>
|
|
@@ -439,7 +456,7 @@ import {
|
|
|
439
456
|
// `error.message` put ofetch's own developer string in the delete dialog:
|
|
440
457
|
// `[PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502`.
|
|
441
458
|
import { errorConverter } from '../utils/data';
|
|
442
|
-
import { stickerCodeHint } from '../utils/vehicle-qr-blank';
|
|
459
|
+
import { BLANK_LIST_NOTE, blankSummaryText, qrStickerSummary, stickerCodeHint, type BlankSummary } from '../utils/vehicle-qr-blank';
|
|
443
460
|
import { isPermissionRefusalError } from '../utils/permission-refusal';
|
|
444
461
|
|
|
445
462
|
|
|
@@ -456,9 +473,11 @@ const props = defineProps({
|
|
|
456
473
|
});
|
|
457
474
|
|
|
458
475
|
|
|
459
|
-
const headers = [
|
|
476
|
+
const headers = computed(() => [
|
|
460
477
|
{ title: "Name", value: "name" },
|
|
461
478
|
{ title: "Vehicle Numbers", value: "plates" },
|
|
479
|
+
// Only while the site's QR switch is on (like the print button).
|
|
480
|
+
...(qrEnabled.value ? [{ title: "QR sticker", value: "qrSticker", sortable: false }] : []),
|
|
462
481
|
{ title: "NRIC", value: "nric" },
|
|
463
482
|
{ title: "Block", value: "block.name" },
|
|
464
483
|
{ title: "Floor", value: "level.name" },
|
|
@@ -467,7 +486,7 @@ const headers = [
|
|
|
467
486
|
// { title: "Type", value: "type" },
|
|
468
487
|
// { title: "Status", value: "status" },
|
|
469
488
|
{ title: "", value: "action" },
|
|
470
|
-
]
|
|
489
|
+
])
|
|
471
490
|
|
|
472
491
|
const plateHeaders = [
|
|
473
492
|
{ title: "Plate Number", value: "plateNumber" },
|
|
@@ -541,11 +560,17 @@ const activeTab = ref<TVehicleTab>("residents");
|
|
|
541
560
|
|
|
542
561
|
|
|
543
562
|
const { formatCamelCaseToWords, formatDate, debounce, maskNRIC } = useUtils();
|
|
544
|
-
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();
|
|
545
564
|
|
|
546
565
|
// Vehicle QR stickers: the site's switch (Settings) decides whether the print
|
|
547
566
|
// and re-issue actions exist; only people who may update vehicles get them.
|
|
548
567
|
const qrEnabled = ref(false);
|
|
568
|
+
const qrStickerFilter = ref("");
|
|
569
|
+
const qrStickerFilterItems = [
|
|
570
|
+
{ title: "All", value: "" },
|
|
571
|
+
{ title: "With sticker", value: "with" },
|
|
572
|
+
{ title: "Without sticker", value: "without" },
|
|
573
|
+
];
|
|
549
574
|
const qrStickersOn = computed(() => qrEnabled.value && props.canUpdateVehicle);
|
|
550
575
|
const qrVehicleIds = ref<string[]>([]);
|
|
551
576
|
const qrReissuePlate = ref<TPlateNumber | null>(null);
|
|
@@ -568,24 +593,56 @@ const qrLinkAskReplace = ref(false);
|
|
|
568
593
|
const qrBusy = ref(false);
|
|
569
594
|
const qrError = ref("");
|
|
570
595
|
// a gentle hint once something is typed, never a block - the server has the final say
|
|
571
|
-
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
|
+
}
|
|
572
626
|
|
|
573
627
|
function askLink(plate: TPlateNumber) {
|
|
574
628
|
qrPlate.value = plate || null;
|
|
575
629
|
qrLinkCode.value = "";
|
|
630
|
+
qrSearch.value = "";
|
|
576
631
|
qrLinkAskReplace.value = false;
|
|
577
632
|
qrError.value = "";
|
|
578
633
|
dialog.qrLink = true;
|
|
634
|
+
loadQrAvailable();
|
|
579
635
|
}
|
|
580
636
|
|
|
581
637
|
async function confirmLink(replace: boolean) {
|
|
582
638
|
const id = qrPlate.value?._id;
|
|
583
|
-
if (!id || !
|
|
639
|
+
if (!id || !qrCodeToLink.value) return;
|
|
584
640
|
qrBusy.value = true;
|
|
585
641
|
qrError.value = "";
|
|
586
642
|
try {
|
|
587
|
-
await linkVehicleQr(
|
|
643
|
+
await linkVehicleQr(qrCodeToLink.value, String(id), replace);
|
|
588
644
|
dialog.qrLink = false;
|
|
645
|
+
loadQrAvailable();
|
|
589
646
|
showMessage(`QR sticker linked to ${qrPlate.value?.plateNumber}`, "success");
|
|
590
647
|
} catch (e: any) {
|
|
591
648
|
qrError.value = errorConverter(e);
|
|
@@ -838,6 +895,7 @@ const { data: getVehiclesReq, refresh: getVehiclesRefresh, pending: getVehiclesP
|
|
|
838
895
|
search: searchInput.value,
|
|
839
896
|
view: activeTab.value,
|
|
840
897
|
site: props.site,
|
|
898
|
+
qrSticker: qrEnabled.value ? qrStickerFilter.value : "",
|
|
841
899
|
}),
|
|
842
900
|
{
|
|
843
901
|
watch: [page],
|
|
@@ -1321,6 +1379,11 @@ watch(searchInput, () => {
|
|
|
1321
1379
|
debouncedSearch()
|
|
1322
1380
|
})
|
|
1323
1381
|
|
|
1382
|
+
watch(qrStickerFilter, () => {
|
|
1383
|
+
page.value = 1;
|
|
1384
|
+
getVehiclesRefresh();
|
|
1385
|
+
})
|
|
1386
|
+
|
|
1324
1387
|
// A new tab starts from its first page and loads straight away.
|
|
1325
1388
|
watch(activeTab, () => {
|
|
1326
1389
|
page.value = 1;
|
|
@@ -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;
|
|
@@ -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
|
|
|
@@ -314,6 +315,17 @@ function getSpecificVehicleById(id: string){
|
|
|
314
315
|
}>("/api/vehicles/qr/blank-stickers", { method: "POST", body: { site, count } });
|
|
315
316
|
}
|
|
316
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
|
+
|
|
317
329
|
// `token` is sent as typed - the server ignores case, spaces and dashes.
|
|
318
330
|
function linkVehicleQr(token: string, vehicleId: string, replace?: boolean) {
|
|
319
331
|
return useNuxtApp().$api<Record<string, any>>("/api/vehicles/qr/link", {
|
|
@@ -352,6 +364,7 @@ function getSpecificVehicleById(id: string){
|
|
|
352
364
|
getVehicleQrStickers,
|
|
353
365
|
regenerateVehicleQr,
|
|
354
366
|
getBlankVehicleQrStickers,
|
|
367
|
+
getAvailableBlankVehicleQr,
|
|
355
368
|
linkVehicleQr,
|
|
356
369
|
unlinkVehicleQr,
|
|
357
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.",
|
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
|
|
@@ -52,3 +52,25 @@ 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
|
+
}
|
|
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.";
|