@7365admin1/layer-common 4.110.1 → 4.111.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,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.111.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ ## 4.110.2
10
+
11
+ ### Patch Changes
12
+
13
+ - aa41164: Staging QA 2026-10-08: comboboxes are outlined like other fields; Edit Unit saves a unit whose level is a bare id (message says unit, not building); Tenancies period buttons fit a phone and the renew message shows a readable date; confirm prompts read "Are you sure you want to".
14
+
3
15
  ## 4.110.1
4
16
 
5
17
  ### Patch Changes
@@ -30,6 +42,7 @@ The report PDF is laid out as a document rather than a screenshot cut into pages
30
42
  The report CSV is built from the report's data instead of read off the first table on screen (`buildReportCsv` in `utils/report-export.ts`, `patrolReportCsv` / `dailyReportCsv` / `monthlyReportCsv` in `utils/nfc-patrol-report.ts`; each tab's `export` event now carries its tables): the report, site, route and date on the first lines, then every table under its title. The Patrol CSV now has every checkpoint, with each skip's remarks and photo link on its row (it used to carry only schedule against actual); the Daily CSV adds a Skipped Checkpoints table, one row per skip naming its run. UTF-8 with a byte order mark so Excel reads it, cells a spreadsheet would run as a formula kept as text, and named like the PDF. Signatures load eagerly so ones below the fold are no longer missing from the PDF.
31
43
 
32
44
  Monthly report: a month that had patrols opens the list of that month's patrol runs (new `PatrolMonthDialog`) - by day then scheduled start, with the month's totals in its title, ten runs a page ("1-10 of 124" and the arrows, like the app's tables) - and each run opens the same Patrol Route Details dialog, checkpoints and skip photos included. Built from the year of logs the report already loads (`mapLogsToMonthRuns`, sharing `monthIndexOf` with the yearly table so a month's list adds up to its row), so opening a month asks the server nothing. The summary-row mapping moved from the daily composable to `mapLogToSummaryRow()` and now carries the run's `date`, which the details dialog shows.
45
+
33
46
  - 8d3d430: NFC Routes: the Security Check Schedule reads at a glance. Each check window is a pill that never breaks across lines ("09:01 – 14:40"), earliest first instead of in the order the times were typed, with "+1" on a window that ends the next day (new `NFCRouteScheduleTimes`, `routeWindows` in `utils/nfc-patrol-schedule.ts`). The table shows the first three and folds the rest into "+N more" (all of them on hover), so a busy route keeps a normal row; the Route Details dialog shows them all under "Security Check Schedule · 9 checks a day". Route days read Monday first with runs as ranges - "Mon – Sat", "Every day" - instead of "Mon, Wed, Fri, Tue, Thu, Sat" (`formatRouteDays`).
34
47
 
35
48
  ## 4.108.0
@@ -3067,11 +3080,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3067
3080
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3068
3081
  own rule from two endpoints the console already calls, unprojected:
3069
3082
 
3070
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3071
- GET /api/roles/id/:role that membership's role document
3083
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3084
+ GET /api/roles/id/:role that membership's role document
3072
3085
 
3073
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3074
- staff = member.type === "admin" && role.type === "admin"
3086
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3087
+ staff = member.type === "admin" && role.type === "admin"
3075
3088
 
3076
3089
  `role.default` is the marker because it is the only property of a platform
