@7365admin1/layer-common 4.125.1 → 4.126.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,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.126.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5e36856: Visitors: "Send pass by SMS" on the Visitors page (Guests tab, approved and not checked out, only when the site switch is on and the user may add visitors), and a "Send visitor pass by SMS" switch in Site Settings > Visitor registration (PM and Security). Shows the server's own words, e.g. "Sent to +65 ••••4567" or "SMS is not set up yet."
8
+
3
9
  ## 4.125.1
4
10
 
5
11
  ### Patch Changes
@@ -3294,11 +3300,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3294
3300
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3295
3301
  own rule from two endpoints the console already calls, unprojected:
3296
3302
 
3297
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3298
- GET /api/roles/id/:role that membership's role document
3303
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3304
+ GET /api/roles/id/:role that membership's role document
3299
3305
 
3300
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3301
- staff = member.type === "admin" && role.type === "admin"
3306
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3307
+ staff = member.type === "admin" && role.type === "admin"
3302
3308
 
3303
3309
  `role.default` is the marker because it is the only property of a platform
3304
3310
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -230,6 +230,9 @@
230
230
  <v-expansion-panel-text>
231
231
  <SiteSettingsVisitorRegistration :site-id="siteId" :site-data="siteData"
232
232
  :can-manage="canManageSiteSettings" @saved="refreshSiteData" @message="show" />
233
+ <v-divider class="my-4" />
234
+ <VisitorPassSmsSettings :site-id="siteId" :saved="(siteData as any)?.metadata?.visitorPassSms"
235
+ :loaded="!!siteData" :read-only="!canManageSiteSettings" @saved="refreshSiteData" />
233
236
  </v-expansion-panel-text>
234
237
  </v-expansion-panel>
235
238
 
@@ -570,6 +570,24 @@
570
570
  </v-list-item>
571
571
  </v-card>
572
572
  </v-menu>
573
+ <!-- Walk-in pass by SMS: only when the site switched it on and this user may add visitors. -->
574
+ <v-menu
575
+ v-else-if="activeTab === 'guests' && item?.status === 'approved' && !item?.checkOut && canAddVisitor && passSmsEnabled"
576
+ >
577
+ <template #activator="{ props }">
578
+ <v-avatar v-bind="props" class="rounded-xl border-md" data-test="visitor-pass-sms-menu">
579
+ <v-icon icon="mdi-dots-vertical" />
580
+ </v-avatar>
581
+ </template>
582
+
583
+ <v-card>
584
+ <v-list-item data-test="visitor-pass-sms-open" @click.stop="openPassSms(item)">
585
+ <template #title>
586
+ <span class="text-caption">Send pass by SMS</span>
587
+ </template>
588
+ </v-list-item>
589
+ </v-card>
590
+ </v-menu>
573
591
  </template>
574
592
 
575
593
  <template v-slot:item.checkInRemarks="{ item }">
@@ -1560,6 +1578,35 @@
1560
1578
  </v-card>
1561
1579
  </v-dialog>
1562
1580
 
1581
+ <v-dialog v-model="passSms.open" max-width="420">
1582
+ <v-card data-test="visitor-pass-sms-dialog">
1583
+ <v-toolbar color="transparent" density="comfortable" class="px-2">
1584
+ <v-toolbar-title class="text-subtitle-1 font-weight-bold">Send pass by SMS</v-toolbar-title>
1585
+ <v-btn icon="mdi-close" variant="text" title="Close" @click="passSms.open = false" />
1586
+ </v-toolbar>
1587
+ <v-card-text>
1588
+ <p class="text-body-2 mb-3">
1589
+ {{ passSms.name }} gets a text with the site name, the visit date and their pass link.
1590
+ </p>
1591
+ <v-text-field v-model.trim="passSms.phone" label="Mobile number" placeholder="e.g. 9123 4567"
1592
+ density="comfortable" variant="outlined" maxlength="30" :disabled="passSms.sending"
1593
+ data-test="visitor-pass-sms-phone" @keyup.enter="sendPassSmsNow" />
1594
+ <p v-if="passSms.result" class="text-body-2 mt-1" :class="passSms.ok ? 'text-success' : 'text-error'"
1595
+ data-test="visitor-pass-sms-result">
1596
+ {{ passSms.result }}
1597
+ </p>
1598
+ </v-card-text>
1599
+ <v-card-actions class="px-6 pb-4">
1600
+ <v-spacer />
1601
+ <v-btn variant="text" @click="passSms.open = false">Close</v-btn>
1602
+ <v-btn class="screen-btn-primary" color="primary-button" variant="flat" :loading="passSms.sending"
1603
+ :disabled="!passSms.phone" data-test="visitor-pass-sms-send" @click="sendPassSmsNow">
1604
+ Send
1605
+ </v-btn>
1606
+ </v-card-actions>
1607
+ </v-card>
1608
+ </v-dialog>
1609
+
1563
1610
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
1564
1611
  </v-row>
