@7365admin1/layer-common 4.114.0 → 4.116.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,18 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.116.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 66b0224: Vehicle Management: plate menu gets "Print sticker again" (same code) and "Undo re-issue"; the re-issue confirm explains it; the Print QR stickers dialog labels each row "Has sticker - prints the same code" / "New sticker" and can list only vehicles with a sticker. Reprinting a plate linked to a blank sticker prints that blank code.
8
+ - b9429e8: Vehicle Management: the QR sticker column and the Preview plate table show each linked plate's printed sticker code (with a copy button) when the server sends it, so staff can match a blank sticker to a vehicle.
9
+
10
+ ## 4.115.0
11
+
12
+ ### Patch Changes
13
+
14
+ - Vehicle Mgmt: fix a blank page on load introduced in 4.114.0 (a watcher ran before the dialog state existed)
15
+
3
16
  ## 4.114.0
4
17
 
5
18
  ### Minor Changes
@@ -3098,11 +3111,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3098
3111
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3099
3112
  own rule from two endpoints the console already calls, unprojected:
3100
3113
 
3101
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3102
- GET /api/roles/id/:role that membership's role document
3114
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3115
+ GET /api/roles/id/:role that membership's role document
3103
3116
 
3104
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3105
- staff = member.type === "admin" && role.type === "admin"
3117
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3118
+ staff = member.type === "admin" && role.type === "admin"
3106
3119
 
3107
3120
  `role.default` is the marker because it is the only property of a platform
3108
3121
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -61,6 +61,9 @@
61
61
  <v-chip size="small" variant="tonal" :color="qrStickerSummary(rowPlates(item)).color" data-test="vehicle-qr-cell">
62
62
  {{ qrStickerSummary(rowPlates(item)).label }}
63
63
  </v-chip>
64
+ <div v-for="plate in rowPlates(item).filter((p: any) => p.qrCode)" :key="plate._id ?? plate.plateNumber">
65
+ <VehicleStickerCode :code="plate.qrCode" :label="rowPlates(item).length > 1 ? plate.plateNumber : ''" />
66
+ </div>
64
67
  </template>
65
68
 
66
69
  <template #item.category="{ value }">
@@ -111,8 +114,12 @@
111
114
  <v-list-subheader v-if="rowPlates(item).length > 1">
112
115
  {{ plate.plateNumber }}
113
116
  </v-list-subheader>
114
- <v-list-item v-if="qrStickersOn" title="Re-issue sticker" prepend-icon="mdi-qrcode-edit"
117
+ <v-list-item v-if="qrStickersOn && plate.hasQrSticker" title="Print sticker again" prepend-icon="mdi-printer"
118
+ data-test="vehicle-qr-print-again" @click="openStickerDialog([String(plate._id)])" />
119
+ <v-list-item v-if="qrStickersOn" title="Re-issue sticker (lost or stolen)" prepend-icon="mdi-qrcode-edit"
115
120
  @click="askReissue(plate as TPlateNumber)" />
121
+ <v-list-item v-if="qrStickersOn" title="Undo re-issue" prepend-icon="mdi-undo"
122
+ data-test="vehicle-qr-undo" @click="askUndo(plate as TPlateNumber)" />
116
123
  <v-list-item v-if="qrStickersOn" title="Link QR sticker" prepend-icon="mdi-link-variant"
117
124
  @click="askLink(plate as TPlateNumber)" />
118
125
  <v-list-item v-if="qrStickersOn" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
@@ -221,6 +228,7 @@
221
228
  <template #item.plateNumber="{ value, item: plateNumberItem }">
222
229
  <span class="d-inline-flex align-center ga-2">
223
230
  {{ value }}
231
+ <VehicleStickerCode v-if="qrEnabled && (plateNumberItem as any).qrCode" :code="(plateNumberItem as any).qrCode" />
224
232
  <AnprCameraBadge :cameras="(plateNumberItem as any).anprCameras ?? []" :plate-number="value"
225
233
  :plate-id="(plateNumberItem as any)._id ?? ''" :status="(plateNumberItem as any).status ?? ''"
226
234
  :type="(plateNumberItem as any).type ?? ''" :end="(plateNumberItem as any).end ?? null"
@@ -241,8 +249,12 @@
241
249
  <v-btn icon="mdi-dots-vertical" v-bind="menuProps" flat size="x-small" />
