@7365admin1/layer-common 4.90.2-staging.485 → 4.90.2-staging.487

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,9 +68,9 @@
68
68
  <!-- Visitor / Resident Credentials -->
69
69
  <template v-if="details.userCred">
70
70
  <v-divider />
71
- <div class="card-details__section">
71
+ <!-- <div class="card-details__section">
72
72
  {{ details.userType ?? "Visitor Information" }}
73
- </div>
73
+ </div> -->
74
74
 
75
75
  <div v-if="details.userCred.email">
76
76
  <div class="card-details__label">Email</div>
@@ -50,18 +50,28 @@
50
50
 
51
51
  <!-- Person Selector -->
52
52
  <InputLabel title="Person" required class="mb-1" />
53
- <v-select
53
+ <!--
54
+ An autocomplete rather than a select: a unit's Management and provider
55
+ lists are short, but a site's can run to dozens, and scrolling for a
56
+ name is slow. `custom-filter` widens the match beyond the title,
57
+ because the thing an operator remembers is often the email or the role
58
+ - and a member's name IS frequently their email address.
59
+ -->
60
+ <v-autocomplete
54
61
  v-model="assignPerson"
62
+ v-model:search="personSearch"
55
63
  :items="assignPeopleItems"
64
+ :custom-filter="filterPerson"
56
65
  density="compact"
57
66
  item-title="name"
58
67
  item-value="assigneeId"
59
68
  hide-details="auto"
60
- placeholder="Select person..."
69
+ placeholder="Search or select person..."
61
70
  persistent-placeholder
62
71
  :loading="peopleLoading"
63
72
  :disabled="peopleLoading"
64
73
  return-object
74
+ :hide-no-data="false"
65
75
  :no-data-text="assignNoDataText"
66
76
  >
67
77
  <!--
@@ -98,7 +108,7 @@
98
108
  </div>
99
109
  </div>
100
110
  </template>
101
- </v-select>
111
+ </v-autocomplete>
102
112
 
103
113
  <!--
104
114
  A card is assigned by writing the person's USER id onto it, so a row
@@ -455,6 +465,20 @@ const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
455
465
  const { getAll: getMembers } = useMember();
456
466
  const { getSiteMembers } = useServiceProvider();
457
467
 
468
+ /**
469
+ * The member type of whichever app this layer is running in
470
+ * ("property_management_agency", "security_agency", ...), which `/api/members`
471
+ * requires and rejects empty.
472
+ *
473
+ * Read HERE, during setup, not inside the loader that uses it:
474
+ * `useRuntimeConfig` needs the Nuxt instance, and that is only guaranteed
475
+ * synchronously in setup. Called from an async function resumed after an
476
+ * await — which is exactly what the loader is — it throws "nuxt instance
477
+ * unavailable", the loader's catch turns that into an empty list, and the
478
+ * Management group silently shows nothing with no request ever sent.
479
+ */
480
+ const APP = String(useRuntimeConfig().public.APP ?? "");
481
+
458
482
  const historyDialog = ref(false);
459
483
  const showAssignForm = ref(false);
460
484
  const assignPerson = ref<Record<string, any> | null>(null);
@@ -471,10 +495,22 @@ const assignPerson = ref<Record<string, any> | null>(null);
471
495
  */
472
496
  type AssignCategory = "residents" | "management" | "providers";
473
497
 