3077
3090
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -387,7 +387,7 @@ async function submit() {
387
387
  emit("success");
388
388
  } catch (error: any) {
389
389
  message.value =
390
- error.response?._data?.message || "Failed to create building";
390
+ error.response?._data?.message || "Could not add the unit. Please try again.";
391
391
  showMessage(message.value, "error");
392
392
  } finally {
393
393
  disable.value = false;
@@ -536,7 +536,8 @@ async function submit() {
536
536
 
537
537
  await updateById(buildingUnit.value._id ?? "", {
538
538
  name: buildingUnit.value.name,
539
- level: buildingUnit.value.level._id,
539
+ // The level arrives as a populated object, or as a bare id when it could not be looked up.
540
+ level: (buildingUnit.value.level as any)?._id ?? buildingUnit.value.level ?? "",
540
541
  // category: buildingUnit.value.category,
541
542
  buildingUnitFiles: (buildingUnit.value.buildingUnitFiles as string[] || []).map((id) => ({ id, name: buildingUnitFilesNames.value[id] ?? "" })),
542
543
  companyName: buildingUnit.value.companyName,
@@ -550,7 +551,7 @@ async function submit() {
550
551
  emit("success");
551
552
  } catch (error: any) {
552
553
  message.value =
553
- error.response?._data?.message || "Failed to create building";
554
+ error.response?._data?.message || "Could not save the unit. Please try again.";
554
555
  } finally {
555
556
  disable.value = false;
556
557
  }
@@ -75,7 +75,7 @@
75
75
  </v-dialog>
76
76
 
77
77
  <v-dialog v-model="dialog.deletePrompt" width="450" persistent>
78
- <CardDeleteConfirmation prompt-title="Are you sure want to delete this announcement?"
78
+ <CardDeleteConfirmation prompt-title="Are you sure you want to delete this announcement?"
79
79
  :loading="loading.deletingAnnouncement" @close="dialog.deletePrompt = false"
80
80
  @delete="handleProceedDeleteAnnouncement" />
81
81
  </v-dialog>
@@ -288,7 +288,7 @@
288
288
  style="max-height: 100vh; overflow-y: auto"
289
289
  class="pa-5 my-5 px-7 text-center"
290
290
  >
291
- Are you sure want to delete this {{ camera.type }} camera?
291
+ Are you sure you want to delete this {{ camera.type }} camera?
292
292
  </v-card-text>
293
293
 
294
294
  <v-toolbar class="pa-0" density="compact">
@@ -70,7 +70,7 @@
70
70
 
71
71
  <v-dialog v-model="dialog.showPrompt" persistent width="540">
72
72
  <DialogReusablePrompt :loading="loadingOwnerProcessing"
73
- :prompt-title="`Are you sure want to ${person.isOwner ? 'remove' : 'add'} (${person.name}) as owner ?`"
73
+ :prompt-title="`Are you sure you want to ${person.isOwner ? 'remove' : 'add'} (${person.name}) as owner ?`"
74
74
  @proceed="handleApprove(person)" @close="dialog.showPrompt = false" />
75
75
  </v-dialog>
76
76
  </v-card-text>
@@ -22,10 +22,11 @@
22
22
  <v-col cols="12">
23
23
  <AppCard class="pa-4">
24
24
  <div class="d-flex align-center flex-wrap ga-2 mb-3">
25
+ <span class="text-body-2">Ending in the next</span>
25
26
  <v-btn-toggle v-model="days" mandatory density="comfortable" variant="outlined" divided data-test="tn-days" @update:model-value="load">
26
- <v-btn :value="30" class="text-none">Next 30 days</v-btn>
27
- <v-btn :value="60" class="text-none">Next 60 days</v-btn>
28
- <v-btn :value="90" class="text-none">Next 90 days</v-btn>
27
+ <v-btn :value="30" class="text-none">30 days</v-btn>
28
+ <v-btn :value="60" class="text-none">60 days</v-btn>
29
+ <v-btn :value="90" class="text-none">90 days</v-btn>
29
30
  </v-btn-toggle>
30
31
  <v-spacer />
31
32
  <v-btn variant="text" icon="mdi-refresh" aria-label="Refresh" :loading="loading" @click="load" />
@@ -217,7 +218,7 @@ async function confirmRenew() {
217
218
  leaseEnd: `${renewEnd.value}T23:59`,
218
219
  });
219
220
  renewOpen.value = false;
220
- say(`Tenancy for ${current.value.unit} renewed to ${renewEnd.value}.`);
221
+ say(`Tenancy for ${current.value.unit} renewed to ${new Date(`${renewEnd.value}T00:00`).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}.`);
221
222
  await load();
222
223
  } catch (e: any) {
223
224
  renewError.value = message(e, "The tenancy could not be renewed. Please try again.");
@@ -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"
@@ -276,7 +284,7 @@
276
284
  snackbar, along with the device's raw `{"ErrorCode":...}` reply.
277
285
  -->
278
286
  <DialogReusablePrompt :loading="approvingVehicle" :message="actionError" message-error :no-permission="revoked.approve"
279
- :prompt-title="`Are you sure want to approve this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
287
+ :prompt-title="`Are you sure you want to approve this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
280
288
  @proceed="submitApprove" @close="closeActionDialog('approveVehicle')" />
281
289
  </v-dialog>
282
290
  <!-- Like Delete, Restore says what it does at the barrier: a whitelist
@@ -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
- <div class="d-flex flex-wrap align-center ga-2 mb-2">
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 &gt; 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>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Check</th></tr>
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
- <p class="text-caption text-medium-emphasis my-2">
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 &gt; 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 &gt; 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" :disabled="!selected.length"
161
- :loading="loading" @click="goReview">
162
- Preview {{ selected.length }} sticker{{ selected.length === 1 ? "" : "s" }}
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[];
@@ -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.110.1",
5
+ "version": "4.111.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.",
@@ -129,6 +129,12 @@ export default defineNuxtPlugin((app) => {
129
129
  density: "comfortable",
130
130
  menuProps: { attach: document.body, zIndex: 3000 },
131
131
  },
132
+ // Same look as the other fields: without this every combobox drew grey and filled.
133
+ VCombobox: {
134
+ variant: "outlined",
135
+ density: "comfortable",
136
+ menuProps: { attach: document.body, zIndex: 3000 },
137
+ },
132
138
  VSelect: {
133
139
  variant: "outlined",
134
140
  density: "comfortable",
@@ -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 plate = escapeHtml(item.plateNumber);
367
- const label = c.label ? `<div class="hint">${escapeHtml(c.label)}</div>` : "";
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(text(item.plateNumber), c.tag.w, 4.6 * c.k)}mm">${plate}</div>` +
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
- `<div class="cut${c.bare ? " free" : ""}"><div><b>${escapeHtml(place || "-")}</b></div>` +
401
- `<div class="owner">Owner: ${escapeHtml(owner || "-")}</div></div></div>`
412
+ `${strip}</div>`
402
413
  );
403
414
  });
404
415
  const marks = cropMarksSvg(c.layout);