@7365admin1/layer-common 4.90.2 → 4.91.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.91.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4401c78: Vehicle QR stickers (web). Settings gets a "Vehicle QR stickers" switch (people who may update vehicles). When it is on, Vehicle Mgmt gets "Print QR stickers": pick vehicles (this page, or every match for the tab and search), check each one's Blk-Level-Unit, owner, plate and any expired / blocklisted warning, then print an A4 sheet of compact stickers (QR drawn in the browser, plate, Powered by Seven365) with a cut-off installer strip under a dashed line naming the unit and owner. Per-vehicle "Re-issue sticker" (the old sticker stops working). Needs core vehicle-qr (core #2644/#2645, API-core #1538/#1539).
8
+
9
+ ## 4.90.3
10
+
11
+ ### Patch Changes
12
+
13
+ - d7c20a9: Visitor form: required and optional fields now match the security mobile app, and every field says "(Required)" or "(Optional)". Phone is optional for Drive-in and Walk-in; NRIC is optional for Delivery; Vehicle Number is required for Drive-in, Pick-Up and Drop-Off; Company Name is required for Contractor; Delivery Company is required. Walk-in no longer shows Company Name, and only the fields on the form are sent when a visitor is saved (no hidden company on Walk-in, no purpose on Drive-in). Pass, Keys and contractor member fields are labelled too.
14
+
3
15
  ## 4.90.2
4
16
 
5
17
  ### Patch Changes
@@ -2022,11 +2034,11 @@
2022
2034
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
2023
2035
  own rule from two endpoints the console already calls, unprojected:
2024
2036
 
2025
- GET /api/members/user/:user/app/admin the Seven365 staff membership
2026
- GET /api/roles/id/:role that membership's role document
2037
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
2038
+ GET /api/roles/id/:role that membership's role document
2027
2039
 
2028
- owner = member.type === "admin" && role.type === "admin" && role.default === true
2029
- staff = member.type === "admin" && role.type === "admin"
2040
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
2041
+ staff = member.type === "admin" && role.type === "admin"
2030
2042
 
2031
2043
  `role.default` is the marker because it is the only property of a platform
2032
2044
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -10,12 +10,12 @@
10
10
  <v-form ref="formRef" v-model="validForm" :disabled="processing" @click="errorScanPassMessage = ''">
11
11
  <v-row no-gutters class="pt-5 ga-2">
12
12
  <v-col cols="12">
13
- <InputLabel class="text-capitalize" title="Full Name" required />
13
+ <InputLabel class="text-capitalize" title="Full Name (Required)" required />
14
14
  <v-text-field v-model.trim="memberForm.name" density="compact" hide-details :rules="[requiredRule]" />
15
15
  </v-col>
16
16
 
17
17
  <v-col cols="12">
18
- <InputLabel class="text-capitalize" title="NRIC" required />
18
+ <InputLabel class="text-capitalize" title="NRIC (Required)" required />
19
19
  <v-menu v-model="memberNRICSearchMenu" location="bottom" offset-y :close-on-content-click="false">
20
20
  <template #activator="{ props: menuProps }">
21
21
  <InputNRICNumber v-bind="menuProps" v-model.trim="memberForm.nric" density="compact" hide-details
@@ -34,7 +34,7 @@
34
34
  </v-col>
35
35
 
36
36
  <v-col cols="12">
37
- <InputLabel class="text-capitalize" title="Phone Number" required />
37
+ <InputLabel class="text-capitalize" title="Phone Number (Required)" required />
38
38
  <v-menu v-model="memberPhoneSearchMenu" location="bottom" offset-y :close-on-content-click="false">
39
39
  <template #activator="{ props: menuProps }">
40
40
  <InputPhoneNumberV2 v-bind="menuProps" v-model="memberForm.contact" density="compact"
@@ -54,7 +54,7 @@
54
54
 
55
55
  <v-col cols="12">
56
56
  <v-autocomplete v-model="selectedPass" v-model:search="passInput" :hide-no-data="false" class="mt-3"
57
- :items="passItemsFilteredFinal" item-title="prefixAndName" item-value="_id" label="Pass (optional)"
57
+ :items="passItemsFilteredFinal" item-title="prefixAndName" item-value="_id" label="Pass (Optional)"
58
58
  variant="outlined" density="compact" :error-messages="errorScanPassMessage" persistent-hint small-chips
59
59
  :clearable="clearable" @click="fetchPasses">
60
60
  <template v-slot:no-data>
@@ -19,7 +19,7 @@
19
19
  :rules="props.passRules"
20
20
  item-title="prefixAndName"
21
21
  item-value="_id"
22
- label="Pass"
22
+ label="Pass (Optional)"
23
23
  variant="outlined"
24
24
  hide-details
25
25
  persistent-hint
@@ -48,7 +48,7 @@
48
48
  :rules="props.passRules"
49
49
  item-title="prefixAndName"
50
50
  item-value="_id"
51
- label="Keys"
51
+ label="Keys (Optional)"
52
52
  multiple
53
53
  variant="outlined"
54
54
  hide-details
@@ -199,6 +199,22 @@
199
199
  </v-expansion-panel-text>
200
200
  </v-expansion-panel>
201
201
 
202
+ <v-expansion-panel v-if="canManageVehicleQr">
203
+ <v-expansion-panel-title>
204
+ <v-icon class="mr-2">mdi-qrcode</v-icon>
205
+ Vehicle QR stickers
206
+ </v-expansion-panel-title>
207
+ <v-expansion-panel-text>
208
+ <v-switch v-model="vehicleQrEnabled" :loading="vehicleQrSaving" :disabled="vehicleQrSaving"
209
+ label="Vehicle QR stickers" color="primary" hide-details data-test="vehicle-qr-switch"
210
+ @update:model-value="saveVehicleQr" />
211
+ <p class="text-caption text-medium-emphasis mt-2 mb-0">
212
+ When on, staff can print a QR sticker for each vehicle from Vehicle Mgmt, and guards can scan it to see
213
+ which unit the vehicle belongs to.
214
+ </p>
215
+ </v-expansion-panel-text>
216
+ </v-expansion-panel>
217
+
202
218
  <slot
203
219
  name="panels"
204
220
  :can-manage="canManageSiteSettings"
@@ -215,6 +231,7 @@ import useFile from "../composables/useFile";
215
231
  import useSiteSettings from "../composables/useSiteSettings";
216
232
  import useUtils from "../composables/useUtils";
217
233
  import useANPRSettings from "../composables/useANPRSettings";
234
+ import useVehicle from "../composables/useVehicle";
218
235
 
