@7365admin1/layer-common 4.0.4-staging.241 → 4.0.4-staging.242

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.
@@ -457,6 +457,15 @@
457
457
  >
458
458
  {{ item?.overnightParking?.status }}
459
459
  </v-card>
460
+ <v-card
461
+ v-else-if="activeTab === 'guests' && item?.status"
462
+ size="small"
463
+ class="rounded-xl text-capitalize text-center elevation-0 py-1 px-2"
464
+ :color="getVisitorStatusColor(item.status)"
465
+ style="max-width: 150px; margin: auto"
466
+ >
467
+ {{ getVisitorStatusLabel(item.status) }}
468
+ </v-card>
460
469
  <span v-else> N/A </span>
461
470
  </template>
462
471
 
@@ -536,6 +545,27 @@
536
545
  <v-divider />
537
546
  </v-card>
538
547
  </v-menu>
548
+ <v-menu v-else-if="activeTab === 'guests' && item?.status === 'pending' && canUpdateVisitor">
549
+ <template #activator="{ props }">
550
+ <v-avatar v-bind="props" class="rounded-xl border-md">
551
+ <v-icon icon="mdi-dots-vertical" />
552
+ </v-avatar>
553
+ </template>
554
+
555
+ <v-card>
556
+ <v-list-item @click.stop="openApproveRejectVisitorDialog(item, 'approved')">
557
+ <template #title>
558
+ <span class="text-caption">Approve</span>
559
+ </template>
560
+ </v-list-item>
561
+ <v-divider />
562
+ <v-list-item @click.stop="openApproveRejectVisitorDialog(item, 'rejected')">
563
+ <template #title>
564
+ <span class="text-caption">Reject</span>
565
+ </template>
566
+ </v-list-item>
567
+ </v-card>
568
+ </v-menu>
539
569
  </template>
540
570
 
541
571
  <template v-slot:item.checkInRemarks="{ item }">
@@ -1179,6 +1209,78 @@
1179
1209
  </v-card>
1180
1210
  </v-dialog>
1181
1211
 
1212
+ <v-dialog
1213
+ v-model="dialog.approveRejectVisitorDialog"
1214
+ transition="dialog-bottom-transition"
1215
+ persistent
1216
+ width="60vh"
1217
+ >
1218
+ <v-card>
1219
+ <v-toolbar density="compact">
1220
+ <v-row
1221
+ no-gutters
1222
+ class="d-flex fill-height justify-space-between align-center px-4"
1223
+ >
1224
+ <span class="font-weight-bold">{{ approveRejectVisitorDialogTitle }}</span>
1225
+ <v-btn
1226
+ icon="mdi-close"
1227
+ variant="text"
1228
+ @click="closeApproveRejectVisitorDialog"
1229
+ />
1230
+ </v-row>
1231
+ </v-toolbar>
1232
+ <v-card-text class="px-4 pb-6">
1233
+ <v-row no-gutters justify="center" align-content="center">
1234
+ <v-col cols="12" class="text-h6 text-center">
1235
+ {{
1236
+ `Are you sure you want to ${approveRejectVisitorDialog.text} the ${approveRejectVisitorDialogTitle}?`
1237
+ }}
1238
+ </v-col>
1239
+
1240
+ <v-col cols="12" md="8" class="mt-4">
1241
+ <v-textarea
1242
+ v-model="visitorApproveRejectRemarks"
1243
+ label="Remarks"
1244
+ placeholder="Enter remarks"
1245
+ outlined
1246
+ rows="3"
1247
+ clearable
1248
+ />
1249
+ </v-col>
1250
+ </v-row>
1251
+ </v-card-text>
1252
+ <v-toolbar class="pa-0" density="compact">
1253
+ <v-row no-gutters>
1254
+ <v-col cols="6">
1255
+ <v-btn
1256
+ text="No"
1257
+ color="grey-lighten-3"
1258
+ variant="flat"
1259
+ height="48"
1260
+ tile
1261
+ block
1262
+ :disabled="isApprovingRejectingVisitor"
1263
+ @click="closeApproveRejectVisitorDialog"
1264
+ />
1265
+ </v-col>
1266
+ <v-col cols="6">
1267
+ <v-btn
1268
+ text="Yes"
1269
+ color="success"
1270
+ variant="flat"
1271
+ height="48"
1272
+ tile
1273
+ block
1274
+ :disabled="!visitorApproveRejectRemarks || isApprovingRejectingVisitor"
1275
+ @click="updateVisitorApprovalStatus"
1276
+ :loading="isApprovingRejectingVisitor"
1277
+ />
1278
+ </v-col>
1279
+ </v-row>
1280
+ </v-toolbar>
1281
+ </v-card>
1282
+ </v-dialog>
1283
+
1182
1284
  <v-dialog v-model="hidQrDialog" max-width="440" persistent>
