@7365admin1/layer-common 4.35.1-staging.431 → 4.35.1-staging.433
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.
|
@@ -106,6 +106,10 @@
|
|
|
106
106
|
:class="{ 'sp-muted': item.companyKnown === false }"
|
|
107
107
|
>{{ item.companyName }}</span>
|
|
108
108
|
<div v-if="item.contactPhone" class="sp-sub">{{ item.contactPhone }}</div>
|
|
109
|
+
<!-- A subcontractor another provider brought onto this site. -->
|
|
110
|
+
<div v-if="item.invitedVia" class="sp-sub" data-test="sp-invited-via">
|
|
111
|
+
Invited by {{ item.invitedVia }}
|
|
112
|
+
</div>
|
|
109
113
|
</template>
|
|
110
114
|
<template #item.invited="{ item }">
|
|
111
115
|
<span class="d-block text-truncate">{{ item.invitedLine || "-" }}</span>
|
|
@@ -631,7 +635,7 @@
|
|
|
631
635
|
<template v-if="!existingAccount">
|
|
632
636
|
<v-col cols="12">
|
|
633
637
|
<v-row no-gutters>
|
|
634
|
-
<InputLabel class="text-capitalize" title="
|
|
638
|
+
<InputLabel class="text-capitalize" title="Service" required />
|
|
635
639
|
<v-col cols="12">
|
|
636
640
|
<v-select
|
|
637
641
|
v-model="serviceProviderInvite.app"
|
|
@@ -667,6 +671,12 @@
|
|
|
667
671
|
</v-col>
|
|
668
672
|
</template>
|
|
669
673
|
|
|
674
|
+
<v-col cols="12">
|
|
675
|
+
<div class="sp-sub" data-test="sp-invite-approval-note">
|
|
676
|
+
Seven365 approves every invitation before the provider receives it.
|
|
677
|
+
</div>
|
|
678
|
+
</v-col>
|
|
679
|
+
|
|
670
680
|
<!-- Error message -->
|
|
671
681
|
<v-col cols="12" class="my-2">
|
|
672
682
|
<v-row no-gutters>
|
|
@@ -752,6 +762,8 @@ type TableRow = {
|
|
|
752
762
|
/** shown under a "Not approved" row, in Seven365's own words */
|
|
753
763
|
rejectionReason?: string;
|
|
754
764
|
siteName?: string;
|
|
765
|
+
/** a subcontractor: the provider that brought it onto this site */
|
|
766
|
+
invitedVia?: string;
|
|
755
767
|
};
|
|
756
768
|
|
|
757
769
|
type ServiceProviderMemberRow = {
|
|
@@ -1259,6 +1271,7 @@ const providerLines = computed<Array<[string, string]>>(() => {
|
|
|
1259
1271
|
["Email", row.contactEmail || row.email],
|
|
1260
1272
|
];
|
|
1261
1273
|
if (row.contactPhone) lines.push(["Phone", row.contactPhone]);
|
|
1274
|
+
if (row.invitedVia) lines.push(["Invited by", row.invitedVia]);
|
|
1262
1275
|
if (row.source === "invite") {
|
|
1263
1276
|
lines.push(["Invited", row.invitedLine || "-"]);
|
|
1264
1277
|
if (row.answeredAt) lines.push(["Accepted", spDay(row.answeredAt)]);
|
|
@@ -1337,6 +1350,7 @@ function mapProviderRows(raw: Record<string, any>[]): TableRow[] {
|
|
|
1337
1350
|
members: formatMembersCell(row.members),
|
|
1338
1351
|
status: statusDisplay(row.status ?? "active", "provider"),
|
|
1339
1352
|
source: row.source === "engagement" ? "engagement" : "provider",
|
|
1353
|
+
invitedVia: String(row.invitedVia ?? ""),
|
|
1340
1354
|
}));
|
|
1341
1355
|
}
|
|
1342
1356
|
|
|
@@ -85,7 +85,8 @@
|
|
|
85
85
|
<v-text-field
|
|
86
86
|
v-model="searchInput"
|
|
87
87
|
density="compact"
|
|
88
|
-
placeholder="
|
|
88
|
+
placeholder="Name, plate, phone, unit..."
|
|
89
|
+
aria-label="Search visitors"
|
|
89
90
|
clearable
|
|
90
91
|
max-width="300"
|
|
91
92
|
append-inner-icon="mdi-magnify"
|
|
@@ -592,6 +593,12 @@
|
|
|
592
593
|
>
|
|
593
594
|
</span>
|
|
594
595
|
</template>
|
|
596
|
+
<template #no-data>
|
|
597
|
+
<div class="table-card__empty">
|
|
598
|
+
<v-icon icon="mdi-magnify-close" size="32" />
|
|
599
|
+
<span>{{ visitorSearchEmptyText(searchQuery) }}</span>
|
|
600
|
+
</div>
|
|
601
|
+
</template>
|
|
595
602
|
</TableMain>
|
|
596
603
|
|
|
597
604
|
<v-dialog v-model="dialog.showSelection" width="450" persistent>
|
|
@@ -1557,6 +1564,11 @@
|
|
|
1557
1564
|
<script setup lang="ts">
|
|
1558
1565
|
import moment from "moment-timezone";
|
|
1559
1566
|
import QRCode from "qrcode";
|
|
1567
|
+
import {
|
|
1568
|
+
VISITOR_SEARCH_DEBOUNCE_MS,
|
|
1569
|
+
visitorSearchEmptyText,
|
|
1570
|
+
visitorSearchQuery,
|
|
1571
|
+
} from "../utils/visitor-search";
|
|
1560
1572
|
|
|
1561
1573
|
|
|
1562
1574
|
const { downloadPDF } = usePDFDownload();
|
|
@@ -1750,6 +1762,20 @@ watch(hidQrDialog, (open) => {
|
|
|
1750
1762
|
|
|
1751
1763
|
//filter states
|
|
1752
1764
|
const searchInput = ref("");
|
|
1765
|
+
// What is actually searched: the box, trimmed, from 2 characters, sent
|
|
1766
|
+
// VISITOR_SEARCH_DEBOUNCE_MS after the last keystroke (cleared at once). The
|
|
1767
|
+
// list used to refetch on every keystroke, and a burst of typing (or a paste,
|
|
1768
|
+
// or a scanner) could leave the table showing the FIRST letter's results.
|
|
1769
|
+
const searchQuery = ref("");
|
|
1770
|
+
// Reads the box when it fires, so a clear that lands inside the wait is not
|
|
1771
|
+
// overwritten by the text that was there before it.
|
|
1772
|
+
const applySearch = debounce(() => {
|
|
1773
|
+
searchQuery.value = visitorSearchQuery(searchInput.value);
|
|
1774
|
+
}, VISITOR_SEARCH_DEBOUNCE_MS);
|
|
1775
|
+
watch(searchInput, (value) => {
|
|
1776
|
+
if (!visitorSearchQuery(value)) searchQuery.value = "";
|
|
1777
|
+
else applySearch();
|
|
1778
|
+
});
|
|
1753
1779
|
|
|
1754
1780
|
const today = new Date();
|
|
1755
1781
|
const oneMonthAgo = new Date();
|
|
@@ -2322,7 +2348,7 @@ const {
|
|
|
2322
2348
|
const params: any = {
|
|
2323
2349
|
page: page.value,
|
|
2324
2350
|
site: siteId,
|
|
2325
|
-
search:
|
|
2351
|
+
search: searchQuery.value,
|
|
2326
2352
|
dateTo: dateTo.value
|
|
2327
2353
|
? moment(dateTo.value).endOf("day").toISOString()
|
|
2328
2354
|
: undefined,
|
|
@@ -2363,7 +2389,9 @@ const {
|
|
|
2363
2389
|
// }
|
|
2364
2390
|
|
|
2365
2391
|
try {
|
|
2366
|
-
|
|
2392
|
+
const res = await getVisitors(params);
|
|
2393
|
+
// Remember which search this answers, so a late reply is not shown.
|
|
2394
|
+
return res ? { ...res, _search: params.search } : res;
|
|
2367
2395
|
} catch (error: any) {
|
|
2368
2396
|
// Refused by the server: say so with its own message, hide the tab and
|
|
2369
2397
|
// go back to Registered - no empty "No data available" table.
|
|
@@ -2380,7 +2408,7 @@ const {
|
|
|
2380
2408
|
{
|
|
2381
2409
|
watch: [
|
|
2382
2410
|
page,
|
|
2383
|
-
|
|
2411
|
+
searchQuery,
|
|
2384
2412
|
dateFrom,
|
|
2385
2413
|
dateTo,
|
|
2386
2414
|
filterTypes,
|
|
@@ -2392,6 +2420,11 @@ const {
|
|
|
2392
2420
|
);
|
|
2393
2421
|
|
|
2394
2422
|
watch(getVisitorReq, (newData: any) => {
|
|
2423
|
+
// An answer for an older search: ask again for the current one.
|
|
2424
|
+
if (newData && newData._search !== searchQuery.value) {
|
|
2425
|
+
getVisitorRefresh();
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2395
2428
|
if (newData) {
|
|
2396
2429
|
items.value = newData.items ?? [];
|
|
2397
2430
|
pages.value = newData.pages ?? 0;
|
|
@@ -2890,7 +2923,7 @@ async function handleNfcCheckout() {
|
|
|
2890
2923
|
function buildReportParams(): any {
|
|
2891
2924
|
const params: any = {
|
|
2892
2925
|
site: siteId,
|
|
2893
|
-
search:
|
|
2926
|
+
search: searchQuery.value,
|
|
2894
2927
|
dateTo: dateTo.value
|
|
2895
2928
|
? moment(dateTo.value).endOf("day").toISOString()
|
|
2896
2929
|
: undefined,
|
|
@@ -2934,7 +2967,7 @@ async function handleDownloadPDFReport() {
|
|
|
2934
2967
|
|
|
2935
2968
|
// Build URL with query parameters
|
|
2936
2969
|
const queryParams = new URLSearchParams();
|
|
2937
|
-
if (
|
|
2970
|
+
if (searchQuery.value) queryParams.append("search", searchQuery.value);
|
|
2938
2971
|
if (dateFrom.value) queryParams.append("dateFrom", dateFrom.value);
|
|
2939
2972
|
if (dateTo.value) queryParams.append("dateTo", dateTo.value);
|
|
2940
2973
|
if (filterTypes.value.length > 0)
|
|
@@ -3083,7 +3116,7 @@ const updateRouteQuery = debounce(
|
|
|
3083
3116
|
);
|
|
3084
3117
|
|
|
3085
3118
|
watch(
|
|
3086
|
-
[
|
|
3119
|
+
[searchQuery, dateFrom, dateTo, filterTypes, activeTab, displayNotCheckedOut],
|
|
3087
3120
|
([search, from, to, types, tab, checkedOut]) => {
|
|
3088
3121
|
const normalizedFrom = normalizeDateOnly(from);
|
|
3089
3122
|
const normalizedTo = normalizeDateOnly(to);
|
|
@@ -3103,7 +3136,7 @@ watch(
|
|
|
3103
3136
|
},
|
|
3104
3137
|
{ deep: true }
|
|
3105
3138
|
);
|
|
3106
|
-
watch([
|
|
3139
|
+
watch([searchQuery, dateFrom, dateTo, filterTypes, activeTab], () => {
|
|
3107
3140
|
page.value = 1;
|
|
3108
3141
|
});
|
|
3109
3142
|
|
|
@@ -3123,9 +3156,13 @@ const syncVisitorFiltersFromRoute = () => {
|
|
|
3123
3156
|
activeTab.value = nextTab;
|
|
3124
3157
|
}
|
|
3125
3158
|
|
|
3126
|
-
|
|
3127
|
-
|
|
3159
|
+
// Compared with the SEARCHED text, not the box: the route only ever holds
|
|
3160
|
+
// what was searched, so comparing with the box put back an older value over
|
|
3161
|
+
// letters typed since. A different value is an outside link (e.g. ?search=).
|
|
3162
|
+
const nextSearch = visitorSearchQuery(route.query.search);
|
|
3163
|
+
if (searchQuery.value !== nextSearch) {
|
|
3128
3164
|
searchInput.value = nextSearch;
|
|
3165
|
+
searchQuery.value = nextSearch;
|
|
3129
3166
|
}
|
|
3130
3167
|
|
|
3131
3168
|
if (route.query.dateFrom) {
|
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.35.1-staging.
|
|
5
|
+
"version": "4.35.1-staging.433",
|
|
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.",
|
|
@@ -182,9 +182,8 @@ const SITE_PAGES: Record<string, TSitePage[]> = {
|
|
|
182
182
|
{ apps: ["security"], page: () => "virtual-patrol/cctv" },
|
|
183
183
|
{ apps: ["property-management"], page: () => "cctv" },
|
|
184
184
|
],
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
],
|
|
185
|
+
// hygiene has its Service Providers page too since 2026-09-28
|
|
186
|
+
serviceProviderInviteAnswer: [{ apps: SERVICE_APPS, page: () => "service-providers" }],
|
|
188
187
|
};
|
|
189
188
|
SITE_PAGES.myVisitors = SITE_PAGES.visitors;
|
|
190
189
|
|
|
@@ -196,6 +195,7 @@ export const WEB_TARGET_KINDS = [
|
|
|
196
195
|
"robot",
|
|
197
196
|
"client",
|
|
198
197
|
"clientInvitation",
|
|
198
|
+
"spApproval",
|
|
199
199
|
];
|
|
200
200
|
|
|
201
201
|
function sitePageTarget(
|
|
@@ -221,6 +221,10 @@ function sitePageTarget(
|
|
|
221
221
|
function staffTarget(kind: string, item: TTargetRow): TNotificationTarget | null {
|
|
222
222
|
const id = String(item.params?.id ?? "").trim();
|
|
223
223
|
if (kind === "robot") return { candidates: [{ app: "org", path: "/super-admin/robots" }] };
|
|
224
|
+
// A service-provider invitation waiting in SP Approvals.
|
|
225
|
+
if (kind === "spApproval") {
|
|
226
|
+
return { candidates: [{ app: "org", path: "/super-admin/sp-approvals" }] };
|
|
227
|
+
}
|
|
224
228
|
if (kind === "clientInvitation") {
|
|
225
229
|
return { candidates: [{ app: "org", path: "/super-admin/client-list" }] };
|
|
226
230
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visitor Management search box rules (VisitorManagement.vue). The server does
|
|
3
|
+
* the matching (name, plate, phone, NRIC, company, purpose, block/unit - see
|
|
4
|
+
* @7365admin1/core visitor-search.util); this only decides WHEN to ask.
|
|
5
|
+
* Browser-safe: no Node globals. Tested by utils/visitor-search.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Characters needed before a search is sent; shorter means "no search". */
|
|
9
|
+
export const VISITOR_SEARCH_MIN_LENGTH = 2;
|
|
10
|
+
|
|
11
|
+
/** Wait after the last keystroke before searching. */
|
|
12
|
+
export const VISITOR_SEARCH_DEBOUNCE_MS = 350;
|
|
13
|
+
|
|
14
|
+
/** The text to send for what is in the box: trimmed, or "" when too short. */
|
|
15
|
+
export function visitorSearchQuery(raw: unknown): string {
|
|
16
|
+
const text = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
|
|
17
|
+
return text.length >= VISITOR_SEARCH_MIN_LENGTH ? text : "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The empty-table sentence: names the search when there is one. */
|
|
21
|
+
export function visitorSearchEmptyText(query: string): string {
|
|
22
|
+
return query ? `No visitors match '${query}'` : "No data available";
|
|
23
|
+
}
|