@7365admin1/layer-common 4.65.3 → 4.67.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 +17 -4
- package/components/InventoryImportDialog.vue +45 -16
- package/components/InventoryItemDialog.vue +1 -1
- package/components/InventoryLinesDialog.vue +1 -1
- package/components/InventoryMain.vue +4 -0
- package/components/InventorySupplierField.vue +93 -0
- package/components/InventorySuppliersTab.vue +210 -0
- package/components/MemberMain.vue +10 -0
- package/components/MemberServiceProviders.vue +327 -0
- package/components/ServiceProviderMain.vue +15 -1
- package/composables/useInventory.ts +14 -0
- package/composables/useServiceProvider.ts +59 -0
- package/package.json +1 -1
- package/types/inventory.d.ts +13 -0
- package/utils/inventory-suppliers.ts +45 -0
- package/utils/notification-target.ts +7 -3
- package/utils/site-provider-members.ts +96 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.67.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 10bc76a: Inventory: supplier list. Receive Stock and opening stock pick the supplier from a searchable list (or add a new one, with a near-duplicate warning); new Suppliers tab with add/edit (only the name required), inactive, and Excel import.
|
|
8
|
+
|
|
9
|
+
## 4.66.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
3
16
|
## 4.65.3
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -1792,11 +1805,11 @@
|
|
|
1792
1805
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1793
1806
|
own rule from two endpoints the console already calls, unprojected:
|
|
1794
1807
|
|
|
1795
|
-
|
|
1796
|
-
|
|
1808
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1809
|
+
GET /api/roles/id/:role that membership's role document
|
|
1797
1810
|
|
|
1798
|
-
|
|
1799
|
-
|
|
1811
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1812
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1800
1813
|
|
|
1801
1814
|
`role.default` is the marker because it is the only property of a platform
|
|
1802
1815
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<ImportExcelDialog
|
|
3
3
|
v-model="show"
|
|
4
|
-
:title="
|
|
5
|
-
:noun="
|
|
6
|
-
name-label="Item"
|
|
7
|
-
:fill-hint="
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
: 'One row per item. Unit, Packing and Category have dropdowns with this store\'s values. Current Stock and the columns after it are the opening stock (date, supplier, PO no.), recorded like a delivery.'
|
|
11
|
-
"
|
|
12
|
-
:duplicate-note="kind === 'receipts' ? 'Every row is a delivery — uploading the same file twice records it twice.' : 'Items already in the store are skipped and never changed, so uploading twice never doubles stock.'"
|
|
13
|
-
:template-file-name="kind === 'receipts' ? `receive-stock-${slug}.xlsx` : `inventory-items-${slug}.xlsx`"
|
|
4
|
+
:title="copy.title"
|
|
5
|
+
:noun="copy.noun"
|
|
6
|
+
:name-label="kind === 'suppliers' ? 'Supplier' : 'Item'"
|
|
7
|
+
:fill-hint="copy.fill"
|
|
8
|
+
:duplicate-note="copy.duplicate"
|
|
9
|
+
:template-file-name="`${copy.file}-${slug}.xlsx`"
|
|
14
10
|
:download-template="downloadTemplate"
|
|
15
11
|
:check="(file: File) => run(file, false)"
|
|
16
12
|
:commit="(file: File) => run(file, true)"
|
|
@@ -26,21 +22,54 @@ import type { TImportResult } from "../utils/import-result";
|
|
|
26
22
|
const show = defineModel<boolean>({ default: false });
|
|
27
23
|
const props = defineProps({
|
|
28
24
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
29
|
-
/** items: the item list with opening stock. receipts: bulk deliveries of existing items. */
|
|
30
|
-
kind: { type: String as PropType<"items" | "receipts">, default: "items" },
|
|
25
|
+
/** items: the item list with opening stock. receipts: bulk deliveries of existing items. suppliers: the supplier list. */
|
|
26
|
+
kind: { type: String as PropType<"items" | "receipts" | "suppliers">, default: "items" },
|
|
31
27
|
});
|
|
32
28
|
const emit = defineEmits(["done"]);
|
|
33
29
|
|
|
34
|
-
const { importItems, importTemplate, receiveTemplate, importReceipts } = useInventory();
|
|
30
|
+
const { importItems, importTemplate, receiveTemplate, importReceipts, supplierTemplate, importSuppliers } = useInventory();
|
|
35
31
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
36
32
|
const slug = computed(() => String(props.store.name ?? "store").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "store");
|
|
37
33
|
|
|
38
|
-
const
|
|
34
|
+
const COPY = {
|
|
35
|
+
items: {
|
|
36
|
+
title: "Import Items from Excel",
|
|
37
|
+
noun: "items",
|
|
38
|
+
fill: "One row per item. Unit, Packing, Category and Supplier have dropdowns with this store's values (a new supplier can still be typed in). Current Stock and the columns after it are the opening stock (date, supplier, PO no.), recorded like a delivery.",
|
|
39
|
+
duplicate: "Items already in the store are skipped and never changed, so uploading twice never doubles stock.",
|
|
40
|
+
file: "inventory-items",
|
|
41
|
+
done: (n: number) => `${n} item(s) added.`,
|
|
42
|
+
},
|
|
43
|
+
receipts: {
|
|
44
|
+
title: "Import Deliveries from Excel",
|
|
45
|
+
noun: "delivery lines",
|
|
46
|
+
fill: "One row per item delivered: date received, supplier (pick from the dropdown, or type a new one), PO no., the item (pick from the dropdown), quantity and price. Rows with the same date, supplier and PO no. are saved as one delivery.",
|
|
47
|
+
duplicate: "Every row is a delivery — uploading the same file twice records it twice.",
|
|
48
|
+
file: "receive-stock",
|
|
49
|
+
done: (n: number) => `${n} delivery line(s) received.`,
|
|
50
|
+
},
|
|
51
|
+
suppliers: {
|
|
52
|
+
title: "Import Suppliers from Excel",
|
|
53
|
+
noun: "suppliers",
|
|
54
|
+
fill: "One row per supplier. Only Supplier Name is required; contact person, phone, email, address, payment terms and notes are optional.",
|
|
55
|
+
duplicate: "Suppliers already in your list are skipped and left unchanged.",
|
|
56
|
+
file: "suppliers",
|
|
57
|
+
done: (n: number) => `${n} supplier(s) added.`,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const copy = computed(() => COPY[props.kind]);
|
|
61
|
+
|
|
62
|
+
const downloadTemplate = () =>
|
|
63
|
+
props.kind === "receipts" ? receiveTemplate(props.store._id) : props.kind === "suppliers" ? supplierTemplate(props.store._id) : importTemplate(props.store._id);
|
|
39
64
|
const run = (file: File, commit: boolean) =>
|
|
40
|
-
(props.kind === "receipts"
|
|
65
|
+
(props.kind === "receipts"
|
|
66
|
+
? importReceipts(props.store._id, file, commit)
|
|
67
|
+
: props.kind === "suppliers"
|
|
68
|
+
? importSuppliers(props.store._id, file, commit)
|
|
69
|
+
: importItems(props.store._id, file, commit)) as Promise<TImportResult>;
|
|
41
70
|
|
|
42
71
|
function finished(result: TImportResult) {
|
|
43
|
-
notify(
|
|
72
|
+
notify(copy.value.done(result.ready));
|
|
44
73
|
emit("done");
|
|
45
74
|
}
|
|
46
75
|
</script>
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
</v-col>
|
|
59
59
|
<v-col cols="12" sm="6">
|
|
60
60
|
<InputLabel title="Supplier" class="mb-1" />
|
|
61
|
-
<v-
|
|
61
|
+
<InventorySupplierField v-if="show" v-model="f.openingSupplier" :store-id="store._id" :disabled="!f.openingQty" hint="Optional. Pick from your supplier list, or type a new one." />
|
|
62
62
|
</v-col>
|
|
63
63
|
<v-col cols="12" sm="6">
|
|
64
64
|
<InputLabel title="PO no." class="mb-1" />
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
<v-row v-if="mode === 'receive'" dense>
|
|
84
84
|
<v-col cols="12" md="6">
|
|
85
85
|
<InputLabel title="Supplier" class="mb-1" />
|
|
86
|
-
<v-
|
|
86
|
+
<InventorySupplierField v-if="show" v-model="supplier" :store-id="store._id" />
|
|
87
87
|
</v-col>
|
|
88
88
|
<v-col cols="12" md="3">
|
|
89
89
|
<InputLabel title="PO number" class="mb-1" />
|
|
@@ -100,6 +100,9 @@
|
|
|
100
100
|
<v-window-item v-if="isStaff" value="reports">
|
|
101
101
|
<InventoryReportsTab :store="store" :sites="storeSites" />
|
|
102
102
|
</v-window-item>
|
|
103
|
+
<v-window-item v-if="isStaff" value="suppliers">
|
|
104
|
+
<InventorySuppliersTab :store="store" />
|
|
105
|
+
</v-window-item>
|
|
103
106
|
<v-window-item v-if="store.access.canManage" value="settings">
|
|
104
107
|
<InventorySettingsTab :store="store" :org-sites="orgSites" @saved="loadStores" />
|
|
105
108
|
</v-window-item>
|
|
@@ -182,6 +185,7 @@ const tabs = computed(() => {
|
|
|
182
185
|
{ value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 },
|
|
183
186
|
...(a.canApprove ? [{ value: "technicians", label: worker.many }] : []),
|
|
184
187
|
...(isStaff.value ? [{ value: "reports", label: "Reports" }] : []),
|
|
188
|
+
...(isStaff.value ? [{ value: "suppliers", label: "Suppliers" }] : []),
|
|
185
189
|
...(a.canManage ? [{ value: "settings", label: "Settings" }] : []),
|
|
186
190
|
];
|
|
187
191
|
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<v-combobox
|
|
4
|
+
v-model="model"
|
|
5
|
+
v-model:search="search"
|
|
6
|
+
:items="names"
|
|
7
|
+
:loading="loading"
|
|
8
|
+
:disabled="disabled"
|
|
9
|
+
:placeholder="placeholder"
|
|
10
|
+
density="comfortable"
|
|
11
|
+
clearable
|
|
12
|
+
hide-details
|
|
13
|
+
maxlength="200"
|
|
14
|
+
aria-label="Supplier — pick from your list or type a new one"
|
|
15
|
+
@blur="snap"
|
|
16
|
+
>
|
|
17
|
+
<template #no-data>
|
|
18
|
+
<v-list-item v-if="typed" prepend-icon="mdi-plus" @click="model = typed">
|
|
19
|
+
<v-list-item-title>Add "{{ typed }}" as a new supplier</v-list-item-title>
|
|
20
|
+
</v-list-item>
|
|
21
|
+
<v-list-item v-else>
|
|
22
|
+
<v-list-item-title class="text-medium-emphasis">No suppliers yet — type a name to add one</v-list-item-title>
|
|
23
|
+
</v-list-item>
|
|
24
|
+
</template>
|
|
25
|
+
<template v-if="typed && !listedSupplierName(typed, names)" #append-item>
|
|
26
|
+
<v-divider />
|
|
27
|
+
<v-list-item prepend-icon="mdi-plus" @click="model = typed">
|
|
28
|
+
<v-list-item-title>Add "{{ typed }}" as a new supplier</v-list-item-title>
|
|
29
|
+
</v-list-item>
|
|
30
|
+
</template>
|
|
31
|
+
</v-combobox>
|
|
32
|
+
<div class="text-caption mt-1" style="min-height: 20px">
|
|
33
|
+
<span v-if="near" class="text-warning">
|
|
34
|
+
Did you mean <a href="#" class="text-warning font-weight-bold" @click.prevent="model = near">{{ near }}</a>? Otherwise "{{ current }}" is added as a new supplier.
|
|
35
|
+
</span>
|
|
36
|
+
<span v-else-if="isNew" class="text-medium-emphasis">New supplier — it is added to your supplier list when you save.</span>
|
|
37
|
+
<span v-else class="text-medium-emphasis">{{ hint }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<script setup lang="ts">
|
|
43
|
+
import useInventory from "../composables/useInventory";
|
|
44
|
+
import { listedSupplierName, nearSupplierName } from "../utils/inventory-suppliers";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Supplier picker: the organisation's supplier list as a searchable dropdown.
|
|
48
|
+
* A name not in the list can still be typed; the server adds it to the list
|
|
49
|
+
* (name only) when the delivery is saved, and matches any spelling of a listed name.
|
|
50
|
+
*/
|
|
51
|
+
const model = defineModel<string>({ default: "" });
|
|
52
|
+
const props = defineProps({
|
|
53
|
+
storeId: { type: String, required: true },
|
|
54
|
+
disabled: { type: Boolean, default: false },
|
|
55
|
+
placeholder: { type: String, default: "Pick a supplier or type a new one" },
|
|
56
|
+
hint: { type: String, default: "Pick from your supplier list, or type a new one." },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const { getSuppliers } = useInventory();
|
|
60
|
+
const names = ref<string[]>([]);
|
|
61
|
+
const loading = ref(false);
|
|
62
|
+
const search = ref<string | null>("");
|
|
63
|
+
|
|
64
|
+
const typed = computed(() => String(search.value ?? "").replace(/\s+/g, " ").trim());
|
|
65
|
+
const current = computed(() => String(model.value ?? "").replace(/\s+/g, " ").trim());
|
|
66
|
+
const isNew = computed(() => !!current.value && !listedSupplierName(current.value, names.value));
|
|
67
|
+
const near = computed(() => (isNew.value ? nearSupplierName(current.value, names.value) : undefined));
|
|
68
|
+
|
|
69
|
+
/** A listed supplier typed in another spelling becomes the list's spelling. */
|
|
70
|
+
function snap() {
|
|
71
|
+
if (model.value == null) return void (model.value = ""); // cleared: no supplier, never null to the server
|
|
72
|
+
const listed = listedSupplierName(model.value, names.value);
|
|
73
|
+
if (listed && listed !== model.value) model.value = listed;
|
|
74
|
+
}
|
|
75
|
+
watch(model, snap);
|
|
76
|
+
|
|
77
|
+
async function load() {
|
|
78
|
+
if (!props.storeId) return;
|
|
79
|
+
loading.value = true;
|
|
80
|
+
try {
|
|
81
|
+
const res = await getSuppliers(props.storeId);
|
|
82
|
+
names.value = [...res.items.map((s) => s.name), ...(res.fromDeliveries ?? []).map((s) => s.name)].sort((a, b) =>
|
|
83
|
+
a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }),
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
names.value = []; // typing a name still works
|
|
87
|
+
} finally {
|
|
88
|
+
loading.value = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
watch(() => props.storeId, load, { immediate: true });
|
|
92
|
+
defineExpose({ reload: load });
|
|
93
|
+
</script>
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<AppCard class="pa-4 mb-4">
|
|
4
|
+
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
5
|
+
Your organisation's suppliers, shared by all its stores. They appear in the Supplier dropdown on Receive Stock and on opening stock.
|
|
6
|
+
Only the name is needed — contact details are optional. A supplier typed on a delivery is added here automatically.
|
|
7
|
+
</p>
|
|
8
|
+
<div class="d-flex flex-wrap align-center ga-2">
|
|
9
|
+
<v-text-field
|
|
10
|
+
v-model="search"
|
|
11
|
+
density="compact"
|
|
12
|
+
prepend-inner-icon="mdi-magnify"
|
|
13
|
+
placeholder="Search name, contact, phone or email"
|
|
14
|
+
hide-details
|
|
15
|
+
clearable
|
|
16
|
+
style="min-width: 240px; max-width: 360px"
|
|
17
|
+
/>
|
|
18
|
+
<v-switch v-if="canManage" v-model="includeInactive" color="primary" density="compact" hide-details label="Show inactive" class="ml-2" />
|
|
19
|
+
<v-spacer />
|
|
20
|
+
<template v-if="canManage">
|
|
21
|
+
<v-btn variant="outlined" class="text-none" prepend-icon="mdi-file-excel-outline" @click="importOpen = true">Import from Excel</v-btn>
|
|
22
|
+
<v-btn variant="flat" class="text-none screen-btn-primary" prepend-icon="mdi-plus" @click="edit()">Add Supplier</v-btn>
|
|
23
|
+
</template>
|
|
24
|
+
</div>
|
|
25
|
+
</AppCard>
|
|
26
|
+
|
|
27
|
+
<AppCard v-if="canManage && fromDeliveries.length" class="pa-4 mb-4">
|
|
28
|
+
<div class="text-subtitle-2 mb-1">Suppliers on past deliveries, not in the list yet</div>
|
|
29
|
+
<p class="text-caption text-medium-emphasis mb-2">Add them so they can be picked, and fill in their details if you want to.</p>
|
|
30
|
+
<div class="d-flex flex-wrap ga-2 align-center">
|
|
31
|
+
<v-chip v-for="s in fromDeliveries" :key="s.name" variant="outlined" append-icon="mdi-plus" :disabled="adding" @click="addName(s.name)">
|
|
32
|
+
{{ s.name }}
|
|
33
|
+
</v-chip>
|
|
34
|
+
<v-btn size="small" variant="text" class="text-none" :loading="adding" @click="addAll">Add all {{ fromDeliveries.length }}</v-btn>
|
|
35
|
+
</div>
|
|
36
|
+
</AppCard>
|
|
37
|
+
|
|
38
|
+
<AppCard class="pa-0">
|
|
39
|
+
<v-data-table :headers="headers" :items="items" :loading="loading" density="comfortable" :items-per-page="25" no-data-text="No suppliers yet. Add one, import a list, or type a new supplier on Receive Stock.">
|
|
40
|
+
<template #item.name="{ item }">
|
|
41
|
+
<span class="font-weight-medium">{{ item.name }}</span>
|
|
42
|
+
<v-chip v-if="item.status === 'inactive'" size="x-small" class="ml-2">Inactive</v-chip>
|
|
43
|
+
</template>
|
|
44
|
+
<template #item.contact="{ item }">
|
|
45
|
+
<div>{{ item.contactPerson || "—" }}</div>
|
|
46
|
+
<div class="text-caption text-medium-emphasis">{{ [item.phone, item.email].filter(Boolean).join(" · ") }}</div>
|
|
47
|
+
</template>
|
|
48
|
+
<template #item.actions="{ item }">
|
|
49
|
+
<v-btn v-if="canManage" size="small" variant="text" class="text-none" @click="edit(item)">Edit</v-btn>
|
|
50
|
+
</template>
|
|
51
|
+
</v-data-table>
|
|
52
|
+
</AppCard>
|
|
53
|
+
|
|
54
|
+
<v-dialog v-model="dialog" max-width="560">
|
|
55
|
+
<AppCard class="pa-6">
|
|
56
|
+
<div class="text-h6 mb-1">{{ editing ? "Edit supplier" : "Add supplier" }}</div>
|
|
57
|
+
<p class="text-body-2 text-medium-emphasis mb-4">Only the name is required. Fill in the rest only if you want to — you can come back to it later.</p>
|
|
58
|
+
<v-form ref="form" @submit.prevent="save">
|
|
59
|
+
<InputLabel title="Supplier name" required class="mb-1" />
|
|
60
|
+
<v-text-field v-model="f.name" density="comfortable" maxlength="200" :rules="[requiredRule]" autofocus />
|
|
61
|
+
<v-row dense>
|
|
62
|
+
<v-col cols="12" sm="6">
|
|
63
|
+
<InputLabel title="Contact person" class="mb-1" />
|
|
64
|
+
<v-text-field v-model="f.contactPerson" density="comfortable" maxlength="120" hint="Optional" persistent-hint />
|
|
65
|
+
</v-col>
|
|
66
|
+
<v-col cols="12" sm="6">
|
|
67
|
+
<InputLabel title="Phone" class="mb-1" />
|
|
68
|
+
<v-text-field v-model="f.phone" density="comfortable" maxlength="40" hint="Optional" persistent-hint />
|
|
69
|
+
</v-col>
|
|
70
|
+
<v-col cols="12" sm="6">
|
|
71
|
+
<InputLabel title="Email" class="mb-1" />
|
|
72
|
+
<v-text-field v-model="f.email" type="email" density="comfortable" maxlength="200" :rules="[emailRule]" hint="Optional" persistent-hint />
|
|
73
|
+
</v-col>
|
|
74
|
+
<v-col cols="12" sm="6">
|
|
75
|
+
<InputLabel title="Payment terms" class="mb-1" />
|
|
76
|
+
<v-text-field v-model="f.paymentTerms" density="comfortable" maxlength="120" placeholder="e.g. 30 days, COD" hint="Optional" persistent-hint />
|
|
77
|
+
</v-col>
|
|
78
|
+
</v-row>
|
|
79
|
+
<InputLabel title="Address" class="mb-1 mt-2" />
|
|
80
|
+
<v-textarea v-model="f.address" density="comfortable" rows="2" auto-grow maxlength="500" hint="Optional" persistent-hint />
|
|
81
|
+
<InputLabel title="Notes" class="mb-1 mt-2" />
|
|
82
|
+
<v-textarea v-model="f.notes" density="comfortable" rows="2" auto-grow maxlength="1000" hint="Optional" persistent-hint />
|
|
83
|
+
<v-switch
|
|
84
|
+
v-if="editing"
|
|
85
|
+
v-model="active"
|
|
86
|
+
color="primary"
|
|
87
|
+
hide-details
|
|
88
|
+
class="mt-2"
|
|
89
|
+
:label="active ? 'Active — shown in the Supplier dropdown' : 'Inactive — hidden from the dropdown, kept on past deliveries'"
|
|
90
|
+
/>
|
|
91
|
+
<div class="d-flex justify-end ga-2 mt-4">
|
|
92
|
+
<v-btn variant="text" class="text-none" @click="dialog = false">Cancel</v-btn>
|
|
93
|
+
<v-btn type="submit" variant="flat" class="text-none screen-btn-primary" :loading="saving">{{ editing ? "Save" : "Add supplier" }}</v-btn>
|
|
94
|
+
</div>
|
|
95
|
+
</v-form>
|
|
96
|
+
</AppCard>
|
|
97
|
+
</v-dialog>
|
|
98
|
+
|
|
99
|
+
<InventoryImportDialog v-model="importOpen" :store="store" kind="suppliers" @done="load" />
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|
|
102
|
+
|
|
103
|
+
<script setup lang="ts">
|
|
104
|
+
import useInventory from "../composables/useInventory";
|
|
105
|
+
import useUtils from "../composables/useUtils";
|
|
106
|
+
|
|
107
|
+
const props = defineProps({
|
|
108
|
+
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const { getSuppliers, createSupplier, updateSupplier } = useInventory();
|
|
112
|
+
const { requiredRule, debounce } = useUtils();
|
|
113
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
114
|
+
|
|
115
|
+
const items = ref<TInventorySupplier[]>([]);
|
|
116
|
+
const fromDeliveries = ref<{ name: string }[]>([]);
|
|
117
|
+
const canManage = ref(false);
|
|
118
|
+
const loading = ref(false);
|
|
119
|
+
const search = ref<string | null>("");
|
|
120
|
+
const includeInactive = ref(false);
|
|
121
|
+
const importOpen = ref(false);
|
|
122
|
+
const adding = ref(false);
|
|
123
|
+
|
|
124
|
+
const headers = [
|
|
125
|
+
{ title: "Supplier", key: "name" },
|
|
126
|
+
{ title: "Contact", key: "contact", sortable: false },
|
|
127
|
+
{ title: "Payment terms", key: "paymentTerms" },
|
|
128
|
+
{ title: "", key: "actions", sortable: false, align: "end" as const },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
async function load() {
|
|
132
|
+
loading.value = true;
|
|
133
|
+
try {
|
|
134
|
+
const res = await getSuppliers(props.store._id, { search: search.value || "", includeInactive: includeInactive.value });
|
|
135
|
+
items.value = res.items;
|
|
136
|
+
fromDeliveries.value = res.fromDeliveries ?? [];
|
|
137
|
+
canManage.value = res.canManage;
|
|
138
|
+
} catch (error: any) {
|
|
139
|
+
notify(error?.data?.message || "Could not load the suppliers.", "error");
|
|
140
|
+
} finally {
|
|
141
|
+
loading.value = false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const reload = debounce(load, 300);
|
|
145
|
+
watch(search, reload);
|
|
146
|
+
watch(includeInactive, load);
|
|
147
|
+
watch(() => props.store._id, load, { immediate: true });
|
|
148
|
+
|
|
149
|
+
async function addName(name: string) {
|
|
150
|
+
adding.value = true;
|
|
151
|
+
try {
|
|
152
|
+
await createSupplier(props.store._id, { name });
|
|
153
|
+
notify(`"${name}" added to your suppliers.`);
|
|
154
|
+
await load();
|
|
155
|
+
} catch (error: any) {
|
|
156
|
+
notify(error?.data?.message || "Could not add the supplier.", "error");
|
|
157
|
+
} finally {
|
|
158
|
+
adding.value = false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function addAll() {
|
|
162
|
+
adding.value = true;
|
|
163
|
+
let added = 0;
|
|
164
|
+
for (const s of fromDeliveries.value) {
|
|
165
|
+
try {
|
|
166
|
+
await createSupplier(props.store._id, { name: s.name });
|
|
167
|
+
added++;
|
|
168
|
+
} catch {
|
|
169
|
+
/* already added by someone else: skip */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
adding.value = false;
|
|
173
|
+
notify(`${added} supplier(s) added.`);
|
|
174
|
+
await load();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- add / edit
|
|
178
|
+
const blank = () => ({ name: "", contactPerson: "", phone: "", email: "", address: "", paymentTerms: "", notes: "" });
|
|
179
|
+
const dialog = ref(false);
|
|
180
|
+
const form = ref();
|
|
181
|
+
const saving = ref(false);
|
|
182
|
+
const editing = ref<TInventorySupplier | null>(null);
|
|
183
|
+
const f = reactive(blank());
|
|
184
|
+
const active = ref(true);
|
|
185
|
+
const emailRule = (v: string) => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || "Enter a valid email, or leave it blank";
|
|
186
|
+
|
|
187
|
+
function edit(s?: TInventorySupplier) {
|
|
188
|
+
editing.value = s ?? null;
|
|
189
|
+
Object.assign(f, blank(), s ? { name: s.name, contactPerson: s.contactPerson, phone: s.phone, email: s.email, address: s.address, paymentTerms: s.paymentTerms, notes: s.notes } : {});
|
|
190
|
+
active.value = s ? s.status === "active" : true;
|
|
191
|
+
dialog.value = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function save() {
|
|
195
|
+
const { valid } = await form.value.validate();
|
|
196
|
+
if (!valid) return;
|
|
197
|
+
saving.value = true;
|
|
198
|
+
try {
|
|
199
|
+
if (editing.value) await updateSupplier(props.store._id, editing.value._id, { ...f, status: active.value ? "active" : "inactive" });
|
|
200
|
+
else await createSupplier(props.store._id, { ...f });
|
|
201
|
+
notify(editing.value ? "Supplier saved." : `"${f.name.trim()}" added to your suppliers.`);
|
|
202
|
+
dialog.value = false;
|
|
203
|
+
await load();
|
|
204
|
+
} catch (error: any) {
|
|
205
|
+
notify(error?.data?.message || "Could not save the supplier.", "error");
|
|
206
|
+
} finally {
|
|
207
|
+
saving.value = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
</script>
|
|
@@ -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="
|
|
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
|
|
|
@@ -83,6 +83,20 @@ export default function useInventory() {
|
|
|
83
83
|
body.append("file", file);
|
|
84
84
|
return api<TImportResult>(`/stores/${store}/receive-import`, { method: "POST", body, query: { commit } });
|
|
85
85
|
},
|
|
86
|
+
|
|
87
|
+
/** The organisation's supplier list (active), plus — for managers — past-delivery names not yet in it. */
|
|
88
|
+
getSuppliers: (store: string, query: { search?: string; includeInactive?: boolean } = {}) =>
|
|
89
|
+
api<{ items: TInventorySupplier[]; fromDeliveries: { name: string; lastUsed: string; count: number }[]; canManage: boolean }>(`/stores/${store}/suppliers`, { query }),
|
|
90
|
+
createSupplier: (store: string, body: Partial<TInventorySupplier> & { name: string }) =>
|
|
91
|
+
api<TInventorySupplier>(`/stores/${store}/suppliers`, { method: "POST", body }),
|
|
92
|
+
updateSupplier: (store: string, id: string, body: Partial<TInventorySupplier>) =>
|
|
93
|
+
api<TInventorySupplier>(`/stores/${store}/suppliers/${id}`, { method: "PATCH", body }),
|
|
94
|
+
supplierTemplate: (store: string) => api<Blob>(`/stores/${store}/suppliers-template`, { responseType: "blob" }),
|
|
95
|
+
importSuppliers: (store: string, file: File, commit: boolean) => {
|
|
96
|
+
const body = new FormData();
|
|
97
|
+
body.append("file", file);
|
|
98
|
+
return api<TImportResult>(`/stores/${store}/suppliers-import`, { method: "POST", body, query: { commit } });
|
|
99
|
+
},
|
|
86
100
|
};
|
|
87
101
|
}
|
|
88
102
|
|
|
@@ -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.
|
|
5
|
+
"version": "4.67.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.",
|
package/types/inventory.d.ts
CHANGED
|
@@ -97,3 +97,16 @@ declare type TInventoryTransaction = {
|
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
declare type TInventoryPeriod = { from: string; to: string; site?: string; locationType?: string };
|
|
100
|
+
|
|
101
|
+
/** An organisation's supplier (shared by its stores). Only the name is required. */
|
|
102
|
+
declare type TInventorySupplier = {
|
|
103
|
+
_id: string;
|
|
104
|
+
name: string;
|
|
105
|
+
contactPerson: string;
|
|
106
|
+
phone: string;
|
|
107
|
+
email: string;
|
|
108
|
+
address: string;
|
|
109
|
+
paymentTerms: string;
|
|
110
|
+
notes: string;
|
|
111
|
+
status: "active" | "inactive";
|
|
112
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supplier-name helpers for the inventory supplier picker. Browser-safe, no
|
|
3
|
+
* Node globals; names are unique so Nuxt auto-imports cannot clash.
|
|
4
|
+
* The server applies the same rules (API-core `utils/inventory-suppliers.ts`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Trimmed, spaces collapsed, lower case: two names with the same key are the same supplier. */
|
|
8
|
+
export function supplierNameKey(name: unknown): string {
|
|
9
|
+
return String(name ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The list's own spelling of `name`, when the list already has it in any spelling. */
|
|
13
|
+
export function listedSupplierName(name: unknown, names: string[]): string | undefined {
|
|
14
|
+
const key = supplierNameKey(name);
|
|
15
|
+
return key ? names.find((n) => supplierNameKey(n) === key) : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COMPANY_WORDS = /\b(pte|ltd|private|limited|co|company|llp|inc|corp|sdn|bhd|the)\b/g;
|
|
19
|
+
const coreName = (name: string) => supplierNameKey(name).replace(/[^a-z0-9 ]+/g, " ").replace(COMPANY_WORDS, " ").replace(/\s+/g, " ").trim();
|
|
20
|
+
|
|
21
|
+
function editDistanceWithin(a: string, b: string, cap: number) {
|
|
22
|
+
if (Math.abs(a.length - b.length) > cap) return cap + 1;
|
|
23
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
24
|
+
for (let i = 1; i <= a.length; i++) {
|
|
25
|
+
const cur = [i];
|
|
26
|
+
for (let j = 1; j <= b.length; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
27
|
+
prev = cur;
|
|
28
|
+
}
|
|
29
|
+
return prev[b.length];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A listed supplier a NEW name probably means ("ABC Trading" → "ABC Trading Pte Ltd"); undefined when listed or nothing close. */
|
|
33
|
+
export function nearSupplierName(name: string, names: string[]): string | undefined {
|
|
34
|
+
if (!supplierNameKey(name) || listedSupplierName(name, names)) return undefined;
|
|
35
|
+
const c = coreName(name);
|
|
36
|
+
if (c.length < 3) return undefined;
|
|
37
|
+
const cap = c.length >= 8 ? 2 : 1;
|
|
38
|
+
return names.find((n) => {
|
|
39
|
+
const o = coreName(n);
|
|
40
|
+
if (!o) return false;
|
|
41
|
+
if (o === c) return true;
|
|
42
|
+
if (o.length >= 3 && (o.startsWith(`${c} `) || c.startsWith(`${o} `))) return true;
|
|
43
|
+
return o.length >= 4 && editDistanceWithin(c, o, cap) <= cap;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -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
|
-
|
|
186
|
-
|
|
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
|
+
}
|