474
- const assignCategories: { value: AssignCategory; label: string }[] = [
475
- { value: "residents", label: "Resident / Tenant" },
476
- { value: "management", label: "Management" },
477
- { value: "providers", label: "Service Provider" },
498
+ const assignCategories: {
499
+ value: AssignCategory;
500
+ label: string;
501
+ /**
502
+ * What the server stores as the card's `holderType` (core
503
+ * `EAccessCardHolderTypes`). Sent with the assignment, because `assignees`
504
+ * alone is ambiguous: it is a `users` id for a resident and a membership id
505
+ * for a provider, and only the group says which to resolve against. These
506
+ * strings are a server contract - the labels above are free to change, these
507
+ * are not.
508
+ */
509
+ holderType: "Visitor/Resident" | "Management" | "Service Provider";
510
+ }[] = [
511
+ { value: "residents", label: "Resident / Tenant", holderType: "Visitor/Resident" },
512
+ { value: "management", label: "Management", holderType: "Management" },
513
+ { value: "providers", label: "Service Provider", holderType: "Service Provider" },
478
514
  ];
479
515
 
480
516
  const assignCategory = ref<AssignCategory>("residents");
@@ -500,6 +536,30 @@ type AssignRow = {
500
536
  };
501
537
 
502
538
  const assignPeopleItems = ref<AssignRow[]>([]);
539
+
540
+ /** What is typed in the person picker. Cleared whenever the group changes. */
541
+ const personSearch = ref("");
542
+
543
+ /**
544
+ * Match a person on everything the row shows, not just the name.
545
+ *
546
+ * Vuetify filters on the title alone by default, which is too narrow here: a
547
+ * Management member's name is often their email address, and two provider
548
+ * people from one company are told apart only by their role. Both of those sit
549
+ * in the second line, so a name-only match would hide the row an operator is
550
+ * actually searching for.
551
+ *
552
+ * The first argument is the title Vuetify matched against; it is ignored in
553
+ * favour of the row itself, which carries all three fields.
554
+ */
555
+ function filterPerson(_title: string, query: string, item: any): boolean {
556
+ const q = String(query ?? "").trim().toLowerCase();
557
+ if (!q) return true;
558
+ const row = item?.raw ?? {};
559
+ return [row.name, row.subtitle, row.meta].some((field) =>
560
+ String(field ?? "").toLowerCase().includes(q),
561
+ );
562
+ }
503
563
  const peopleLoading = ref(false);
504
564
  const assignLoading = ref(false);
505
565
  const assignError = ref("");
@@ -597,7 +657,7 @@ async function loadManagementRows(): Promise<AssignRow[]> {
597
657
  org: props.orgId,
598
658
  siteId: props.siteId,
599
659
  unit: props.unit._id,
600
- type: String(useRuntimeConfig().public.APP ?? ""),
660
+ type: APP,
601
661
  status: "active",
602
662
  page: 1,
603
663
  limit: 50,
@@ -678,9 +738,19 @@ async function loadAssignPeople() {
678
738
  : await loadResidentRows();
679
739
  if (seq !== assignLoadSeq) return;
680
740
  assignPeopleItems.value = rows;
681
- } catch {
741
+ } catch (e: any) {
682
742
  if (seq !== assignLoadSeq) return;
683
743
  assignPeopleItems.value = [];
744
+ /*
745
+ * Say that the list failed rather than showing an empty one.
746
+ *
747
+ * This swallowed everything, so a group whose request 400'd, or never got
748
+ * sent at all, looked exactly like a group with nobody in it - there was
749
+ * nothing on screen to tell the two apart, and no way to tell from the UI
750
+ * that anything had gone wrong.
751
+ */
752
+ assignError.value =
753
+ e?.data?.message ?? e?.message ?? "Could not load the list. Please try again.";
684
754
  } finally {
685
755
  if (seq === assignLoadSeq) peopleLoading.value = false;
686
756
  }
@@ -688,6 +758,9 @@ async function loadAssignPeople() {
688
758
 
689
759
  function onAssignCategoryChange() {
690
760
  assignPerson.value = null;
761
+ // A term typed for the previous group means nothing in the next one, and
762
+ // leaving it would show an empty list over a list that has people in it.
763
+ personSearch.value = "";
691
764
  assignError.value = "";
692
765
  loadAssignPeople();
693
766
  }
@@ -695,6 +768,7 @@ function onAssignCategoryChange() {
695
768
  async function openAssignForm() {
696
769
  showAssignForm.value = true;
697
770
  assignPerson.value = null;
771
+ personSearch.value = "";
698
772
  assignCategory.value = "residents";
699
773
  assignError.value = "";
700
774
  assignPeopleItems.value = [];
@@ -714,6 +788,7 @@ async function openAssignForm() {
714
788
  function cancelAssign() {
715
789
  showAssignForm.value = false;
716
790
  assignPerson.value = null;
791
+ personSearch.value = "";
717
792
  assignCategory.value = "residents";
718
793
  assignPeopleItems.value = [];
719
794
  assignError.value = "";
@@ -744,6 +819,8 @@ async function confirmAssign() {
744
819
  type: selectedCardType.value,
745
820
  acm_url: encryptedAcmUrl.value,
746
821
  id: props.selectedCardInUnit?._id,
822
+ holderType: assignCategories.find((c) => c.value === assignCategory.value)
823
+ ?.holderType,
747
824
  });
748
825
  emit("assign-to-person", {
749
826
  card: props.selectedCardInUnit,
@@ -199,6 +199,22 @@
199
199
  </v-expansion-panel-text>
200
200
  </v-expansion-panel>
201
201
 
202
+ <v-expansion-panel v-if="canManageVehicleQr">
203
+ <v-expansion-panel-title>
204
+ <v-icon class="mr-2">mdi-qrcode</v-icon>
205
+ Vehicle QR stickers
206
+ </v-expansion-panel-title>
207
+ <v-expansion-panel-text>
208
+ <v-switch v-model="vehicleQrEnabled" :loading="vehicleQrSaving" :disabled="vehicleQrSaving"
209
+ label="Vehicle QR stickers" color="primary" hide-details data-test="vehicle-qr-switch"
210
+ @update:model-value="saveVehicleQr" />
211
+ <p class="text-caption text-medium-emphasis mt-2 mb-0">
212
+ When on, staff can print a QR sticker for each vehicle from Vehicle Mgmt, and guards can scan it to see
213
+ which unit the vehicle belongs to.
214
+ </p>
215
+ </v-expansion-panel-text>
216
+ </v-expansion-panel>
217
+
202
218
  <slot
203
219
  name="panels"
204
220
  :can-manage="canManageSiteSettings"
@@ -215,6 +231,7 @@ import useFile from "../composables/useFile";
215
231
  import useSiteSettings from "../composables/useSiteSettings";
216
232
  import useUtils from "../composables/useUtils";
217
233
  import useANPRSettings from "../composables/useANPRSettings";
234
+ import useVehicle from "../composables/useVehicle";
218
235
 
219
236
  const props = defineProps({
220
237
  siteId: { type: String, required: true },
@@ -233,6 +250,7 @@ const { canManageSiteInformation,
233
250
  canManageCCTVCamera,
234
251
  canManageDeliveryCompanies,
235
252
  canManageResidentVisitors,
253
+ canManageVehicleQr,
236
254
  } = useSettingsPermission();
237
255
 
238
256
  const { getSiteById, updateSitebyId, updateSiteInformation } =
@@ -405,6 +423,36 @@ function handleUpdateCompanies(value: string[]) {
405
423
 
406
424
  const route = useRoute();
407
425
 
426
+ // Vehicle QR stickers switch (off until someone turns it on).
427
+ const { getVehicleQrSettings, setVehicleQrSettings } = useVehicle();
428
+ const vehicleQrEnabled = ref(false);
429
+ const vehicleQrSaving = ref(false);
430
+
431
+ await useLazyAsyncData(`vehicle-qr-${props.siteId}`, async () => {
432
+ if (!canManageVehicleQr.value) return null;
433
+ try {
434
+ vehicleQrEnabled.value = !!(await getVehicleQrSettings(props.siteId))?.enabled;
435
+ } catch {
436
+ vehicleQrEnabled.value = false;
437
+ }
438
+ return null;
439
+ });
440
+
441
+ async function saveVehicleQr(value: boolean | null) {
442
+ const enabled = !!value;
443
+ vehicleQrSaving.value = true;
444
+ try {
445
+ const res = await setVehicleQrSettings(props.siteId, enabled);
446
+ vehicleQrEnabled.value = !!res?.enabled;
447
+ show(enabled ? "Vehicle QR stickers turned on" : "Vehicle QR stickers turned off", "success");
448
+ } catch (e: any) {
449
+ vehicleQrEnabled.value = !enabled;
450
+ show(e?.data?.message || "Could not change the Vehicle QR stickers setting", "error");
451
+ } finally {
452
+ vehicleQrSaving.value = false;
453
+ }
454
+ }
455
+
408
456
  function show(message: string, color: string) {
409
457
  toast.show = true;
410
458
  toast.message = message;
@@ -8,6 +8,9 @@
8
8
  <v-col cols="12">
9
9
  <PageHeader title="Vehicle Mgmt">
10
10
  <template #actions>
11
+ <AppButton v-if="qrStickersOn" icon="mdi-qrcode" variant="ghost" @click="openStickerDialog()">
12
+ Print QR stickers
13
+ </AppButton>
11
14
  <AppButton v-if="canCreateVehicle" icon="mdi-plus" @click="handleAddVehicleClick">
12
15
  Add Vehicle
13
16
  </AppButton>
@@ -82,17 +85,19 @@
82
85
  be drawn.
83
86
  -->
84
87
  <template #item.action="{ item }">
85
- <v-menu v-if="rowHasVehicleActions(item, vehiclePermissions)" location="bottom end">
88
+ <v-menu v-if="rowHasVehicleActions(item, vehiclePermissions) || qrStickersOn" location="bottom end">
86
89
  <template #activator="{ props: menuProps }">
87
90
  <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" aria-label="Vehicle actions"
88
91
  @click.stop />
89
92
  </template>
90
93
  <v-list density="compact">
91
94
  <template v-for="plate in rowPlates(item)" :key="plate._id ?? plate.plateNumber">
92
- <template v-if="vehiclePlateActions(plate, vehiclePermissions).length">
95
+ <template v-if="vehiclePlateActions(plate, vehiclePermissions).length || qrStickersOn">
93
96
  <v-list-subheader v-if="rowPlates(item).length > 1">
94
97
  {{ plate.plateNumber }}
95
98
  </v-list-subheader>
99
+ <v-list-item v-if="qrStickersOn" title="Re-issue sticker" prepend-icon="mdi-qrcode-edit"
100
+ @click="askReissue(plate as TPlateNumber)" />
96
101
  <v-list-item v-for="action in vehiclePlateActions(plate, vehiclePermissions)" :key="action.key"
97
102
  :title="action.title" :prepend-icon="action.icon" :base-color="action.color"
98
103
  @click="runVehicleAction(action.key, plate as TPlateNumber)" />
@@ -212,11 +217,13 @@
212
217
  v-tooltip:top="'History'" @click="openHistory(plateNumberItem as TPlateNumber)" />
213
218
  </template>
214
219
  <template #item.action="{ item: plateNumberItem }">
215
- <v-menu v-if="vehiclePlateActions(plateNumberItem, vehiclePermissions).length">
220
+ <v-menu v-if="vehiclePlateActions(plateNumberItem, vehiclePermissions).length || qrStickersOn">
216
221
  <template #activator="{ props: menuProps }">
217
222
  <v-btn icon="mdi-dots-vertical" v-bind="menuProps" flat size="x-small" />
218
223
  </template>
219
224
  <v-list density="compact">
225
+ <v-list-item v-if="qrStickersOn" title="Re-issue sticker" prepend-icon="mdi-qrcode-edit"
226
+ @click="askReissue(plateNumberItem as TPlateNumber)" />
220
227
  <v-list-item
221
228
  v-for="action in vehiclePlateActions(plateNumberItem, vehiclePermissions)"
222
229
  :key="action.key" :title="action.title" :prepend-icon="action.icon"
@@ -341,6 +348,26 @@
341
348
  </v-card-text>
342
349
  </v-card>
343
350
  </v-dialog>
351
+ <VehicleQrStickerDialog v-if="qrStickersOn" v-model="dialog.qrStickers" :site="props.site" :rows="items"
352
+ :view="activeTab" :search="searchInput" :vehicle-ids="qrVehicleIds" />
353
+
354
+ <v-dialog v-model="dialog.qrReissue" width="460">
355
+ <v-card>
356
+ <v-card-title>Re-issue sticker?</v-card-title>
357
+ <v-card-text>
358
+ A new QR sticker will be made for {{ qrReissuePlate?.plateNumber }}. The old sticker will stop working, so
359
+ the new one must be pasted on the car.
360
+ <v-alert v-if="qrReissueError" type="error" variant="tonal" density="compact" class="mt-3">{{ qrReissueError }}</v-alert>
361
+ </v-card-text>
362
+ <v-card-actions>
363
+ <v-spacer />
364
+ <v-btn variant="text" class="text-none" @click="dialog.qrReissue = false">Cancel</v-btn>
365
+ <v-btn color="primary" variant="flat" class="text-none" :loading="qrReissuing" @click="confirmReissue">
366
+ Re-issue
367
+ </v-btn>
368
+ </v-card-actions>
369
+ </v-card>
370
+ </v-dialog>
344
371
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
345
372
  </v-row>
346
373
  </template>
@@ -460,7 +487,53 @@ const activeTab = ref<TVehicleTab>("residents");
460
487
 
461
488
 
462
489
  const { formatCamelCaseToWords, formatDate, debounce, maskNRIC } = useUtils();
463
- const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts } = useVehicle();
490
+ const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr } = useVehicle();
491
+
492
+ // Vehicle QR stickers: the site's switch (Settings) decides whether the print
493
+ // and re-issue actions exist; only people who may update vehicles get them.
494
+ const qrEnabled = ref(false);
495
+ const qrStickersOn = computed(() => qrEnabled.value && props.canUpdateVehicle);
496
+ const qrVehicleIds = ref<string[]>([]);
497
+ const qrReissuePlate = ref<TPlateNumber | null>(null);
498
+ const qrReissuing = ref(false);
499
+ const qrReissueError = ref("");
500
+
501
+ async function loadQrSetting() {
502
+ try {
503
+ qrEnabled.value = !!(await getVehicleQrSettings(props.site))?.enabled;
504
+ } catch {
505
+ qrEnabled.value = false;
506
+ }
507
+ }
508
+ onMounted(loadQrSetting);
509
+
510
+ function openStickerDialog(ids: string[] = []) {
511
+ qrVehicleIds.value = ids;
512
+ dialog.qrStickers = true;
513
+ }
514
+
515
+ function askReissue(plate: TPlateNumber) {
516
+ qrReissuePlate.value = plate || null;
517
+ qrReissueError.value = "";
518
+ dialog.qrReissue = true;
519
+ }
520
+
521
+ async function confirmReissue() {
522
+ const id = qrReissuePlate.value?._id;
523
+ if (!id) return;
524
+ qrReissuing.value = true;
525
+ qrReissueError.value = "";
526
+ try {
527
+ await regenerateVehicleQr(String(id));
528
+ dialog.qrReissue = false;
529
+ dialog.showMoreActions = false;
530
+ openStickerDialog([String(id)]);
531
+ } catch (e: any) {
532
+ qrReissueError.value = errorConverter(e);
533
+ } finally {
534
+ qrReissuing.value = false;
535
+ }
536
+ }
464
537
 
465
538
  /**
466
539
  * Active ANPR cameras per list at the site. A live plate on fewer cameras
@@ -543,7 +616,9 @@ const dialog = reactive({
543
616
  blockVehicle: false,
544
617
  unblockVehicle: false,
545
618
  history: false,
546
- bulkVehicle: false
619
+ bulkVehicle: false,
620
+ qrStickers: false,
621
+ qrReissue: false,
547
622
  });
548
623
 
549
624
 
@@ -0,0 +1,249 @@
1
+ <template>
2
+ <v-dialog :model-value="modelValue" width="1100" scrollable persistent @update:model-value="close">
3
+ <v-card data-test="vehicle-qr-dialog">
4
+ <v-card-title class="d-flex align-center">
5
+ <v-icon class="mr-2">mdi-qrcode</v-icon>
6
+ {{ step === "select" ? "Print QR stickers" : "Check before printing" }}
7
+ <v-spacer />
8
+ <v-btn icon="mdi-close" variant="text" size="small" aria-label="Close" @click="close(false)" />
9
+ </v-card-title>
10
+
11
+ <v-card-text>
12
+ <v-alert v-if="error" type="error" variant="tonal" density="compact" class="mb-3">{{ error }}</v-alert>
13
+
14
+ <!-- Step 1: choose vehicles -->
15
+ <template v-if="step === 'select'">
16
+ <div class="d-flex flex-wrap align-center ga-2 mb-2">
17
+ <v-checkbox-btn :model-value="allOnPageTicked" :indeterminate="someOnPageTicked && !allOnPageTicked"
18
+ aria-label="Select all on this page" @update:model-value="(v: any) => tickGroup(plates.map((p) => p.id), !!v)" />
19
+ <span class="text-body-2">Select all on this page</span>
20
+ <v-spacer />
21
+ <v-btn variant="tonal" size="small" class="text-none" :loading="loadingAll" @click="loadAll">
22
+ Select all matching the current tab and search
23
+ </v-btn>
24
+ </div>
25
+ <v-table density="compact" fixed-header height="360">
26
+ <thead>
27
+ <tr><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Status</th></tr>
28
+ </thead>
29
+ <tbody>
30
+ <tr v-for="p in plates" :key="p.id">
31
+ <td><v-checkbox-btn :model-value="selected.includes(p.id)" :aria-label="`Select ${p.plateNumber}`"
32
+ @update:model-value="(v: any) => tickGroup([p.id], !!v)" /></td>
33
+ <td>{{ p.unitLabel || "-" }}</td>
34
+ <td>{{ p.ownerName || "-" }}</td>
35
+ <td class="font-weight-bold">{{ p.plateNumber }}</td>
36
+ <td>{{ p.status || "-" }}</td>
37
+ </tr>
38
+ <tr v-if="!plates.length"><td colspan="5" class="text-center text-medium-emphasis py-6">No vehicles to list</td></tr>
39
+ </tbody>
40
+ </v-table>
41
+ <p class="text-caption text-medium-emphasis mt-2 mb-0">
42
+ Up to {{ STICKER_MAX_PER_REQUEST }} stickers at a time. Ticked: {{ selected.length }}.
43
+ </p>
44
+ </template>
45
+
46
+ <!-- Step 2: review + preview -->
47
+ <template v-else>
48
+ <v-alert v-if="missing.length" type="warning" variant="tonal" density="compact" class="mb-3">
49
+ {{ missing.length }} vehicle(s) could not be found and were skipped.
50
+ </v-alert>
51
+ <v-alert v-if="dropped" type="info" variant="tonal" density="compact" class="mb-3">
52
+ {{ dropped }} more vehicle(s) were over the {{ STICKER_MAX_PER_REQUEST }} limit - print again for the rest.
53
+ </v-alert>
54
+ <v-table density="compact" fixed-header height="220">
55
+ <thead>
56
+ <tr><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Check</th></tr>
57
+ </thead>
58
+ <tbody>
59
+ <tr v-for="it in items" :key="it.vehicleId">
60
+ <td><v-checkbox-btn :model-value="ticked.includes(it.vehicleId)" :aria-label="`Print ${it.plateNumber}`"
61
+ @update:model-value="(v: any) => (ticked = setIds(ticked, [it.vehicleId], !!v))" /></td>
62
+ <td>{{ installerLine(it).place || "-" }}</td>
63
+ <td>{{ installerLine(it).owner || "-" }}</td>
64
+ <td class="font-weight-bold">{{ it.plateNumber }}</td>
65
+ <td>
66
+ <v-chip v-if="stickerWarning(it.status)" size="small" variant="tonal"
67
+ :color="stickerWarning(it.status)!.level" prepend-icon="mdi-alert">
68
+ {{ stickerWarning(it.status)!.text }}
69
+ </v-chip>
70
+ <span v-else class="text-medium-emphasis">OK</span>
71
+ </td>
72
+ </tr>
73
+ </tbody>
74
+ </v-table>
75
+ <p class="text-caption text-medium-emphasis my-2">
76
+ Expired, blocklisted and inactive vehicles start unticked. Each sticker has a cut-off strip under the
77
+ dashed line saying which unit and owner it is for - cut it off before pasting the sticker on the car.
78
+ </p>
79
+ <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview" />
80
+ </template>
81
+ </v-card-text>
82
+
83
+ <v-card-actions>
84
+ <v-btn v-if="step === 'review' && !fromReissue" variant="text" class="text-none" @click="step = 'select'">Back</v-btn>
85
+ <v-spacer />
86
+ <v-btn variant="text" class="text-none" @click="close(false)">Cancel</v-btn>
87
+ <v-btn v-if="step === 'select'" color="primary" variant="flat" class="text-none" :disabled="!selected.length"
88
+ :loading="loading" @click="goReview">
89
+ Preview {{ selected.length }} sticker{{ selected.length === 1 ? "" : "s" }}
90
+ </v-btn>
91
+ <v-btn v-else color="primary" variant="flat" class="text-none" prepend-icon="mdi-printer"
92
+ :disabled="!ticked.length || !docHtml" @click="print">
93
+ Print {{ ticked.length }} sticker{{ ticked.length === 1 ? "" : "s" }}
94
+ </v-btn>
95
+ </v-card-actions>
96
+ </v-card>
97
+ </v-dialog>
98
+ </template>
99
+
100
+ <script lang="ts" setup>
101
+ import QRCode from "qrcode";
102
+ import useVehicle from "../composables/useVehicle";
103
+ import { errorConverter } from "../utils/data";
104
+ import {
105
+ STICKER_MAX_PER_REQUEST,
106
+ buildStickerDocument,
107
+ defaultPlateIds,
108
+ defaultStickerIds,
109
+ idsForRequest,
110
+ installerLine,
111
+ setIds,
112
+ stickerPlateRows,
113
+ stickerWarning,
114
+ type StickerItem,
115
+ type StickerPlateRow,
116
+ } from "../utils/vehicle-qr-sticker";
117
+
118
+ const props = defineProps({
119
+ modelValue: { type: Boolean, default: false },
120
+ site: { type: String, required: true },
121
+ /** The list's rows on the current page. */
122
+ rows: { type: Array as PropType<Array<Record<string, any>>>, default: () => [] },
123
+ /** The list's current tab and search, used by "select all matching". */
124
+ view: { type: String, default: "" },
125
+ search: { type: String, default: "" },
126
+ /** Vehicle ids to go straight to review with (a re-issued sticker). */
127
+ vehicleIds: { type: Array as PropType<string[]>, default: () => [] },
128
+ });
129
+ const emit = defineEmits(["update:modelValue"]);
130
+
131
+ const { getVehicles, getVehicleQrStickers } = useVehicle();
132
+
133
+ const step = ref<"select" | "review">("select");
134
+ const plates = ref<StickerPlateRow[]>([]);
135
+ const selected = ref<string[]>([]);
136
+ const items = ref<StickerItem[]>([]);
137
+ const ticked = ref<string[]>([]);
138
+ const missing = ref<string[]>([]);
139
+ const dropped = ref(0);
140
+ const loading = ref(false);
141
+ const loadingAll = ref(false);
142
+ const error = ref("");
143
+ const docHtml = ref("");
144
+ const frame = ref<HTMLIFrameElement | null>(null);
145
+ const fromReissue = computed(() => props.vehicleIds.length > 0);
146
+
147
+ const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
148
+ const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
149
+
150
+ function tickGroup(ids: string[], on: boolean) {
151
+ selected.value = setIds(selected.value, ids, on);
152
+ }
153
+
154
+ function close(value: boolean) {
155
+ emit("update:modelValue", value);
156
+ }
157
+
158
+ watch(
159
+ () => props.modelValue,
160
+ async (open) => {
161
+ if (!open) return;
162
+ error.value = "";
163
+ items.value = [];
164
+ docHtml.value = "";
165
+ step.value = "select";
166
+ plates.value = stickerPlateRows(props.rows);
167
+ selected.value = defaultPlateIds(plates.value);
168
+ if (props.vehicleIds.length) {
169
+ selected.value = [...props.vehicleIds];
170
+ await goReview();
171
+ }
172
+ },
173
+ { immediate: true },
174
+ );
175
+
176
+ /** Every page of the current tab + search, up to the per-request cap. */
177
+ async function loadAll() {
178
+ loadingAll.value = true;
179
+ error.value = "";
180
+ try {
181
+ const all: Array<Record<string, any>> = [];
182
+ let page = 1;
183
+ let pages = 1;
184
+ do {
185
+ const res: any = await getVehicles({ page, limit: 100, search: props.search, view: props.view, site: props.site });
186
+ all.push(...(res?.items ?? []));
187
+ pages = res?.pages || 1;
188
+ page += 1;
189
+ } while (page <= pages && stickerPlateRows(all).length < STICKER_MAX_PER_REQUEST);
190
+ plates.value = stickerPlateRows(all);
191
+ selected.value = defaultPlateIds(plates.value);
192
+ } catch (e: any) {
193
+ error.value = errorConverter(e);
194
+ } finally {
195
+ loadingAll.value = false;
196
+ }
197
+ }
198
+
199
+ async function goReview() {
200
+ loading.value = true;
201
+ error.value = "";
202
+ try {
203
+ const { ids, dropped: over } = idsForRequest(selected.value);
204
+ const res = await getVehicleQrStickers(props.site, ids);
205
+ items.value = (res?.items ?? []) as StickerItem[];
206
+ missing.value = res?.missing ?? [];
207
+ dropped.value = over;
208
+ ticked.value = defaultStickerIds(items.value);
209
+ step.value = "review";
210
+ } catch (e: any) {
211
+ error.value = errorConverter(e);
212
+ } finally {
213
+ loading.value = false;
214
+ }
215
+ }
216
+
217
+ // The same document is the preview and the print job. Error correction Q and a
218
+ // 4-module quiet zone; the QR is drawn at 32 mm so it scans from arm's length.
219
+ let renderToken = 0;
220
+ watch([items, ticked], async () => {
221
+ const mine = ++renderToken;
222
+ const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
223
+ if (!chosen.length) {
224
+ docHtml.value = "";
225
+ return;
226
+ }
227
+ const svgs = await Promise.all(
228
+ chosen.map((i) => QRCode.toString(i.qr, { type: "svg", errorCorrectionLevel: "Q", margin: 4 })),
229
+ );
230
+ if (mine === renderToken) docHtml.value = buildStickerDocument(chosen, svgs);
231
+ });
232
+
233
+ function print() {
234
+ const win = frame.value?.contentWindow;
235
+ if (!win) return;
236
+ win.focus();
237
+ win.print();
238
+ }
239
+ </script>
240
+
241
+ <style scoped>
242
+ .sticker-preview {
243
+ width: 100%;
244
+ height: 460px;
245
+ border: 1px solid var(--v-border-color, #ccc);
246
+ border-radius: 8px;
247
+ background: #e9e9e9;
248
+ }
249
+ </style>
@@ -319,7 +319,23 @@ export default function useAccessManagement() {
319
319
  );
320
320
  }
321
321
 
322
- function assignUser(payload: { assignees: string[]; unit: string; type: string; acm_url: string; id?: string }) {
322
+ /**
323
+ * `holderType` is the GROUP the card was given from — "Visitor/Resident",
324
+ * "Management" or "Service Provider" (core `EAccessCardHolderTypes`).
325
+ *
326
+ * It is what lets the server resolve the holder afterwards: `assignees`
327
+ * carries a `users` id for a resident, but a membership id for a service
328
+ * provider, and only the group says which collection to look in. Omit it and
329
+ * the card stores `holderType: null`, so card details cannot name the holder.
330
+ */
331
+ function assignUser(payload: {
332
+ assignees: string[];
333
+ unit: string;
334
+ type: string;
335
+ acm_url: string;
336
+ id?: string;
337
+ holderType?: "Visitor/Resident" | "Management" | "Service Provider";
338
+ }) {
323
339
  return useNuxtApp().$api<Record<string, any>>(
324
340
  `/api/access-management/assign-user`,
325
341
  { method: "POST", body: payload }
@@ -122,6 +122,19 @@ export function useSettingsPermission() {
122
122
  );
123
123
  });
124
124
 
125
+ // Vehicle QR stickers switch: whoever may update vehicles (same grant the
126
+ // server checks on PUT /api/vehicles/qr/settings/site/:site).
127
+ const vehiclePermissions: TPermissions = {
128
+ "vehicle-mgmt": {
129
+ "update-vehicle": { check: true, description: "Allows the user to update vehicle details." },
130
+ },
131
+ };
132
+ const canManageVehicleQr = computed(() => {
133
+ if (!userAppRole.value) return false;
134
+ if (userAppRole.value.permissions.includes("*")) return true;
135
+ return hasPermission(userAppRole.value, vehiclePermissions, "vehicle-mgmt", "update-vehicle");
136
+ });
137
+
125
138
  const canManageSiteSettings = computed(() => {
126
139
  return canManageSiteInformation.value || canManageANPRCamera.value || canManageCCTVCamera.value || canManageDeliveryCompanies.value || canManageResidentVisitors.value || canManageWorkOrderSettings.value || canManageBillingAndSOAConfiguration.value || canManageNFCManagement.value || canManageNFCPatrolSettings.value || canManageEntryPass.value || canManageRedDotSettings.value
127
140
  })
@@ -138,6 +151,7 @@ export function useSettingsPermission() {
138
151
  canManageNFCPatrolSettings,
139
152
  canManageEntryPass,
140
153
  canManageRedDotSettings,
154
+ canManageVehicleQr,
141
155
  canManageSiteSettings
142
156
 
143
157
  };
@@ -272,6 +272,41 @@ function getSpecificVehicleById(id: string){
272
272
  }
273
273
 
274
274
 
275
+ // Vehicle QR stickers (core vehicle-qr). Per-site switch, bulk sticker data,
276
+ // and re-issue (the old sticker stops working).
277
+ function getVehicleQrSettings(site: string) {
278
+ return useNuxtApp().$api<{ site: string; enabled: boolean }>(
279
+ `/api/vehicles/qr/settings/site/${site}`,
280
+ { method: "GET" },
281
+ );
282
+ }
283
+
284
+ function setVehicleQrSettings(site: string, enabled: boolean) {
285
+ return useNuxtApp().$api<{ site: string; enabled: boolean }>(
286
+ `/api/vehicles/qr/settings/site/${site}`,
287
+ { method: "PUT", body: { enabled } },
288
+ );
289
+ }
290
+
291
+ function getVehicleQrStickers(site: string, vehicleIds: string[]) {
292
+ return useNuxtApp().$api<{
293
+ site: { _id: string; name: string };
294
+ items: Array<Record<string, any>>;
295
+ missing: string[];
296
+ }>("/api/vehicles/qr/stickers", {
297
+ method: "POST",
298
+ body: { site, vehicleIds },
299
+ });
300
+ }
301
+
302
+ function regenerateVehicleQr(id: string) {
303
+ return useNuxtApp().$api<{ vehicleId: string; qr: string; token: string }>(
304
+ `/api/vehicles/qr/regenerate/id/${id}`,
305
+ { method: "PUT" },
306
+ );
307
+ }
308
+
309
+
275
310
  return {
276
311
  getVehicles,
277
312
  addVehicle,
@@ -290,6 +325,10 @@ function getSpecificVehicleById(id: string){
290
325
  getActiveCameraCounts,
291
326
  syncVehicle,
292
327
  searchMultipleVehiclesByUnitId,
293
- getSpecificVehicleById
328
+ getSpecificVehicleById,
329
+ getVehicleQrSettings,
330
+ setVehicleQrSettings,
331
+ getVehicleQrStickers,
332
+ regenerateVehicleQr,
294
333
  };
295
334
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.90.2-staging.485",
5
+ "version": "4.90.2-staging.487",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Vehicle QR stickers - the pure parts (labels, selection, paging, print page).
3
+ *
4
+ * Kept free of Vue and of the QR library so `node --test` can run it. The
5
+ * sticker itself carries NO resident detail: just the QR, the plate and the
6
+ * Seven365 line. Block / level / unit / owner appear ONLY on the installer
7
+ * strip under the dashed cut line, which is cut off before the sticker is
8
+ * pasted on the car.
9
+ */
10
+
11
+ /** The server's most vehicles per request (POST /api/vehicles/qr/stickers). */
12
+ export const STICKER_MAX_PER_REQUEST = 500;
13
+
14
+ /** A4 sheet: 4 columns x 4 rows of 47 mm cells. */
15
+ export const STICKER_COLUMNS = 4;
16
+ export const STICKER_ROWS = 4;
17
+ export const STICKERS_PER_PAGE = STICKER_COLUMNS * STICKER_ROWS;
18
+
19
+ export type StickerItem = {
20
+ vehicleId: string;
21
+ qr: string;
22
+ token?: string;
23
+ block?: string;
24
+ level?: string;
25
+ unit?: string;
26
+ plateNumber?: string;
27
+ ownerName?: string;
28
+ status?: string;
29
+ };
30
+
31
+ export type StickerPlateRow = {
32
+ id: string;
33
+ plateNumber: string;
34
+ status: string;
35
+ ownerName: string;
36
+ unitLabel: string;
37
+ };
38
+
39
+ const text = (v: unknown) => (v == null ? "" : String(v).trim());
40
+
41
+ /** "Blk 4 #02-01". Tolerates a unit that already carries its level ("02-01"). */
42
+ export function stickerUnitLabel(parts: { block?: unknown; level?: unknown; unit?: unknown }): string {
43
+ const block = text(parts.block).replace(/^(blk|block)\.?\s*/i, "");
44
+ const level = text(parts.level).replace(/^(level|lvl|floor)\.?\s*/i, "");
45
+ const unit = text(parts.unit).replace(/^(unit)\.?\s*/i, "").replace(/^#/, "");
46
+ const home = unit.includes("-") || !level ? unit : `${level}-${unit}`;
47
+ return [block ? `Blk ${block}` : "", home ? `#${home}` : ""].filter(Boolean).join(" ");
48
+ }
49
+
50
+ /** The installer strip under the cut line: where it goes and whose car. */
51
+ export function installerLine(item: Pick<StickerItem, "block" | "level" | "unit" | "ownerName">): {
52
+ place: string;
53
+ owner: string;
54
+ } {
55
+ return { place: stickerUnitLabel(item), owner: text(item.ownerName) };
56
+ }
57
+
58
+ export type StickerWarning = { level: "error" | "warning"; text: string };
59
+
60
+ /** What the preview says about a status. Null = nothing to warn about. */
61
+ export function stickerWarning(status: unknown): StickerWarning | null {
62
+ switch (text(status).toLowerCase()) {
63
+ case "expired":
64
+ return { level: "error", text: "Season pass expired" };
65
+ case "blocklisted":
66
+ return { level: "error", text: "On the blocklist" };
67
+ case "inactive":
68
+ return { level: "warning", text: "Vehicle is inactive" };
69
+ case "pending":
70
+ return { level: "warning", text: "Waiting for approval" };
71
+ default:
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** Stickers that should NOT be ticked for printing until someone chooses to. */
77
+ export function stickerStartsUnticked(status: unknown): boolean {
78
+ return stickerWarning(status)?.level === "error" || text(status).toLowerCase() === "inactive";
79
+ }
80
+
81
+ /** The ids ticked by default in the preview. */
82
+ export function defaultStickerIds(items: Pick<StickerItem, "vehicleId" | "status">[]): string[] {
83
+ return items.filter((i) => !stickerStartsUnticked(i.status)).map((i) => i.vehicleId);
84
+ }
85
+
86
+ /** One selectable row per plate, from the list's grouped rows. */
87
+ export function stickerPlateRows(rows: Array<Record<string, any>>): StickerPlateRow[] {
88
+ const out: StickerPlateRow[] = [];
89
+ for (const row of rows ?? []) {
90
+ const plates: any[] = Array.isArray(row?.plates) && row.plates.length ? row.plates : [row];
91
+ for (const plate of plates) {
92
+ if (!plate?._id) continue;
93
+ out.push({
94
+ id: String(plate._id),
95
+ plateNumber: text(plate.plateNumber),
96
+ status: text(plate.status).toLowerCase(),
97
+ ownerName: text(row?.name ?? plate.name),
98
+ unitLabel: stickerUnitLabel({
99
+ block: row?.block?.name ?? row?.block,
100
+ level: row?.level?.name ?? row?.level,
101
+ unit: row?.unit?.name ?? row?.unit,
102
+ }),
103
+ });
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Plates ticked on first open: live ones only (active / season pass). */
110
+ export function defaultPlateIds(plates: StickerPlateRow[]): string[] {
111
+ return plates.filter((p) => p.status === "active" || p.status === "temporary").map((p) => p.id);
112
+ }
113
+
114
+ /** Tick or untick a group of ids (e.g. "select all on this page"). */
115
+ export function setIds(selected: string[], ids: string[], on: boolean): string[] {
116
+ const rest = selected.filter((id) => !ids.includes(id));
117
+ return on ? [...rest, ...ids] : rest;
118
+ }
119
+
120
+ /** The ids to send: unique, in order, at most what the server accepts. */
121
+ export function idsForRequest(ids: string[]): { ids: string[]; dropped: number } {
122
+ const unique = [...new Set(ids)];
123
+ return { ids: unique.slice(0, STICKER_MAX_PER_REQUEST), dropped: Math.max(0, unique.length - STICKER_MAX_PER_REQUEST) };
124
+ }
125
+
126
+ /** Split into sheets. */
127
+ export function stickerPages<T>(items: T[], perPage: number = STICKERS_PER_PAGE): T[][] {
128
+ const pages: T[][] = [];
129
+ for (let i = 0; i < items.length; i += perPage) pages.push(items.slice(i, i + perPage));
130
+ return pages;
131
+ }
132
+
133
+ export function escapeHtml(value: unknown): string {
134
+ return text(value)
135
+ .replace(/&/g, "&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
+ }