@7365admin1/layer-common 4.87.0 → 4.89.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,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.89.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f4bbb6e: Members > Service Providers: the row of company chips above the table is replaced by a Company filter in the toolbar (each company with its service, head count and "via" inviter; narrowed by the Service filter) and a one-line summary ("7 companies · 37 people · 1 company has not added anyone yet").
8
+
9
+ ## 4.88.0
10
+
11
+ ### Minor Changes
12
+
13
+ - d635323: Add `useSiteActions(org)` and `utils/site-actions.ts`: whether the signed-in person may add or delete sites (and add organisations) in an organisation, as core answers it. Site-confined members are never offered these buttons.
14
+
3
15
  ## 4.87.0
4
16
 
5
17
  ### Minor Changes
@@ -1992,11 +2004,11 @@
1992
2004
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1993
2005
  own rule from two endpoints the console already calls, unprojected:
1994
2006
 
1995
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1996
- GET /api/roles/id/:role that membership's role document
2007
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
2008
+ GET /api/roles/id/:role that membership's role document
1997
2009
 
1998
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1999
- staff = member.type === "admin" && role.type === "admin"
2010
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
2011
+ staff = member.type === "admin" && role.type === "admin"
2000
2012
 
2001
2013
  `role.default` is the marker because it is the only property of a platform
2002
2014
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -22,21 +22,10 @@
22
22
  </template>
23
23
  </PageHeader>
24
24
 
25
- <!-- One chip per provider company; a click filters to that company. -->
26
- <div v-if="companies.length" class="sp-chips mb-3" data-test="sp-members-companies">
27
- <button
28
- v-for="c in companies"
29
- :key="c.orgId"
30
- type="button"
31
- class="sp-chip"
32
- :class="{ 'sp-chip--active': company === c.orgId }"
33
- :aria-pressed="company === c.orgId"
34
- @click="company = company === c.orgId ? '' : c.orgId"
35
- >
36
- <span class="sp-chip__name">{{ companyChipText(c.name, c.people) }}</span>
37
- <span class="sp-chip__type">{{ c.typeLabel }}<template v-if="c.invitedVia"> · via {{ c.invitedVia }}</template></span>
38
- </button>
39
- </div>
25
+ <!-- Who is on the list at a glance; pick one company with the Company filter. -->
26
+ <p v-if="companies.length" class="sp-summary mb-3" data-test="sp-members-summary">
27
+ {{ summaryText }}
28
+ </p>
40
29
 
41
30
  <AppCard class="table-card">
42
31
  <CardToolbar :count="countText">
@@ -75,6 +64,25 @@
75
64
  aria-label="Service"
76
65
  data-test="sp-members-type"
77
66
  />
67
+ <v-autocomplete
68
+ v-model="company"
69
+ :items="companyItems"
70
+ item-title="title"
71
+ item-value="value"
72
+ density="compact"
73
+ variant="outlined"
74
+ hide-details
75
+ clearable
76
+ placeholder="All companies"
77
+ no-data-text="No company for this service"
78
+ class="sp-company-filter"
79
+ aria-label="Company"
80
+ data-test="sp-members-company"
81
+ >
82
+ <template #item="{ props: itemProps, item }">
83
+ <v-list-item v-bind="itemProps" :subtitle="item.raw.subtitle" />
84
+ </template>
85
+ </v-autocomplete>
78
86
  <AppButton
79
87
  variant="icon"
80
88
  icon="mdi-refresh"
@@ -151,10 +159,11 @@ import type {
151
159
  TSiteProviderMember,
152
160
  } from "../composables/useServiceProvider";
153
161
  import {
154
- companyChipText,
162
+ companyFilterItems,
155
163
  concernMailto,
156
164
  filterProviderMembers,
157
165
  providerMembersEmptyText,
166
+ providerSummaryText,
158
167
  serviceTypeOptions,
159
168
  } from "../utils/site-provider-members";
160
169
  import { spDay } from "../utils/sp-inviter-row";
@@ -189,7 +198,7 @@ const loading = ref(false);
189
198
  const exporting = ref(false);
190
199
  const search = ref("");
191
200
  const type = ref("");
192
- const company = ref("");
201
+ const company = ref<string | null>("");
193
202
  const snackbar = ref(false);
194
203
  const message = ref("");
195
204
 
@@ -209,9 +218,18 @@ const typeItems = computed(() => [
209
218
  ...serviceTypeOptions(items.value),
210
219
  ]);
211
220
 
221
+ const companyItems = computed(() => companyFilterItems(companies.value, type.value));
222
+ const summaryText = computed(() => providerSummaryText(companies.value));
223
+ // A company of another service cannot match; drop it when the service changes.
224
+ watch(type, () => {
225
+ if (company.value && !companyItems.value.some((c) => c.value === company.value)) {
226
+ company.value = "";
227
+ }
228
+ });
229
+
212
230
  const filters = computed(() => ({
213
231
  type: type.value,
214
- company: company.value,
232
+ company: company.value || "",
215
233
  search: search.value,
216
234
  }));
217
235
 