1565
1612
  </template>
@@ -1567,6 +1614,7 @@
1567
1614
  <script setup lang="ts">
1568
1615
  import moment from "moment-timezone";
1569
1616
  import QRCode from "qrcode";
1617
+ import { isVisitorPassSmsOn } from "../composables/useVisitor";
1570
1618
  import {
1571
1619
  VISITOR_SEARCH_DEBOUNCE_MS,
1572
1620
  visitorSearchEmptyText,
@@ -1637,6 +1685,7 @@ const {
1637
1685
  typeFieldMap,
1638
1686
  updateVisitor,
1639
1687
  visitorDataFromScannedQRCodeCheckIn,
1688
+ sendPassSms,
1640
1689
  } = useVisitor();
1641
1690
  const { visitorCheckout } = useAccessManagement();
1642
1691
  const {
@@ -1673,6 +1722,37 @@ const hidQrVisitor = ref<Record<string, any> | null>(null);
1673
1722
  const hidQrConfig = ref({ enabled: false, readerId: "", validityMinutes: 60 });
1674
1723
  let hidQrTimer: ReturnType<typeof setInterval> | null = null;
1675
1724
  const hidQrEnabled = computed(() => hidQrConfig.value.enabled && Boolean(hidQrConfig.value.readerId));
1725
+ // Walk-in pass by SMS: read with the HID settings (same site document).
1726
+ const passSmsEnabled = ref(false);
1727
+ const passSms = reactive({ open: false, id: "", name: "", phone: "", sending: false, result: "", ok: false });
1728
+
1729
+ function openPassSms(item: Record<string, any>) {
1730
+ Object.assign(passSms, {
1731
+ open: true,
1732
+ id: String(item?._id ?? ""),
1733
+ name: item?.name || "The visitor",
1734
+ phone: String(item?.contact ?? ""),
1735
+ sending: false,
1736
+ result: "",
1737
+ ok: false,
1738
+ });
1739
+ }
1740
+
1741
+ async function sendPassSmsNow() {
1742
+ if (!passSms.id || !passSms.phone || passSms.sending) return;
1743
+ passSms.sending = true;
1744
+ passSms.result = "";
1745
+ try {
1746
+ const res: any = await sendPassSms(passSms.id, passSms.phone);
1747
+ passSms.ok = true;
1748
+ passSms.result = res?.message || "Sent.";
1749
+ } catch (error: any) {
1750
+ passSms.ok = false;
1751
+ passSms.result = error?.data?.message || "The SMS could not be sent. Please try again in a minute.";
1752
+ } finally {
1753
+ passSms.sending = false;
1754
+ }
1755
+ }
1676
1756
  const hidQrRemainingSeconds = computed(() => {
1677
1757
  const expiry = new Date(hidQrExpiresAt.value).getTime();
1678
1758
  return Number.isFinite(expiry) ? Math.max(0, Math.ceil((expiry - hidQrNow.value) / 1000)) : 0;
@@ -1683,6 +1763,7 @@ async function loadHidQrConfig() {
1683
1763
  try {
1684
1764
  const response: any = await getSiteById(siteId);
1685
1765
  const site = response?.data ?? response;
1766
+ passSmsEnabled.value = isVisitorPassSmsOn(site);
1686
1767
  const config = site?.metadata?.hidQrCodePass ?? {};
1687
1768
  hidQrConfig.value = {
1688
1769
  enabled: Boolean(config.enabled && config.onlineMode !== false),
@@ -1690,6 +1771,7 @@ async function loadHidQrConfig() {
1690
1771
  validityMinutes: Number(config.validityMinutes) > 0 ? Number(config.validityMinutes) : 60,
1691
1772
  };
1692
1773
  } catch {
1774
+ passSmsEnabled.value = false;
1693
1775
  hidQrConfig.value = { enabled: false, readerId: "", validityMinutes: 60 };
1694
1776
  }
1695
1777
  }
@@ -0,0 +1,57 @@
1
+ <template>
2
+ <div data-test="visitor-pass-sms-settings">
3
+ <v-switch v-model="enabled" :disabled="readOnly" label="Send visitor pass by SMS" color="primary" hide-details
4
+ data-test="visitor-pass-sms-switch" />
5
+ <p class="text-caption text-medium-emphasis mt-1 mb-3">
6
+ Staff can text an approved visitor their pass link (the QR they show at the guardhouse). The text has the site
7
+ name, the visit date and the link only.
8
+ </p>
9
+ <v-btn v-if="!readOnly" class="screen-btn-primary" color="primary-button" variant="flat" text="Save"
10
+ :loading="saving" :disabled="!loaded" data-test="visitor-pass-sms-save" @click="save" />
11
+ <Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
12
+ </div>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
17
+
18
+ /**
19
+ * Site setting `metadata.visitorPassSms` (core site-feature-settings).
20
+ * Off = today: no "Send pass by SMS" action anywhere.
21
+ */
22
+ const props = defineProps({
23
+ siteId: { type: String, required: true },
24
+ saved: { type: Object as PropType<{ enabled?: boolean } | null>, default: null },
25
+ readOnly: { type: Boolean, default: false },
26
+ /** The site has been read; Save stays off until then. */
27
+ loaded: { type: Boolean, default: false },
28
+ });
29
+ const emit = defineEmits(["saved"]);
30
+
31
+ const enabled = ref(false);
32
+ watch(
33
+ () => props.saved,
34
+ (v) => {
35
+ enabled.value = v?.enabled === true;
36
+ },
37
+ { immediate: true },
38
+ );
39
+
40
+ const { saveSiteFeature } = useSiteFeatureSettings();
41
+ const saving = ref(false);
42
+ const toast = reactive({ show: false, message: "", color: "" });
43
+
44
+ async function save() {
45
+ if (!props.loaded) return;
46
+ saving.value = true;
47
+ try {
48
+ await saveSiteFeature(props.siteId, "visitorPassSms", { enabled: enabled.value });
49
+ Object.assign(toast, { show: true, message: "Saved.", color: "success" });
50
+ emit("saved");
51
+ } catch (e: any) {
52
+ Object.assign(toast, { show: true, message: e?.data?.message || "Could not save.", color: "error" });
53
+ } finally {
54
+ saving.value = false;
55
+ }
56
+ }
57
+ </script>
@@ -15,6 +15,7 @@ export const SITE_FEATURE_KEYS = [
15
15
  "feedbackSettings",
16
16
  "dataRetention",
17
17
  "maStaffRegistration",
18
+ "visitorPassSms",
18
19
  ] as const;
19
20
 
20
21
  export type TSiteFeatureKey = (typeof SITE_FEATURE_KEYS)[number];
@@ -173,6 +173,14 @@ export default function () {
173
173
  );
174
174
  }
175
175
 
176
+ /** Text the visit's pass link (walk-in pass by SMS). `phone` empty = the visit's contact. */
177
+ async function sendPassSms(_id: string, phone?: string) {
178
+ return await useNuxtApp().$api<{ message: string; to?: string }>(
179
+ `/api/visitor-transactions/${_id}/send-pass-sms`,
180
+ { method: "POST", body: phone ? { phone } : {} }
181
+ );
182
+ }
183
+
176
184
  async function updateVisitor(_id: string, payload: Partial<TVisitorPayload>) {
177
185
  return await useNuxtApp().$api<Record<string, any>>(
178
186
  `/api/visitor-transactions/id/${_id}`,
@@ -271,6 +279,7 @@ export default function () {
271
279
  validateVisitorQrCode,
272
280
  updateVisitorPassKey,
273
281
  visitorDataFromScannedQRCodeCheckIn,
282
+ sendPassSms,
274
283
  };
275
284
  }
276
285
 
@@ -293,6 +302,11 @@ export function getVisitorFieldSwitches(site: any): TVisitorFieldSwitches {
293
302
  };
294
303
  }
295
304
 
305
+ /** "Send visitor pass by SMS" (`site.metadata.visitorPassSms`): off unless the site saved it on. */
306
+ export function isVisitorPassSmsOn(site: any): boolean {
307
+ return site?.metadata?.visitorPassSms?.enabled === true;
308
+ }
309
+
296
310
  /** Shown under the NRIC field on a "keep only the last 4" site. */
297
311
  export const NRIC_LAST4_HINT = "Only the last 4 characters will be kept.";
298
312
 
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.125.1",
5
+ "version": "4.126.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.",