242
250
  </template>
243
251
  <v-list density="compact">
244
- <v-list-item v-if="qrStickersOn" title="Re-issue sticker" prepend-icon="mdi-qrcode-edit"
245
- @click="askReissue(plateNumberItem as TPlateNumber)" />
252
+ <v-list-item v-if="qrStickersOn && (plateNumberItem as any).hasQrSticker" title="Print sticker again" prepend-icon="mdi-printer"
253
+ data-test="vehicle-qr-print-again" @click="openStickerDialog([String((plateNumberItem as any)._id)])" />
254
+ <v-list-item v-if="qrStickersOn" title="Re-issue sticker (lost or stolen)" prepend-icon="mdi-qrcode-edit"
255
+ @click="askReissue((plateNumberItem as any) as TPlateNumber)" />
256
+ <v-list-item v-if="qrStickersOn" title="Undo re-issue" prepend-icon="mdi-undo"
257
+ data-test="vehicle-qr-undo" @click="askUndo((plateNumberItem as any) as TPlateNumber)" />
246
258
  <v-list-item v-if="qrStickersOn" title="Link QR sticker" prepend-icon="mdi-link-variant"
247
259
  @click="askLink(plateNumberItem as TPlateNumber)" />
248
260
  <v-list-item v-if="qrStickersOn" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
@@ -378,8 +390,9 @@
378
390
  <v-card>
379
391
  <v-card-title>Re-issue sticker?</v-card-title>
380
392
  <v-card-text>
381
- A new QR sticker will be made for {{ qrReissuePlate?.plateNumber }}. The old sticker will stop working, so
382
- the new one must be pasted on the car.
393
+ Use this when a sticker is lost or stolen. A new QR sticker will be made for {{ qrReissuePlate?.plateNumber }}.
394
+ The old sticker stops working, so the new one must be pasted on the car. You can undo this afterwards
395
+ (Undo re-issue) if the old sticker turns up. To print the same sticker again, use Print sticker again instead.
383
396
  <v-alert v-if="qrReissueError" type="error" variant="tonal" density="compact" class="mt-3">{{ qrReissueError }}</v-alert>
384
397
  </v-card-text>
385
398
  <v-card-actions>
@@ -392,6 +405,21 @@
392
405
  </v-card>
393
406
  </v-dialog>
394
407
 
408
+ <v-dialog v-model="dialog.qrUndo" width="460">
409
+ <v-card data-test="vehicle-qr-undo-dialog">
410
+ <v-card-title>Undo re-issue?</v-card-title>
411
+ <v-card-text>
412
+ {{ qrPlate?.plateNumber }}: {{ UNDO_REISSUE_NOTE }}
413
+ <v-alert v-if="qrError" type="error" variant="tonal" density="compact" class="mt-3">{{ qrError }}</v-alert>
414
+ </v-card-text>
415
+ <v-card-actions>
416
+ <v-spacer />
417
+ <v-btn variant="text" class="text-none" @click="dialog.qrUndo = false">Cancel</v-btn>
418
+ <v-btn color="primary" variant="flat" class="text-none" :loading="qrBusy" @click="confirmUndo">Undo re-issue</v-btn>
419
+ </v-card-actions>
420
+ </v-card>
421
+ </v-dialog>
422
+
395
423
  <v-dialog v-model="dialog.qrLink" width="460">
396
424
  <v-card data-test="vehicle-qr-link-dialog">
397
425
  <v-card-title>Link QR sticker</v-card-title>
@@ -456,7 +484,7 @@ import {
456
484
  // `error.message` put ofetch's own developer string in the delete dialog:
457
485
  // `[PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502`.
458
486
  import { errorConverter } from '../utils/data';
459
- import { BLANK_LIST_NOTE, blankSummaryText, qrStickerSummary, stickerCodeHint, type BlankSummary } from '../utils/vehicle-qr-blank';
487
+ import { BLANK_LIST_NOTE, UNDO_REISSUE_NOTE, blankSummaryText, qrStickerSummary, stickerCodeHint, type BlankSummary } from '../utils/vehicle-qr-blank';
460
488
  import { isPermissionRefusalError } from '../utils/permission-refusal';
461
489
 
462
490
 
@@ -560,7 +588,7 @@ const activeTab = ref<TVehicleTab>("residents");
560
588
 
561
589
 
562
590
  const { formatCamelCaseToWords, formatDate, debounce, maskNRIC } = useUtils();
563
- const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr, linkVehicleQr, unlinkVehicleQr, getAvailableBlankVehicleQr } = useVehicle();
591
+ const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr, undoReissueVehicleQr, linkVehicleQr, unlinkVehicleQr, getAvailableBlankVehicleQr } = useVehicle();
564
592
 
