@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
- Type the code printed on the sticker. It will show {{ qrPlate?.plateNumber }} when scanned.
397
- <v-text-field v-model="qrLinkCode" label="Sticker code" placeholder="ABCD-EFGH-JKMN-PQRS-TVWX-YZ01" density="compact"
398
- variant="outlined" class="mt-3" autofocus :disabled="qrBusy" :hint="qrLinkHint" persistent-hint
399
- data-test="vehicle-qr-link-code" @keyup.enter="confirmLink(false)" />
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="!qrLinkCode.trim()"
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(() => (qrLinkCode.value.trim() ? stickerCodeHint(qrLinkCode.value) : ""));
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 || !qrLinkCode.value.trim()) return;
639
+ if (!id || !qrCodeToLink.value) return;
601
640
  qrBusy.value = true;
602
641
  qrError.value = "";
603
642
  try {
604
- await linkVehicleQr(qrLinkCode.value.trim(), String(id), replace);
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 &gt; 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.113.0",
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.";