@7365admin1/layer-common 4.65.1 → 4.65.3

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,30 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.65.3
4
+
5
+ ### Patch Changes
6
+
7
+ - dcb2632: Invitations list: a site app now lists only the current site's invitations.
8
+ `InvitationMain` sends the page's `siteId` to `GET /api/verifications` (it was
9
+ commented out, so every site showed the whole organisation's invitations - owner
10
+ report 2026-09-28, Wing Tai / The LakeGarden Residences). The org console sends
11
+ no site and is unchanged. The empty table now reads "No pending invitations for
12
+ <site>. Use Invite member to add someone." / "No expired invitations."
13
+
14
+ Members → Assign Role: a member's role from ANOTHER site (roleState other-site,
15
+ other-org, deleted, missing) is no longer pre-filled or offered ("Role already
16
+ assigned"); only this site's roles are listed. A site with no role says "No roles
17
+ created for <site> yet. Create one in Roles & Permissions." with a link that
18
+ returns to Members after the role is created (`?returnTo=`).
19
+
20
+ - 9e895e2: Visitor Management search box: waits 350 ms after typing and searches from 2 characters (trimmed), so a burst of typing, a paste or a scanner no longer leaves the table showing the first letter's results; a late reply for an older search is never shown; a new search goes back to page 1; clearing the box resets at once; the address-bar sync no longer puts back an older value over letters typed since; the empty table says "No visitors match '<search>'"; Download Report uses the same search as the table.
21
+
22
+ ## 4.65.2
23
+
24
+ ### Patch Changes
25
+
26
+ - dbd4245: Inventory Stock Movement: the Received column labels opening stock received in the month ("incl. N opening stock").
27
+
3
28
  ## 4.65.1
4
29
 
5
30
  ### Patch Changes
@@ -1767,11 +1792,11 @@
1767
1792
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1768
1793
  own rule from two endpoints the console already calls, unprojected:
1769
1794
 
1770
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1771
- GET /api/roles/id/:role that membership's role document
1795
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1796
+ GET /api/roles/id/:role that membership's role document
1772
1797
 
1773
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1774
- staff = member.type === "admin" && role.type === "admin"
1798
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1799
+ staff = member.type === "admin" && role.type === "admin"
1775
1800
 
1776
1801
  `role.default` is the marker because it is the only property of a platform
1777
1802
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -112,6 +112,9 @@
112
112
  <template #item.locationType="{ value }">{{ value === "tenant_unit" ? "Tenant unit" : "Common area" }}</template>
113
113
  <template #item.unitCost="{ value }">{{ money(value) }}</template>
114
114
  <template #item.totalCost="{ value }">{{ money(value) }}</template>
115
+ <template #item.received="{ item }">
116
+ {{ item.received }}<span v-if="item.receivedOpening" class="d-block text-caption text-medium-emphasis">incl. {{ item.receivedOpening }} opening stock</span>
117
+ </template>
115
118
  <template #item.receivedCost="{ value }">{{ money(value) }}</template>
116
119
  <template #item.usedCost="{ value }">{{ money(value) }}</template>
117
120
  <template #item.siteUsedCost="{ value }">{{ money(value) }}</template>
@@ -181,7 +184,7 @@ const barWidth = (v: number) => {
181
184
  };
182
185
 
183
186
  const help: Record<string, string> = {
184
- movement: "For each item: stock at the start of the month, what came in, what was issued and returned, count differences, and stock at the end.",
187
+ movement: "For each item: stock at the start of the month, what came in (deliveries and opening stock), what was issued and returned, count differences, and stock at the end.",
185
188
  usage: "Every recorded usage: where it was used, for which tenant, by whom, and the cost. Use this for tenant billing. Tick records and mark them checked once verified.",
186
189
  technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
187
190
  purchases: "Every delivery received, and opening stock entered with new items (Type: Opening stock), with totals by month and by supplier.",
@@ -50,6 +50,7 @@
50
50
  hide-default-footer
51
51
  style="max-height: calc(100vh - (126px))"
52
52
  :loading="loading"
53
+ :no-data-text="emptyText"
53
54
  >
54
55
  <template #item.permissions="{ value }">
55
56
  <span class="text-caption font-weight-bold text-capitalize">
@@ -137,6 +138,9 @@
137
138
  </template>
138
139
 
139
140
  <script setup lang="ts">
141
+ // Relative import: this layer's `utils/` is not auto-imported into consumers.
142
+ import { invitationsEmptyText } from "../utils/invitations-empty-text";
143
+
140
144
  /** The same two tabs, same labels, same routes. */
141
145
  const invitationTabs = [
142
146
  { status: "pending", label: "Pending" },
@@ -260,7 +264,10 @@ const {
260
264
  type: "member-invite",
261
265
  app: props.app,
262
266
  orgId: props.org,
263
- // siteId: props.siteId,
267
+ // A site app lists only THIS site's invitations; the server checks the
268
+ // caller reaches it (owner report 2026-09-28: LakeGarden's invitations
269
+ // showed on every Wing Tai site). The org console passes no site.
270
+ siteId: props.siteId,
264
271
  }),
265
272
  {
266
273
  watch: [() => props.status]
@@ -269,6 +276,22 @@ const {
269
276
 
270
277
  const loading = computed(() => getVerificationsStatus.value === "pending");
271
278
 
279
+ // A plain empty state instead of "No data available" (QA sweep 2026-09-28).
280
+ const siteName = ref("");
281
+ if (props.siteId) {
282
+ useSite()
283
+ .getSiteById(props.siteId)
284
+ .then((site) => (siteName.value = site?.name ?? ""))
285
+ .catch(() => {});
286
+ }
287
+ const emptyText = computed(() =>
288
+ invitationsEmptyText({
289
+ status: props.status,
290
+ siteName: props.siteId ? siteName.value || "this site" : "",
291
+ canInvite: props.inviteMember,
292
+ }),
293
+ );
294
+
272
295
  watchEffect(() => {
273
296
  if (getVerificationsReq.value) {
274
297
  items.value = getVerificationsReq.value.items;
@@ -250,7 +250,11 @@
250
250
  <v-col cols="12">
251
251
  <v-row no-gutters>
252
252
  <InputLabel class="text-capitalize" title="Name" required />
253
- <v-col cols="12">
253
+ <v-col v-if="noSiteRoles" cols="12" class="py-2" data-test="assign-role-empty">
254
+ <p class="text-body-2 mb-2">{{ noSiteRolesText }}</p>
255
+ <NuxtLink v-if="rolesPageLink" :to="rolesPageLink">Go to Roles &amp; Permissions</NuxtLink>
256
+ </v-col>
257
+ <v-col v-else cols="12">
254
258
  <v-select
255
259
  v-model="selectedRole"
256
260
  :items="pickerRoles"
@@ -287,7 +291,7 @@
287
291
  <v-btn
288
292
  class="screen-btn-primary"
289
293
  variant="flat"
290
- :disabled="!memberRoleForm"
294
+ :disabled="!memberRoleForm || noSiteRoles"
291
295
  type="submit"
292
296
  >
293
297
  Submit
@@ -300,7 +304,14 @@
300
304
  </template>
301
305
 
302
306
  <script setup lang="ts">
303
- import { memberRoleChip, otherSiteBannerText, withCurrentRole } from "../utils/role";
307
+ import {
308
+ memberRoleChip,
309
+ noRolesForSiteText,
310
+ otherSiteBannerText,
311
+ roleUsableHere,
312
+ safeReturnTo,
313
+ withCurrentRole,
314
+ } from "../utils/role";
304
315
 
305
316
  const props = defineProps({
306
317
  /** The design's page title. Every app that mounts this screen calls it
@@ -547,9 +558,14 @@ function setMember({
547
558
  }
548
559
 
549
560
  selectedMemberId.value = role._id;
550
- selectedRole.value = role.role;
551
- selectedMemberRole.value = role.role;
552
- selectedMemberRoleName.value = role.roleName ?? "";
561
+ // A role the member cannot use HERE (another site's, another organisation's,
562
+ // deleted, none) is neither pre-filled nor offered (owner report 2026-09-28,
563
+ // One Pearl Bank: The Loft's "Security" appeared as if created for this site).
564
+ const usable = roleUsableHere(role);
565
+ selectedRole.value = usable ? role.role : null;
566
+ selectedMemberRole.value = usable ? role.role : "";
567
+ selectedMemberRoleName.value = usable ? role.roleName ?? "" : "";
568
+ if (dialog) refreshRoles();
553
569
  }
554
570
 
555
571
  const roles = ref<Array<Record<string, any>>>([]);
@@ -593,7 +609,7 @@ const pickerRoles = computed(() =>
593
609
  * filter would be decorative -- the same trap the server's own Redis key had.
594
610
  */
595
611
  const { getRoles } = useRole();
596
- const { data: getAllRoleReq } = useLazyAsyncData(
612
+ const { data: getAllRoleReq, refresh: refreshRoles } = useLazyAsyncData(
597
613
  `get-roles-${props.type}-${props.orgId}-${props.siteId}`,
598
614
  () =>
599
615
  getRoles({
@@ -610,6 +626,33 @@ watchEffect(() => {
610
626
  }
611
627
  });
612
628
 
629
+ /* No role exists for this site yet: say so and point at Roles & Permissions,
630
+ which sends the user back here after creating one (`returnTo`). */
631
+ const noSiteRoles = computed(
632
+ () => !!props.siteId && !!getAllRoleReq.value && pickerRoles.value.length === 0
633
+ );
634
+ const assignSiteName = ref("");
635
+ if (props.siteId) {
636
+ useSite()
637
+ .getSiteById(props.siteId)
638
+ .then((site) => (assignSiteName.value = site?.name ?? ""))
639
+ .catch(() => {});
640
+ }
641
+ const noSiteRolesText = computed(() => noRolesForSiteText(assignSiteName.value));
642
+ const memberRoute = useRoute();
643
+ const rolesPageLink = computed(() => {
644
+ const here = memberRoute.path;
645
+ const path = here.replace(/\/members(\/.*)?$/, "/roles-permissions");
646
+ if (path === here) return "";
647
+ try {
648
+ return useRouter().resolve(path).matched.length
649
+ ? `${path}?returnTo=${encodeURIComponent(here)}`
650
+ : "";
651
+ } catch {
652
+ return "";
653
+ }
654
+ });
655
+
613
656
  watchEffect(() => {
614
657
  if (selectedRole.value) {
615
658
  message.value = "";
@@ -204,7 +204,7 @@
204
204
 
205
205
  <script setup lang="ts">
206
206
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
207
- import { showSiteField } from "../utils/role";
207
+ import { safeReturnTo, showSiteField } from "../utils/role";
208
208
  import { holdsPermission } from "../utils/permission-spellings";
209
209
  import {
210
210
  catalogueForOrg,
@@ -509,6 +509,10 @@ const siteFieldShown = computed(() =>
509
509
  function success() {
510
510
  createDialog.value = false;
511
511
  getRoles();
512
+ // Came here from Members -> Assign Role with no role for the site: go back to
513
+ // finish assigning (same-app path only).
514
+ const back = safeReturnTo(useRoute().query.returnTo);
515
+ if (back) navigateTo(back);
512
516
  }
513
517
 
514
518
  const previewDialog = ref(false);
@@ -85,7 +85,8 @@
85
85
  <v-text-field
86
86
  v-model="searchInput"
87
87
  density="compact"
88
- placeholder="Search"
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>
@@ -1455,6 +1462,11 @@
1455
1462
  <script setup lang="ts">
1456
1463
  import moment from "moment-timezone";
1457
1464
  import QRCode from "qrcode";
1465
+ import {
1466
+ VISITOR_SEARCH_DEBOUNCE_MS,
1467
+ visitorSearchEmptyText,
1468
+ visitorSearchQuery,
1469
+ } from "../utils/visitor-search";
1458
1470
 
1459
1471
 
1460
1472
  const { downloadPDF } = usePDFDownload();
@@ -1648,6 +1660,20 @@ watch(hidQrDialog, (open) => {
1648
1660
 
1649
1661
  //filter states
1650
1662
  const searchInput = ref("");
1663
+ // What is actually searched: the box, trimmed, from 2 characters, sent
1664
+ // VISITOR_SEARCH_DEBOUNCE_MS after the last keystroke (cleared at once). The
1665
+ // list used to refetch on every keystroke, and a burst of typing (or a paste,
1666
+ // or a scanner) could leave the table showing the FIRST letter's results.
1667
+ const searchQuery = ref("");
1668
+ // Reads the box when it fires, so a clear that lands inside the wait is not
1669
+ // overwritten by the text that was there before it.
1670
+ const applySearch = debounce(() => {
1671
+ searchQuery.value = visitorSearchQuery(searchInput.value);
1672
+ }, VISITOR_SEARCH_DEBOUNCE_MS);
1673
+ watch(searchInput, (value) => {
1674
+ if (!visitorSearchQuery(value)) searchQuery.value = "";
1675
+ else applySearch();
1676
+ });
1651
1677
 
1652
1678
  const today = new Date();
1653
1679
  const oneMonthAgo = new Date();
@@ -2154,7 +2180,7 @@ const {
2154
2180
  const params: any = {
2155
2181
  page: page.value,
2156
2182
  site: siteId,
2157
- search: searchInput.value,
2183
+ search: searchQuery.value,
2158
2184
  dateTo: dateTo.value
2159
2185
  ? moment(dateTo.value).endOf("day").toISOString()
2160
2186
  : undefined,
@@ -2195,7 +2221,9 @@ const {
2195
2221
  // }
2196
2222
 
2197
2223
  try {
2198
- return await getVisitors(params);
2224
+ const res = await getVisitors(params);
2225
+ // Remember which search this answers, so a late reply is not shown.
2226
+ return res ? { ...res, _search: params.search } : res;
2199
2227
  } catch (error: any) {
2200
2228
  // Refused by the server: say so with its own message, hide the tab and
2201
2229
  // go back to Registered - no empty "No data available" table.
@@ -2212,7 +2240,7 @@ const {
2212
2240
  {
2213
2241
  watch: [
2214
2242
  page,
2215
- searchInput,
2243
+ searchQuery,
2216
2244
  dateFrom,
2217
2245
  dateTo,
2218
2246
  filterTypes,
@@ -2224,6 +2252,11 @@ const {
2224
2252
  );
2225
2253
 
2226
2254
  watch(getVisitorReq, (newData: any) => {
2255
+ // An answer for an older search: ask again for the current one.
2256
+ if (newData && newData._search !== searchQuery.value) {
2257
+ getVisitorRefresh();
2258
+ return;
2259
+ }
2227
2260
  if (newData) {
2228
2261
  items.value = newData.items ?? [];
2229
2262
  pages.value = newData.pages ?? 0;
@@ -2722,7 +2755,7 @@ async function handleNfcCheckout() {
2722
2755
  function buildReportParams(): any {
2723
2756
  const params: any = {
2724
2757
  site: siteId,
2725
- search: searchInput.value,
2758
+ search: searchQuery.value,
2726
2759
  dateTo: dateTo.value
2727
2760
  ? moment(dateTo.value).endOf("day").toISOString()
2728
2761
  : undefined,
@@ -2766,7 +2799,7 @@ async function handleDownloadPDFReport() {
2766
2799
 
2767
2800
  // Build URL with query parameters
2768
2801
  const queryParams = new URLSearchParams();
2769
- if (searchInput.value) queryParams.append("search", searchInput.value);
2802
+ if (searchQuery.value) queryParams.append("search", searchQuery.value);
2770
2803
  if (dateFrom.value) queryParams.append("dateFrom", dateFrom.value);
2771
2804
  if (dateTo.value) queryParams.append("dateTo", dateTo.value);
2772
2805
  if (filterTypes.value.length > 0)
@@ -2915,7 +2948,7 @@ const updateRouteQuery = debounce(
2915
2948
  );
2916
2949
 
2917
2950
  watch(
2918
- [searchInput, dateFrom, dateTo, filterTypes, activeTab, displayNotCheckedOut],
2951
+ [searchQuery, dateFrom, dateTo, filterTypes, activeTab, displayNotCheckedOut],
2919
2952
  ([search, from, to, types, tab, checkedOut]) => {
2920
2953
  const normalizedFrom = normalizeDateOnly(from);
2921
2954
  const normalizedTo = normalizeDateOnly(to);
@@ -2935,7 +2968,7 @@ watch(
2935
2968
  },
2936
2969
  { deep: true }
2937
2970
  );
2938
- watch([searchInput, dateFrom, dateTo, filterTypes, activeTab], () => {
2971
+ watch([searchQuery, dateFrom, dateTo, filterTypes, activeTab], () => {
2939
2972
  page.value = 1;
2940
2973
  });
2941
2974
 
@@ -2955,9 +2988,13 @@ const syncVisitorFiltersFromRoute = () => {
2955
2988
  activeTab.value = nextTab;
2956
2989
  }
2957
2990
 
2958
- const nextSearch = (route.query.search as string) || "";
2959
- if (searchInput.value !== nextSearch) {
2991
+ // Compared with the SEARCHED text, not the box: the route only ever holds
2992
+ // what was searched, so comparing with the box put back an older value over
2993
+ // letters typed since. A different value is an outside link (e.g. ?search=).
2994
+ const nextSearch = visitorSearchQuery(route.query.search);
2995
+ if (searchQuery.value !== nextSearch) {
2960
2996
  searchInput.value = nextSearch;
2997
+ searchQuery.value = nextSearch;
2961
2998
  }
2962
2999
 
2963
3000
  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.65.1",
5
+ "version": "4.65.3",
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,14 @@
1
+ /**
2
+ * The Invitations table's empty state (QA sweep 2026-09-28), e.g.
3
+ * "No pending invitations for The LakeGarden Residences. Use Invite member to
4
+ * add someone." / "No expired invitations."
5
+ */
6
+ export function invitationsEmptyText({
7
+ status = "pending",
8
+ siteName = "",
9
+ canInvite = true,
10
+ }: { status?: string; siteName?: string; canInvite?: boolean } = {}): string {
11
+ if (status === "expired") return "No expired invitations.";
12
+ const where = siteName ? ` for ${siteName}` : "";
13
+ return `No pending invitations${where}.${canInvite ? " Use Invite member to add someone." : ""}`;
14
+ }
package/utils/role.ts CHANGED
@@ -73,3 +73,24 @@ export function otherSiteBannerText(count: unknown): string | null {
73
73
  ? "1 member here has a role from another site. Assign them a role for this site."
74
74
  : `${n} members here have a role from another site. Assign each one a role for this site.`;
75
75
  }
76
+
77
+ /**
78
+ * Can this member's CURRENT role be used on the screen's site? The server's
79
+ * `roleState` is empty for a usable role and names the problem otherwise
80
+ * ("other-site", "other-org", "deleted", "missing"). An unusable role is never
81
+ * pre-filled in Assign Role nor offered in its list (owner report 2026-09-28).
82
+ */
83
+ export function roleUsableHere(member: { role?: unknown; roleState?: unknown }): boolean {
84
+ return !!member?.role && !member?.roleState;
85
+ }
86
+
87
+ /** Assign Role's empty state when the site has no role of its own yet. */
88
+ export function noRolesForSiteText(siteName?: string | null): string {
89
+ return `No roles created for ${siteName || "this site"} yet. Create one in Roles & Permissions.`;
90
+ }
91
+
92
+ /** A same-app path to return to (`?returnTo=`), or "" - never another origin. */
93
+ export function safeReturnTo(value: unknown): string {
94
+ const path = Array.isArray(value) ? value[0] : value;
95
+ return typeof path === "string" && /^\/(?!\/)[^\s\\]*$/.test(path) ? path : "";
96
+ }
@@ -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
+ }