@7365admin1/layer-common 4.65.3 → 4.66.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,12 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.66.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c8c7091: Members > Service Providers: new `MemberServiceProviders` component listing every person from the site's service providers who can open its information - company chips with head counts, service and company filters, search, role at the site, date added, status, "Invited by" for subcontractors, Export to Excel and an "Email company" action. `MemberMain` takes `extraTabs` so an app can add the tab beside Active / Inactive.
8
+ - 4a3ea41: Service Providers screen: a subcontractor another provider brought onto the site reads "Invited by <provider>" (list and details), the invite form says Seven365 approves every invitation and calls the field "Service", and the bell opens SP Approvals for a waiting invitation and the hygiene Service Providers page for an answered one.
9
+
3
10
  ## 4.65.3
4
11
 
5
12
  ### Patch Changes
@@ -1792,11 +1799,11 @@
1792
1799
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1793
1800
  own rule from two endpoints the console already calls, unprojected:
1794
1801
 
1795
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1796
- GET /api/roles/id/:role that membership's role document
1802
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1803
+ GET /api/roles/id/:role that membership's role document
1797
1804
 
1798
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1799
- staff = member.type === "admin" && role.type === "admin"
1805
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1806
+ staff = member.type === "admin" && role.type === "admin"
1800
1807
 
1801
1808
  `role.default` is the marker because it is the only property of a platform
1802
1809
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -402,6 +402,15 @@ const props = defineProps({
402
402
  },
403
403
  ],
404
404
  },
405
+ /**
406
+ * Tabs after Active / Inactive that the app renders itself on the same
407
+ * route (e.g. property management's "Service Providers"). Clicking one
408
+ * sets the route's `status` like the built-in tabs do.
409
+ */
410
+ extraTabs: {
411
+ type: Array as PropType<Array<{ name: string; status: string }>>,
412
+ default: () => [],
413
+ },
405
414
  });
406
415
 
407
416
  const items = ref<Array<Record<string, any>>>([]);
