@7365admin1/layer-common 4.90.3 → 4.92.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 +20 -4
- package/components/InvitationForm.vue +31 -4
- package/components/InvitationMain.vue +4 -1
- package/components/SiteSettings.vue +48 -0
- package/components/VehicleManagement.vue +80 -5
- package/components/VehicleQrStickerDialog.vue +249 -0
- package/composables/useSettingsPermission.ts +14 -0
- package/composables/useUser.ts +6 -2
- package/composables/useVehicle.ts +40 -1
- package/package.json +1 -1
- package/utils/invite-sites.ts +25 -0
- package/utils/vehicle-qr-sticker.ts +184 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.92.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9883bfd: Invite member: pick several sites in one invitation.
|
|
8
|
+
|
|
9
|
+
- `InvitationForm`: when it is the org-level "Invite member" (no fixed site), the Site field is a multi-select with removable chips (at least one site required). One site: roles are that site's roles plus organisation roles, as before. Two or more: only organisation roles are offered (a site role is valid at its own site only), with a hint saying so and how to give different roles per site. Site apps and onboarding keep their fixed single site.
|
|
10
|
+
- `useUser().inviteMemberClient` takes `sites`: one site sends `siteId` as before, several send `siteIds` (needs the matching `@7365admin1/core` release; older core ignores it). New helper `utils/invite-sites.ts`.
|
|
11
|
+
- `InvitationMain`: the Organization app's Invitations list no longer filters to `app: organization`, so site invitations sent from there are listed (the "Site Invited To" column shows each site). Site apps are unchanged.
|
|
12
|
+
|
|
13
|
+
## 4.91.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 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).
|
|
18
|
+
|
|
3
19
|
## 4.90.3
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
|
@@ -2028,11 +2044,11 @@
|
|
|
2028
2044
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
2029
2045
|
own rule from two endpoints the console already calls, unprojected:
|
|
2030
2046
|
|
|
2031
|
-
|
|
2032
|
-
|
|
2047
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
2048
|
+
GET /api/roles/id/:role that membership's role document
|
|
2033
2049
|
|
|
2034
|
-
|
|
2035
|
-
|
|
2050
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
2051
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
2036
2052
|
|
|
2037
2053
|
`role.default` is the marker because it is the only property of a platform
|
|
2038
2054
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -88,13 +88,24 @@
|
|
|
88
88
|
persistent-hint
|
|
89
89
|
data-test="invite-fixed-site"
|
|
90
90
|
></v-text-field>
|
|
91
|
+
<!--
|
|
92
|
+
Several sites (owner request 2026-10-02): one invitation per
|
|
93
|
+
site, one e-mail. One role for all of them, so 2+ sites offer
|
|
94
|
+
organisation roles only (see `roleSiteFor`).
|
|
95
|
+
-->
|
|
91
96
|
<v-autocomplete
|
|
92
97
|
v-else
|
|
93
|
-
v-model="invite.
|
|
98
|
+
v-model="invite.sites"
|
|
94
99
|
:items="sites"
|
|
100
|
+
multiple
|
|
101
|
+
chips
|
|
102
|
+
closable-chips
|
|
95
103
|
class="filter-field"
|
|
96
104
|
density="comfortable"
|
|
97
|
-
:rules="[
|
|
105
|
+
:rules="[sitesRule]"
|
|
106
|
+
:hint="invite.sites.length > 1 ? severalSitesHint : ''"
|
|
107
|
+
:persistent-hint="invite.sites.length > 1"
|
|
108
|
+
data-test="invite-sites"
|
|
98
109
|
@update:model-value="handleUpdateSite"
|
|
99
110
|
></v-autocomplete>
|
|
100
111
|
</v-col>
|
|
@@ -163,6 +174,7 @@ import { useLocalSetup } from '../composables/useLocalSetup';
|
|
|
163
174
|
import useRole from '../composables/useRole';
|
|
164
175
|
import useSite from '../composables/useSite';
|
|
165
176
|
import { inviteRoleOptions } from '../utils/invite-role-options';
|
|
177
|
+
import { roleSiteFor } from '../utils/invite-sites';
|
|
166
178
|
import useUser from '../composables/useUser';
|
|
167
179
|
import useUtils from '../composables/useUtils';
|
|
168
180
|
|
|
@@ -276,6 +288,8 @@ const invite = ref<Record<string, any>>({
|
|
|
276
288
|
role: "",
|
|
277
289
|
org: "",
|
|
278
290
|
site: "",
|
|
291
|
+
// the sites picked in the Site field (several allowed); `site` follows it
|
|
292
|
+
sites: [] as string[],
|
|
279
293
|
siteName: "",
|
|
280
294
|
isMemberInvite: false,
|
|
281
295
|
});
|
|
@@ -501,9 +515,17 @@ watchEffect(() => {
|
|
|
501
515
|
|
|
502
516
|
const roleOptions = computed(() => inviteRoleOptions(roles.value));
|
|
503
517
|
|
|
518
|
+
const severalSitesHint =
|
|
519
|
+
"Several sites: pick an organisation role - it applies at each site. For a different role per site, invite one site at a time.";
|
|
520
|
+
|
|
521
|
+
const sitesRule = (v: string[]) =>
|
|
522
|
+
(Array.isArray(v) && v.length > 0) || "Pick at least one site";
|
|
523
|
+
|
|
504
524
|
const noRolesText = computed(() =>
|
|
505
525
|
rolesError?.value
|
|
506
526
|
? "The roles could not be loaded. You may not have access to this organisation's roles; refresh, or sign in again."
|
|
527
|
+
: !fixedSite.value && invite.value.sites.length > 1
|
|
528
|
+
? "No organisation roles for this service yet. Create one in Roles & Permissions (Roles for <service>, use at any site), or invite one site at a time."
|
|
507
529
|
: fixedSite.value
|
|
508
530
|
? "No roles have been created for this site yet. Create one in Roles & Permissions first."
|
|
509
531
|
: "No roles have been created yet. Create one in Roles & Permissions first."
|
|
@@ -525,6 +547,7 @@ const rolesPageLink = computed(() => {
|
|
|
525
547
|
async function handleUpdateApp(value: string) {
|
|
526
548
|
invite.value.role = "";
|
|
527
549
|
if (!fixedSite.value) {
|
|
550
|
+
invite.value.sites = [];
|
|
528
551
|
invite.value.site = "";
|
|
529
552
|
invite.value.siteName = "";
|
|
530
553
|
}
|
|
@@ -544,12 +567,16 @@ function resetInvite() {
|
|
|
544
567
|
invite.value.role = "";
|
|
545
568
|
// Same site as before: a fixed site stays fixed, a picked one is cleared.
|
|
546
569
|
invite.value.site = fixedSite.value;
|
|
570
|
+
invite.value.sites = [];
|
|
547
571
|
if (!invite.value.site) invite.value.siteName = "";
|
|
548
572
|
message.value = "";
|
|
549
573
|
}
|
|
550
574
|
|
|
551
|
-
function handleUpdateSite(
|
|
552
|
-
|
|
575
|
+
function handleUpdateSite(siteIds: string[]) {
|
|
576
|
+
// The roles list follows `site`: the one chosen site, or "" for several
|
|
577
|
+
// (organisation roles only); the role watch clears a role no longer offered.
|
|
578
|
+
invite.value.site = roleSiteFor(siteIds);
|
|
579
|
+
const obj = sites.value.find((x) => x?.value === invite.value.site);
|
|
553
580
|
invite.value.siteName = obj?.title || "";
|
|
554
581
|
}
|
|
555
582
|
|
|
@@ -262,7 +262,10 @@ const {
|
|
|
262
262
|
search: headerSearch.value,
|
|
263
263
|
// type: "user-invite,member-invite",
|
|
264
264
|
type: "member-invite",
|
|
265
|
-
|
|
265
|
+
// The org console (organization app, no site) lists every invitation of
|
|
266
|
+
// the organisation, including the site invitations sent from there
|
|
267
|
+
// (owner request 2026-10-02); the server's list scoping is unchanged.
|
|
268
|
+
app: props.app === "organization" && !props.siteId ? "" : props.app,
|
|
266
269
|
orgId: props.org,
|
|
267
270
|
// A site app lists only THIS site's invitations; the server checks the
|
|
268
271
|
// caller reaches it (owner report 2026-09-28: LakeGarden's invitations
|
|
@@ -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>
|
|
@@ -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
|
};
|
package/composables/useUser.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { inviteSitePayload } from "../utils/invite-sites";
|
|
2
|
+
|
|
1
3
|
export default function useUser() {
|
|
2
4
|
function inviteUser({
|
|
3
5
|
email = "",
|
|
@@ -65,7 +67,10 @@ export default function useUser() {
|
|
|
65
67
|
org = "",
|
|
66
68
|
site = "",
|
|
67
69
|
siteName = "",
|
|
70
|
+
sites = [] as string[],
|
|
68
71
|
} = {}) {
|
|
72
|
+
// `sites` = the several chosen sites; the old single `site` still works.
|
|
73
|
+
const chosen = sites.length ? sites : site ? [site] : [];
|
|
69
74
|
return useNuxtApp().$api<Record<string, any>>("/api/auth/invite/member", {
|
|
70
75
|
method: "POST",
|
|
71
76
|
body: {
|
|
@@ -74,8 +79,7 @@ export default function useUser() {
|
|
|
74
79
|
role,
|
|
75
80
|
name,
|
|
76
81
|
org,
|
|
77
|
-
...(
|
|
78
|
-
...(siteName && { siteName }),
|
|
82
|
+
...inviteSitePayload(chosen, siteName),
|
|
79
83
|
},
|
|
80
84
|
});
|
|
81
85
|
}
|
|
@@ -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.92.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,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sites an "Invite member" request names (owner request 2026-10-02:
|
|
3
|
+
* invite one person to several sites). One site keeps the old `siteId` shape;
|
|
4
|
+
* 2 or more send `siteIds` (the server writes one invitation per site and
|
|
5
|
+
* names every site in one e-mail).
|
|
6
|
+
*/
|
|
7
|
+
export function inviteSitePayload(
|
|
8
|
+
sites: string[],
|
|
9
|
+
siteName = "",
|
|
10
|
+
): Record<string, unknown> {
|
|
11
|
+
if (!sites.length) return {};
|
|
12
|
+
if (sites.length === 1) {
|
|
13
|
+
return { siteId: sites[0], ...(siteName && { siteName }) };
|
|
14
|
+
}
|
|
15
|
+
return { siteIds: sites };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The one site a role is looked up for: the single chosen site, or "" for 2+
|
|
20
|
+
* (a site role is valid at its own site only, so several sites offer
|
|
21
|
+
* organisation roles only).
|
|
22
|
+
*/
|
|
23
|
+
export function roleSiteFor(sites: string[]): string {
|
|
24
|
+
return sites.length === 1 ? sites[0] : "";
|
|
25
|
+
}
|
|
@@ -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
|
+
}
|