1183
1285
  <v-card>
1184
1286
  <v-toolbar color="transparent" density="comfortable" class="px-2">
@@ -1436,6 +1538,7 @@ const dialog = reactive({
1436
1538
  showVisitorDataFromScannedQRCode: false,
1437
1539
  approveRejectOvernightParkingRequestDialog: false,
1438
1540
  returnNfcCard: false,
1541
+ approveRejectVisitorDialog: false,
1439
1542
  });
1440
1543
 
1441
1544
  const snapshotImageUrl = ref("");
@@ -1488,6 +1591,18 @@ const headers = computed(() => {
1488
1591
  list.push({ title: "Action", value: "action" });
1489
1592
  }
1490
1593
 
1594
+ // Guests is the only non-overnight-parking tab whose rows carry a
1595
+ // top-level status (approved/pending/rejected) and an Approve/Reject
1596
+ // action - without these two, the item.status/item.action template slots
1597
+ // have no header to render against and v-data-table never shows them,
1598
+ // no matter what their own v-if says.
1599
+ if (tab === "guests") {
1600
+ list.push(
1601
+ { title: "Status", value: "status" },
1602
+ { title: "Action", value: "action" },
1603
+ );
1604
+ }
1605
+
1491
1606
  return list;
1492
1607
  });
1493
1608
 
@@ -1590,6 +1705,77 @@ function closeApproveRejectOvernightParkingDialog() {
1590
1705
  overNightParkingRequestApproveRejectRemarks.value = "";
1591
1706
  }
1592
1707
 