@@ -442,6 +451,7 @@ const normalizedRouteParams = computed(() => {
442
451
  const tabOptions = [
443
452
  { name: "Active", status: "active" },
444
453
  { name: "Inactive", status: "suspended" },
454
+ ...props.extraTabs,
445
455
  ];
446
456
 
447
457
  function toRoute(status: any) {
@@ -0,0 +1,327 @@
1
+ <template>
2
+ <div>
3
+ <!--
4
+ Members > Service Providers (owner request 2026-09-28). The property
5
+ manager sees who from its service providers can open this site's
6
+ information. Read only: each provider manages its own people.
7
+ -->
8
+ <PageHeader
9
+ :title="props.title"
10
+ subtitle="People from your service providers who can open this site's information. Their company manages who is on this list."
11
+ >
12
+ <template #actions>
13
+ <AppButton
14
+ variant="ghost"
15
+ :loading="exporting"
16
+ :disabled="!visible.length"
17
+ data-test="sp-members-export"
18
+ @click="exportExcel"
19
+ >
20
+ Export to Excel
21
+ </AppButton>
22
+ </template>
23
+ </PageHeader>
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>
40
+
41
+ <AppCard class="table-card">
42
+ <CardToolbar :count="countText">
43
+ <template #tabs>
44
+ <button
45
+ v-for="tab in props.tabs"
46
+ :key="tab.status"
47
+ type="button"
48
+ role="tab"
49
+ class="app-tab"
50
+ :class="{ 'app-tab--active': tab.status === props.status }"
51
+ :aria-selected="tab.status === props.status"
52
+ @click="toTab(tab.status)"
53
+ >
54
+ {{ tab.name }}
55
+ </button>
56
+ </template>
57
+
58
+ <template #right>
59
+ <AppField
60
+ v-model="search"
61
+ search
62
+ compact
63
+ placeholder="Search name, email, company"
64
+ klass="app-toolbar__search"
65
+ />
66
+ <v-select
67
+ v-model="type"
68
+ :items="typeItems"
69
+ item-title="title"
70
+ item-value="value"
71
+ density="compact"
72
+ variant="outlined"
73
+ hide-details
74
+ class="sp-type-filter"
75
+ aria-label="Service"
76
+ data-test="sp-members-type"
77
+ />
78
+ <AppButton
79
+ variant="icon"
80
+ icon="mdi-refresh"
81
+ aria-label="Refresh"
82
+ @click="load()"
83
+ />
84
+ </template>
85
+ </CardToolbar>
86
+
87
+ <div class="app-table-scroll">
88
+ <v-data-table
89
+ :headers="headers"
90
+ :items="visible"
91
+ item-value="_id"
92
+ :items-per-page="50"
93
+ fixed-header
94
+ :loading="loading"
95
+ style="max-height: calc(100vh - 320px)"
96
+ >
97
+ <template #item.name="{ item }">
98
+ <div class="font-weight-medium">{{ item.name || "-" }}</div>
99
+ <div class="sp-sub">{{ item.email }}</div>
100
+ </template>
101
+ <template #item.company="{ item }">
102
+ <div>{{ item.company || "-" }}</div>
103
+ <div v-if="item.invitedVia" class="sp-sub">Invited by {{ item.invitedVia }}</div>
104
+ </template>
105
+ <template #item.typeLabel="{ item }">
106
+ <StatusChip :dot="false" pill tone="info" :label="item.typeLabel" />
107
+ </template>
108
+ <template #item.role="{ item }">
109
+ {{ item.role || "No role" }}
110
+ </template>
111
+ <template #item.addedAt="{ item }">
112
+ {{ spDay(item.addedAt) || "-" }}
113
+ </template>
114
+ <template #item.status="{ item }">
115
+ <StatusChip
116
+ :tone="item.status === 'active' ? 'ok' : 'neutral'"
117
+ :label="item.status === 'active' ? 'Active' : 'Inactive'"
118
+ />
119
+ </template>
120
+ <template #item.actions="{ item }">
121
+ <v-btn
122
+ v-if="mailFor(item)"
123
+ variant="text"
124
+ size="small"
125
+ class="text-none"
126
+ :href="mailFor(item)"
127
+ data-test="sp-members-email"
128
+ >
129
+ Email company
130
+ </v-btn>
131
+ </template>
132
+ <template #no-data>
133
+ <div class="pa-8 text-center text-body-2 text-medium-emphasis" data-test="sp-members-empty">
134
+ {{ emptyText }}
135
+ </div>
136
+ </template>
137
+ </v-data-table>
138
+ </div>
139
+ </AppCard>
140
+
141
+ <Snackbar v-model="snackbar" :text="message" color="error" />
142
+ </div>
143
+ </template>
144
+
145
+ <script setup lang="ts">
146
+ import type {
147
+ TSiteProviderCompany,
148
+ TSiteProviderMember,
149
+ } from "../composables/useServiceProvider";
150
+ import {
151
+ companyChipText,
152
+ concernMailto,
153
+ filterProviderMembers,
154
+ providerMembersEmptyText,
155
+ serviceTypeOptions,
156
+ } from "../utils/site-provider-members";
157
+ import { spDay } from "../utils/sp-inviter-row";
158
+ import { saveBlob } from "../composables/useInventory";
159
+
160
+ const props = defineProps({
161
+ title: { type: String, default: "Members" },
162
+ siteId: { type: String, default: "" },
163
+ siteName: { type: String, default: "" },
164
+ /** the page's current tab (route `status`) */
165
+ status: { type: String, default: "service-providers" },
166
+ /** the Members tabs, in order; clicking one changes the route's `status` */
167
+ tabs: {
168
+ type: Array as PropType<Array<{ name: string; status: string }>>,
169
+ default: () => [
170
+ { name: "Active", status: "active" },
171
+ { name: "Inactive", status: "suspended" },
172
+ { name: "Service Providers", status: "service-providers" },
173
+ ],
174
+ },
175
+ });
176
+
177
+ const { getSiteMembers, exportSiteMembers } = useServiceProvider();
178
+ const { getSiteById } = useSite();
179
+ // The site in words, for the export file name and the email subject.
180
+ const siteLabel = ref(props.siteName);
181
+
182
+ const items = ref<TSiteProviderMember[]>([]);
183
+ const companies = ref<TSiteProviderCompany[]>([]);
184
+ const loading = ref(false);
185
+ const exporting = ref(false);
186
+ const search = ref("");
187
+ const type = ref("");
188
+ const company = ref("");
189
+ const snackbar = ref(false);
190
+ const message = ref("");
191
+
192
+ const headers = [
193
+ { title: "Name", key: "name", sortable: true, minWidth: "180px" },
194
+ { title: "Company", key: "company", sortable: true, minWidth: "160px" },
195
+ { title: "Service", key: "typeLabel", sortable: true },
196
+ { title: "Role at this site", key: "role", sortable: true },
197
+ { title: "Added", key: "addedAt", sortable: true },
198
+ { title: "Status", key: "status", sortable: true },
199
+ { title: "", key: "actions", sortable: false, align: "end" as const },
200
+ ];
201
+
202
+ const typeItems = computed(() => [
203
+ { title: "All services", value: "" },
204
+ ...serviceTypeOptions(items.value),
205
+ ]);
206
+
207
+ const filters = computed(() => ({
208
+ type: type.value,
209
+ company: company.value,
210
+ search: search.value,
211
+ }));
212
+
213
+ const visible = computed(() => filterProviderMembers(items.value, filters.value));
214
+
215
+ const countText = computed(() =>
216
+ loading.value && !items.value.length
217
+ ? ""
218
+ : `${visible.value.length} of ${items.value.length}`,
219
+ );
220
+
221
+ const emptyText = computed(() =>
222
+ providerMembersEmptyText({
223
+ companies: companies.value.length,
224
+ total: items.value.length,
225
+ filtered: Boolean(type.value || company.value || search.value.trim()),
226
+ }),
227
+ );
228
+
229
+ function mailFor(item: TSiteProviderMember) {
230
+ const c = companies.value.find((x) => x.orgId === item.companyId);
231
+ return concernMailto({
232
+ companyEmail: c?.email,
233
+ person: item.name || item.email,
234
+ siteName: siteLabel.value,
235
+ });
236
+ }
237
+
238
+ async function load() {
239
+ if (!props.siteId) return;
240
+ loading.value = true;
241
+ try {
242
+ const data = await getSiteMembers(props.siteId);
243
+ items.value = Array.isArray(data?.items) ? data.items : [];
244
+ companies.value = Array.isArray(data?.companies) ? data.companies : [];
245
+ } catch (error: any) {
246
+ items.value = [];
247
+ companies.value = [];
248
+ message.value = error?.data?.message || "Could not load your service providers' people.";
249
+ snackbar.value = true;
250
+ } finally {
251
+ loading.value = false;
252
+ }
253
+ }
254
+
255
+ async function exportExcel() {
256
+ exporting.value = true;
257
+ try {
258
+ const blob = await exportSiteMembers(props.siteId, filters.value);
259
+ saveBlob(blob, `${siteLabel.value || "Site"} - service provider people.xlsx`);
260
+ } catch {
261
+ message.value = "Could not export the list.";
262
+ snackbar.value = true;
263
+ } finally {
264
+ exporting.value = false;
265
+ }
266
+ }
267
+
268
+ const route = useRoute();
269
+ function toTab(status: string) {
270
+ if (status === props.status) return;
271
+ navigateTo({
272
+ name: route.name as string,
273
+ params: { ...(route.params as Record<string, string>), status },
274
+ });
275
+ }
276
+
277
+ watch(() => props.siteId, load);
278
+ onMounted(async () => {
279
+ load();
280
+ if (!siteLabel.value && props.siteId) {
281
+ const site: any = await getSiteById(props.siteId).catch(() => null);
282
+ siteLabel.value = site?.name ?? "";
283
+ }
284
+ });
285
+ </script>
286
+
287
+ <style scoped>
288
+ .sp-chips {
289
+ display: flex;
290
+ flex-wrap: wrap;
291
+ gap: 8px;
292
+ }
293
+
294
+ .sp-chip {
295
+ display: flex;
296
+ flex-direction: column;
297
+ align-items: flex-start;
298
+ padding: 8px 12px;
299
+ border: 1px solid var(--border);
300
+ border-radius: var(--r-inner);
301
+ background: var(--card);
302
+ color: var(--text);
303
+ text-align: left;
304
+ cursor: pointer;
305
+ }
306
+
307
+ .sp-chip--active {
308
+ border-color: var(--accent-strong);
309
+ box-shadow: inset 0 0 0 1px var(--accent-strong);
310
+ }
311
+
312
+ .sp-chip__name {
313
+ font-weight: 600;
314
+ font-size: 14px;
315
+ }
316
+
317
+ .sp-chip__type,
318
+ .sp-sub {
319
+ font-size: 12px;
320
+ color: var(--muted);
321
+ }
322
+
323
+ .sp-type-filter {
324
+ min-width: 170px;
325
+ max-width: 220px;
326
+ }
327
+ </style>
@@ -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="App" required />
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
 
@@ -1,3 +1,31 @@
1
+ /** One person from a provider company with a membership at this site. */
2
+ export type TSiteProviderMember = {
3
+ _id: string;
4
+ name: string;
5
+ email: string;
6
+ companyId: string;
7
+ company: string;
8
+ /** the company's service code (e.g. "security_agency") */
9
+ type: string;
10
+ typeLabel: string;
11
+ role: string;
12
+ status: string;
13
+ addedAt?: string | null;
14
+ /** a subcontractor: the provider that brought its company onto the site */
15
+ invitedVia?: string;
16
+ };
17
+
18
+ export type TSiteProviderCompany = {
19
+ orgId: string;
20
+ name: string;
21
+ type: string;
22
+ typeLabel: string;
23
+ email?: string;
24
+ since?: string | null;
25
+ invitedVia?: string;
26
+ people: number;
27
+ };
28
+
1
29
  export default function useServiceProvider() {
2
30
  const serviceProviders = useState<Array<TServiceProvider>>(
3
31
  "serviceProviders",
@@ -259,7 +287,38 @@ export default function useServiceProvider() {
259
287
  );
260
288
  }
261
289
 
290
+ /**
291
+ * Members > Service Providers: the provider people with access to this
292
+ * site, and one entry per provider company with its head count. The
293
+ * site's own organisation only - the server refuses anyone else.
294
+ */
295
+ function getSiteMembers(siteId: string): Promise<{
296
+ items: TSiteProviderMember[];
297
+ companies: TSiteProviderCompany[];
298
+ }> {
299
+ return useNuxtApp().$api("/api/service-providers/site-members", {
300
+ method: "GET",
301
+ query: { siteId },
302
+ });
303
+ }
304
+
305
+ /** The same list as an Excel file, under the same filters. */
306
+ function exportSiteMembers(
307
+ siteId: string,
308
+ filters: { type?: string; company?: string; search?: string } = {},
309
+ ): Promise<Blob> {
310
+ const query: Record<string, string> = { siteId, format: "xlsx" };
311
+ for (const [k, v] of Object.entries(filters)) if (v) query[k] = v;
312
+ return useNuxtApp().$api<Blob>("/api/service-providers/site-members", {
313
+ method: "GET",
314
+ query,
315
+ responseType: "blob",
316
+ });
317
+ }
318
+
262
319
  return {
320
+ getSiteMembers,
321
+ exportSiteMembers,
263
322
  serviceProviders,
264
323
  serviceProvider,
265
324
  serviceProviderCategories,
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.3",
5
+ "version": "4.66.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.",
@@ -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
- serviceProviderInviteAnswer: [
186
- { apps: SERVICE_APPS.filter((a) => a !== "hygiene"), page: () => "service-providers" },
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,96 @@
1
+ /**
2
+ * Members > Service Providers (owner request 2026-09-28): the property
3
+ * manager sees every person from its service providers who can open this
4
+ * site's information. Pure helpers, so the screen's wording and filters are
5
+ * tested without mounting it. The server applies the same filter rule to the
6
+ * Excel export (`filterSiteProviderMembers` in core).
7
+ */
8
+
9
+ export type TProviderMemberRow = {
10
+ name?: string;
11
+ email?: string;
12
+ company?: string;
13
+ companyId?: string;
14
+ type?: string;
15
+ role?: string;
16
+ invitedVia?: string;
17
+ };
18
+
19
+ export type TProviderMemberFilters = {
20
+ type?: string;
21
+ company?: string;
22
+ search?: string;
23
+ };
24
+
25
+ export function filterProviderMembers<T extends TProviderMemberRow>(
26
+ items: T[],
27
+ { type = "", company = "", search = "" }: TProviderMemberFilters,
28
+ ): T[] {
29
+ const needle = search.trim().toLowerCase();
30
+ return items.filter(
31
+ (m) =>
32
+ (!type || m.type === type) &&
33
+ (!company || m.companyId === company) &&
34
+ (!needle ||
35
+ [m.name, m.email, m.company, m.role, m.invitedVia]
36
+ .map((v) => String(v ?? "").toLowerCase())
37
+ .some((v) => v.includes(needle))),
38
+ );
39
+ }
40
+
41
+ /** "Aigle Security · 12 people" */
42
+ export function companyChipText(name: string, people: number): string {
43
+ return `${name || "Unnamed company"} · ${people} ${people === 1 ? "person" : "people"}`;
44
+ }
45
+
46
+ /** What an empty table says: nothing on site, nobody added yet, or the filters. */
47
+ export function providerMembersEmptyText({
48
+ companies,
49
+ total,
50
+ filtered,
51
+ }: {
52
+ companies: number;
53
+ total: number;
54
+ filtered: boolean;
55
+ }): string {
56
+ if (!companies) return "No service providers work at this site yet.";
57
+ if (!total) return "Your service providers have not given anyone access to this site yet.";
58
+ if (filtered) return "No one matches these filters.";
59
+ return "No one to show.";
60
+ }
61
+
62
+ /** The service types present, for the filter, in a stable order. */
63
+ export function serviceTypeOptions(
64
+ items: Array<{ type?: string; typeLabel?: string }>,
65
+ ): Array<{ title: string; value: string }> {
66
+ const seen = new Map<string, string>();
67
+ for (const m of items) {
68
+ const value = String(m.type ?? "");
69
+ if (value && !seen.has(value)) seen.set(value, String(m.typeLabel || value));
70
+ }
71
+ return [...seen.entries()]
72
+ .map(([value, title]) => ({ title, value }))
73
+ .sort((a, b) => a.title.localeCompare(b.title));
74
+ }
75
+
76
+ /**
77
+ * "Email the company" about one person: a mail to the provider company,
78
+ * naming the person and the site. Nothing is changed in the app - the
79
+ * provider manages its own people.
80
+ */
81
+ export function concernMailto({
82
+ companyEmail,
83
+ person,
84
+ siteName,
85
+ }: {
86
+ companyEmail?: string;
87
+ person: string;
88
+ siteName?: string;
89
+ }): string {
90
+ const to = String(companyEmail ?? "").trim();
91
+ if (!to) return "";
92
+ const where = siteName ? ` at ${siteName}` : "";
93
+ const subject = `About ${person}${where}`;
94
+ const body = `Hello,\n\nWe would like to talk about ${person}'s access${where}.\n\n`;
95
+ return `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
96
+ }