@7365admin1/layer-common 4.90.2-staging.485 → 4.90.2-staging.487
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/components/AccessCardDetailsDialog.vue +2 -2
- package/components/AccessCardPreviewDialog.vue +86 -9
- package/components/SiteSettings.vue +48 -0
- package/components/VehicleManagement.vue +80 -5
- package/components/VehicleQrStickerDialog.vue +249 -0
- package/composables/useAccessManagement.ts +17 -1
- package/composables/useSettingsPermission.ts +14 -0
- package/composables/useVehicle.ts +40 -1
- package/package.json +1 -1
- package/utils/vehicle-qr-sticker.ts +184 -0
|
@@ -68,9 +68,9 @@
|
|
|
68
68
|
<!-- Visitor / Resident Credentials -->
|
|
69
69
|
<template v-if="details.userCred">
|
|
70
70
|
<v-divider />
|
|
71
|
-
<div class="card-details__section">
|
|
71
|
+
<!-- <div class="card-details__section">
|
|
72
72
|
{{ details.userType ?? "Visitor Information" }}
|
|
73
|
-
</div>
|
|
73
|
+
</div> -->
|
|
74
74
|
|
|
75
75
|
<div v-if="details.userCred.email">
|
|
76
76
|
<div class="card-details__label">Email</div>
|
|
@@ -50,18 +50,28 @@
|
|
|
50
50
|
|
|
51
51
|
<!-- Person Selector -->
|
|
52
52
|
<InputLabel title="Person" required class="mb-1" />
|
|
53
|
-
|
|
53
|
+
<!--
|
|
54
|
+
An autocomplete rather than a select: a unit's Management and provider
|
|
55
|
+
lists are short, but a site's can run to dozens, and scrolling for a
|
|
56
|
+
name is slow. `custom-filter` widens the match beyond the title,
|
|
57
|
+
because the thing an operator remembers is often the email or the role
|
|
58
|
+
- and a member's name IS frequently their email address.
|
|
59
|
+
-->
|
|
60
|
+
<v-autocomplete
|
|
54
61
|
v-model="assignPerson"
|
|
62
|
+
v-model:search="personSearch"
|
|
55
63
|
:items="assignPeopleItems"
|
|
64
|
+
:custom-filter="filterPerson"
|
|
56
65
|
density="compact"
|
|
57
66
|
item-title="name"
|
|
58
67
|
item-value="assigneeId"
|
|
59
68
|
hide-details="auto"
|
|
60
|
-
placeholder="
|
|
69
|
+
placeholder="Search or select person..."
|
|
61
70
|
persistent-placeholder
|
|
62
71
|
:loading="peopleLoading"
|
|
63
72
|
:disabled="peopleLoading"
|
|
64
73
|
return-object
|
|
74
|
+
:hide-no-data="false"
|
|
65
75
|
:no-data-text="assignNoDataText"
|
|
66
76
|
>
|
|
67
77
|
<!--
|
|
@@ -98,7 +108,7 @@
|
|
|
98
108
|
</div>
|
|
99
109
|
</div>
|
|
100
110
|
</template>
|
|
101
|
-
</v-
|
|
111
|
+
</v-autocomplete>
|
|
102
112
|
|
|
103
113
|
<!--
|
|
104
114
|
A card is assigned by writing the person's USER id onto it, so a row
|
|
@@ -455,6 +465,20 @@ const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
|
|
|
455
465
|
const { getAll: getMembers } = useMember();
|
|
456
466
|
const { getSiteMembers } = useServiceProvider();
|
|
457
467
|
|
|
468
|
+
/**
|
|
469
|
+
* The member type of whichever app this layer is running in
|
|
470
|
+
* ("property_management_agency", "security_agency", ...), which `/api/members`
|
|
471
|
+
* requires and rejects empty.
|
|
472
|
+
*
|
|
473
|
+
* Read HERE, during setup, not inside the loader that uses it:
|
|
474
|
+
* `useRuntimeConfig` needs the Nuxt instance, and that is only guaranteed
|
|
475
|
+
* synchronously in setup. Called from an async function resumed after an
|
|
476
|
+
* await — which is exactly what the loader is — it throws "nuxt instance
|
|
477
|
+
* unavailable", the loader's catch turns that into an empty list, and the
|
|
478
|
+
* Management group silently shows nothing with no request ever sent.
|
|
479
|
+
*/
|
|
480
|
+
const APP = String(useRuntimeConfig().public.APP ?? "");
|
|
481
|
+
|
|
458
482
|
const historyDialog = ref(false);
|
|
459
483
|
const showAssignForm = ref(false);
|
|
460
484
|
const assignPerson = ref<Record<string, any> | null>(null);
|
|
@@ -471,10 +495,22 @@ const assignPerson = ref<Record<string, any> | null>(null);
|
|
|
471
495
|
*/
|
|
472
496
|
type AssignCategory = "residents" | "management" | "providers";
|
|
473
497
|
|
|
474
|
-
const assignCategories: {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
498
|
+
const assignCategories: {
|
|
499
|
+
value: AssignCategory;
|
|
500
|
+
label: string;
|
|
501
|
+
/**
|
|
502
|
+
* What the server stores as the card's `holderType` (core
|
|
503
|
+
* `EAccessCardHolderTypes`). Sent with the assignment, because `assignees`
|
|
504
|
+
* alone is ambiguous: it is a `users` id for a resident and a membership id
|
|
505
|
+
* for a provider, and only the group says which to resolve against. These
|
|
506
|
+
* strings are a server contract - the labels above are free to change, these
|
|
507
|
+
* are not.
|
|
508
|
+
*/
|
|
509
|
+
holderType: "Visitor/Resident" | "Management" | "Service Provider";
|
|
510
|
+
}[] = [
|
|
511
|
+
{ value: "residents", label: "Resident / Tenant", holderType: "Visitor/Resident" },
|
|
512
|
+
{ value: "management", label: "Management", holderType: "Management" },
|
|
513
|
+
{ value: "providers", label: "Service Provider", holderType: "Service Provider" },
|
|
478
514
|
];
|
|
479
515
|
|
|
480
516
|
const assignCategory = ref<AssignCategory>("residents");
|
|
@@ -500,6 +536,30 @@ type AssignRow = {
|
|
|
500
536
|
};
|
|
501
537
|
|
|
502
538
|
const assignPeopleItems = ref<AssignRow[]>([]);
|
|
539
|
+
|
|
540
|
+
/** What is typed in the person picker. Cleared whenever the group changes. */
|
|
541
|
+
const personSearch = ref("");
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Match a person on everything the row shows, not just the name.
|
|
545
|
+
*
|
|
546
|
+
* Vuetify filters on the title alone by default, which is too narrow here: a
|
|
547
|
+
* Management member's name is often their email address, and two provider
|
|
548
|
+
* people from one company are told apart only by their role. Both of those sit
|
|
549
|
+
* in the second line, so a name-only match would hide the row an operator is
|
|
550
|
+
* actually searching for.
|
|
551
|
+
*
|
|
552
|
+
* The first argument is the title Vuetify matched against; it is ignored in
|
|
553
|
+
* favour of the row itself, which carries all three fields.
|
|
554
|
+
*/
|
|
555
|
+
function filterPerson(_title: string, query: string, item: any): boolean {
|
|
556
|
+
const q = String(query ?? "").trim().toLowerCase();
|
|
557
|
+
if (!q) return true;
|
|
558
|
+
const row = item?.raw ?? {};
|
|
559
|
+
return [row.name, row.subtitle, row.meta].some((field) =>
|
|
560
|
+
String(field ?? "").toLowerCase().includes(q),
|
|
561
|
+
);
|
|
562
|
+
}
|
|
503
563
|
const peopleLoading = ref(false);
|
|
504
564
|
const assignLoading = ref(false);
|
|
505
565
|
const assignError = ref("");
|
|
@@ -597,7 +657,7 @@ async function loadManagementRows(): Promise<AssignRow[]> {
|
|
|
597
657
|
org: props.orgId,
|
|
598
658
|
siteId: props.siteId,
|
|
599
659
|
unit: props.unit._id,
|
|
600
|
-
type:
|
|
660
|
+
type: APP,
|
|
601
661
|
status: "active",
|
|
602
662
|
page: 1,
|
|
603
663
|
limit: 50,
|
|
@@ -678,9 +738,19 @@ async function loadAssignPeople() {
|
|
|
678
738
|
: await loadResidentRows();
|
|
679
739
|
if (seq !== assignLoadSeq) return;
|
|
680
740
|
assignPeopleItems.value = rows;
|
|
681
|
-
} catch {
|
|
741
|
+
} catch (e: any) {
|
|
682
742
|
if (seq !== assignLoadSeq) return;
|
|
683
743
|
assignPeopleItems.value = [];
|
|
744
|
+
/*
|
|
745
|
+
* Say that the list failed rather than showing an empty one.
|
|
746
|
+
*
|
|
747
|
+
* This swallowed everything, so a group whose request 400'd, or never got
|
|
748
|
+
* sent at all, looked exactly like a group with nobody in it - there was
|
|
749
|
+
* nothing on screen to tell the two apart, and no way to tell from the UI
|
|
750
|
+
* that anything had gone wrong.
|
|
751
|
+
*/
|
|
752
|
+
assignError.value =
|
|
753
|
+
e?.data?.message ?? e?.message ?? "Could not load the list. Please try again.";
|
|
684
754
|
} finally {
|
|
685
755
|
if (seq === assignLoadSeq) peopleLoading.value = false;
|
|
686
756
|
}
|
|
@@ -688,6 +758,9 @@ async function loadAssignPeople() {
|
|
|
688
758
|
|
|
689
759
|
function onAssignCategoryChange() {
|
|
690
760
|
assignPerson.value = null;
|
|
761
|
+
// A term typed for the previous group means nothing in the next one, and
|
|
762
|
+
// leaving it would show an empty list over a list that has people in it.
|
|
763
|
+
personSearch.value = "";
|
|
691
764
|
assignError.value = "";
|
|
692
765
|
loadAssignPeople();
|
|
693
766
|
}
|
|
@@ -695,6 +768,7 @@ function onAssignCategoryChange() {
|
|
|
695
768
|
async function openAssignForm() {
|
|
696
769
|
showAssignForm.value = true;
|
|
697
770
|
assignPerson.value = null;
|
|
771
|
+
personSearch.value = "";
|
|
698
772
|
assignCategory.value = "residents";
|
|
699
773
|
assignError.value = "";
|
|
700
774
|
assignPeopleItems.value = [];
|
|
@@ -714,6 +788,7 @@ async function openAssignForm() {
|
|
|
714
788
|
function cancelAssign() {
|
|
715
789
|
showAssignForm.value = false;
|
|
716
790
|
assignPerson.value = null;
|
|
791
|
+
personSearch.value = "";
|
|
717
792
|
assignCategory.value = "residents";
|
|
718
793
|
assignPeopleItems.value = [];
|
|
719
794
|
assignError.value = "";
|
|
@@ -744,6 +819,8 @@ async function confirmAssign() {
|
|
|
744
819
|
type: selectedCardType.value,
|
|
745
820
|
acm_url: encryptedAcmUrl.value,
|
|
746
821
|
id: props.selectedCardInUnit?._id,
|
|
822
|
+
holderType: assignCategories.find((c) => c.value === assignCategory.value)
|
|
823
|
+
?.holderType,
|
|
747
824
|
});
|
|
748
825
|
emit("assign-to-person", {
|
|
749
826
|
card: props.selectedCardInUnit,
|
|
@@ -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>
|
|
@@ -319,7 +319,23 @@ export default function useAccessManagement() {
|
|
|
319
319
|
);
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
/**
|
|
323
|
+
* `holderType` is the GROUP the card was given from — "Visitor/Resident",
|
|
324
|
+
* "Management" or "Service Provider" (core `EAccessCardHolderTypes`).
|
|
325
|
+
*
|
|
326
|
+
* It is what lets the server resolve the holder afterwards: `assignees`
|
|
327
|
+
* carries a `users` id for a resident, but a membership id for a service
|
|
328
|
+
* provider, and only the group says which collection to look in. Omit it and
|
|
329
|
+
* the card stores `holderType: null`, so card details cannot name the holder.
|
|
330
|
+
*/
|
|
331
|
+
function assignUser(payload: {
|
|
332
|
+
assignees: string[];
|
|
333
|
+
unit: string;
|
|
334
|
+
type: string;
|
|
335
|
+
acm_url: string;
|
|
336
|
+
id?: string;
|
|
337
|
+
holderType?: "Visitor/Resident" | "Management" | "Service Provider";
|
|
338
|
+
}) {
|
|
323
339
|
return useNuxtApp().$api<Record<string, any>>(
|
|
324
340
|
`/api/access-management/assign-user`,
|
|
325
341
|
{ method: "POST", body: payload }
|
|
@@ -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-staging.
|
|
5
|
+
"version": "4.90.2-staging.487",
|
|
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, "&")
|
|
136
|
+
.replace(/</g, "<")
|
|
137
|
+
.replace(/>/g, ">")
|
|
138
|
+
.replace(/"/g, """)
|
|
139
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|