@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 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
- GET /api/members/user/:user/app/admin the Seven365 staff membership
2032
- GET /api/roles/id/:role that membership's role document
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
- owner = member.type === "admin" && role.type === "admin" && role.default === true
2035
- staff = member.type === "admin" && role.type === "admin"
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.site"
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="[requiredRule]"
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(siteId: string) {
552
- const obj = sites.value.find((x) => x?.value === siteId);
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
- app: props.app,
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
  };
@@ -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
- ...(site && { siteId: site }),
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.90.3",
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, "&amp;")
136
+ .replace(/</g, "&lt;")
137
+ .replace(/>/g, "&gt;")
138
+ .replace(/"/g, "&quot;")
139
+ .replace(/'/g, "&#39;");
140
+ }
141
+
142
+ const PRINT_CSS = `
143
+ @page { size: A4; margin: 6mm; }
144
+ * { box-sizing: border-box; }
145
+ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family: Arial, Helvetica, sans-serif; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
146
+ .page { display: grid; grid-template-columns: repeat(${STICKER_COLUMNS}, 47mm); gap: 2mm; justify-content: center; align-content: start; width: 198mm; height: 285mm; margin: 0 auto; break-after: page; }
147
+ .page:last-child { break-after: auto; }
148
+ .cell { width: 47mm; break-inside: avoid; }
149
+ .card { width: 47mm; height: 58mm; border: 0.3mm solid #1a1a1a; border-radius: 3mm; padding: 2mm 2mm 1.6mm; display: flex; flex-direction: column; align-items: center; justify-content: space-between; text-align: center; }
150
+ .qr { width: 32mm; height: 32mm; display: block; }
151
+ .qr svg { width: 100%; height: 100%; display: block; }
152
+ .plate { font-size: 13pt; font-weight: 800; letter-spacing: 0.3mm; line-height: 1.1; }
153
+ .hint { font-size: 5.8pt; color: #333; line-height: 1.2; }
154
+ .brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt; color: #222; border-top: 0.2mm solid #ccc; width: 100%; padding-top: 1mm; }
155
+ .mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm; background: #111; color: #fff; font-weight: 800; font-size: 5pt; line-height: 3.2mm; text-align: center; }
156
+ .cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top: 1mm; font-size: 6.5pt; line-height: 1.25; color: #111; min-height: 8mm; }
157
+ .cut .owner { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
158
+ .cut .note { color: #666; font-size: 5.5pt; }
159
+ @media screen { body { background: #e9e9e9; zoom: 0.75; padding: 4mm 0; } .page { background: #fff; margin-bottom: 4mm; height: auto; min-height: 285mm; box-shadow: 0 0 2mm rgba(0,0,0,.25); } }
160
+ `;
161
+
162
+ /**
163
+ * One full HTML document: A4 sheets of stickers. `qrSvgs` is parallel to
164
+ * `items` (the QR for each, already drawn). Every value is HTML-escaped - an
165
+ * owner name is typed by a person.
166
+ */
167
+ export function buildStickerDocument(items: StickerItem[], qrSvgs: string[]): string {
168
+ const cells = items.map((item, i) => {
169
+ const { place, owner } = installerLine(item);
170
+ return (
171
+ `<div class="cell"><div class="card">` +
172
+ `<div class="qr">${qrSvgs[i] ?? ""}</div>` +
173
+ `<div class="plate">${escapeHtml(item.plateNumber)}</div>` +
174
+ `<div class="hint">Scan with iSecure365 / iMA365</div>` +
175
+ `<div class="brand"><span class="mark">7</span>Powered by Seven365</div>` +
176
+ `</div>` +
177
+ `<div class="cut"><div><b>${escapeHtml(place || "-")}</b></div>` +
178
+ `<div class="owner">Owner: ${escapeHtml(owner || "-")}</div>` +
179
+ `<div class="note">Installer: cut here, then paste on this car</div></div></div>`
180
+ );
181
+ });
182
+ const pages = stickerPages(cells).map((p) => `<div class="page">${p.join("")}</div>`);
183
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Vehicle QR stickers</title><style>${PRINT_CSS}</style></head><body>${pages.join("")}</body></html>`;
184
+ }