@7365admin1/layer-common 4.125.0 → 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 +16 -4
- package/components/Robot/Setup.vue +12 -10
- package/components/SiteSettings.vue +3 -0
- package/components/VisitorManagement.vue +82 -0
- package/components/VisitorPassSmsSettings.vue +57 -0
- package/composables/useSiteFeatureSettings.ts +1 -0
- package/composables/useVisitor.ts +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 4.125.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- b2e5424: Robotics setup: fix the Setup view staying blank (floor names state was read before it was declared).
|
|
14
|
+
|
|
3
15
|
## 4.125.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -3288,11 +3300,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3288
3300
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3289
3301
|
own rule from two endpoints the console already calls, unprojected:
|
|
3290
3302
|
|
|
3291
|
-
|
|
3292
|
-
|
|
3303
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3304
|
+
GET /api/roles/id/:role that membership's role document
|
|
3293
3305
|
|
|
3294
|
-
|
|
3295
|
-
|
|
3306
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3307
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3296
3308
|
|
|
3297
3309
|
`role.default` is the marker because it is the only property of a platform
|
|
3298
3310
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -143,6 +143,18 @@ async function refresh() {
|
|
|
143
143
|
await loadLifts();
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Declared BEFORE `steps`: the progress watch below runs at once and reads it (a later `const` is a TDZ error).
|
|
147
|
+
// Floor names: the list the panel loaded or saved; a save re-reads the robots so the checklist shows the new floors.
|
|
148
|
+
const floorsEl = ref<HTMLElement | null>(null);
|
|
149
|
+
const siteFloors = ref<Array<{ key: number; name: string }>>([]);
|
|
150
|
+
const mapFloors = computed(() => [...new Set<number>(((saved.value?.levels ?? []) as any[]).map((l) => Number(l?.floorNo)).filter(Number.isInteger))]);
|
|
151
|
+
let firstFloors = true;
|
|
152
|
+
function floorsSaved(list: Array<{ key: number; name: string }>) {
|
|
153
|
+
siteFloors.value = list;
|
|
154
|
+
if (firstFloors) return void (firstFloors = false);
|
|
155
|
+
void refresh();
|
|
156
|
+
}
|
|
157
|
+
|
|
146
158
|
const steps = computed(() =>
|
|
147
159
|
robotSetupSteps({
|
|
148
160
|
orgModules: orgModules.value,
|
|
@@ -165,16 +177,6 @@ function go(target: TSetupTarget) {
|
|
|
165
177
|
if (target) emit("go", target);
|
|
166
178
|
}
|
|
167
179
|
|
|
168
|
-
// Floor names: the list the panel loaded or saved; a save re-reads the robots so the checklist shows the new floors.
|
|
169
|
-
const floorsEl = ref<HTMLElement | null>(null);
|
|
170
|
-
const siteFloors = ref<Array<{ key: number; name: string }>>([]);
|
|
171
|
-
const mapFloors = computed(() => [...new Set<number>(((saved.value?.levels ?? []) as any[]).map((l) => Number(l?.floorNo)).filter(Number.isInteger))]);
|
|
172
|
-
let firstFloors = true;
|
|
173
|
-
function floorsSaved(list: Array<{ key: number; name: string }>) {
|
|
174
|
-
siteFloors.value = list;
|
|
175
|
-
if (firstFloors) return void (firstFloors = false);
|
|
176
|
-
void refresh();
|
|
177
|
-
}
|
|
178
180
|
</script>
|
|
179
181
|
|
|
180
182
|
<style scoped>
|
|
@@ -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>
|
|
@@ -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.
|
|
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.",
|