@7365admin1/layer-common 4.42.0 → 4.43.1

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,24 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.43.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 64e84bb: Service Providers screen: extracted the service-type code-to-label lookup (the one behind "security_agency" showing as "Security") into a tested pure function, with unit tests covering every known service type code.
8
+ - 238f9be: Service Providers screen: the TYPE column still showed the raw code (e.g. "security_agency" instead of "Security") for every row, because the API's `nature` field is not free text - it mirrors the same raw code as `type` - and the label lookup treated any truthy `nature` as an override. Only treat `nature` as free text when it is not itself a known service type code.
9
+
10
+ ## 4.43.0
11
+
12
+ ### Minor Changes
13
+
14
+ - bb75a19: Service Providers screen (inviter's side): accepted providers appear under Service Providers for the site; the Invitations tab shows only this site's invitations with the company that accepted, who invited and when, when it was answered or expired, and a View provider panel; "Members" is now "Staff on site" and is not shown for invitations.
15
+
16
+ ## 4.42.1
17
+
18
+ ### Patch Changes
19
+
20
+ - 531a883: Dashboard Customize: the widget toggles use the platform switch (`app-switch`), so ON and OFF are clearly different in light and dark mode, and Submit uses the theme's primary colour. The Customize choice is now remembered per signed-in user and per app (one `dashboard-widgets` cookie storing hidden widgets), so another user on the same browser, or another service app, no longer inherits it, and new widgets appear by default. Widget visibility follows the estate permission rule (`grantsPermission`): a saved role with an empty permission list sees every widget, and every shipped spelling of a grant counts. Hiding a KPI tile no longer leaves a gap in the row.
21
+
3
22
  ## 4.42.0
4
23
 
5
24
  ### Minor Changes
@@ -1477,11 +1496,11 @@
1477
1496
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1478
1497
  own rule from two endpoints the console already calls, unprojected:
1479
1498
 
1480
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1481
- GET /api/roles/id/:role that membership's role document
1499
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1500
+ GET /api/roles/id/:role that membership's role document
1482
1501
 
1483
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1484
- staff = member.type === "admin" && role.type === "admin"
1502
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1503
+ staff = member.type === "admin" && role.type === "admin"
1485
1504
 
1486
1505
  `role.default` is the marker because it is the only property of a platform
1487
1506
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -317,7 +317,7 @@
317
317
  cols="12"
318
318
  sm="6"
319
319
  md="4"
320
- lg="3"
320
+ :lg="kpiColLg"
321
321
  class="d-flex"
322
322
  >
323
323
  <!--
@@ -1107,14 +1107,19 @@
1107
1107
  {{ widget.description }}
1108
1108
  </div>
1109
1109
  </div>
1110
+ <!--
1111
+ `app-switch` is the platform toggle (primitives.css): blue
1112
+ track + white knob right when ON, grey knob left when OFF, in
1113
+ both themes. The old blue-grey colour drew ON as near-black on the
1114
+ dark card and grey on the light one - on and off looked alike.
1115
+ -->
1110
1116
  <v-switch
1111
1117
  v-model="draftVisibleWidgetKeys"
1112
1118
  :value="widget.key"
1113
- color="blue-grey-darken-4"
1114
- inset
1119
+ class="app-switch flex-grow-0"
1115
1120
  hide-details
1116
1121
  density="compact"
1117
- aria-label="Toggle dashboard widget"
1122
+ :aria-label="`Show ${widget.title}`"
1118
1123
  />
1119
1124
  </div>
1120
1125
  </v-card>
@@ -1136,7 +1141,7 @@
1136
1141
  </v-btn>
1137
1142
  <v-btn
1138
1143
  variant="flat"
1139
- color="blue-grey-darken-4"
1144
+ color="primary"
1140
1145
  class="text-none font-weight-bold px-6"
1141
1146
  style="min-width: 110px"
1142
1147
  @click="submitCustomize"
@@ -1227,6 +1232,12 @@ import type { CookieOptions } from "#app";
1227
1232
  // `plugins/vuetify.ts`), and a bare call resolved to nothing when driven.
1228
1233
  import { buildWorkOrderStatus } from "../utils/dashboard";
1229
1234
  import { permissionsDenied } from "../utils/module-routes";
1235
+ import { grantsPermission } from "../utils/permission-spellings";
1236
+ import {
1237
+ hiddenDashboardWidgets,
1238
+ saveHiddenDashboardWidgets,
1239
+ type TDashboardWidgetPrefs,
1240
+ } from "../utils/dashboard-widget-prefs";
1230
1241
 
1231
1242
  // ─── Types ────────────────────────────────────────────────────────────────────
1232
1243
 
@@ -1482,21 +1493,20 @@ function canViewWidget(key: string) {
1482
1493
  return false;
1483
1494
  }
1484
1495
 
1485
- if (permissions.includes("*")) return true;
1496
+ // The estate rule (`grantsPermission`): a saved role with an EMPTY list, or
1497
+ // `*`, means everything. core seeds default roles with `permissions: []`, so
1498
+ // an exact `includes` test showed those accounts an empty dashboard while
1499
+ // every menu in the app let them in. `grantsPermission` also accepts every
1500
+ // shipped spelling of a grant (`work-orders:` / `workOrder:` / ...).
1501
+ if (!permissions.length || permissions.includes("*")) return true;
1486
1502
 
1487
1503
  if (bySchedule) {
1488
1504
  return hasSchedulePermission(permissions, "see-all-schedules");
1489
1505
  }
1490
1506
 
1491
- if (isPropertyMode.value && modulePermissions) {
1492
- return modulePermissions.some((permission) =>
1493
- permissions.includes(permission)
1494
- );
1495
- }
1496
-
1497
1507
  if (modulePermissions) {
1498
1508
  return modulePermissions.some((permission) =>
1499
- permissions.includes(permission)
1509
+ grantsPermission(permissions, permission)
1500
1510
  );
1501
1511
  }
1502
1512
 
@@ -2049,33 +2059,28 @@ const securityDefaultWidgetKeys = [
2049
2059
  ];
2050
2060
 
2051
2061
  /**
2052
- * THE CUSTOMISE CHOICE, REMEMBERED.
2062
+ * THE CUSTOMISE CHOICE, REMEMBERED - per signed-in user, per app.
2053
2063
  *
2054
- * Hiding a widget worked and then forgot: nothing was written anywhere, so a
2055
- * reload brought every panel back and the dialog was decoration.
2064
+ * A cookie on the layer's own `cookieConfig` (the parent-domain cookie `theme`
2065
+ * and `landing-page` already use; localStorage would be per-hostname and there
2066
+ * is no preferences API on the server). What is stored and why it is keyed by
2067
+ * user and app is in `utils/dashboard-widget-prefs.ts`.
2056
2068
  *
2057
- * A COOKIE, and specifically the layer's own `cookieConfig` - the same
2058
- * mechanism `sid`, `user`, `landing-page` and the light/dark preference already
2059
- * travel on (see `composables/useThemePreference.ts` for the full reasoning).
2060
- * localStorage would be per-origin, and this is one component shipped into
2061
- * seven applications on seven hostnames. No new storage mechanism, and no
2062
- * invented endpoint - there is no save API on the server for this.
2063
- *
2064
- * One cookie per dashboard MODE, because the three modes have different widget
2065
- * sets and a security member's choice must not decide what a hygiene member
2066
- * sees.
2069
+ * The earlier `dashboard-widgets-<mode>` cookies are not read: they belonged
2070
+ * to whoever last used the browser, and to five apps at once.
2067
2071
  */
2068
- const widgetPrefsCookie = useCookie<string[] | null>(
2069
- `dashboard-widgets-${
2070
- isPropertyMode.value
2071
- ? "property"
2072
- : isSecurityMode.value
2073
- ? "security"
2074
- : "service"
2075
- }`,
2076
- runtimeConfig.public.cookieConfig as CookieOptions<string[] | null> & {
2077
- readonly?: false;
2078
- }
2072
+ const cookieOptions = runtimeConfig.public.cookieConfig as CookieOptions<any> & {
2073
+ readonly?: false;
2074
+ };
2075
+ const widgetPrefsCookie = useCookie<TDashboardWidgetPrefs | null>(
2076
+ "dashboard-widgets",
2077
+ cookieOptions
2078
+ );
2079
+ // The `user` cookie holds the signed-in user's id (`useLocalAuth`).
2080
+ const widgetPrefsUser = String(useCookie("user", cookieOptions).value ?? "");
2081
+ const widgetPrefsApp = String(props.serviceType ?? "");
2082
+ const hiddenByUser = ref<string[]>(
2083
+ hiddenDashboardWidgets(widgetPrefsCookie.value, widgetPrefsUser, widgetPrefsApp)
2079
2084
  );
2080
2085
 
2081
2086
  const defaultVisibleWidgetKeys = computed(() => {
@@ -2096,26 +2101,10 @@ const defaultVisibleWidgetKeys = computed(() => {
2096
2101
  watch(
2097
2102
  defaultVisibleWidgetKeys,
2098
2103
  (keys) => {
2099
- const nextKeys = [...keys];
2100
- if (!visibleWidgetKeys.value.length) {
2101
- // A remembered choice wins on first paint, filtered against the keys this
2102
- // mode actually has so a stale cookie cannot resurrect a removed widget.
2103
- // `Array.isArray` rather than a length check: "the user hid everything"
2104
- // and "there is no cookie" are different answers.
2105
- const remembered = Array.isArray(widgetPrefsCookie.value)
2106
- ? widgetPrefsCookie.value.filter((k) => nextKeys.includes(k))
2107
- : null;
2108
- const initial = remembered ?? nextKeys;
2109
- visibleWidgetKeys.value = initial;
2110
- draftVisibleWidgetKeys.value = [...initial];
2111
- return;
2112
- }
2113
- visibleWidgetKeys.value = visibleWidgetKeys.value.filter((k) =>
2114
- nextKeys.includes(k)
2115
- );
2116
- draftVisibleWidgetKeys.value = draftVisibleWidgetKeys.value.filter((k) =>
2117
- nextKeys.includes(k)
2104
+ visibleWidgetKeys.value = keys.filter(
2105
+ (k) => !hiddenByUser.value.includes(k)
2118
2106
  );
2107
+ draftVisibleWidgetKeys.value = [...visibleWidgetKeys.value];
2119
2108
  },
2120
2109
  { immediate: true }
2121
2110
  );
@@ -2546,6 +2535,13 @@ const visibleKpiCards = computed(() =>
2546
2535
  kpiCards.value.filter((card) => isWidgetVisible(card.key))
2547
2536
  );
2548
2537
 
2538
+ // Four tiles fill a row at `lg`; hiding one in Customize left a hole where it
2539
+ // had been. Fewer tiles share the row instead.
2540
+ const kpiColLg = computed(() => {
2541
+ const shown = visibleKpiCards.value.length;
2542
+ return shown >= 4 || shown === 0 ? 3 : 12 / shown;
2543
+ });
2544
+
2549
2545
  /**
2550
2546
  * Nothing to draw, and which of the two reasons it is.
2551
2547
  *
@@ -3599,10 +3595,20 @@ function resetCustomizeDraft() {
3599
3595
  }
3600
3596
 
3601
3597
  function submitCustomize() {
3602
- visibleWidgetKeys.value = [...draftVisibleWidgetKeys.value];
3603
- // The write is the whole point - see `widgetPrefsCookie`. Saving on Submit
3604
- // rather than on every switch keeps Cancel meaning cancel.
3605
- widgetPrefsCookie.value = [...draftVisibleWidgetKeys.value];
3598
+ const hidden = defaultVisibleWidgetKeys.value.filter(
3599
+ (k) => !draftVisibleWidgetKeys.value.includes(k)
3600
+ );
3601
+ hiddenByUser.value = hidden;
3602
+ visibleWidgetKeys.value = defaultVisibleWidgetKeys.value.filter(
3603
+ (k) => !hidden.includes(k)
3604
+ );
3605
+ // The write is the whole point - see `widgetPrefsCookie`.
3606
+ widgetPrefsCookie.value = saveHiddenDashboardWidgets(
3607
+ widgetPrefsCookie.value,
3608
+ widgetPrefsUser,
3609
+ widgetPrefsApp,
3610
+ hidden
3611
+ );
3606
3612
  widgetSearch.value = "";
3607
3613
  customizeDialog.value = false;
3608
3614
  }
@@ -101,9 +101,14 @@
101
101
  style="max-height: calc(100vh - 280px)"
102
102
  >
103
103
  <template #item.companyName="{ item }">
104
- <span class="d-block text-truncate sp-col-company">{{
105
- item.companyName
106
- }}</span>
104
+ <span
105
+ class="d-block text-truncate sp-col-company"
106
+ :class="{ 'sp-muted': item.companyKnown === false }"
107
+ >{{ item.companyName }}</span>
108
+ <div v-if="item.contactPhone" class="sp-sub">{{ item.contactPhone }}</div>
109
+ </template>
110
+ <template #item.invited="{ item }">
111
+ <span class="d-block text-truncate">{{ item.invitedLine || "-" }}</span>
107
112
  </template>
108
113
  <template #item.email="{ item }">
109
114
  <span class="d-block text-truncate sp-col-email">{{
@@ -128,9 +133,10 @@
128
133
  -->
129
134
  <StatusChip
130
135
  class="text-none"
131
- :status="item.statusRaw || item.status"
136
+ :status="item.viewStatus || item.statusRaw || item.status"
132
137
  :label="item.status"
133
138
  />
139
+ <div v-if="item.statusLine" class="mt-1 sp-sub">{{ item.statusLine }}</div>
134
140
  <!-- Was `text-caption text-medium-emphasis` - Roboto 12px at
135
141
  Vuetify's 60% opacity. The design's sub-line is `--muted`. -->
136
142
  <div v-if="item.rejectionReason" class="mt-1 sp-reject-reason">
@@ -157,6 +163,11 @@
157
163
  </v-btn>
158
164
  </template>
159
165
  <v-list density="compact" min-width="220">
166
+ <v-list-item
167
+ v-if="item.providerOrgId"
168
+ title="View provider"
169
+ @click="openProvider(item)"
170
+ />
160
171
  <v-list-item
161
172
  title="Cancel invitation"
162
173
  :disabled="!canCancelInvite(item)"
@@ -178,6 +189,21 @@
178
189
  />
179
190
  </v-list>
180
191
  </v-menu>
192
+ <!--
193
+ A provider on this site through an accepted invitation. Its row is
194
+ the engagement itself, not a stored service-providers record, so
195
+ the In-Active move (which acts on that record) does not apply.
196
+ -->
197
+ <v-btn
198
+ v-else-if="item.source === 'engagement'"
199
+ icon
200
+ variant="text"
201
+ density="comfortable"
202
+ aria-label="View provider"
203
+ @click="openProvider(item)"
204
+ >
205
+ <v-icon>mdi-information-outline</v-icon>
206
+ </v-btn>
181
207
  <v-menu v-else-if="item.source === 'provider'" location="bottom end">
182
208
  <template #activator="{ props: menuProps }">
183
209
  <v-btn
@@ -239,6 +265,26 @@
239
265
 
240
266
  Same members, same two columns, same loading and empty copy, same close.
241
267
  -->
268
+ <!-- The provider, as the organisation that invited it may see it. -->
269
+ <v-dialog v-model="providerDialog" max-width="480">
270
+ <v-card class="screen-modal">
271
+ <v-card-title>{{ providerRow?.companyName }}</v-card-title>
272
+ <v-card-text class="screen-modal__body">
273
+ <dl class="sp-details">
274
+ <template v-for="line in providerLines" :key="line[0]">
275
+ <dt>{{ line[0] }}</dt>
276
+ <dd>{{ line[1] }}</dd>
277
+ </template>
278
+ </dl>
279
+ </v-card-text>
280
+ <div class="screen-modal__footer">
281
+ <v-btn class="screen-btn-ghost" variant="outlined" @click="providerDialog = false">
282
+ Close
283
+ </v-btn>
284
+ </div>
285
+ </v-card>
286
+ </v-dialog>
287
+
242
288
  <v-dialog v-model="viewDialog" max-width="514">
243
289
  <v-card class="screen-modal">
244
290
  <v-card-title>Members ({{ memberDialogCount }})</v-card-title>
@@ -669,6 +715,13 @@ import useVerification from "../composables/useVerification";
669
715
  // import useRole from "../composables/useRole";
670
716
  import { errorConverter } from "../utils/data";
671
717
  import { pickRouteName } from "../utils/route-name";
718
+ import {
719
+ sentCompany,
720
+ sentInvitedLine,
721
+ sentStatusLine,
722
+ spDay,
723
+ spTypeLabel,
724
+ } from "../utils/sp-inviter-row";
672
725
 
673
726
  type ListTab = "active" | "pending" | "inactive";
674
727
 
@@ -681,9 +734,21 @@ type TableRow = {
681
734
  type: string;
682
735
  members: string;
683
736
  status: string;
684
- source: "provider" | "invite";
737
+ /** provider: a stored row; engagement: on the site through an accepted
738
+ * invitation; invite: an invitation this organisation sent. */
739
+ source: "provider" | "engagement" | "invite";
685
740
  /** the stored status, for deciding which actions a row may offer */
686
741
  statusRaw?: string;
742
+ /** what the row reads as (a lapsed "pending" is "expired") - the chip tone */
743
+ viewStatus?: string;
744
+ statusLine?: string;
745
+ companyKnown?: boolean;
746
+ invitedLine?: string;
747
+ providerOrgId?: string;
748
+ contactEmail?: string;
749
+ contactPhone?: string;
750
+ answeredAt?: string | null;
751
+ onSite?: boolean;
687
752
  /** shown under a "Not approved" row, in Seven365's own words */
688
753
  rejectionReason?: string;
689
754
  siteName?: string;
@@ -774,7 +839,7 @@ const billingRouteName = pickRouteName(
774
839
  "service-provider-mgmt-billing"
775
840
  );
776
841
 
777
- const headers = [
842
+ const providerHeaders = [
778
843
  {
779
844
  title: "Company name",
780
845
  value: "companyName",
@@ -797,7 +862,8 @@ const headers = [
797
862
  maxWidth: "180px",
798
863
  },
799
864
  {
800
- title: "Members",
865
+ // The provider's people assigned to this site - not its whole headcount.
866
+ title: "Staff on site",
801
867
  value: "members",
802
868
  sortable: false,
803
869
  width: "88px",
@@ -819,6 +885,20 @@ const headers = [
819
885
  },
820
886
  ];
821
887
 
888
+ /** The Invitations tab: who it went to, who sent it and when, what happened. */
889
+ const inviteHeaders = [
890
+ { ...providerHeaders[0], title: "Company" },
891
+ { ...providerHeaders[1], title: "Invited email" },
892
+ { ...providerHeaders[2], title: "Service" },
893
+ { title: "Invited", value: "invited", sortable: false, minWidth: "150px" },
894
+ { ...providerHeaders[4], width: "200px" },
895
+ providerHeaders[5],
896
+ ];
897
+
898
+ const headers = computed(() =>
899
+ listTab.value === "pending" ? inviteHeaders : providerHeaders
900
+ );
901
+
822
902
  const { natureOfBusinessV3 } = useLocal();
823
903
  const { requiredRule, emailRule, debounce } = useUtils();
824
904
  const { getUserByEmail } = useUser();
@@ -1158,19 +1238,40 @@ function rowActionLabel(row: TableRow) {
1158
1238
  }
1159
1239
 
1160
1240
  function typeDisplay(type: string, nature?: string) {
1161
- if (nature && String(nature).trim()) return String(nature);
1162
- // useLocal returns a plain array; reading `.value` off it was always
1163
- // undefined, so every row fell through to the raw stored slug
1164
- // ("security_agency") instead of "Security".
1165
- const source = natureOfBusinessV3 as any;
1166
- const list = (Array.isArray(source) ? source : source?.value) as Array<{
1167
- title?: string;
1168
- value?: string;
1169
- }>;
1170
- const hit = list?.find((x) => x.value === type);
1171
- return hit?.title ?? type ?? "-";
1241
+ return spTypeLabel(type, nature, natureOfBusinessV3);
1242
+ }
1243
+
1244
+ const providerDialog = ref(false);
1245
+ const providerRow = ref<TableRow | null>(null);
1246
+
1247
+ function openProvider(row: TableRow) {
1248
+ providerRow.value = row;
1249
+ providerDialog.value = true;
1172
1250
  }
1173
1251
 
1252
+ /** Only what the inviting organisation needs; nothing about the provider's
1253
+ * own staff or other clients. */
1254
+ const providerLines = computed<Array<[string, string]>>(() => {
1255
+ const row = providerRow.value;
1256
+ if (!row) return [];
1257
+ const lines: Array<[string, string]> = [
1258
+ ["Service", row.type],
1259
+ ["Email", row.contactEmail || row.email],
1260
+ ];
1261
+ if (row.contactPhone) lines.push(["Phone", row.contactPhone]);
1262
+ if (row.source === "invite") {
1263
+ lines.push(["Invited", row.invitedLine || "-"]);
1264
+ if (row.answeredAt) lines.push(["Accepted", spDay(row.answeredAt)]);
1265
+ lines.push([
1266
+ "On this site",
1267
+ row.onSite ? "Yes" : "No - removed since it accepted",
1268
+ ]);
1269
+ } else {
1270
+ lines.push(["Staff on this site", row.members]);
1271
+ }
1272
+ return lines.filter((line) => line[1]);
1273
+ });
1274
+
1174
1275
  function statusDisplay(raw: string, source: TableRow["source"]) {
1175
1276
  if (source === "invite") return "Pending";
1176
1277
  const s = (raw || "").toLowerCase();
@@ -1228,12 +1329,14 @@ function mapProviderRows(raw: Record<string, any>[]): TableRow[] {
1228
1329
  _id: String(row._id ?? ""),
1229
1330
  userId: String(row.user ?? ""),
1230
1331
  serviceProviderOrgId: String(row.serviceProviderOrgId ?? ""),
1231
- companyName: row.name ?? "-",
1332
+ companyName: row.name || "-",
1232
1333
  email: formatEmailCell(row.email),
1334
+ contactEmail: String(row.email ?? ""),
1335
+ contactPhone: String(row.contact ?? ""),
1233
1336
  type: typeDisplay(row.type ?? "", row.nature),
1234
1337
  members: formatMembersCell(row.members),
1235
1338
  status: statusDisplay(row.status ?? "active", "provider"),
1236
- source: "provider",
1339
+ source: row.source === "engagement" ? "engagement" : "provider",
1237
1340
  }));
1238
1341
  }
1239
1342
 
@@ -1247,24 +1350,35 @@ function inviteTypeLabel(t: string) {
1247
1350
  }
1248
1351
 
1249
1352
  function mapInviteRows(raw: Record<string, any>[]): TableRow[] {
1250
- return raw.map((row) => ({
1353
+ return raw.map((row) => {
1354
+ const company = sentCompany(row);
1355
+ return {
1251
1356
  _id: String(row._id ?? ""),
1252
1357
  userId: String(row.user ?? ""),
1253
- serviceProviderOrgId: String(row.metadata?.serviceProviderOrgId ?? ""),
1254
- companyName: row.metadata?.name || "-",
1358
+ serviceProviderOrgId: String(row.providerOrgId || row.metadata?.serviceProviderOrgId || ""),
1359
+ companyName: company.text,
1360
+ companyKnown: company.known,
1255
1361
  email: formatEmailCell(row.email),
1256
1362
  type: row.metadata?.app
1257
1363
  ? typeDisplay(row.metadata.app)
1258
1364
  : inviteTypeLabel(row.type ?? ""),
1259
- members: "-",
1260
- // Every invitation used to read "Pending" whatever had happened to it. It
1261
- // now says which of the seven states it is actually in, in words.
1262
- status: row.statusLabel || serviceProviderInviteLabel(row.status),
1365
+ members: "",
1366
+ status:
1367
+ row.statusLabel || serviceProviderInviteLabel(row.viewStatus || row.status),
1263
1368
  statusRaw: String(row.status ?? ""),
1369
+ viewStatus: String(row.viewStatus || row.status || ""),
1370
+ statusLine: sentStatusLine(row),
1371
+ invitedLine: sentInvitedLine(row),
1372
+ providerOrgId: String(row.providerOrgId ?? ""),
1373
+ contactEmail: String(row.contactEmail ?? ""),
1374
+ contactPhone: String(row.contactPhone ?? ""),
1375
+ answeredAt: row.answeredAt ?? null,
1376
+ onSite: Boolean(row.onSite),
1264
1377
  rejectionReason: row.rejectionReason ?? "",
1265
1378
  siteName: row.metadata?.siteName ?? "",
1266
1379
  source: "invite",
1267
- }));
1380
+ };
1381
+ });
1268
1382
  }
1269
1383
 
1270
1384
  async function loadList() {
@@ -1294,6 +1408,8 @@ async function loadList() {
1294
1408
  // cancellation is visible here instead of the row simply vanishing.
1295
1409
  const data = await getInvitations({
1296
1410
  org: props.orgId,
1411
+ // this site's invitations, not every site the organisation has
1412
+ siteId: props.siteId,
1297
1413
  status:
1298
1414
  "awaiting-approval,pending,complete,rejected,declined,cancelled,expired",
1299
1415
  page: page.value,
@@ -1313,6 +1429,9 @@ async function loadList() {
1313
1429
  search: searchText.value?.trim() || "",
1314
1430
  limit: pageSize,
1315
1431
  status: statusParam,
1432
+ // the active tab also lists providers on this site through an
1433
+ // accepted invitation
1434
+ ...(statusParam === "active" ? { include: "engagements" } : {}),
1316
1435
  });
1317
1436
  items.value = mapProviderRows(rowsOf(data));
1318
1437
  pages.value = data.pages ?? 0;
@@ -1569,6 +1688,32 @@ async function submitServiceProviderInvite() {
1569
1688
 
1570
1689
  /* The design's sub-line: `--muted` at 12px, the same figure batch I's
1571
1690
  `AttachmentsViewer` settled on. There is no `--fs-sub` token. */
1691
+ .sp-muted {
1692
+ color: var(--muted);
1693
+ }
1694
+
1695
+ .sp-sub {
1696
+ font-family: var(--font-sans);
1697
+ font-size: 12px;
1698
+ color: var(--muted);
1699
+ }
1700
+
1701
+ .sp-details {
1702
+ display: grid;
1703
+ grid-template-columns: max-content 1fr;
1704
+ gap: 8px 16px;
1705
+ margin: 0;
1706
+ }
1707
+
1708
+ .sp-details dt {
1709
+ color: var(--muted);
1710
+ }
1711
+
1712
+ .sp-details dd {
1713
+ margin: 0;
1714
+ overflow-wrap: anywhere;
1715
+ }
1716
+
1572
1717
  .sp-reject-reason,
1573
1718
  .sp-no-permission,
1574
1719
  .sp-members-note {
@@ -58,6 +58,8 @@ export default function useServiceProvider() {
58
58
  orgId = "",
59
59
  siteId = "",
60
60
  status = "",
61
+ /** "engagements": also providers engaged through an accepted invitation. */
62
+ include = "",
61
63
  } = {}) {
62
64
  return useNuxtApp().$api<Record<string, any>>("/api/service-providers", {
63
65
  method: "GET",
@@ -69,6 +71,7 @@ export default function useServiceProvider() {
69
71
  orgId,
70
72
  siteId,
71
73
  ...(status ? { status } : {}),
74
+ ...(include ? { include } : {}),
72
75
  },
73
76
  });
74
77
  }
@@ -18,6 +18,21 @@ export type TServiceProviderInvitation = {
18
18
  expireAt?: string;
19
19
  deletedAt?: string | null;
20
20
  metadata?: Record<string, any>;
21
+ /** A lapsed "pending" reads "expired"; `status` stays the stored value. */
22
+ viewStatus?: string;
23
+ /** The company that accepted it, or the new company's name (sign-up). */
24
+ companyName?: string;
25
+ providerOrgId?: string;
26
+ service?: string;
27
+ invitedByName?: string;
28
+ invitedAt?: string | null;
29
+ /** When it was accepted, declined, cancelled or expired. */
30
+ answeredAt?: string | null;
31
+ /** Shown only once accepted. */
32
+ contactEmail?: string;
33
+ contactPhone?: string;
34
+ /** Accepted and still engaged on the site. */
35
+ onSite?: boolean;
21
36
  history?: Array<{
22
37
  action: string;
23
38
  at: string;
@@ -73,7 +88,9 @@ export default function useServiceProviderInvitation() {
73
88
 
74
89
  function getInvitations({
75
90
  org = "",
91
+ siteId = "",
76
92
  status = "",
93
+ search = "",
77
94
  page = 1,
78
95
  limit = 20,
79
96
  } = {}): Promise<{
@@ -83,7 +100,14 @@ export default function useServiceProviderInvitation() {
83
100
  }> {
84
101
  return useNuxtApp().$api(base, {
85
102
  method: "GET",
86
- query: { org, status, page, limit },
103
+ query: {
104
+ org,
105
+ status,
106
+ page,
107
+ limit,
108
+ ...(siteId ? { siteId } : {}),
109
+ ...(search ? { search } : {}),
110
+ },
87
111
  });
88
112
  }
89
113
 
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.42.0",
5
+ "version": "4.43.1",
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,54 @@
1
+ /**
2
+ * The dashboard's Customize choice, as it is remembered in one cookie.
3
+ *
4
+ * WHOSE and WHICH APP. The old cookie was keyed only by dashboard mode, so the
5
+ * next person to sign in on the same browser inherited the last person's
6
+ * choice, and Hygiene, M&E, Pest, Landscape and Pool all shared ONE choice -
7
+ * hiding a panel in one hid it in the other four.
8
+ *
9
+ * HIDDEN, not shown. The old cookie listed the widgets to show, so any widget
10
+ * added after someone once pressed Submit stayed hidden for them forever.
11
+ * Remembering what was switched OFF lets a new widget appear by default.
12
+ *
13
+ * Still a cookie on the layer's `cookieConfig` (the same parent-domain cookie
14
+ * `theme` and `landing-page` use): there is no preferences API on the server.
15
+ * One cookie holds the signed-in user's choice for every app; a different user
16
+ * signing in starts from the defaults and replaces it on their first Submit.
17
+ */
18
+ export type TDashboardWidgetPrefs = {
19
+ user: string;
20
+ hidden: Record<string, string[]>;
21
+ };
22
+
23
+ function prefsFor(stored: unknown, user: string) {
24
+ if (!user || !stored || typeof stored !== "object") return null;
25
+ const prefs = stored as Partial<TDashboardWidgetPrefs>;
26
+ if (prefs.user !== user) return null;
27
+ if (!prefs.hidden || typeof prefs.hidden !== "object") return null;
28
+ return prefs.hidden;
29
+ }
30
+
31
+ /** The widgets this user switched off in this app. Empty = the defaults. */
32
+ export function hiddenDashboardWidgets(
33
+ stored: unknown,
34
+ user: string,
35
+ app: string,
36
+ ): string[] {
37
+ const list = prefsFor(stored, user)?.[app];
38
+ return Array.isArray(list)
39
+ ? list.filter((key): key is string => typeof key === "string")
40
+ : [];
41
+ }
42
+
43
+ /** The cookie value after this user saves `hidden` for this app. */
44
+ export function saveHiddenDashboardWidgets(
45
+ stored: unknown,
46
+ user: string,
47
+ app: string,
48
+ hidden: readonly string[],
49
+ ): TDashboardWidgetPrefs {
50
+ return {
51
+ user,
52
+ hidden: { ...(prefsFor(stored, user) ?? {}), [app]: [...hidden] },
53
+ };
54
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The inviting organisation's view of one service-provider invitation, in
3
+ * words. Pure, so it is tested without a browser.
4
+ *
5
+ * The company name comes from the server (`companyName`: the company that
6
+ * accepted, or the new company's name a sign-up invitation carried). While an
7
+ * invitation is unanswered the company is genuinely not known yet - the cell
8
+ * says so instead of drawing "-".
9
+ */
10
+
11
+ export type TSentInvitation = {
12
+ status?: string;
13
+ viewStatus?: string;
14
+ statusLabel?: string;
15
+ companyName?: string;
16
+ metadata?: Record<string, any>;
17
+ invitedByName?: string;
18
+ invitedAt?: string | null;
19
+ createdAt?: string | null;
20
+ answeredAt?: string | null;
21
+ onSite?: boolean;
22
+ };
23
+
24
+ const ANSWERED: Record<string, string> = {
25
+ complete: "Accepted",
26
+ declined: "Declined",
27
+ cancelled: "Cancelled",
28
+ rejected: "Not approved",
29
+ expired: "Expired",
30
+ };
31
+
32
+ type TNatureOption = { title?: string; value?: string };
33
+
34
+ /**
35
+ * The service type in words: the org's free-text nature if it gave one,
36
+ * else the label from the nature-of-business list, else the raw stored code
37
+ * ("security_agency") as a last resort. `useLocal` can hand back a plain
38
+ * array or a ref-like `{ value }` wrapper - read whichever shape was passed.
39
+ *
40
+ * `nature` is NOT always free text: both `_getServiceProviders` and the
41
+ * `customer.sites`-derived `getServiceProvidersForSite` aggregations in
42
+ * @7365admin1/core set it to `organization.nature`, the SAME raw code `type`
43
+ * carries ("security_agency") - not a human-typed description. Treating it as
44
+ * free text made the raw code win over the label lookup for every row, since
45
+ * `nature` is truthy whenever it is set at all. Only trust it as an override
46
+ * when it is NOT itself one of the known codes.
47
+ */
48
+ export function spTypeLabel(type: string, nature: string | undefined, source: unknown): string {
49
+ const list = (Array.isArray(source) ? source : (source as any)?.value) as
50
+ | TNatureOption[]
51
+ | undefined;
52
+ const codeMatch = (value?: string) => list?.find((x) => x.value === value);
53
+
54
+ if (nature && String(nature).trim() && !codeMatch(nature)) return String(nature);
55
+
56
+ const hit = codeMatch(type) ?? codeMatch(nature);
57
+ return hit?.title ?? type ?? nature ?? "-";
58
+ }
59
+
60
+ export function spDay(value?: string | null): string {
61
+ if (!value) return "";
62
+ const d = new Date(value);
63
+ return Number.isNaN(d.getTime())
64
+ ? ""
65
+ : d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
66
+ }
67
+
68
+ /** The company cell: the name, or why there is none yet. */
69
+ export function sentCompany(row: TSentInvitation): { text: string; known: boolean } {
70
+ const name = String(row.companyName || row.metadata?.name || "").trim();
71
+ if (name) return { text: name, known: true };
72
+ const status = row.viewStatus || row.status || "";
73
+ return {
74
+ text: ["pending", "awaiting-approval"].includes(status) ? "Not accepted yet" : "Not accepted",
75
+ known: false,
76
+ };
77
+ }
78
+
79
+ /** "Invited by Tommy on 24 Sep 2026". */
80
+ export function sentInvitedLine(row: TSentInvitation): string {
81
+ const who = String(row.invitedByName ?? "").trim();
82
+ const when = spDay(row.invitedAt ?? row.createdAt);
83
+ return [who ? `by ${who}` : "", when ? `on ${when}` : ""].filter(Boolean).join(" ");
84
+ }
85
+
86
+ /** The line under the status: when it was answered, and what it means now. */
87
+ export function sentStatusLine(row: TSentInvitation): string {
88
+ const status = row.viewStatus || row.status || "";
89
+ const when = spDay(row.answeredAt);
90
+ if (status === "complete" && row.onSite === false) {
91
+ return `Accepted${when ? ` on ${when}` : ""} · no longer on this site`;
92
+ }
93
+ if (ANSWERED[status]) return when ? `${ANSWERED[status]} on ${when}` : "";
94
+ if (status === "awaiting-approval") return "Seven365 checks it before it is sent";
95
+ if (status === "pending") return "Sent - waiting for their answer";
96
+ return "";
97
+ }