565
593
  // Vehicle QR stickers: the site's switch (Settings) decides whether the print
566
594
  // and re-issue actions exist; only people who may update vehicles get them.
@@ -618,7 +646,6 @@ watch(qrSearch, (v) => {
618
646
  if (dialog.qrLink) qrSearchTimer = setTimeout(() => loadQrAvailable(String(v ?? "")), 250);
619
647
  });
620
648
  watch(qrStickersOn, (on) => on && loadQrAvailable());
621
- watch(() => dialog.qrStickers, (open) => !open && loadQrAvailable());
622
649
  function onQrScanned(code: string) {
623
650
  qrLinkCode.value = code;
624
651
  qrSearch.value = code;
@@ -675,6 +702,28 @@ async function confirmUnlink() {
675
702
  }
676
703
  }
677
704
 
705
+ function askUndo(plate: TPlateNumber) {
706
+ qrPlate.value = plate || null;
707
+ qrError.value = "";
708
+ dialog.qrUndo = true;
709
+ }
710
+
711
+ async function confirmUndo() {
712
+ const id = qrPlate.value?._id;
713
+ if (!id) return;
714
+ qrBusy.value = true;
715
+ qrError.value = "";
716
+ try {
717
+ await undoReissueVehicleQr(String(id));
718
+ dialog.qrUndo = false;
719
+ showMessage(`Earlier QR sticker restored for ${qrPlate.value?.plateNumber}`, "success");
720
+ } catch (e: any) {
721
+ qrError.value = errorConverter(e);
722
+ } finally {
723
+ qrBusy.value = false;
724
+ }
725
+ }
726
+
678
727
  function openStickerDialog(ids: string[] = []) {
679
728
  qrVehicleIds.value = ids;
680
729
  dialog.qrStickers = true;
@@ -787,9 +836,11 @@ const dialog = reactive({
787
836
  bulkVehicle: false,
788
837
  qrStickers: false,
789
838
  qrReissue: false,
839
+ qrUndo: false,
790
840
  qrLink: false,
791
841
  qrUnlink: false,
792
842
  });
843
+ watch(() => dialog.qrStickers, (open) => !open && loadQrAvailable());
793
844
 
794
845
 
795
846
 
@@ -33,27 +33,32 @@
33
33
  </div>
34
34
  <div v-if="mode === 'vehicles'" class="d-flex flex-wrap align-center ga-2 mb-2">
35
35
  <v-checkbox-btn :model-value="allOnPageTicked" :indeterminate="someOnPageTicked && !allOnPageTicked"
36
- aria-label="Select all on this page" @update:model-value="(v: any) => tickGroup(plates.map((p) => p.id), !!v)" />
36
+ aria-label="Select all on this page" @update:model-value="(v: any) => tickGroup(shownPlates.map((p) => p.id), !!v)" />
37
37
  <span class="text-body-2">Select all on this page</span>
38
38
  <v-spacer />
39
39
  <v-btn variant="tonal" size="small" class="text-none" :loading="loadingAll" @click="loadAll">
40
40
  Select all matching the current tab and search
41
41
  </v-btn>
42
42
  </div>
43
+ <v-checkbox v-if="mode === 'vehicles' && !fromReissue" v-model="onlyWithSticker" density="compact" hide-details
44
+ label="Only vehicles with a sticker (reprint)" data-test="vehicle-qr-only-sticker" />
43
45
  <v-table v-if="mode === 'vehicles'" density="compact" fixed-header height="360">
44
46
  <thead>
45
- <tr><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Status</th></tr>
47
+ <tr><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Status</th><th>Sticker</th></tr>
46
48
  </thead>
47
49
  <tbody>
48
- <tr v-for="p in plates" :key="p.id">
50
+ <tr v-for="p in shownPlates" :key="p.id">
49
51
  <td><v-checkbox-btn :model-value="selected.includes(p.id)" :aria-label="`Select ${p.plateNumber}`"
50
52
  @update:model-value="(v: any) => tickGroup([p.id], !!v)" /></td>
51
53
  <td>{{ p.unitLabel || "-" }}</td>
52
54
  <td>{{ p.ownerName || "-" }}</td>
53
55
  <td class="font-weight-bold">{{ p.plateNumber }}</td>
54
56
  <td>{{ p.status || "-" }}</td>
57
+ <td class="text-caption" :class="p.hasSticker ? 'text-success' : 'text-medium-emphasis'" data-test="vehicle-qr-row-note">
58
+ {{ p.hasSticker ? STICKER_SAME_NOTE : STICKER_NEW_NOTE }}
59
+ </td>
55
60
  </tr>
56
- <tr v-if="!plates.length"><td colspan="5" class="text-center text-medium-emphasis py-6">No vehicles to list</td></tr>
61
+ <tr v-if="!shownPlates.length"><td colspan="6" class="text-center text-medium-emphasis py-6">No vehicles to list</td></tr>
57
62
  </tbody>
58
63
  </v-table>
59
64
  <p v-if="mode === 'vehicles'" class="text-caption text-medium-emphasis mt-2 mb-0">
@@ -227,7 +232,7 @@
227
232
  <script lang="ts" setup>
228
233
  import QRCode from "qrcode";
229
234
  import useVehicle from "../composables/useVehicle";
230
- import { BLANK_DEFAULT_COUNT, BLANK_MAX_COUNT, BLANK_STRIP_NOTE, blankCount, blankStickerItems, blankSummaryText, type BlankSummary } from "../utils/vehicle-qr-blank";
235
+ import { BLANK_DEFAULT_COUNT, BLANK_MAX_COUNT, BLANK_STRIP_NOTE, STICKER_NEW_NOTE, STICKER_SAME_NOTE, blankCount, blankStickerItems, blankSummaryText, reprintItems, type BlankSummary } from "../utils/vehicle-qr-blank";
231
236
  import { errorConverter } from "../utils/data";
232
237
  import { saveFile, stickerBundleName, stickerFileNames, svgToImage, zipFiles, type ImageFormat } from "../utils/vehicle-qr-export";
233
238
  import {
@@ -370,8 +375,10 @@ const sizeItems = computed(() => {
370
375
  });
371
376
  const sizeLines = computed(() => stickerSizeLines(normalizeDesign(design)));
372
377
 
373
- const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
374
- const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
378
+ const onlyWithSticker = ref(false);
379
+ const shownPlates = computed(() => (onlyWithSticker.value ? plates.value.filter((p) => p.hasSticker) : plates.value));
380
+ const allOnPageTicked = computed(() => shownPlates.value.length > 0 && shownPlates.value.every((p) => selected.value.includes(p.id)));
381
+ const someOnPageTicked = computed(() => shownPlates.value.some((p) => selected.value.includes(p.id)));
375
382
 
376
383
  function tickGroup(ids: string[], on: boolean) {
377
384
  selected.value = setIds(selected.value, ids, on);
@@ -444,7 +451,7 @@ async function goReview() {
444
451
  isBlank.value = false;
445
452
  const { ids, dropped: over } = idsForRequest(selected.value);
446
453
  const res = await getVehicleQrStickers(props.site, ids);
447
- items.value = (res?.items ?? []) as StickerItem[];
454
+ items.value = reprintItems((res?.items ?? []) as StickerItem[]);
448
455
  siteName.value = String((res as any)?.site?.name ?? "");
449
456
  missing.value = res?.missing ?? [];
450
457
  dropped.value = over;
@@ -0,0 +1,27 @@
1
+ <template>
2
+ <span class="d-inline-flex align-center ga-1 text-caption" data-test="vehicle-sticker-code">
3
+ <span v-if="label" class="text-medium-emphasis">{{ label }}</span>
4
+ <code class="vehicle-sticker-code">{{ code }}</code>
5
+ <v-btn :icon="copied ? 'mdi-check' : 'mdi-content-copy'" flat size="x-small" density="compact"
6
+ aria-label="Copy sticker code" @click.stop="copy" />
7
+ </span>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ // A blank sticker's printed code, with a copy button. Shown only when the server sent it.
12
+ const props = defineProps<{ code: string; label?: string }>();
13
+ const copied = ref(false);
14
+ async function copy() {
15
+ try {
16
+ await navigator.clipboard.writeText(props.code);
17
+ copied.value = true;
18
+ setTimeout(() => (copied.value = false), 1500);
19
+ } catch {
20
+ /* clipboard blocked: the code is still readable on screen */
21
+ }
22
+ }
23
+ </script>
24
+
25
+ <style scoped>
26
+ .vehicle-sticker-code { font-size: 0.75rem; letter-spacing: 0.02em; }
27
+ </style>
@@ -307,6 +307,12 @@ function getSpecificVehicleById(id: string){
307
307
  );
308
308
  }
309
309
 
310
+ function undoReissueVehicleQr(id: string) {
311
+ return useNuxtApp().$api<{ vehicleId: string; restored: boolean }>(`/api/vehicles/qr/undo-reissue/id/${id}`, {
312
+ method: "PUT",
313
+ });
314
+ }
315
+
310
316
  // Blank (pre-generated) stickers: print codes first, link them to vehicles later.
311
317
  function getBlankVehicleQrStickers(site: string, count: number) {
312
318
  return useNuxtApp().$api<{
@@ -363,6 +369,7 @@ function getSpecificVehicleById(id: string){
363
369
  setVehicleQrSettings,
364
370
  getVehicleQrStickers,
365
371
  regenerateVehicleQr,
372
+ undoReissueVehicleQr,
366
373
  getBlankVehicleQrStickers,
367
374
  getAvailableBlankVehicleQr,
368
375
  linkVehicleQr,
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.114.0",
5
+ "version": "4.116.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.",
@@ -53,6 +53,27 @@ export function plateLines(plate: string): string[] {
53
53
  return isBlankCode(plate) ? [plate.slice(0, 14), plate.slice(15)] : [plate];
54
54
  }
55
55
 
56
+ const BLANK_TOKEN = /^[0-9A-HJKMNP-TV-Z]{24}$/;
57
+
58
+ /**
59
+ * Reprinting a plate whose sticker was a blank one: print that same code in the
60
+ * plate slot (as the original blank sticker did) so the new sticker matches the
61
+ * old one. Any other sticker keeps its plate number.
62
+ */
63
+ export function reprintItems<T extends { token?: string; plateNumber?: string }>(items: T[]): T[] {
64
+ return items.map((i) =>
65
+ i.token && BLANK_TOKEN.test(i.token) ? { ...i, plateNumber: (i.token.match(/.{4}/g) ?? []).join("-") } : i,
66
+ );
67
+ }
68
+
69
+ /** The print dialog's per-row note: a plate that has a sticker prints the same code again. */
70
+ export const STICKER_SAME_NOTE = "Has sticker - prints the same code";
71
+ export const STICKER_NEW_NOTE = "New sticker";
72
+
73
+ /** Shown before undoing a re-issue: stickers re-issued before 11 Oct 2026 saved nothing to go back to. */
74
+ export const UNDO_REISSUE_NOTE =
75
+ "Puts back the sticker this vehicle had before its last re-issue (the newer one stops working). Only possible for re-issues made after 11 Oct 2026; if the earlier sticker was a blank one, its code is back in the available list and can be linked again.";
76
+
56
77
  /** The list's "QR sticker" cell for one row: how many of its plates already hold a sticker. */
57
78
  export function qrStickerSummary(plates: Array<{ hasQrSticker?: boolean }>): { label: string; color: string } {
58
79
  const total = plates.length;
@@ -181,6 +181,8 @@ export type StickerPlateRow = {
181
181
  status: string;
182
182
  ownerName: string;
183
183
  unitLabel: string;
184
+ /** The list says this plate already has a sticker (printing it again prints the same code). */
185
+ hasSticker: boolean;
184
186
  };
185
187
 
186
188
  const text = (v: unknown) => (v == null ? "" : String(v).trim());
@@ -242,6 +244,7 @@ export function stickerPlateRows(rows: Array<Record<string, any>>): StickerPlate
242
244
  plateNumber: text(plate.plateNumber),
243
245
  status: text(plate.status).toLowerCase(),
244
246
  ownerName: text(row?.name ?? plate.name),
247
+ hasSticker: plate.hasQrSticker === true,
245
248
  unitLabel: stickerUnitLabel({
246
249
  block: row?.block?.name ?? row?.block,
247
250
  level: row?.level?.name ?? row?.level,