219
236
  const props = defineProps({
220
237
  siteId: { type: String, required: true },
@@ -233,6 +250,7 @@ const { canManageSiteInformation,
233
250
  canManageCCTVCamera,
234
251
  canManageDeliveryCompanies,
235
252
  canManageResidentVisitors,
253
+ canManageVehicleQr,
236
254
  } = useSettingsPermission();
237
255
 
238
256
  const { getSiteById, updateSitebyId, updateSiteInformation } =
@@ -405,6 +423,36 @@ function handleUpdateCompanies(value: string[]) {
405
423
 
406
424
  const route = useRoute();
407
425
 
426
+ // Vehicle QR stickers switch (off until someone turns it on).
427
+ const { getVehicleQrSettings, setVehicleQrSettings } = useVehicle();
428
+ const vehicleQrEnabled = ref(false);
429
+ const vehicleQrSaving = ref(false);
430
+
431
+ await useLazyAsyncData(`vehicle-qr-${props.siteId}`, async () => {
432
+ if (!canManageVehicleQr.value) return null;
433
+ try {
434
+ vehicleQrEnabled.value = !!(await getVehicleQrSettings(props.siteId))?.enabled;
435
+ } catch {
436
+ vehicleQrEnabled.value = false;
437
+ }
438
+ return null;
439
+ });
440
+
441
+ async function saveVehicleQr(value: boolean | null) {
442
+ const enabled = !!value;
443
+ vehicleQrSaving.value = true;
444
+ try {
445
+ const res = await setVehicleQrSettings(props.siteId, enabled);
446
+ vehicleQrEnabled.value = !!res?.enabled;
447
+ show(enabled ? "Vehicle QR stickers turned on" : "Vehicle QR stickers turned off", "success");
448
+ } catch (e: any) {
449
+ vehicleQrEnabled.value = !enabled;
450
+ show(e?.data?.message || "Could not change the Vehicle QR stickers setting", "error");
451
+ } finally {
452
+ vehicleQrSaving.value = false;
453
+ }
454
+ }
455
+
408
456
  function show(message: string, color: string) {
409
457
  toast.show = true;
410
458
  toast.message = message;
@@ -8,6 +8,9 @@
8
8
  <v-col cols="12">
9
9
  <PageHeader title="Vehicle Mgmt">
10
10
  <template #actions>
11
+ <AppButton v-if="qrStickersOn" icon="mdi-qrcode" variant="ghost" @click="openStickerDialog()">
12
+ Print QR stickers
13
+ </AppButton>
11
14
  <AppButton v-if="canCreateVehicle" icon="mdi-plus" @click="handleAddVehicleClick">
12
15
  Add Vehicle
13
16
  </AppButton>
@@ -82,17 +85,19 @@
82
85
  be drawn.
83
86
  -->
84
87
  <template #item.action="{ item }">
85
- <v-menu v-if="rowHasVehicleActions(item, vehiclePermissions)" location="bottom end">
88
+ <v-menu v-if="rowHasVehicleActions(item, vehiclePermissions) || qrStickersOn" location="bottom end">
86
89
  <template #activator="{ props: menuProps }">
87
90
  <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" aria-label="Vehicle actions"
88
91
  @click.stop />
89
92
  </template>
90
93
  <v-list density="compact">
91
94
  <template v-for="plate in rowPlates(item)" :key="plate._id ?? plate.plateNumber">
92
- <template v-if="vehiclePlateActions(plate, vehiclePermissions).length">
95
+ <template v-if="vehiclePlateActions(plate, vehiclePermissions).length || qrStickersOn">
93
96
  <v-list-subheader v-if="rowPlates(item).length > 1">
94
97
  {{ plate.plateNumber }}
95
98
  </v-list-subheader>
99
+ <v-list-item v-if="qrStickersOn" title="Re-issue sticker" prepend-icon="mdi-qrcode-edit"
100
+ @click="askReissue(plate as TPlateNumber)" />
96
101
  <v-list-item v-for="action in vehiclePlateActions(plate, vehiclePermissions)" :key="action.key"
97
102
  :title="action.title" :prepend-icon="action.icon" :base-color="action.color"
98
103
  @click="runVehicleAction(action.key, plate as TPlateNumber)" />
@@ -212,11 +217,13 @@
212
217
  v-tooltip:top="'History'" @click="openHistory(plateNumberItem as TPlateNumber)" />
213
218
  </template>
214
219
  <template #item.action="{ item: plateNumberItem }">
215
- <v-menu v-if="vehiclePlateActions(plateNumberItem, vehiclePermissions).length">
220
+ <v-menu v-if="vehiclePlateActions(plateNumberItem, vehiclePermissions).length || qrStickersOn">
216
221
  <template #activator="{ props: menuProps }">
217
222
  <v-btn icon="mdi-dots-vertical" v-bind="menuProps" flat size="x-small" />
218
223
  </template>
219
224
  <v-list density="compact">
225
+ <v-list-item v-if="qrStickersOn" title="Re-issue sticker" prepend-icon="mdi-qrcode-edit"
226
+ @click="askReissue(plateNumberItem as TPlateNumber)" />
220
227
  <v-list-item
221
228
  v-for="action in vehiclePlateActions(plateNumberItem, vehiclePermissions)"
222
229
  :key="action.key" :title="action.title" :prepend-icon="action.icon"
@@ -341,6 +348,26 @@
341
348
  </v-card-text>
342
349
  </v-card>
343
350
  </v-dialog>
351
+ <VehicleQrStickerDialog v-if="qrStickersOn" v-model="dialog.qrStickers" :site="props.site" :rows="items"
352
+ :view="activeTab" :search="searchInput" :vehicle-ids="qrVehicleIds" />
353
+
354
+ <v-dialog v-model="dialog.qrReissue" width="460">
355
+ <v-card>
356
+ <v-card-title>Re-issue sticker?</v-card-title>
357
+ <v-card-text>
358
+ A new QR sticker will be made for {{ qrReissuePlate?.plateNumber }}. The old sticker will stop working, so
359
+ the new one must be pasted on the car.
360
+ <v-alert v-if="qrReissueError" type="error" variant="tonal" density="compact" class="mt-3">{{ qrReissueError }}</v-alert>
361
+ </v-card-text>
362
+ <v-card-actions>
363
+ <v-spacer />
364
+ <v-btn variant="text" class="text-none" @click="dialog.qrReissue = false">Cancel</v-btn>
365
+ <v-btn color="primary" variant="flat" class="text-none" :loading="qrReissuing" @click="confirmReissue">
366
+ Re-issue
367
+ </v-btn>
368
+ </v-card-actions>
369
+ </v-card>
370
+ </v-dialog>
344
371
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
345
372
  </v-row>
346
373
  </template>
@@ -460,7 +487,53 @@ const activeTab = ref<TVehicleTab>("residents");
460
487
 
461
488
 
462
489
  const { formatCamelCaseToWords, formatDate, debounce, maskNRIC } = useUtils();
463
- const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts } = useVehicle();
490
+ const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr } = useVehicle();
491
+
492
+ // Vehicle QR stickers: the site's switch (Settings) decides whether the print
493
+ // and re-issue actions exist; only people who may update vehicles get them.
494
+ const qrEnabled = ref(false);
495
+ const qrStickersOn = computed(() => qrEnabled.value && props.canUpdateVehicle);
496
+ const qrVehicleIds = ref<string[]>([]);
497
+ const qrReissuePlate = ref<TPlateNumber | null>(null);
498
+ const qrReissuing = ref(false);
499
+ const qrReissueError = ref("");
500
+
501
+ async function loadQrSetting() {
502
+ try {
503
+ qrEnabled.value = !!(await getVehicleQrSettings(props.site))?.enabled;
504
+ } catch {
505
+ qrEnabled.value = false;
506
+ }
507
+ }
508
+ onMounted(loadQrSetting);
509
+
510
+ function openStickerDialog(ids: string[] = []) {
511
+ qrVehicleIds.value = ids;
512
+ dialog.qrStickers = true;
513
+ }
514
+
515
+ function askReissue(plate: TPlateNumber) {
516
+ qrReissuePlate.value = plate || null;
517
+ qrReissueError.value = "";
518
+ dialog.qrReissue = true;
519
+ }
520
+
521
+ async function confirmReissue() {
522
+ const id = qrReissuePlate.value?._id;
523
+ if (!id) return;
524
+ qrReissuing.value = true;
525
+ qrReissueError.value = "";
526
+ try {
527
+ await regenerateVehicleQr(String(id));
528
+ dialog.qrReissue = false;
529
+ dialog.showMoreActions = false;
530
+ openStickerDialog([String(id)]);
531
+ } catch (e: any) {
532
+ qrReissueError.value = errorConverter(e);
533
+ } finally {
534
+ qrReissuing.value = false;
535
+ }
536
+ }
464
537
 
465
538
  /**
466
539
  * Active ANPR cameras per list at the site. A live plate on fewer cameras
@@ -543,7 +616,9 @@ const dialog = reactive({
543
616
  blockVehicle: false,
544
617
  unblockVehicle: false,
545
618
  history: false,
546
- bulkVehicle: false
619
+ bulkVehicle: false,
620
+ qrStickers: false,
621
+ qrReissue: false,
547
622
  });
548
623
 
549
624
 
@@ -0,0 +1,249 @@
1
+ <template>
2
+ <v-dialog :model-value="modelValue" width="1100" scrollable persistent @update:model-value="close">
3
+ <v-card data-test="vehicle-qr-dialog">
4
+ <v-card-title class="d-flex align-center">
5
+ <v-icon class="mr-2">mdi-qrcode</v-icon>
6
+ {{ step === "select" ? "Print QR stickers" : "Check before printing" }}
7
+ <v-spacer />
8
+ <v-btn icon="mdi-close" variant="text" size="small" aria-label="Close" @click="close(false)" />
9
+ </v-card-title>
10
+
11
+ <v-card-text>
12
+ <v-alert v-if="error" type="error" variant="tonal" density="compact" class="mb-3">{{ error }}</v-alert>
13
+
14
+ <!-- Step 1: choose vehicles -->
15
+ <template v-if="step === 'select'">
16
+ <div class="d-flex flex-wrap align-center ga-2 mb-2">
17
+ <v-checkbox-btn :model-value="allOnPageTicked" :indeterminate="someOnPageTicked && !allOnPageTicked"
18
+ aria-label="Select all on this page" @update:model-value="(v: any) => tickGroup(plates.map((p) => p.id), !!v)" />
19
+ <span class="text-body-2">Select all on this page</span>
20
+ <v-spacer />
21
+ <v-btn variant="tonal" size="small" class="text-none" :loading="loadingAll" @click="loadAll">
22
+ Select all matching the current tab and search
23
+ </v-btn>
24
+ </div>
25
+ <v-table density="compact" fixed-header height="360">
26
+ <thead>
27
+ <tr><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Status</th></tr>
28
+ </thead>
29
+ <tbody>
30
+ <tr v-for="p in plates" :key="p.id">
31
+ <td><v-checkbox-btn :model-value="selected.includes(p.id)" :aria-label="`Select ${p.plateNumber}`"
32
+ @update:model-value="(v: any) => tickGroup([p.id], !!v)" /></td>
33
+ <td>{{ p.unitLabel || "-" }}</td>
34
+ <td>{{ p.ownerName || "-" }}</td>
35
+ <td class="font-weight-bold">{{ p.plateNumber }}</td>
36
+ <td>{{ p.status || "-" }}</td>
37
+ </tr>
38
+ <tr v-if="!plates.length"><td colspan="5" class="text-center text-medium-emphasis py-6">No vehicles to list</td></tr>
39
+ </tbody>
40
+ </v-table>
41
+ <p class="text-caption text-medium-emphasis mt-2 mb-0">
42
+ Up to {{ STICKER_MAX_PER_REQUEST }} stickers at a time. Ticked: {{ selected.length }}.
43
+ </p>
44
+ </template>
45
+
46
+ <!-- Step 2: review + preview -->
47
+ <template v-else>
48
+ <v-alert v-if="missing.length" type="warning" variant="tonal" density="compact" class="mb-3">
49
+ {{ missing.length }} vehicle(s) could not be found and were skipped.
50
+ </v-alert>
51
+ <v-alert v-if="dropped" type="info" variant="tonal" density="compact" class="mb-3">
52
+ {{ dropped }} more vehicle(s) were over the {{ STICKER_MAX_PER_REQUEST }} limit - print again for the rest.
53
+ </v-alert>
54
+ <v-table density="compact" fixed-header height="220">
55
+ <thead>
56
+ <tr><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Check</th></tr>
57
+ </thead>
58
+ <tbody>
59
+ <tr v-for="it in items" :key="it.vehicleId">
60
+ <td><v-checkbox-btn :model-value="ticked.includes(it.vehicleId)" :aria-label="`Print ${it.plateNumber}`"
61
+ @update:model-value="(v: any) => (ticked = setIds(ticked, [it.vehicleId], !!v))" /></td>
62
+ <td>{{ installerLine(it).place || "-" }}</td>
63
+ <td>{{ installerLine(it).owner || "-" }}</td>
64
+ <td class="font-weight-bold">{{ it.plateNumber }}</td>
65
+ <td>
66
+ <v-chip v-if="stickerWarning(it.status)" size="small" variant="tonal"
67
+ :color="stickerWarning(it.status)!.level" prepend-icon="mdi-alert">
68
+ {{ stickerWarning(it.status)!.text }}
69
+ </v-chip>
70
+ <span v-else class="text-medium-emphasis">OK</span>
71
+ </td>
72
+ </tr>
73
+ </tbody>
74
+ </v-table>
75
+ <p class="text-caption text-medium-emphasis my-2">
76
+ Expired, blocklisted and inactive vehicles start unticked. Each sticker has a cut-off strip under the
77
+ dashed line saying which unit and owner it is for - cut it off before pasting the sticker on the car.
78
+ </p>
79
+ <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview" />
80
+ </template>
81
+ </v-card-text>
82
+
83
+ <v-card-actions>
84
+ <v-btn v-if="step === 'review' && !fromReissue" variant="text" class="text-none" @click="step = 'select'">Back</v-btn>
85
+ <v-spacer />
86
+ <v-btn variant="text" class="text-none" @click="close(false)">Cancel</v-btn>
87
+ <v-btn v-if="step === 'select'" color="primary" variant="flat" class="text-none" :disabled="!selected.length"
88
+ :loading="loading" @click="goReview">
89
+ Preview {{ selected.length }} sticker{{ selected.length === 1 ? "" : "s" }}
90
+ </v-btn>
91
+ <v-btn v-else color="primary" variant="flat" class="text-none" prepend-icon="mdi-printer"
92
+ :disabled="!ticked.length || !docHtml" @click="print">
93
+ Print {{ ticked.length }} sticker{{ ticked.length === 1 ? "" : "s" }}
94
+ </v-btn>
95
+ </v-card-actions>
96
+ </v-card>
97
+ </v-dialog>
98
+ </template>
99
+
100
+ <script lang="ts" setup>
101
+ import QRCode from "qrcode";
102
+ import useVehicle from "../composables/useVehicle";
103
+ import { errorConverter } from "../utils/data";
104
+ import {
105
+ STICKER_MAX_PER_REQUEST,
106
+ buildStickerDocument,
107
+ defaultPlateIds,
108
+ defaultStickerIds,
109
+ idsForRequest,
110
+ installerLine,
111
+ setIds,
112
+ stickerPlateRows,
113
+ stickerWarning,
114
+ type StickerItem,
115
+ type StickerPlateRow,
116
+ } from "../utils/vehicle-qr-sticker";
117
+
118
+ const props = defineProps({
119
+ modelValue: { type: Boolean, default: false },
120
+ site: { type: String, required: true },
121
+ /** The list's rows on the current page. */
122
+ rows: { type: Array as PropType<Array<Record<string, any>>>, default: () => [] },
123
+ /** The list's current tab and search, used by "select all matching". */
124
+ view: { type: String, default: "" },
125
+ search: { type: String, default: "" },
126
+ /** Vehicle ids to go straight to review with (a re-issued sticker). */
127
+ vehicleIds: { type: Array as PropType<string[]>, default: () => [] },
128
+ });
129
+ const emit = defineEmits(["update:modelValue"]);
130
+
131
+ const { getVehicles, getVehicleQrStickers } = useVehicle();
132
+
133
+ const step = ref<"select" | "review">("select");
134
+ const plates = ref<StickerPlateRow[]>([]);
135
+ const selected = ref<string[]>([]);
136
+ const items = ref<StickerItem[]>([]);
137
+ const ticked = ref<string[]>([]);
138
+ const missing = ref<string[]>([]);
139
+ const dropped = ref(0);
140
+ const loading = ref(false);
141
+ const loadingAll = ref(false);
142
+ const error = ref("");
143
+ const docHtml = ref("");
144
+ const frame = ref<HTMLIFrameElement | null>(null);
145
+ const fromReissue = computed(() => props.vehicleIds.length > 0);
146
+
147
+ const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
148
+ const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
149
+
150
+ function tickGroup(ids: string[], on: boolean) {
151
+ selected.value = setIds(selected.value, ids, on);
152
+ }
153
+
154
+ function close(value: boolean) {
155
+ emit("update:modelValue", value);
156
+ }
157
+
158
+ watch(
159
+ () => props.modelValue,
160
+ async (open) => {
161
+ if (!open) return;
162
+ error.value = "";
163
+ items.value = [];
164
+ docHtml.value = "";
165
+ step.value = "select";
166
+ plates.value = stickerPlateRows(props.rows);
167
+ selected.value = defaultPlateIds(plates.value);
168
+ if (props.vehicleIds.length) {
169
+ selected.value = [...props.vehicleIds];
170
+ await goReview();
171
+ }
172
+ },
173
+ { immediate: true },
174
+ );
175
+
176
+ /** Every page of the current tab + search, up to the per-request cap. */
177
+ async function loadAll() {
178
+ loadingAll.value = true;
179
+ error.value = "";
180
+ try {
181
+ const all: Array<Record<string, any>> = [];
182
+ let page = 1;
183
+ let pages = 1;
184
+ do {
185
+ const res: any = await getVehicles({ page, limit: 100, search: props.search, view: props.view, site: props.site });
186
+ all.push(...(res?.items ?? []));
187
+ pages = res?.pages || 1;
188
+ page += 1;
189
+ } while (page <= pages && stickerPlateRows(all).length < STICKER_MAX_PER_REQUEST);
190
+ plates.value = stickerPlateRows(all);
191
+ selected.value = defaultPlateIds(plates.value);
192
+ } catch (e: any) {
193
+ error.value = errorConverter(e);
194
+ } finally {
195
+ loadingAll.value = false;
196
+ }
197
+ }
198
+
199
+ async function goReview() {
200
+ loading.value = true;
201
+ error.value = "";
202
+ try {
203
+ const { ids, dropped: over } = idsForRequest(selected.value);
204
+ const res = await getVehicleQrStickers(props.site, ids);
205
+ items.value = (res?.items ?? []) as StickerItem[];
206
+ missing.value = res?.missing ?? [];
207
+ dropped.value = over;
208
+ ticked.value = defaultStickerIds(items.value);
209
+ step.value = "review";
210
+ } catch (e: any) {
211
+ error.value = errorConverter(e);
212
+ } finally {
213
+ loading.value = false;
214
+ }
215
+ }
216
+
217
+ // The same document is the preview and the print job. Error correction Q and a
218
+ // 4-module quiet zone; the QR is drawn at 32 mm so it scans from arm's length.
219
+ let renderToken = 0;
220
+ watch([items, ticked], async () => {
221
+ const mine = ++renderToken;
222
+ const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
223
+ if (!chosen.length) {
224
+ docHtml.value = "";
225
+ return;
226
+ }
227
+ const svgs = await Promise.all(
228
+ chosen.map((i) => QRCode.toString(i.qr, { type: "svg", errorCorrectionLevel: "Q", margin: 4 })),
229
+ );
230
+ if (mine === renderToken) docHtml.value = buildStickerDocument(chosen, svgs);
231
+ });
232
+
233
+ function print() {
234
+ const win = frame.value?.contentWindow;
235
+ if (!win) return;
236
+ win.focus();
237
+ win.print();
238
+ }
239
+ </script>
240
+
241
+ <style scoped>
242
+ .sticker-preview {
243
+ width: 100%;
244
+ height: 460px;
245
+ border: 1px solid var(--v-border-color, #ccc);
246
+ border-radius: 8px;
247
+ background: #e9e9e9;
248
+ }
249
+ </style>
@@ -16,14 +16,14 @@
16
16
  <v-form ref="formRef" v-model="validForm" :disabled="processing" @click="errorMessage = ''">
17
17
  <v-row no-gutters class="pt-4">
18
18
  <v-col v-if="shouldShowField('attachments')" class="w-100">
19
- <InputLabel class="text-capitalize" title="Attach Image" />
19
+ <InputLabel class="text-capitalize" title="Attach Image (Optional)" />
20
20
  <InputFileV2 v-model="visitor.attachments" multiple :max-length="3" />
21
21
  </v-col>
22
22
 
23
23
  <v-col v-if="shouldShowField('nric')" cols="12">
24
24
  <v-row>
25
25
  <v-col cols="12">
26
- <InputLabel class="text-capitalize" title="NRIC" :required="requireNRIC" />
26
+ <InputLabel class="text-capitalize" :title="requireNRIC ? 'NRIC (Required)' : 'NRIC (Optional)'" :required="requireNRIC" />
27
27
  <v-menu v-model="nricSearchMenu" location="bottom" offset-y :close-on-content-click="false"
28
28
  content-class="visitor-form__menu">
29
29
  <template #activator="{ props }">
@@ -55,11 +55,11 @@
55
55
  <v-col v-if="shouldShowField('contact')" cols="12">
56
56
  <v-row>
57
57
  <v-col cols="12">
58
- <InputLabel class="text-capitalize" title="Phone Number" required />
58
+ <InputLabel class="text-capitalize" :title="requireContact ? 'Phone Number (Required)' : 'Phone Number (Optional)'" :required="requireContact" />
59
59
  <v-menu v-model="phoneSearchMenu" location="bottom" offset-y :close-on-content-click="false"
60
60
  content-class="visitor-form__menu">
61
61
  <template #activator="{ props }">
62
- <InputPhoneNumberV2 v-bind="props" v-model="visitor.contact" :rules="[requiredRule]"
62
+ <InputPhoneNumberV2 v-bind="props" v-model="visitor.contact" :rules="[requireContact ? requiredRule : () => true]"
63
63
  @focus="isPhoneFieldFocused = true" @blur="isPhoneFieldFocused = false" density="comfortable"
64
64
  :key="currentAutofillSource + 'phone-key'" @update:model-value="handleUpdatePhoneNumber" />
65
65
  </template>
@@ -87,12 +87,13 @@
87
87
  <v-col v-if="shouldShowField('plateNumber')" cols="12">
88
88
  <v-row>
89
89
  <v-col cols="12">
90
- <InputLabel class="text-capitalize" title="Vehicle Number" :required="isVehicleRequired" />
90
+ <InputLabel class="text-capitalize" :title="isVehicleRequired ? 'Vehicle Number (Required)' : 'Vehicle Number (Optional)'" :required="isVehicleRequired" />
91
91
  <!-- <v-text-field v-model.trim.uppercase="visitor.plateNumber" density="comfortable" /> -->
92
92
  <v-menu v-model="vehicleSearchMenu" location="bottom" offset-y :close-on-content-click="false"
93
93
  content-class="visitor-form__menu">
94
94
  <template #activator="{ props }">
95
95
  <InputVehicleNumber v-bind="props" v-model="visitor.plateNumber" density="comfortable"
96
+ :rules="[isVehicleRequired ? requiredRule : () => true]"
96
97
  @update:model-value="handleUpdateVehicleNumber" @focus="handleFocusPlateNumber"
97
98
  @blur="isPlateNumberFieldFocused = false" />
98
99
  </template>
@@ -117,7 +118,7 @@
117
118
  </v-col>
118
119
 
119
120
  <v-col v-if="shouldShowField('contractorType')" cols="12">
120
- <InputLabel class="text-capitalize" title="Contractor Type" required />
121
+ <InputLabel class="text-capitalize" title="Contractor Type (Required)" required />
121
122
  <v-combobox v-model="contractorTypeObj" v-model:search="contractorTypeInput" ref="contractorTypeCombo"
122
123
  autocomplete="off" :hide-no-data="false" @update:focused="handleFocusedContractorType"
123
124
  :items="contractorTypes" :rules="[requiredRule]" item-value="value"
@@ -139,7 +140,7 @@
139
140
  <v-col v-if="shouldShowField('name')" cols="12">
140
141
  <v-row>
141
142
  <v-col cols="12">
142
- <InputLabel class="text-capitalize" title="Full Name" required />
143
+ <InputLabel class="text-capitalize" title="Full Name (Required)" required />
143
144
  <v-text-field v-model.trim="visitor.name" density="comfortable" :rules="[requiredRule]" />
144
145
  </v-col>
145
146
  </v-row>
@@ -159,7 +160,7 @@
159
160
  place of `primary`, which is the darker link tone. Without
160
161
  the label this was the only required field in the form with
161
162
  nothing naming it: just a bare Food/Parcel pair. -->
162
- <InputLabel class="text-capitalize" title="Delivery Type" required />
163
+ <InputLabel class="text-capitalize" title="Delivery Type (Required)" required />
163
164
  <v-radio-group v-model="visitor.deliveryType" inline color="accent" :rules="[requiredRule]">
164
165
  <v-radio label="Food" value="Food"></v-radio>
165
166
  <v-radio label="Parcel" value="Parcel"></v-radio>
@@ -170,8 +171,9 @@
170
171
 
171
172
  <template v-if="shouldShowField('company')">
172
173
  <v-col cols="12">
173
- <InputLabel class="text-capitalize" title="Company Name" />
174
+ <InputLabel class="text-capitalize" :title="requireCompany ? 'Company Name (Required)' : 'Company Name (Optional)'" :required="requireCompany" />
174
175
  <v-combobox v-model="visitor.company" v-model:search="companyNameInput" ref="companyCombo"
176
+ :rules="[requireCompany ? requiredRule : () => true]"
175
177
  autocomplete="off" :hide-no-data="true" :items="companyNames" item-value="value"
176
178
  @update:model-value="handleSelectCompanyName" variant="outlined" density="comfortable" persistent-hint
177
179
  small-chips>
@@ -182,8 +184,9 @@
182
184
 
183
185
  <template v-if="shouldShowField('delivery-company')">
184
186
  <v-col cols="12">
185
- <InputLabel class="text-capitalize" title="Delivery Company" />
187
+ <InputLabel class="text-capitalize" title="Delivery Company (Required)" required />
186
188
  <v-combobox v-model="deliveryCompany" v-model:search="deliveryCompanyInput" ref="companyCombo"
189
+ :rules="[requiredRule]"
187
190
  autocomplete="off" :hide-no-data="false" :items="deliveryCompanyList" item-value="value"
188
191
  :loading="siteDataPending" variant="outlined" density="comfortable" persistent-hint small-chips>
189
192
  <template v-slot:no-data>
@@ -205,7 +208,7 @@
205
208
 
206
209
 
207
210
  <v-col v-if="shouldShowField('block')" cols="12">
208
- <InputLabel class="text-capitalize" title="Block" required />
211
+ <InputLabel class="text-capitalize" title="Block (Required)" required />
209
212
  <v-autocomplete v-model="visitor.block" :items="blocksArray" item-value="value" autocomplete="off"
210
213
  item-title="title" :key="blockListDataPending ? 'block-list-pending' : 'block-list-ready'
211
214
  " :loading="blockListDataPending" @update:model-value="handleChangeBlock" density="comfortable"
@@ -213,13 +216,13 @@
213
216
  </v-col>
214
217
 
215
218
  <v-col v-if="shouldShowField('level')" cols="12">
216
- <InputLabel class="text-capitalize" title="Level" required />
219
+ <InputLabel class="text-capitalize" title="Level (Required)" required />
217
220
  <v-autocomplete v-model="visitor.level" :items="levelsArray" density="comfortable" autocomplete="off"
218
221
  :disabled="!visitor.block" :key="levelsDataPending ? 'level-list-pending' : 'level-list-ready'
219
222
  " :loading="levelsDataPending" @update:model-value="handleChangeLevel" :rules="[requiredRule]" />
220
223
  </v-col>
221
224
  <v-col v-if="shouldShowField('unit')" cols="12">
222
- <InputLabel class="text-capitalize" title="Unit" required />
225
+ <InputLabel class="text-capitalize" title="Unit (Required)" required />
223
226
  <v-autocomplete v-model="visitor.unit" :items="unitsArray" density="comfortable" autocomplete="off"
224
227
  item-title="title" item-value="value" :disabled="!visitor.level"
225
228
  :key="unitsDataPending ? 'unit-list-pending' : 'unit-list-ready'" :loading="unitsDataPending"
@@ -227,14 +230,14 @@
227
230
  </v-col>
228
231
 
229
232
  <v-col v-if="shouldShowField('unit')" cols="12">
230
- <InputLabel class="text-capitalize" title="Registered Unit Company Name" required
233
+ <InputLabel class="text-capitalize" title="Registered Unit Company Name"
231
234
  :key="'unit-key' + visitor.level" />
232
235
  <v-text-field v-model.trim="registeredUnitCompanyName" density="comfortable"
233
236
  :loading="buildingUnitDataPending" readonly class="no-pointer" />
234
237
  </v-col>
235
238
 
236
239
  <v-col v-if="shouldShowField('remarks')" cols="12">
237
- <InputLabel class="text-capitalize" title="Remarks" />
240
+ <InputLabel class="text-capitalize" title="Remarks (Optional)" />
238
241
  <v-textarea v-model="visitor.remarks" density="comfortable" :rows="3" no-resize />
239
242
  </v-col>
240
243
 
@@ -831,12 +834,29 @@ const formMode = computed<"add" | "edit" | "register">(() => {
831
834
  return selectedUnregisteredVisitor.value ? "register" : prop.mode;
832
835
  });
833
836
 
837
+ // Fields this form actually has for the visitor type - drives both what is
838
+ // shown and what is sent on submit, so nothing outside the form is saved.
839
+ // typeFieldMap also feeds the details dialog, so it can list more than this:
840
+ // - walk-in "company": not on the security mobile app's walk-in, hidden here
841
+ // but still shown on older walk-in records in the details dialog;
842
+ // - guest "purpose": never drawn in this form.
843
+ const formFieldsExcluded: Partial<Record<TVisitorType, string[]>> = {
844
+ "walk-in": ["company"],
845
+ guest: ["purpose"],
846
+ };
847
+
848
+ const isFormField = (
849
+ fieldKey: keyof TVisitorPayload | "delivery-company"
850
+ ) => {
851
+ if (formFieldsExcluded[prop.type]?.includes(fieldKey)) return false;
852
+ return !!typeFieldMap[prop.type]?.includes(fieldKey);
853
+ };
854
+
834
855
  const shouldShowField = (
835
856
  fieldKey: keyof TVisitorPayload | "delivery-company"
836
857
  ) => {
837
858
  if ((!withStep2.value && !withStep3.value) || contractorStep.value === 1) {
838
- const visibleFields = typeFieldMap[prop.type];
839
- return visibleFields?.includes(fieldKey);
859
+ return isFormField(fieldKey);
840
860
  }
841
861
  };
842
862
 
@@ -866,8 +886,21 @@ const showShowKeysField = computed(() => {
866
886
  return ["guest", "contractor", "walk-in"].includes(prop.type);
867
887
  });
868
888
 
889
+ // Required / optional per type - kept in line with the security mobile app.
869
890
  const requireNRIC = computed(() => {
870
- return prop.type !== "walk-in" && prop.type !== "guest";
891
+ return prop.type === "contractor";
892
+ });
893
+
894
+ const requireContact = computed(() => {
895
+ return ["contractor", "delivery"].includes(prop.type);
896
+ });
897
+
898
+ const isVehicleRequired = computed(() => {
899
+ return ["guest", "pick-up", "drop-off"].includes(prop.type);
900
+ });
901
+
902
+ const requireCompany = computed(() => {
903
+ return prop.type === "contractor";
871
904
  });
872
905
 
873
906
  const hidQrCodePassConfig = computed(() => {
@@ -1942,7 +1975,8 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
1942
1975
  if (formMode.value === "add" || formMode.value === "register") {
1943
1976
  const allowedFields = typeFieldMap[prop.type];
1944
1977
  for (const key of allowedFields as (keyof TVisitorPayload)[]) {
1945
- if (visitor[key] !== undefined) {
1978
+ // only what the form has (see isFormField)
1979
+ if (isFormField(key) && visitor[key] !== undefined) {
1946
1980
  payload = {
1947
1981
  ...payload,
1948
1982
  [key]: visitor[key],
@@ -122,6 +122,19 @@ export function useSettingsPermission() {
122
122
  );
123
123
  });
124
124
 
125
+ // Vehicle QR stickers switch: whoever may update vehicles (same grant the
126
+ // server checks on PUT /api/vehicles/qr/settings/site/:site).
127
+ const vehiclePermissions: TPermissions = {
128
+ "vehicle-mgmt": {
129
+ "update-vehicle": { check: true, description: "Allows the user to update vehicle details." },
130
+ },
131
+ };
132
+ const canManageVehicleQr = computed(() => {
133
+ if (!userAppRole.value) return false;
134
+ if (userAppRole.value.permissions.includes("*")) return true;
135
+ return hasPermission(userAppRole.value, vehiclePermissions, "vehicle-mgmt", "update-vehicle");
136
+ });
137
+
125
138
  const canManageSiteSettings = computed(() => {
126
139
  return canManageSiteInformation.value || canManageANPRCamera.value || canManageCCTVCamera.value || canManageDeliveryCompanies.value || canManageResidentVisitors.value || canManageWorkOrderSettings.value || canManageBillingAndSOAConfiguration.value || canManageNFCManagement.value || canManageNFCPatrolSettings.value || canManageEntryPass.value || canManageRedDotSettings.value
127
140
  })
@@ -138,6 +151,7 @@ export function useSettingsPermission() {
138
151
  canManageNFCPatrolSettings,
139
152
  canManageEntryPass,
140
153
  canManageRedDotSettings,
154
+ canManageVehicleQr,
141
155
  canManageSiteSettings
142
156
 
143
157
  };
@@ -272,6 +272,41 @@ function getSpecificVehicleById(id: string){
272
272
  }
273
273
 
274
274
 
275
+ // Vehicle QR stickers (core vehicle-qr). Per-site switch, bulk sticker data,
276
+ // and re-issue (the old sticker stops working).
277
+ function getVehicleQrSettings(site: string) {
278
+ return useNuxtApp().$api<{ site: string; enabled: boolean }>(
279
+ `/api/vehicles/qr/settings/site/${site}`,
280
+ { method: "GET" },
281
+ );
282
+ }
283
+
284
+ function setVehicleQrSettings(site: string, enabled: boolean) {
285
+ return useNuxtApp().$api<{ site: string; enabled: boolean }>(
286
+ `/api/vehicles/qr/settings/site/${site}`,
287
+ { method: "PUT", body: { enabled } },
288
+ );
289
+ }
290
+
291
+ function getVehicleQrStickers(site: string, vehicleIds: string[]) {
292
+ return useNuxtApp().$api<{
293
+ site: { _id: string; name: string };
294
+ items: Array<Record<string, any>>;
295
+ missing: string[];
296
+ }>("/api/vehicles/qr/stickers", {
297
+ method: "POST",
298
+ body: { site, vehicleIds },
299
+ });
300
+ }
301
+
302
+ function regenerateVehicleQr(id: string) {
303
+ return useNuxtApp().$api<{ vehicleId: string; qr: string; token: string }>(
304
+ `/api/vehicles/qr/regenerate/id/${id}`,
305
+ { method: "PUT" },
306
+ );
307
+ }
308
+
309
+
275
310
  return {
276
311
  getVehicles,
277
312
  addVehicle,
@@ -290,6 +325,10 @@ function getSpecificVehicleById(id: string){
290
325
  getActiveCameraCounts,
291
326
  syncVehicle,
292
327
  searchMultipleVehiclesByUnitId,
293
- getSpecificVehicleById
328
+ getSpecificVehicleById,
329
+ getVehicleQrSettings,
330
+ setVehicleQrSettings,
331
+ getVehicleQrStickers,
332
+ regenerateVehicleQr,
294
333
  };
295
334
  }
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.90.2",
5
+ "version": "4.91.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.",
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Vehicle QR stickers - the pure parts (labels, selection, paging, print page).
3
+ *
4
+ * Kept free of Vue and of the QR library so `node --test` can run it. The
5
+ * sticker itself carries NO resident detail: just the QR, the plate and the
6
+ * Seven365 line. Block / level / unit / owner appear ONLY on the installer
7
+ * strip under the dashed cut line, which is cut off before the sticker is
8
+ * pasted on the car.
9
+ */
10
+
11
+ /** The server's most vehicles per request (POST /api/vehicles/qr/stickers). */
12
+ export const STICKER_MAX_PER_REQUEST = 500;
13
+
14
+ /** A4 sheet: 4 columns x 4 rows of 47 mm cells. */
15
+ export const STICKER_COLUMNS = 4;
16
+ export const STICKER_ROWS = 4;
17
+ export const STICKERS_PER_PAGE = STICKER_COLUMNS * STICKER_ROWS;
18
+
19
+ export type StickerItem = {
20
+ vehicleId: string;
21
+ qr: string;
22
+ token?: string;
23
+ block?: string;
24
+ level?: string;
25
+ unit?: string;
26
+ plateNumber?: string;
27
+ ownerName?: string;
28
+ status?: string;
29
+ };
30
+
31
+ export type StickerPlateRow = {
32
+ id: string;
33
+ plateNumber: string;
34
+ status: string;
35
+ ownerName: string;
36
+ unitLabel: string;
37
+ };
38
+
39
+ const text = (v: unknown) => (v == null ? "" : String(v).trim());
40
+
41
+ /** "Blk 4 #02-01". Tolerates a unit that already carries its level ("02-01"). */
42
+ export function stickerUnitLabel(parts: { block?: unknown; level?: unknown; unit?: unknown }): string {
43
+ const block = text(parts.block).replace(/^(blk|block)\.?\s*/i, "");
44
+ const level = text(parts.level).replace(/^(level|lvl|floor)\.?\s*/i, "");
45
+ const unit = text(parts.unit).replace(/^(unit)\.?\s*/i, "").replace(/^#/, "");
46
+ const home = unit.includes("-") || !level ? unit : `${level}-${unit}`;
47
+ return [block ? `Blk ${block}` : "", home ? `#${home}` : ""].filter(Boolean).join(" ");
48
+ }
49
+
50
+ /** The installer strip under the cut line: where it goes and whose car. */
51
+ export function installerLine(item: Pick<StickerItem, "block" | "level" | "unit" | "ownerName">): {
52
+ place: string;
53
+ owner: string;
54
+ } {
55
+ return { place: stickerUnitLabel(item), owner: text(item.ownerName) };
56
+ }
57
+
58
+ export type StickerWarning = { level: "error" | "warning"; text: string };
59
+
60
+ /** What the preview says about a status. Null = nothing to warn about. */
61
+ export function stickerWarning(status: unknown): StickerWarning | null {
62
+ switch (text(status).toLowerCase()) {
63
+ case "expired":
64
+ return { level: "error", text: "Season pass expired" };
65
+ case "blocklisted":
66
+ return { level: "error", text: "On the blocklist" };
67
+ case "inactive":
68
+ return { level: "warning", text: "Vehicle is inactive" };
69
+ case "pending":
70
+ return { level: "warning", text: "Waiting for approval" };
71
+ default:
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** Stickers that should NOT be ticked for printing until someone chooses to. */
77
+ export function stickerStartsUnticked(status: unknown): boolean {
78
+ return stickerWarning(status)?.level === "error" || text(status).toLowerCase() === "inactive";
79
+ }
80
+
81
+ /** The ids ticked by default in the preview. */
82
+ export function defaultStickerIds(items: Pick<StickerItem, "vehicleId" | "status">[]): string[] {
83
+ return items.filter((i) => !stickerStartsUnticked(i.status)).map((i) => i.vehicleId);
84
+ }
85
+
86
+ /** One selectable row per plate, from the list's grouped rows. */
87
+ export function stickerPlateRows(rows: Array<Record<string, any>>): StickerPlateRow[] {
88
+ const out: StickerPlateRow[] = [];
89
+ for (const row of rows ?? []) {
90
+ const plates: any[] = Array.isArray(row?.plates) && row.plates.length ? row.plates : [row];
91
+ for (const plate of plates) {
92
+ if (!plate?._id) continue;
93
+ out.push({
94
+ id: String(plate._id),
95
+ plateNumber: text(plate.plateNumber),
96
+ status: text(plate.status).toLowerCase(),
97
+ ownerName: text(row?.name ?? plate.name),
98
+ unitLabel: stickerUnitLabel({
99
+ block: row?.block?.name ?? row?.block,
100
+ level: row?.level?.name ?? row?.level,
101
+ unit: row?.unit?.name ?? row?.unit,
102
+ }),
103
+ });
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Plates ticked on first open: live ones only (active / season pass). */
110
+ export function defaultPlateIds(plates: StickerPlateRow[]): string[] {
111
+ return plates.filter((p) => p.status === "active" || p.status === "temporary").map((p) => p.id);
112
+ }
113
+
114
+ /** Tick or untick a group of ids (e.g. "select all on this page"). */
115
+ export function setIds(selected: string[], ids: string[], on: boolean): string[] {
116
+ const rest = selected.filter((id) => !ids.includes(id));
117
+ return on ? [...rest, ...ids] : rest;
118
+ }
119
+
120
+ /** The ids to send: unique, in order, at most what the server accepts. */
121
+ export function idsForRequest(ids: string[]): { ids: string[]; dropped: number } {
122
+ const unique = [...new Set(ids)];
123
+ return { ids: unique.slice(0, STICKER_MAX_PER_REQUEST), dropped: Math.max(0, unique.length - STICKER_MAX_PER_REQUEST) };
124
+ }
125
+
126
+ /** Split into sheets. */
127
+ export function stickerPages<T>(items: T[], perPage: number = STICKERS_PER_PAGE): T[][] {
128
+ const pages: T[][] = [];
129
+ for (let i = 0; i < items.length; i += perPage) pages.push(items.slice(i, i + perPage));
130
+ return pages;
131
+ }
132
+
133
+ export function escapeHtml(value: unknown): string {
134
+ return text(value)
135
+ .replace(/&/g, "&amp;")
136
+ .replace(/</g, "&lt;")
137
+ .replace(/>/g, "&gt;")
138
+ .replace(/"/g, "&quot;")
139
+ .replace(/'/g, "&#39;");
140
+ }
141
+
142
+ const PRINT_CSS = `
143
+ @page { size: A4; margin: 6mm; }
144
+ * { box-sizing: border-box; }
145
+ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family: Arial, Helvetica, sans-serif; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
146
+ .page { display: grid; grid-template-columns: repeat(${STICKER_COLUMNS}, 47mm); gap: 2mm; justify-content: center; align-content: start; width: 198mm; height: 285mm; margin: 0 auto; break-after: page; }
147
+ .page:last-child { break-after: auto; }
148
+ .cell { width: 47mm; break-inside: avoid; }
149
+ .card { width: 47mm; height: 58mm; border: 0.3mm solid #1a1a1a; border-radius: 3mm; padding: 2mm 2mm 1.6mm; display: flex; flex-direction: column; align-items: center; justify-content: space-between; text-align: center; }
150
+ .qr { width: 32mm; height: 32mm; display: block; }
151
+ .qr svg { width: 100%; height: 100%; display: block; }
152
+ .plate { font-size: 13pt; font-weight: 800; letter-spacing: 0.3mm; line-height: 1.1; }
153
+ .hint { font-size: 5.8pt; color: #333; line-height: 1.2; }
154
+ .brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt; color: #222; border-top: 0.2mm solid #ccc; width: 100%; padding-top: 1mm; }
155
+ .mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm; background: #111; color: #fff; font-weight: 800; font-size: 5pt; line-height: 3.2mm; text-align: center; }
156
+ .cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top: 1mm; font-size: 6.5pt; line-height: 1.25; color: #111; min-height: 8mm; }
157
+ .cut .owner { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
158
+ .cut .note { color: #666; font-size: 5.5pt; }
159
+ @media screen { body { background: #e9e9e9; zoom: 0.75; padding: 4mm 0; } .page { background: #fff; margin-bottom: 4mm; height: auto; min-height: 285mm; box-shadow: 0 0 2mm rgba(0,0,0,.25); } }
160
+ `;
161
+
162
+ /**
163
+ * One full HTML document: A4 sheets of stickers. `qrSvgs` is parallel to
164
+ * `items` (the QR for each, already drawn). Every value is HTML-escaped - an
165
+ * owner name is typed by a person.
166
+ */
167
+ export function buildStickerDocument(items: StickerItem[], qrSvgs: string[]): string {
168
+ const cells = items.map((item, i) => {
169
+ const { place, owner } = installerLine(item);
170
+ return (
171
+ `<div class="cell"><div class="card">` +
172
+ `<div class="qr">${qrSvgs[i] ?? ""}</div>` +
173
+ `<div class="plate">${escapeHtml(item.plateNumber)}</div>` +
174
+ `<div class="hint">Scan with iSecure365 / iMA365</div>` +
175
+ `<div class="brand"><span class="mark">7</span>Powered by Seven365</div>` +
176
+ `</div>` +
177
+ `<div class="cut"><div><b>${escapeHtml(place || "-")}</b></div>` +
178
+ `<div class="owner">Owner: ${escapeHtml(owner || "-")}</div>` +
179
+ `<div class="note">Installer: cut here, then paste on this car</div></div></div>`
180
+ );
181
+ });
182
+ const pages = stickerPages(cells).map((p) => `<div class="page">${p.join("")}</div>`);
183
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Vehicle QR stickers</title><style>${PRINT_CSS}</style></head><body>${pages.join("")}</body></html>`;
184
+ }