@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 +16 -4
- package/components/MemberInformation.vue +4 -4
- package/components/PassInformation.vue +2 -2
- package/components/SiteSettings.vue +48 -0
- package/components/VehicleManagement.vue +80 -5
- package/components/VehicleQrStickerDialog.vue +249 -0
- package/components/VisitorForm.vue +53 -19
- 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
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
|
-
|
|
2026
|
-
|
|
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
|
-
|
|
2029
|
-
|
|
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 (
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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, "&")
|
|
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
|
+
}
|