@@ -294,41 +312,21 @@ onMounted(async () => {
294
312
  </script>
295
313
 
296
314
  <style scoped>
297
- .sp-chips {
298
- display: flex;
299
- flex-wrap: wrap;
300
- gap: 8px;
301
- }
302
-
303
- .sp-chip {
304
- display: flex;
305
- flex-direction: column;
306
- align-items: flex-start;
307
- padding: 8px 12px;
308
- border: 1px solid var(--border);
309
- border-radius: var(--r-inner);
310
- background: var(--card);
311
- color: var(--text);
312
- text-align: left;
313
- cursor: pointer;
314
- }
315
-
316
- .sp-chip--active {
317
- border-color: var(--accent-strong);
318
- box-shadow: inset 0 0 0 1px var(--accent-strong);
319
- }
320
-
321
- .sp-chip__name {
322
- font-weight: 600;
315
+ .sp-summary {
323
316
  font-size: 14px;
317
+ color: var(--muted);
324
318
  }
325
319
 
326
- .sp-chip__type,
327
320
  .sp-sub {
328
321
  font-size: 12px;
329
322
  color: var(--muted);
330
323
  }
331
324
 
325
+ .sp-company-filter {
326
+ min-width: 220px;
327
+ max-width: 300px;
328
+ }
329
+
332
330
  .sp-type-filter {
333
331
  min-width: 170px;
334
332
  max-width: 220px;
@@ -0,0 +1,29 @@
1
+ import { NO_SITE_ACTIONS, siteActionsOf, type TSiteActions } from "../utils/site-actions";
2
+
3
+ /**
4
+ * May the signed-in person add / delete sites (and add organisations) in this
5
+ * organisation? Re-asked whenever the organisation changes; hidden until core
6
+ * answers. See `utils/site-actions.ts`.
7
+ */
8
+ export default function useSiteActions(org: MaybeRefOrGetter<string>) {
9
+ const actions = ref<TSiteActions>({ ...NO_SITE_ACTIONS });
10
+
11
+ watch(
12
+ () => toValue(org),
13
+ async (id) => {
14
+ actions.value = { ...NO_SITE_ACTIONS };
15
+ if (!id) return;
16
+ try {
17
+ const res = await useNuxtApp().$api<Record<string, any>>("/api/customer-sites", {
18
+ query: { org: id, status: "active", limit: 1, withActions: true },
19
+ });
20
+ if (toValue(org) === id) actions.value = siteActionsOf(res);
21
+ } catch {
22
+ // stays hidden - the server refuses the action anyway
23
+ }
24
+ },
25
+ { immediate: true },
26
+ );
27
+
28
+ return actions;
29
+ }
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.87.0",
5
+ "version": "4.89.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.",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * What the signed-in person may do with an organisation's sites, as core
3
+ * answers it (`GET /api/customer-sites?withActions=true` -> `siteActions`).
4
+ *
5
+ * Owner rule 2026-10-01: creating, deleting or removing sites is for Seven365
6
+ * Super Admin, the organisation's owner and organisation-level members with the
7
+ * right; a member confined to sites never - whatever their role says. The
8
+ * server decides and refuses (403); the UI only hides what would be refused.
9
+ * Anything missing or unreadable reads as "no" (the button stays hidden).
10
+ */
11
+ export type TSiteActions = { create: boolean; delete: boolean; createCustomer: boolean };
12
+
13
+ export const NO_SITE_ACTIONS: TSiteActions = Object.freeze({ create: false, delete: false, createCustomer: false });
14
+
15
+ export function siteActionsOf(response: unknown): TSiteActions {
16
+ const a = (response as { siteActions?: Record<string, unknown> } | null)?.siteActions ?? {};
17
+ return { create: a.create === true, delete: a.delete === true, createCustomer: a.createCustomer === true };
18
+ }
@@ -43,6 +43,43 @@ export function companyChipText(name: string, people: number): string {
43
43
  return `${name || "Unnamed company"} · ${people} ${people === 1 ? "person" : "people"}`;
44
44
  }
45
45
 
46
+ /**
47
+ * The Company filter (owner 2026-10-02: a proper filter instead of a row of
48
+ * company chips): one entry per company, narrowed to the chosen service,
49
+ * named with its service, head count and who brought it in.
50
+ */
51
+ export function companyFilterItems(
52
+ companies: Array<{ orgId: string; name?: string; type?: string; typeLabel?: string; people: number; invitedVia?: string }>,
53
+ type = "",
54
+ ): Array<{ title: string; value: string; subtitle: string }> {
55
+ return companies
56
+ .filter((c) => !type || c.type === type)
57
+ .map((c) => ({
58
+ title: c.name || "Unnamed company",
59
+ value: c.orgId,
60
+ subtitle: [
61
+ c.typeLabel || c.type || "",
62
+ `${c.people} ${c.people === 1 ? "person" : "people"}`,
63
+ c.invitedVia ? `via ${c.invitedVia}` : "",
64
+ ]
65
+ .filter(Boolean)
66
+ .join(" · "),
67
+ }));
68
+ }
69
+
70
+ /** "7 companies · 37 people · 1 company has not added anyone yet" */
71
+ export function providerSummaryText(companies: Array<{ people: number }>): string {
72
+ const people = companies.reduce((n, c) => n + (c.people || 0), 0);
73
+ const idle = companies.filter((c) => !c.people).length;
74
+ return [
75
+ `${companies.length} ${companies.length === 1 ? "company" : "companies"}`,
76
+ `${people} ${people === 1 ? "person" : "people"}`,
77
+ idle ? `${idle} ${idle === 1 ? "company has" : "companies have"} not added anyone yet` : "",
78
+ ]
79
+ .filter(Boolean)
80
+ .join(" · ");
81
+ }
82
+
46
83
  /** What an empty table says: nothing on site, nobody added yet, or the filters. */
47
84
  export function providerMembersEmptyText({
48
85
  companies,