1708
+ /** Self-service submissions arrive with `status: "pending"` when the site's
1709
+ Self-Service Auto-Approvals toggle is off - Approve/Reject here is what
1710
+ actually decides the visitor's outcome, mirroring the Overnight Parking
1711
+ approve/reject pattern above but against the top-level visitor status
1712
+ (via the existing staff update endpoint) rather than a nested field. */
1713
+ function getVisitorStatusColor(status: string) {
1714
+ switch (status) {
1715
+ case "approved":
1716
+ return "success";
1717
+ case "rejected":
1718
+ return "error";
1719
+ case "pending":
1720
+ return "warning";
1721
+ default:
1722
+ return "grey";
1723
+ }
1724
+ }
1725
+
1726
+ function getVisitorStatusLabel(status: string) {
1727
+ return status === "pending" ? "Pending Approval" : status;
1728
+ }
1729
+
1730
+ const selectedVisitorForApproval = ref<Record<string, any>>({});
1731
+ const visitorApproveRejectRemarks = ref("");
1732
+ const approveRejectVisitorDialog = ref({ text: "", status: "" });
1733
+ const isApprovingRejectingVisitor = ref(false);
1734
+
1735
+ const approveRejectVisitorDialogTitle = computed(() => {
1736
+ const type = selectedVisitorForApproval.value?.type as string | undefined;
1737
+ const label = type ? type.replace("-", " ") : "Visitor";
1738
+ return `${label.charAt(0).toUpperCase()}${label.slice(1)} Registration Request`;
1739
+ });
1740
+
1741
+ function openApproveRejectVisitorDialog(visitor: Record<string, any>, status: "approved" | "rejected") {
1742
+ selectedVisitorForApproval.value = visitor;
1743
+ approveRejectVisitorDialog.value = {
1744
+ text: status === "approved" ? "Approve" : "Reject",
1745
+ status,
1746
+ };
1747
+ visitorApproveRejectRemarks.value = "";
1748
+ dialog.approveRejectVisitorDialog = true;
1749
+ }
1750
+
1751
+ function closeApproveRejectVisitorDialog() {
1752
+ dialog.approveRejectVisitorDialog = false;
1753
+ selectedVisitorForApproval.value = {};
1754
+ approveRejectVisitorDialog.value = { text: "", status: "" };
1755
+ visitorApproveRejectRemarks.value = "";
1756
+ }
1757
+
1758
+ async function updateVisitorApprovalStatus() {
1759
+ isApprovingRejectingVisitor.value = true;
1760
+ try {
1761
+ await updateVisitor(selectedVisitorForApproval.value._id, {
1762
+ status: approveRejectVisitorDialog.value.status,
1763
+ remarks: visitorApproveRejectRemarks.value,
1764
+ updatedBy: currentUser.value?._id,
1765
+ } as any);
1766
+ showMessage(
1767
+ `Visitor registration successfully ${approveRejectVisitorDialog.value.status}`,
1768
+ "success"
1769
+ );
1770
+ await getVisitorRefresh();
1771
+ closeApproveRejectVisitorDialog();
1772
+ } catch (error) {
1773
+ showMessage(errorConverter(error), "error");
1774
+ } finally {
1775
+ isApprovingRejectingVisitor.value = false;
1776
+ }
1777
+ }
1778
+
1593
1779
  function mappedAttachments(attachments: string[]) {
1594
1780
  return attachments.map((x, index) => {
1595
1781
  return {
@@ -1665,17 +1851,12 @@ function toRoute(tab: any) {
1665
1851
 
1666
1852
  const obj = tabOptions.value.find((x) => x.value === tab);
1667
1853
  if (!obj) return;
1854
+ // Don't call getVisitorRefresh() here - activeTab is already in the
1855
+ // useLazyAsyncData `watch` array below, so changing it (which already
1856
+ // happened via v-model before this handler runs) already triggers a
1857
+ // refetch on its own. Calling refresh() again here fired the same
1858
+ // request a second time on every tab click.
1668
1859
  page.value = 1;
1669
- getVisitorRefresh();
1670
- // navigateTo({
1671
- // name: routeName,
1672
- // params: {
1673
- // org: orgId,
1674
- // },
1675
- // // query: {
1676
- // // tab
1677
- // // },
1678
- // });
1679
1860
  }
1680
1861
  const {
1681
1862
  data: getVisitorReq,
@@ -1702,8 +1883,15 @@ const {
1702
1883
  };
1703
1884
 
1704
1885
  if (activeTab.value === "guests") {
1705
- params.type = "guest";
1706
- params.status = "approved";
1886
+ // Self-service Contractor submissions land here too, not just Visitor
1887
+ // (type: "guest") - the "Registered" tab hard-filters status="registered",
1888
+ // which a pending/approved self-service contractor never has, so without
1889
+ // this it had no tab that would ever show it.
1890
+ params.type = "guest,contractor";
1891
+ // A self-service submission awaiting Approve/Reject (Self-Service
1892
+ // Auto-Approvals off) lands here as "pending" alongside already
1893
+ // "approved" guests, rather than being hidden until someone acts on it.
1894
+ params.status = "approved,pending";
1707
1895
  } else if (activeTab.value === "resident-transactions") {
1708
1896
  params.status = "registered";
1709
1897
  params.type = "resident,tenant";
@@ -2237,8 +2425,11 @@ function buildReportParams(): any {
2237
2425
  };
2238
2426
 
2239
2427
  if (activeTab.value === "guests") {
2240
- params.type = "guest";
2241
- params.status = "approved";
2428
+ // Kept in sync with the Guests tab's own list query above - otherwise a
2429
+ // report generated from this tab would silently omit contractors and
2430
+ // pending rows the screen is actually showing.
2431
+ params.type = "guest,contractor";
2432
+ params.status = "approved,pending";
2242
2433
  } else if (activeTab.value === "resident-transactions") {
2243
2434
  params.status = "registered";
2244
2435
  params.type = "resident,tenant";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Wraps the browser's Contact Picker API ("or select contact in your phone
3
+ * book" on the self-service onboarding form). Support is narrow - Android
4
+ * Chrome/Edge over HTTPS only, not iOS Safari or desktop - so `isSupported`
5
+ * must gate whether the UI even offers this, rather than showing a button
6
+ * that silently does nothing everywhere else.
7
+ */
8
+ export default function useContactPicker() {
9
+ const isSupported = computed(() => {
10
+ return (
11
+ typeof navigator !== "undefined" &&
12
+ "contacts" in navigator &&
13
+ "ContactsManager" in window
14
+ );
15
+ });
16
+
17
+ async function pickContact(): Promise<{ name?: string; tel?: string } | null> {
18
+ if (!isSupported.value) return null;
19
+ try {
20
+ const props = ["name", "tel"];
21
+ const contacts = await (navigator as any).contacts.select(props, { multiple: false });
22
+ const contact = contacts?.[0];
23
+ if (!contact) return null;
24
+ return {
25
+ name: contact.name?.[0],
26
+ tel: contact.tel?.[0],
27
+ };
28
+ } catch {
29
+ // User cancelled the picker, or the browser refused - either way this
30
+ // is not an error worth surfacing, the visitor can just type instead.
31
+ return null;
32
+ }
33
+ }
34
+
35
+ return { isSupported, pickContact };
36
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Public, unauthenticated calls for the self-service visitor onboarding page
3
+ * (reached by scanning the gate QR code printed from Site Settings > Entry
4
+ * Pass > Self-Service). Every endpoint here is deliberately a separate,
5
+ * narrower public route from its staff-facing equivalent in `useBuilding`/
6
+ * `useVisitor`/`useSiteEntryPassSettings` - see the server-side route
7
+ * comments for why each one is safe to leave unauthenticated.
8
+ */
9
+ export default function usePublicVisitorOnboarding() {
10
+ function getOnboardingSettings(siteId: string) {
11
+ return useNuxtApp().$api<Record<string, any>>(
12
+ `/api/access-management/settings/${siteId}/onboarding`,
13
+ { method: "GET" },
14
+ );
15
+ }
16
+
17
+ function getBlocks(siteId: string) {
18
+ return useNuxtApp().$api<Record<string, any>>(
19
+ `/api/buildings/list/site/resident/${siteId}`,
20
+ { method: "GET" },
21
+ );
22
+ }
23
+
24
+ function getLevels(siteId: string, blockId: string) {
25
+ return useNuxtApp().$api<Record<string, any>>(
26
+ `/api/building-levels/list/site/resident/${siteId}`,
27
+ { method: "GET", query: { block: blockId } },
28
+ );
29
+ }
30
+
31
+ function getUnits(siteId: string, block: string, level: string) {
32
+ return useNuxtApp().$api<Record<string, any>>(
33
+ `/api/building-units/resident/site/${siteId}/block/${block}/level/${level}`,
34
+ { method: "GET" },
35
+ );
36
+ }
37
+
38
+ function createVisitor(siteId: string, payload: Record<string, any>) {
39
+ return useNuxtApp().$api<Record<string, any>>(
40
+ `/api/visitor-transactions/self-service/${siteId}`,
41
+ { method: "POST", body: payload },
42
+ );
43
+ }
44
+
45
+ // The "2" collection is the one actually populated/used by
46
+ // OvernightParkingAvailability.vue - the un-suffixed v1 collection this
47
+ // used to read from is a separate, unpopulated legacy store.
48
+ function getOvernightParkingHours(siteId: string) {
49
+ return useNuxtApp().$api<Record<string, any>>(
50
+ `/api/overnight-parking-approval-settings2/site/${siteId}/public`,
51
+ { method: "GET" },
52
+ );
53
+ }
54
+
55
+ // Backs the visitor's own preview/QR page, linked from their registration
56
+ // email so it still works after they've closed the browser.
57
+ function getSelfServicePreview(id: string) {
58
+ return useNuxtApp().$api<Record<string, any>>(
59
+ `/api/visitor-transactions/self-service/preview/${id}`,
60
+ { method: "GET" },
61
+ );
62
+ }
63
+
64
+ return {
65
+ getOnboardingSettings,
66
+ getBlocks,
67
+ getLevels,
68
+ getUnits,
69
+ createVisitor,
70
+ getOvernightParkingHours,
71
+ getSelfServicePreview,
72
+ };
73
+ }
@@ -227,6 +227,7 @@ export default function useWebUsb() {
227
227
  address?: string,
228
228
  qrHeader?: string,
229
229
  qrSubText?: string,
230
+ layout?: "receipt" | "onboarding",
230
231
  ) => {
231
232
  if (!isWebUsbSupported.value) {
232
233
  throw new Error("Web USB is not supported in this browser");
@@ -245,7 +246,9 @@ export default function useWebUsb() {
245
246
  };
246
247
 
247
248
  let printTestResult;
248
- if (forQr && urlImage) {
249
+ if (forQr && urlImage && layout === "onboarding") {
250
+ printTestResult = await printOnboardingQrCode(device, urlImage, qrHeader, qrSubText);
251
+ } else if (forQr && urlImage) {
249
252
  printTestResult = await printQrCode(
250
253
  device,
251
254
  urlImage,
@@ -399,6 +402,66 @@ export default function useWebUsb() {
399
402
  return canvas;
400
403
  }
401
404
 
405
+ /**
406
+ * The self-service onboarding QR: one large QR code, a "SCAN QR CODE"-style
407
+ * subtext, and a dark site-name banner along the bottom - a single-purpose
408
+ * gate sign, not the multi-copy visitor-pass receipt `createReceiptLayout`
409
+ * produces (that one prints several QRs to be cut apart and handed out).
410
+ */
411
+ async function createOnboardingQrLayout(
412
+ qrUrlImage: string,
413
+ header?: string,
414
+ subText?: string,
415
+ ): Promise<HTMLCanvasElement> {
416
+ const canvas = document.createElement("canvas");
417
+ canvas.width = 576;
418
+ canvas.height = 760;
419
+ const ctx = canvas.getContext("2d")!;
420
+ ctx.fillStyle = "#fff";
421
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
422
+ ctx.textAlign = "center";
423
+
424
+ const qrSize = 400;
425
+ const qrCanvas = await QRCode.toCanvas(qrUrlImage, { width: qrSize, margin: 1 });
426
+ ctx.drawImage(qrCanvas, (canvas.width - qrSize) / 2, 40, qrSize, qrSize);
427
+
428
+ ctx.fillStyle = "#000";
429
+ ctx.font = "bold 26px Arial";
430
+ ctx.fillText((subText || "SCAN QR CODE").toUpperCase(), canvas.width / 2, 480);
431
+
432
+ const bannerHeight = 110;
433
+ const bannerY = canvas.height - bannerHeight;
434
+ ctx.fillStyle = "#000";
435
+ ctx.fillRect(0, bannerY, canvas.width, bannerHeight);
436
+ ctx.fillStyle = "#fff";
437
+ ctx.font = "bold 34px Arial";
438
+ ctx.fillText((header || "").toUpperCase(), canvas.width / 2, bannerY + bannerHeight / 2 + 12);
439
+
440
+ return canvas;
441
+ }
442
+
443
+ const printOnboardingQrCode = async (
444
+ device: USBDevice,
445
+ urlImage: string,
446
+ header?: string,
447
+ subText?: string,
448
+ ) => {
449
+ try {
450
+ const { endpointNumber } = getPrinterConnection(device);
451
+ const canvas = await createOnboardingQrLayout(urlImage, header, subText);
452
+ const rasterData = canvasToRaster(canvas);
453
+ await device.transferOut(endpointNumber, rasterData.buffer);
454
+
455
+ const CUT = new Uint8Array([0x1d, 0x56, 0x41, 0x10]);
456
+ await device.transferOut(endpointNumber, CUT.buffer);
457
+
458
+ return { success: true, message: "Onboarding QR print sent successfully" };
459
+ } catch (error: unknown) {
460
+ console.error("[WebUSB] printOnboardingQrCode error:", error);
461
+ return { success: false, error: getErrorMessage(error, "Unable to print onboarding QR code") };
462
+ }
463
+ };
464
+
402
465
  function canvasToRaster(canvas: HTMLCanvasElement): Uint8Array {
403
466
  const ctx = canvas.getContext("2d");
404
467
  if (!ctx) throw new Error("Canvas 2D context not available");
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.0.4-staging.241",
5
+ "version": "4.0.4-staging.242",
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.",