@7365admin1/layer-common 4.35.1-staging.433 → 4.35.1-staging.434
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.
|
@@ -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>
|
|
@@ -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.35.1-staging.
|
|
5
|
+
"version": "4.35.1-staging.434",
|
|
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,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
|
+
}
|