@7365admin1/layer-common 4.71.1 → 4.72.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 +16 -4
- package/components/BuildingManagement/units.vue +1 -1
- package/components/InventoryItemDetail.vue +7 -4
- package/components/InventoryItemDialog.vue +21 -1
- package/components/InventoryLinesDialog.vue +25 -3
- package/components/InventoryMain.vue +26 -1
- package/components/InventoryMyStockTab.vue +167 -5
- package/components/InventoryReportsTab.vue +59 -3
- package/components/InventoryRequestsTab.vue +2 -2
- package/components/InventoryStockTab.vue +79 -1
- package/components/InventoryTechniciansTab.vue +3 -2
- package/composables/useInventory.ts +18 -3
- package/package.json +1 -1
- package/types/inventory.d.ts +34 -2
- package/utils/inventory-store-label.ts +14 -1
- package/utils/inventory-used-by.ts +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.72.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- eb2e98f: Inventory: "Main store · <company>" with who manages it, a request-first view for other companies, pending-approval banner for the owner; Used by on items (form, chips, column, filter, bulk edit, report filter); Give to colleague (My Stock) with confirm/decline; Hand-overs report; name · company · service on requests, holdings, history, reports; Issue Stock "Give to" grouped by company with a Used-by warning.
|
|
8
|
+
|
|
9
|
+
## 4.71.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 99042e0: Building Management → Units import dialog: the instructions no longer ask for a block number (the import takes it from the chosen building).
|
|
14
|
+
|
|
3
15
|
## 4.71.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1867,11 +1879,11 @@
|
|
|
1867
1879
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1868
1880
|
own rule from two endpoints the console already calls, unprojected:
|
|
1869
1881
|
|
|
1870
|
-
|
|
1871
|
-
|
|
1882
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1883
|
+
GET /api/roles/id/:role that membership's role document
|
|
1872
1884
|
|
|
1873
|
-
|
|
1874
|
-
|
|
1885
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1886
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1875
1887
|
|
|
1876
1888
|
`role.default` is the marker because it is the only property of a platform
|
|
1877
1889
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
title="Import Blocks, Levels and Units from Excel"
|
|
32
32
|
noun="rows"
|
|
33
33
|
name-label="Building / Level / Unit"
|
|
34
|
-
fill-hint="One row per unit:
|
|
34
|
+
fill-hint="One row per unit: building name, level and unit. Pick Building Name and Level from the dropdowns (type a new name for a new building). No block number needed — an existing building keeps its own and a new one gets the next free number."
|
|
35
35
|
duplicate-note="Units already on that level are skipped and left unchanged."
|
|
36
36
|
template-file-name="units-import-template.xlsx"
|
|
37
37
|
:download-template="() => downloadTemplate('building-units', { site })"
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
</td>
|
|
59
59
|
<td class="text-right">{{ t.type === "use" ? "" : t.storeBalance }}</td>
|
|
60
60
|
<td class="text-caption">{{ describe(t) }}</td>
|
|
61
|
-
<td class="text-caption">{{ t.createdByName }}</td>
|
|
61
|
+
<td class="text-caption">{{ (t as any).createdByLabel || t.createdByName }}</td>
|
|
62
62
|
</tr>
|
|
63
63
|
<tr v-if="!history.length && !loading">
|
|
64
64
|
<td colspan="6" class="text-center text-medium-emphasis py-4">Nothing recorded yet.</td>
|
|
@@ -108,11 +108,14 @@ const formatDate = (d: string) => standardFormatDate(d);
|
|
|
108
108
|
function describe(t: TInventoryTransaction) {
|
|
109
109
|
const parts: string[] = [];
|
|
110
110
|
if (t.type === "receive") parts.push(t.supplier || "", t.reference ? `PO ${t.reference}` : "");
|
|
111
|
-
|
|
112
|
-
if (t.type === "
|
|
111
|
+
const who = (t as any).holderLabel || t.holderName;
|
|
112
|
+
if (t.type === "issue") parts.push(`${t.qty} to ${who}`, t.reference || "");
|
|
113
|
+
if (t.type === "return") parts.push(`${t.qty} from ${who}`);
|
|
114
|
+
// Give to colleague: one row per holder.
|
|
115
|
+
if (t.type === "handover") parts.push(who || "", t.remarks || "");
|
|
113
116
|
if (t.type === "use")
|
|
114
117
|
parts.push(
|
|
115
|
-
`${
|
|
118
|
+
`${who} used ${t.qty}`,
|
|
116
119
|
t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area || "",
|
|
117
120
|
t.reference || "",
|
|
118
121
|
);
|
|
@@ -36,6 +36,21 @@
|
|
|
36
36
|
<v-text-field v-model.number="f.unitCost" type="number" min="0" step="0.01" prefix="$" density="comfortable" :rules="[moneyRule]" hint="Price of one unit" persistent-hint />
|
|
37
37
|
</v-col>
|
|
38
38
|
</v-row>
|
|
39
|
+
<!-- Main store only: which service providers see and may request it (owner 2026-09-29). -->
|
|
40
|
+
<template v-if="store.ownerType === 'property'">
|
|
41
|
+
<InputLabel title="Used by" class="mb-1 mt-2" />
|
|
42
|
+
<v-select
|
|
43
|
+
:model-value="f.usedBy"
|
|
44
|
+
:items="usedByItems"
|
|
45
|
+
multiple
|
|
46
|
+
chips
|
|
47
|
+
closable-chips
|
|
48
|
+
density="comfortable"
|
|
49
|
+
:hint="USED_BY_HINT"
|
|
50
|
+
persistent-hint
|
|
51
|
+
@update:model-value="(v: string[]) => (f.usedBy = nextUsedBy(f.usedBy, v))"
|
|
52
|
+
/>
|
|
53
|
+
</template>
|
|
39
54
|
<!-- Stock already on the shelf, recorded like a delivery (one "Opening stock" entry in the item's history). -->
|
|
40
55
|
<template v-if="!item">
|
|
41
56
|
<div class="text-subtitle-2 mt-4">Opening stock</div>
|
|
@@ -93,6 +108,9 @@
|
|
|
93
108
|
<script setup lang="ts">
|
|
94
109
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
95
110
|
import useUtils from "../composables/useUtils";
|
|
111
|
+
import { normaliseUsedBy, nextUsedBy, USED_BY_ALL, USED_BY_ALL_LABEL, USED_BY_HINT, USED_BY_OPTIONS } from "../utils/inventory-used-by";
|
|
112
|
+
|
|
113
|
+
const usedByItems = [{ value: USED_BY_ALL, title: USED_BY_ALL_LABEL }, ...USED_BY_OPTIONS];
|
|
96
114
|
|
|
97
115
|
const show = defineModel<boolean>({ default: false });
|
|
98
116
|
const props = defineProps({
|
|
@@ -126,6 +144,7 @@ const blank = () => ({
|
|
|
126
144
|
openingReceivedBy: "",
|
|
127
145
|
openingRemarks: "",
|
|
128
146
|
description: "",
|
|
147
|
+
usedBy: [USED_BY_ALL] as string[],
|
|
129
148
|
});
|
|
130
149
|
const today = new Date().toLocaleDateString("en-CA");
|
|
131
150
|
const f = reactive(blank());
|
|
@@ -135,7 +154,7 @@ const inUse = ref(true);
|
|
|
135
154
|
watch(show, (open) => {
|
|
136
155
|
if (!open) return;
|
|
137
156
|
const i = props.item;
|
|
138
|
-
Object.assign(f, blank(), i ? { name: i.name, unit: i.unit, packing: i.packing ?? "", code: i.code, category: i.category, reorderLevel: i.reorderLevel, unitCost: i.unitCost ?? 0, description: i.description } : {});
|
|
157
|
+
Object.assign(f, blank(), i ? { name: i.name, unit: i.unit, packing: i.packing ?? "", code: i.code, category: i.category, reorderLevel: i.reorderLevel, unitCost: i.unitCost ?? 0, description: i.description, usedBy: normaliseUsedBy(i.usedBy) } : {});
|
|
139
158
|
photo.value = i?.photo ? [i.photo] : [];
|
|
140
159
|
inUse.value = i ? i.status === "active" : true;
|
|
141
160
|
});
|
|
@@ -154,6 +173,7 @@ async function save() {
|
|
|
154
173
|
unitCost: Number(f.unitCost || 0),
|
|
155
174
|
description: f.description,
|
|
156
175
|
photo: photo.value[0] ?? "",
|
|
176
|
+
...(props.store.ownerType === "property" ? { usedBy: normaliseUsedBy(f.usedBy) } : {}),
|
|
157
177
|
};
|
|
158
178
|
try {
|
|
159
179
|
if (props.item) {
|
|
@@ -16,19 +16,23 @@
|
|
|
16
16
|
v-if="mode === 'issue'"
|
|
17
17
|
v-model="holder"
|
|
18
18
|
:items="people"
|
|
19
|
-
item-title="name"
|
|
19
|
+
:item-title="(p: any) => p.label || p.name"
|
|
20
20
|
item-value="_id"
|
|
21
21
|
:loading="peopleLoading"
|
|
22
22
|
placeholder="Search a name"
|
|
23
23
|
density="comfortable"
|
|
24
24
|
:rules="[requiredRule]"
|
|
25
25
|
no-filter
|
|
26
|
+
hint="Everyone at this property who can take stock: your staff and the service providers' people."
|
|
27
|
+
persistent-hint
|
|
26
28
|
@update:search="searchPeople"
|
|
27
29
|
>
|
|
28
30
|
<template #item="{ props: p, item }">
|
|
29
|
-
<v-list-
|
|
31
|
+
<v-list-subheader v-if="groupStarts(item.raw)">{{ item.raw.company || "Other" }}</v-list-subheader>
|
|
32
|
+
<v-list-item v-bind="p" :title="item.raw.name" :subtitle="[item.raw.serviceLabel, item.raw.email].filter(Boolean).join(' · ')" />
|
|
30
33
|
</template>
|
|
31
34
|
</v-autocomplete>
|
|
35
|
+
<v-alert v-if="usedByWarning" type="warning" variant="tonal" density="compact" class="mt-2">{{ usedByWarning }}</v-alert>
|
|
32
36
|
<v-text-field v-else :model-value="holderName" density="comfortable" readonly />
|
|
33
37
|
</template>
|
|
34
38
|
|
|
@@ -129,6 +133,7 @@
|
|
|
129
133
|
<script setup lang="ts">
|
|
130
134
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
131
135
|
import useUtils from "../composables/useUtils";
|
|
136
|
+
import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
|
|
132
137
|
|
|
133
138
|
type TMode = "receive" | "issue" | "request" | "return";
|
|
134
139
|
type TRow = { item: string; qty: number | null; unitCost?: number | null };
|
|
@@ -230,7 +235,24 @@ async function loadItems(search = "") {
|
|
|
230
235
|
}
|
|
231
236
|
const searchItems = debounce((q: string) => loadItems(q || ""), 300);
|
|
232
237
|
|
|
233
|
-
|
|
238
|
+
type TPerson = { _id: string; name: string; orgName: string; email?: string; company?: string; service?: string; serviceLabel?: string; label?: string };
|
|
239
|
+
const people = ref<TPerson[]>([]);
|
|
240
|
+
/** A company heading above the first person of each company (the server sorts by company). */
|
|
241
|
+
const groupStarts = (p: TPerson) => {
|
|
242
|
+
const i = people.value.indexOf(p);
|
|
243
|
+
return i === 0 || (i > 0 && people.value[i - 1].company !== p.company);
|
|
244
|
+
};
|
|
245
|
+
/** Used by: warn before the server refuses an item meant for another service. */
|
|
246
|
+
const usedByWarning = computed(() => {
|
|
247
|
+
if (props.mode !== "issue" || !holder.value) return "";
|
|
248
|
+
const person = people.value.find((p) => p._id === holder.value);
|
|
249
|
+
if (!person?.service || person.service === "property") return "";
|
|
250
|
+
const blocked = rows.value
|
|
251
|
+
.map((r) => itemChoices.value.find((c) => c._id === r.item))
|
|
252
|
+
.filter((i): i is TInventoryItem => Boolean(i) && usedByChips(i!.usedBy).length > 0 && !normaliseUsedBy(i!.usedBy).includes(person.service!));
|
|
253
|
+
if (!blocked.length) return "";
|
|
254
|
+
return `${blocked.map((i) => i.name).join(", ")} ${blocked.length === 1 ? "is" : "are"} for ${usedByText(blocked[0].usedBy)} only — ${person.name} works for ${person.serviceLabel || "another service"}.`;
|
|
255
|
+
});
|
|
234
256
|
const peopleLoading = ref(false);
|
|
235
257
|
const searchPeople = debounce(async (q: string) => {
|
|
236
258
|
peopleLoading.value = true;
|
|
@@ -82,6 +82,23 @@
|
|
|
82
82
|
</AppCard>
|
|
83
83
|
|
|
84
84
|
<template v-else>
|
|
85
|
+
<!-- Owner rule 2026-09-29: another organisation's store is for requesting only. -->
|
|
86
|
+
<v-alert v-if="requestOnly" type="info" variant="tonal" density="compact" class="mb-4" icon="mdi-cart-arrow-down">
|
|
87
|
+
<div class="font-weight-medium">Request items from the main store</div>
|
|
88
|
+
<div class="text-body-2">Pick the items you need and press Request Stock. You are told when the store keeper approves it.</div>
|
|
89
|
+
</v-alert>
|
|
90
|
+
<v-alert
|
|
91
|
+
v-else-if="store.access.canApprove && pendingCount > 0 && tab !== 'requests'"
|
|
92
|
+
type="warning"
|
|
93
|
+
variant="tonal"
|
|
94
|
+
density="compact"
|
|
95
|
+
class="mb-4"
|
|
96
|
+
>
|
|
97
|
+
<div class="d-flex align-center flex-wrap ga-2">
|
|
98
|
+
<span>{{ pendingCount === 1 ? "1 request is" : `${pendingCount} requests are` }} waiting for your approval.</span>
|
|
99
|
+
<v-btn size="small" variant="flat" color="warning" class="text-none" @click="tab = 'requests'">Review requests</v-btn>
|
|
100
|
+
</div>
|
|
101
|
+
</v-alert>
|
|
85
102
|
<v-tabs v-model="tab" color="primary" class="mb-4" show-arrows>
|
|
86
103
|
<v-tab v-for="t in tabs" :key="t.value" :value="t.value" class="text-none">
|
|
87
104
|
{{ t.label }}
|
|
@@ -123,7 +140,7 @@ import useInventory from "../composables/useInventory";
|
|
|
123
140
|
import useWorkerTerm from "../composables/useWorkerTerm";
|
|
124
141
|
import useSite from "../composables/useSite";
|
|
125
142
|
import useUtils from "../composables/useUtils";
|
|
126
|
-
import { inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
|
|
143
|
+
import { inventoryManagedBy, inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
|
|
127
144
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
128
145
|
|
|
129
146
|
const props = defineProps({
|
|
@@ -167,8 +184,16 @@ const storeSites = computed(() =>
|
|
|
167
184
|
})),
|
|
168
185
|
);
|
|
169
186
|
|
|
187
|
+
// Another organisation's (main) store: the person requests from it and manages nothing there.
|
|
188
|
+
const requestOnly = computed(() => Boolean(store.value && store.value.owner === false));
|
|
189
|
+
const managedBy = computed(() => (store.value ? inventoryManagedBy(store.value) : ""));
|
|
190
|
+
|
|
170
191
|
const subtitle = computed(() => {
|
|
171
192
|
if (!store.value) return "";
|
|
193
|
+
if (store.value.main && store.value.owner === false) {
|
|
194
|
+
// "Main store · managed by Management Pte Ltd (Property management)"
|
|
195
|
+
return managedBy.value ? `Main store · ${managedBy.value.replace(/^Managed/, "managed")}` : "Main store";
|
|
196
|
+
}
|
|
172
197
|
if (storeSites.value.length > 1) {
|
|
173
198
|
const supplies = inventorySuppliesText(storeSites.value.map((s) => s.name));
|
|
174
199
|
return stores.value.length > 1 ? `${inventoryStoreLabel(store.value, stores.value)} · ${supplies}` : supplies;
|
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
<v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">Request Stock</v-btn>
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
|
+
<!-- Give to colleague: what waits for me to confirm, and what I gave that waits for them. -->
|
|
10
|
+
<AppCard v-for="h in toConfirm" :key="h._id" class="pa-4 mb-3 d-flex flex-wrap align-center ga-3" style="border-left: 4px solid rgb(var(--v-theme-warning))">
|
|
11
|
+
<v-icon icon="mdi-hand-coin-outline" color="warning" />
|
|
12
|
+
<div class="flex-grow-1">
|
|
13
|
+
<div class="font-weight-medium">{{ h.fromLabel || h.fromName }} is giving you {{ h.qty }} {{ h.unit }} {{ h.itemName }}</div>
|
|
14
|
+
<div class="text-caption text-medium-emphasis">{{ standardFormatDate(h.createdAt) }}{{ h.note ? ` · ${h.note}` : "" }} · Confirm once you have it in hand.</div>
|
|
15
|
+
</div>
|
|
16
|
+
<v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" :loading="acting === h._id + 'd'" @click="decide(h, false)">Decline</v-btn>
|
|
17
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === h._id + 'a'" @click="decide(h, true)">I received it</v-btn>
|
|
18
|
+
</AppCard>
|
|
19
|
+
<AppCard v-for="h in waiting" :key="h._id" class="pa-3 mb-3 d-flex flex-wrap align-center ga-3">
|
|
20
|
+
<v-icon icon="mdi-timer-sand" color="medium-emphasis" />
|
|
21
|
+
<div class="flex-grow-1 text-body-2">Waiting for {{ h.toName }} to confirm {{ h.qty }} {{ h.unit }} {{ h.itemName }}.</div>
|
|
22
|
+
<v-btn class="text-none" variant="text" size="small" :loading="acting === h._id + 'c'" @click="cancelHandover(h)">Cancel</v-btn>
|
|
23
|
+
</AppCard>
|
|
24
|
+
|
|
9
25
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
10
26
|
<AppCard v-else-if="!holdings.length" class="pa-6 text-center">
|
|
11
27
|
<div class="text-body-1 mb-1">You are not holding any stock.</div>
|
|
@@ -23,7 +39,10 @@
|
|
|
23
39
|
<div class="font-weight-medium">{{ h.itemName }}</div>
|
|
24
40
|
<div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
|
|
25
41
|
</div>
|
|
26
|
-
<
|
|
42
|
+
<div class="d-flex flex-column ga-1">
|
|
43
|
+
<v-btn v-if="h.qty > 0 && ctl.recordUsage" class="text-none screen-btn-primary" variant="flat" size="small" @click="use(h)">Record usage</v-btn>
|
|
44
|
+
<v-btn v-if="h.qty > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openGive(h)">Give to colleague</v-btn>
|
|
45
|
+
</div>
|
|
27
46
|
</AppCard>
|
|
28
47
|
</v-col>
|
|
29
48
|
</v-row>
|
|
@@ -44,10 +63,10 @@
|
|
|
44
63
|
<tr v-for="t in activity" :key="t._id">
|
|
45
64
|
<td class="text-no-wrap">{{ standardFormatDate(t.date) }}</td>
|
|
46
65
|
<td>{{ t.itemName }}</td>
|
|
47
|
-
<td>{{ t
|
|
66
|
+
<td>{{ activityText(t) }}</td>
|
|
48
67
|
<td class="text-right">{{ t.qty }}</td>
|
|
49
68
|
<td class="text-caption">
|
|
50
|
-
{{ [t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area, t.reference].filter(Boolean).join(" · ") }}
|
|
69
|
+
{{ [t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area, t.type === "handover" ? t.remarks : t.reference].filter(Boolean).join(" · ") }}
|
|
51
70
|
</td>
|
|
52
71
|
<td class="text-right">{{ t.holderBalance }}</td>
|
|
53
72
|
</tr>
|
|
@@ -58,6 +77,54 @@
|
|
|
58
77
|
</v-table>
|
|
59
78
|
|
|
60
79
|
<InventoryUsageDialog v-model="usage.show" :store="store" :site-id="siteId" :holding="usage.holding" :sites="sites" @done="changed" />
|
|
80
|
+
<v-dialog v-model="give.show" max-width="520" persistent>
|
|
81
|
+
<v-card class="screen-modal">
|
|
82
|
+
<v-card-title class="d-flex align-center">
|
|
83
|
+
Give to colleague
|
|
84
|
+
<v-spacer />
|
|
85
|
+
<v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="give.show = false" />
|
|
86
|
+
</v-card-title>
|
|
87
|
+
<v-card-text class="screen-modal__body">
|
|
88
|
+
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
89
|
+
{{ give.holding?.itemName }}: you hold {{ give.holding?.qty }} {{ give.holding?.unit }}. It moves to your colleague once they confirm they received it.
|
|
90
|
+
</p>
|
|
91
|
+
<v-form ref="giveForm">
|
|
92
|
+
<InputLabel title="Colleague" required class="mb-1" />
|
|
93
|
+
<v-autocomplete
|
|
94
|
+
v-model="give.to"
|
|
95
|
+
:items="colleagues"
|
|
96
|
+
:item-title="(p: any) => p.label || p.name"
|
|
97
|
+
item-value="_id"
|
|
98
|
+
:loading="colleaguesLoading"
|
|
99
|
+
placeholder="Search a name"
|
|
100
|
+
density="comfortable"
|
|
101
|
+
no-filter
|
|
102
|
+
:rules="[(v: string) => !!v || 'Choose who you are giving it to']"
|
|
103
|
+
@update:search="searchColleagues"
|
|
104
|
+
/>
|
|
105
|
+
<InputLabel title="Quantity" required class="mb-1" />
|
|
106
|
+
<v-text-field
|
|
107
|
+
v-model.number="give.qty"
|
|
108
|
+
type="number"
|
|
109
|
+
min="1"
|
|
110
|
+
density="comfortable"
|
|
111
|
+
:suffix="give.holding?.unit"
|
|
112
|
+
:rules="[giveQtyRule]"
|
|
113
|
+
hint="Counted in the unit the store issues it in."
|
|
114
|
+
persistent-hint
|
|
115
|
+
/>
|
|
116
|
+
<InputLabel title="Note (optional)" class="mb-1 mt-2" />
|
|
117
|
+
<v-text-field v-model="give.note" density="comfortable" maxlength="500" placeholder="e.g. for the lift lobby job" />
|
|
118
|
+
<InputLabel title="Photo (optional)" class="mb-1" />
|
|
119
|
+
<InventoryPhotoInput v-model="give.photos" :max="1" />
|
|
120
|
+
</v-form>
|
|
121
|
+
</v-card-text>
|
|
122
|
+
<div class="screen-modal__footer">
|
|
123
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="give.saving" @click="give.show = false">Cancel</v-btn>
|
|
124
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="give.saving" @click="submitGive">Give</v-btn>
|
|
125
|
+
</div>
|
|
126
|
+
</v-card>
|
|
127
|
+
</v-dialog>
|
|
61
128
|
<InventoryLinesDialog v-model="showRequest" mode="request" :store="store" :site-id="siteId" @done="changed" />
|
|
62
129
|
</div>
|
|
63
130
|
</template>
|
|
@@ -88,6 +155,100 @@ const activity = ref<TInventoryTransaction[]>([]);
|
|
|
88
155
|
const loading = ref(false);
|
|
89
156
|
const showRequest = ref(false);
|
|
90
157
|
const usage = reactive({ show: false, holding: null as TInventoryHolding | null });
|
|
158
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
159
|
+
|
|
160
|
+
const handovers = ref<TInventoryHandover[]>([]);
|
|
161
|
+
const me = computed(() => String(currentUser.value?._id ?? ""));
|
|
162
|
+
const toConfirm = computed(() => handovers.value.filter((h) => h.status === "pending" && String(h.to) === me.value));
|
|
163
|
+
const waiting = computed(() => handovers.value.filter((h) => h.status === "pending" && String(h.from) === me.value));
|
|
164
|
+
const acting = ref("");
|
|
165
|
+
|
|
166
|
+
function activityText(t: TInventoryTransaction) {
|
|
167
|
+
if (t.type === "issue") return "Taken from store";
|
|
168
|
+
if (t.type === "return") return "Given back to store";
|
|
169
|
+
if (t.type === "handover") return (t.holderChange ?? 0) < 0 ? "Given to colleague" : "Received from colleague";
|
|
170
|
+
return typeLabels[t.type];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function loadHandovers() {
|
|
174
|
+
try {
|
|
175
|
+
handovers.value = (await api.getHandovers(props.store._id, { mine: true, status: "pending", limit: 50 })).items;
|
|
176
|
+
} catch {
|
|
177
|
+
handovers.value = [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function decide(h: TInventoryHandover, accept: boolean) {
|
|
182
|
+
acting.value = h._id + (accept ? "a" : "d");
|
|
183
|
+
try {
|
|
184
|
+
if (accept) await api.acceptHandover(h._id);
|
|
185
|
+
else await api.declineHandover(h._id, "");
|
|
186
|
+
notify(accept ? `You received ${h.qty} ${h.unit} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
|
|
187
|
+
changed();
|
|
188
|
+
} catch (error: any) {
|
|
189
|
+
notify(error?.data?.message || "Could not update the hand-over.", "error");
|
|
190
|
+
} finally {
|
|
191
|
+
acting.value = "";
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function cancelHandover(h: TInventoryHandover) {
|
|
196
|
+
acting.value = h._id + "c";
|
|
197
|
+
try {
|
|
198
|
+
await api.cancelHandover(h._id);
|
|
199
|
+
notify("Hand-over cancelled.");
|
|
200
|
+
changed();
|
|
201
|
+
} catch (error: any) {
|
|
202
|
+
notify(error?.data?.message || "Could not cancel.", "error");
|
|
203
|
+
} finally {
|
|
204
|
+
acting.value = "";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const giveForm = ref();
|
|
209
|
+
const give = reactive({ show: false, holding: null as TInventoryHolding | null, to: "", qty: 1 as number | null, note: "", photos: [] as string[], saving: false });
|
|
210
|
+
const pendingOf = (item: string) => waiting.value.filter((h) => String(h.item) === item).reduce((s, h) => s + h.qty, 0);
|
|
211
|
+
const giveQtyRule = (v: number | null) => {
|
|
212
|
+
if (!v || v < 1 || !Number.isInteger(Number(v))) return "Enter a whole number";
|
|
213
|
+
const free = (give.holding?.qty ?? 0) - pendingOf(String(give.holding?.item ?? ""));
|
|
214
|
+
return Number(v) <= free || `You can give up to ${Math.max(0, free)}`;
|
|
215
|
+
};
|
|
216
|
+
const colleagues = ref<{ _id: string; name: string; label?: string }[]>([]);
|
|
217
|
+
const colleaguesLoading = ref(false);
|
|
218
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
219
|
+
function searchColleagues(q: string) {
|
|
220
|
+
clearTimeout(searchTimer);
|
|
221
|
+
searchTimer = setTimeout(async () => {
|
|
222
|
+
colleaguesLoading.value = true;
|
|
223
|
+
try {
|
|
224
|
+
colleagues.value = await api.getColleagues(props.store._id, q || "");
|
|
225
|
+
} catch {
|
|
226
|
+
colleagues.value = [];
|
|
227
|
+
} finally {
|
|
228
|
+
colleaguesLoading.value = false;
|
|
229
|
+
}
|
|
230
|
+
}, 300);
|
|
231
|
+
}
|
|
232
|
+
function openGive(h: TInventoryHolding) {
|
|
233
|
+
Object.assign(give, { show: true, holding: h, to: "", qty: 1, note: "", photos: [], saving: false });
|
|
234
|
+
searchColleagues("");
|
|
235
|
+
}
|
|
236
|
+
async function submitGive() {
|
|
237
|
+
const { valid } = await giveForm.value.validate();
|
|
238
|
+
if (!valid || !give.holding) return;
|
|
239
|
+
give.saving = true;
|
|
240
|
+
try {
|
|
241
|
+
await api.createHandover(props.store._id, { site: props.siteId, item: String(give.holding.item), to: give.to, qty: Number(give.qty), note: give.note, photos: give.photos });
|
|
242
|
+
const name = colleagues.value.find((c) => c._id === give.to)?.name ?? "your colleague";
|
|
243
|
+
notify(`Waiting for ${name} to confirm.`);
|
|
244
|
+
give.show = false;
|
|
245
|
+
changed();
|
|
246
|
+
} catch (error: any) {
|
|
247
|
+
notify(error?.data?.message || "Could not give it.", "error");
|
|
248
|
+
} finally {
|
|
249
|
+
give.saving = false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
91
252
|
|
|
92
253
|
async function load() {
|
|
93
254
|
loading.value = true;
|
|
@@ -108,9 +269,10 @@ function use(h: TInventoryHolding) {
|
|
|
108
269
|
|
|
109
270
|
function changed() {
|
|
110
271
|
load();
|
|
272
|
+
loadHandovers();
|
|
111
273
|
emit("changed");
|
|
112
274
|
}
|
|
113
275
|
|
|
114
|
-
watch(() => props.store._id, load);
|
|
115
|
-
onMounted(load);
|
|
276
|
+
watch(() => props.store._id, () => (load(), loadHandovers()));
|
|
277
|
+
onMounted(() => (load(), loadHandovers()));
|
|
116
278
|
</script>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
<v-btn value="usage" class="text-none">Usage</v-btn>
|
|
7
7
|
<v-btn v-if="ctl.fullReports" value="technicians" class="text-none">{{ worker.many }}</v-btn>
|
|
8
8
|
<v-btn v-if="ctl.fullReports" value="purchases" class="text-none">Purchases</v-btn>
|
|
9
|
+
<v-btn value="handovers" class="text-none">Hand-overs</v-btn>
|
|
9
10
|
</v-btn-toggle>
|
|
10
11
|
<v-select
|
|
11
12
|
v-if="report === 'purchases'"
|
|
@@ -41,6 +42,24 @@
|
|
|
41
42
|
hide-details
|
|
42
43
|
style="max-width: 260px"
|
|
43
44
|
/>
|
|
45
|
+
<v-select
|
|
46
|
+
v-if="store.ownerType === 'property'"
|
|
47
|
+
v-model="usedBy"
|
|
48
|
+
:items="USED_BY_FILTERS"
|
|
49
|
+
label="Used by"
|
|
50
|
+
density="compact"
|
|
51
|
+
hide-details
|
|
52
|
+
style="max-width: 220px"
|
|
53
|
+
/>
|
|
54
|
+
<v-select
|
|
55
|
+
v-if="serviceChoices.length > 1"
|
|
56
|
+
v-model="serviceFilter"
|
|
57
|
+
:items="[{ value: '', title: 'All companies' }, ...serviceChoices]"
|
|
58
|
+
label="Company / service"
|
|
59
|
+
density="compact"
|
|
60
|
+
hide-details
|
|
61
|
+
style="max-width: 260px"
|
|
62
|
+
/>
|
|
44
63
|
<v-checkbox v-if="report === 'usage'" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
|
|
45
64
|
<v-spacer />
|
|
46
65
|
<v-btn v-if="ctl.fullReports" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-file-excel-outline" :loading="exporting" @click="exportExcel">
|
|
@@ -99,7 +118,7 @@
|
|
|
99
118
|
<v-data-table
|
|
100
119
|
v-model="selected"
|
|
101
120
|
:headers="tableHeaders"
|
|
102
|
-
:items="
|
|
121
|
+
:items="shownRows"
|
|
103
122
|
:loading="loading"
|
|
104
123
|
:show-select="report === 'usage' && canCheck"
|
|
105
124
|
item-value="_id"
|
|
@@ -109,6 +128,11 @@
|
|
|
109
128
|
no-data-text="Nothing recorded in this period."
|
|
110
129
|
>
|
|
111
130
|
<template #item.date="{ value }">{{ standardFormatDate(value) }}</template>
|
|
131
|
+
<template #item.createdAt="{ value }">{{ standardFormatDate(value) }}</template>
|
|
132
|
+
<template #item.decidedAt="{ value }">{{ value ? standardFormatDate(value) : "—" }}</template>
|
|
133
|
+
<template #item.status="{ value }">
|
|
134
|
+
<v-chip size="x-small" variant="tonal" :color="value === 'accepted' ? 'success' : value === 'pending' ? 'warning' : undefined">{{ handoverStatus[value] ?? value }}</v-chip>
|
|
135
|
+
</template>
|
|
112
136
|
<template #item.locationType="{ value }">{{ value === "tenant_unit" ? "Tenant unit" : "Common area" }}</template>
|
|
113
137
|
<template #item.unitCost="{ value }">{{ money(value) }}</template>
|
|
114
138
|
<template #item.totalCost="{ value }">{{ money(value) }}</template>
|
|
@@ -144,6 +168,7 @@ import useWorkerTerm, { workerAppKey } from "../composables/useWorkerTerm";
|
|
|
144
168
|
import useUtils from "../composables/useUtils";
|
|
145
169
|
import useFile from "../composables/useFile";
|
|
146
170
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
171
|
+
import { USED_BY_FILTERS } from "../utils/inventory-used-by";
|
|
147
172
|
|
|
148
173
|
const props = defineProps({
|
|
149
174
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -166,11 +191,12 @@ const appSlug = workerAppKey();
|
|
|
166
191
|
const now = new Date();
|
|
167
192
|
const month = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
|
|
168
193
|
// An approver without `view-reports` gets only the usage check screen.
|
|
169
|
-
const report = ref<"movement" | "usage" | "technicians" | "purchases">(ctl.value.fullReports ? "movement" : "usage");
|
|
194
|
+
const report = ref<"movement" | "usage" | "technicians" | "purchases" | "handovers">(ctl.value.fullReports ? "movement" : "usage");
|
|
170
195
|
const span = ref<"month" | "year">("year");
|
|
171
196
|
const site = ref("");
|
|
172
197
|
const locationType = ref("");
|
|
173
198
|
const uncheckedOnly = ref(false);
|
|
199
|
+
const usedBy = ref("");
|
|
174
200
|
const rows = ref<any[]>([]);
|
|
175
201
|
const selected = ref<string[]>([]);
|
|
176
202
|
const purchases = reactive({ byMonth: [] as { month: string; deliveries: number; totalCost: number }[], bySupplier: [] as { supplier: string; deliveries: number; totalCost: number }[] });
|
|
@@ -191,8 +217,19 @@ const help: Record<string, string> = {
|
|
|
191
217
|
usage: "Every recorded usage: where it was used, for which tenant, by whom, and the cost. Use this for tenant billing. Tick records and mark them checked once verified.",
|
|
192
218
|
technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
|
|
193
219
|
purchases: "Every delivery received, and opening stock entered with new items (Type: Opening stock), with totals by month and by supplier.",
|
|
220
|
+
handovers: "Stock passed from one person to a colleague: who gave it, who received it, and whether they confirmed.",
|
|
194
221
|
};
|
|
195
222
|
|
|
223
|
+
const handoverStatus: Record<string, string> = { pending: "Waiting to confirm", accepted: "Confirmed", declined: "Declined", cancelled: "Cancelled" };
|
|
224
|
+
|
|
225
|
+
// "Company · Service" of the person on each row, for the filter (usage, staff and hand-over reports).
|
|
226
|
+
const companyOf = (r: any) => [r.holderCompany ?? r.fromCompany, r.holderServiceLabel ?? r.fromServiceLabel].filter(Boolean).join(" · ");
|
|
227
|
+
const serviceFilter = ref("");
|
|
228
|
+
const serviceChoices = computed(() =>
|
|
229
|
+
[...new Set(rows.value.map(companyOf).filter(Boolean))].sort().map((v) => ({ value: v, title: v })),
|
|
230
|
+
);
|
|
231
|
+
const shownRows = computed(() => (serviceFilter.value ? rows.value.filter((r) => companyOf(r) === serviceFilter.value || [r.toCompany, r.toServiceLabel].filter(Boolean).join(" · ") === serviceFilter.value) : rows.value));
|
|
232
|
+
|
|
196
233
|
const headers: Record<string, any[]> = {
|
|
197
234
|
movement: [
|
|
198
235
|
{ title: "Item", key: "name" },
|
|
@@ -219,6 +256,8 @@ const headers: Record<string, any[]> = {
|
|
|
219
256
|
{ title: "Unit price", key: "unitCost" },
|
|
220
257
|
{ title: "Total", key: "totalCost" },
|
|
221
258
|
{ title: worker.one, key: "technician" },
|
|
259
|
+
{ title: "Company", key: "holderCompany" },
|
|
260
|
+
{ title: "Service", key: "holderServiceLabel" },
|
|
222
261
|
{ title: "Photos", key: "photos", sortable: false },
|
|
223
262
|
{ title: "Reference", key: "reference" },
|
|
224
263
|
{ title: "Reported by", key: "reportedBy" },
|
|
@@ -226,15 +265,30 @@ const headers: Record<string, any[]> = {
|
|
|
226
265
|
],
|
|
227
266
|
technicians: [
|
|
228
267
|
{ title: "Name", key: "technician" },
|
|
268
|
+
{ title: "Company", key: "holderCompany" },
|
|
269
|
+
{ title: "Service", key: "holderServiceLabel" },
|
|
229
270
|
{ title: "Item", key: "itemName" },
|
|
230
271
|
{ title: "Unit", key: "unit" },
|
|
231
272
|
{ title: "Holding at start", key: "opening" },
|
|
232
273
|
{ title: "Taken", key: "taken" },
|
|
274
|
+
{ title: "From colleagues", key: "gotFromColleagues" },
|
|
233
275
|
{ title: "Used", key: "used" },
|
|
234
276
|
{ title: "Returned", key: "returned" },
|
|
277
|
+
{ title: "To colleagues", key: "gaveToColleagues" },
|
|
235
278
|
{ title: "Holding at end", key: "closing" },
|
|
236
279
|
{ title: "In hand now", key: "inHandNow" },
|
|
237
280
|
],
|
|
281
|
+
handovers: [
|
|
282
|
+
{ title: "Date", key: "createdAt" },
|
|
283
|
+
{ title: "Item", key: "itemName" },
|
|
284
|
+
{ title: "Qty", key: "qty" },
|
|
285
|
+
{ title: "Unit", key: "unit" },
|
|
286
|
+
{ title: "Given by", key: "fromLabel" },
|
|
287
|
+
{ title: "Given to", key: "toLabel" },
|
|
288
|
+
{ title: "Status", key: "status" },
|
|
289
|
+
{ title: "Confirmed / declined", key: "decidedAt" },
|
|
290
|
+
{ title: "Note", key: "note" },
|
|
291
|
+
],
|
|
238
292
|
purchases: [
|
|
239
293
|
{ title: "Date", key: "date" },
|
|
240
294
|
{ title: "Type", key: "kind" },
|
|
@@ -271,6 +325,7 @@ function period(): TInventoryPeriod & { unchecked?: boolean } {
|
|
|
271
325
|
...((report.value === "usage" || report.value === "movement") && site.value ? { site: site.value } : {}),
|
|
272
326
|
...(report.value === "usage" && locationType.value ? { locationType: locationType.value } : {}),
|
|
273
327
|
...(report.value === "usage" && uncheckedOnly.value ? { unchecked: true } : {}),
|
|
328
|
+
...(usedBy.value ? { usedBy: usedBy.value } : {}),
|
|
274
329
|
};
|
|
275
330
|
}
|
|
276
331
|
|
|
@@ -319,6 +374,7 @@ async function exportExcel() {
|
|
|
319
374
|
}
|
|
320
375
|
}
|
|
321
376
|
|
|
322
|
-
watch([report, month, span, site, locationType, uncheckedOnly, () => props.store._id], load);
|
|
377
|
+
watch([report, month, span, site, locationType, uncheckedOnly, usedBy, () => props.store._id], load);
|
|
378
|
+
watch(report, () => (serviceFilter.value = ""));
|
|
323
379
|
onMounted(load);
|
|
324
380
|
</script>
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
<AppCard v-for="r in requests" :key="r._id" class="pa-4 mb-3">
|
|
22
22
|
<div class="d-flex flex-wrap align-center ga-2 mb-2">
|
|
23
|
-
<div class="font-weight-medium">{{ r.requestedByName || "Someone" }}</div>
|
|
23
|
+
<div class="font-weight-medium">{{ r.requestedByLabel || r.requestedByName || "Someone" }}</div>
|
|
24
24
|
<div class="text-caption text-medium-emphasis">{{ standardFormatDateTime(r.createdAt) }}</div>
|
|
25
25
|
<v-chip size="small" :color="chip[r.status].color" variant="tonal">{{ chip[r.status].label }}</v-chip>
|
|
26
26
|
<!-- One store can supply several properties: say which one this request is for. -->
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
<v-dialog v-model="decision.show" max-width="620" persistent scrollable>
|
|
48
48
|
<v-card v-if="decision.request" class="screen-modal">
|
|
49
49
|
<v-card-title class="d-flex align-center">
|
|
50
|
-
Request from {{ decision.request.requestedByName }}
|
|
50
|
+
Request from {{ decision.request.requestedByLabel || decision.request.requestedByName }}
|
|
51
51
|
<v-spacer />
|
|
52
52
|
<v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="decision.show = false" />
|
|
53
53
|
</v-card-title>
|
|
@@ -35,7 +35,25 @@
|
|
|
35
35
|
<v-list-item v-if="legacyToCopy" prepend-icon="mdi-content-copy" title="Copy from old Equipment list" @click="showLegacy = true" />
|
|
36
36
|
</v-list>
|
|
37
37
|
</v-menu>
|
|
38
|
+
<v-btn
|
|
39
|
+
v-if="usedByOwner && selected.length"
|
|
40
|
+
class="text-none screen-btn-ghost"
|
|
41
|
+
variant="outlined"
|
|
42
|
+
prepend-icon="mdi-account-group-outline"
|
|
43
|
+
@click="openBulk"
|
|
44
|
+
>
|
|
45
|
+
Set "Used by" for {{ selected.length }} item{{ selected.length === 1 ? "" : "s" }}
|
|
46
|
+
</v-btn>
|
|
38
47
|
<v-spacer />
|
|
48
|
+
<v-select
|
|
49
|
+
v-if="usedByOwner"
|
|
50
|
+
v-model="usedByFilter"
|
|
51
|
+
:items="USED_BY_FILTERS"
|
|
52
|
+
label="Used by"
|
|
53
|
+
density="compact"
|
|
54
|
+
hide-details
|
|
55
|
+
style="min-width: 200px; max-width: 240px"
|
|
56
|
+
/>
|
|
39
57
|
<v-checkbox v-model="lowOnly" label="Low stock only" density="compact" hide-details class="flex-grow-0" />
|
|
40
58
|
<v-text-field
|
|
41
59
|
v-model="search"
|
|
@@ -56,6 +74,8 @@
|
|
|
56
74
|
v-model:page="page"
|
|
57
75
|
:pages="pages"
|
|
58
76
|
:page-range="pageRange"
|
|
77
|
+
:show-select="usedByOwner"
|
|
78
|
+
v-model:selected="selected"
|
|
59
79
|
@refresh="load"
|
|
60
80
|
@row-click="(row: any) => openItem(row.item)"
|
|
61
81
|
>
|
|
@@ -68,6 +88,9 @@
|
|
|
68
88
|
<div>
|
|
69
89
|
<div class="font-weight-medium">{{ item.name }}</div>
|
|
70
90
|
<div class="text-caption text-medium-emphasis">{{ inventoryItemMeta(item) }}</div>
|
|
91
|
+
<div v-if="isMain && usedByChips(item.usedBy).length" class="d-flex flex-wrap ga-1 mt-1">
|
|
92
|
+
<v-chip v-for="c in usedByChips(item.usedBy)" :key="c" size="x-small" variant="tonal" color="primary">{{ c }}</v-chip>
|
|
93
|
+
</div>
|
|
71
94
|
</div>
|
|
72
95
|
</div>
|
|
73
96
|
</template>
|
|
@@ -78,6 +101,7 @@
|
|
|
78
101
|
<v-chip v-if="item.status === 'inactive'" size="x-small" variant="tonal" class="ml-2">Not in use</v-chip>
|
|
79
102
|
</template>
|
|
80
103
|
<template #item.reorderLevel="{ value }">{{ value || "—" }}</template>
|
|
104
|
+
<template #item.usedBy="{ item }">{{ usedByText(item.usedBy) }}</template>
|
|
81
105
|
<template #item.unitCost="{ value }">{{ value !== undefined ? money(value) : "—" }}</template>
|
|
82
106
|
<template #no-data>
|
|
83
107
|
<div class="py-8 text-center">
|
|
@@ -94,6 +118,29 @@
|
|
|
94
118
|
<InventoryImportDialog v-model="showImport" :store="store" @done="done" />
|
|
95
119
|
<InventoryImportDialog v-model="showReceiveImport" :store="store" kind="receipts" @done="done" />
|
|
96
120
|
<InventoryLegacyImportDialog v-model="showLegacy" :store="store" @done="done" />
|
|
121
|
+
<v-dialog v-model="bulk.show" max-width="480">
|
|
122
|
+
<v-card class="screen-modal">
|
|
123
|
+
<v-card-title>Set "Used by"</v-card-title>
|
|
124
|
+
<v-card-text class="screen-modal__body">
|
|
125
|
+
<p class="text-body-2 mb-3">For the {{ selected.length }} selected item{{ selected.length === 1 ? "" : "s" }}.</p>
|
|
126
|
+
<v-select
|
|
127
|
+
:model-value="bulk.usedBy"
|
|
128
|
+
:items="usedByItems"
|
|
129
|
+
multiple
|
|
130
|
+
chips
|
|
131
|
+
closable-chips
|
|
132
|
+
density="comfortable"
|
|
133
|
+
:hint="USED_BY_HINT"
|
|
134
|
+
persistent-hint
|
|
135
|
+
@update:model-value="(v: string[]) => (bulk.usedBy = nextUsedBy(bulk.usedBy, v))"
|
|
136
|
+
/>
|
|
137
|
+
</v-card-text>
|
|
138
|
+
<div class="screen-modal__footer">
|
|
139
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="bulk.saving" @click="bulk.show = false">Cancel</v-btn>
|
|
140
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="bulk.saving" @click="saveBulk">Save</v-btn>
|
|
141
|
+
</div>
|
|
142
|
+
</v-card>
|
|
143
|
+
</v-dialog>
|
|
97
144
|
<InventoryItemDetail v-model="detail.show" :store="store" :item="detail.item" @edit="editItem" @done="done" />
|
|
98
145
|
</div>
|
|
99
146
|
</template>
|
|
@@ -105,6 +152,7 @@ import useUtils from "../composables/useUtils";
|
|
|
105
152
|
import { inventoryItemMeta } from "../utils/inventory-store-label";
|
|
106
153
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
107
154
|
import { hasLegacyEquipmentToCopy } from "../utils/inventory-legacy";
|
|
155
|
+
import { nextUsedBy, USED_BY_ALL, USED_BY_ALL_LABEL, USED_BY_FILTERS, USED_BY_HINT, USED_BY_OPTIONS, usedByChips, usedByText } from "../utils/inventory-used-by";
|
|
108
156
|
|
|
109
157
|
const props = defineProps({
|
|
110
158
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -114,7 +162,34 @@ const props = defineProps({
|
|
|
114
162
|
const ctl = computed(() => inventoryControls(props.store.access));
|
|
115
163
|
const emit = defineEmits(["changed"]);
|
|
116
164
|
|
|
117
|
-
const { getItems, getLegacyEquipment } = useInventory();
|
|
165
|
+
const { getItems, getLegacyEquipment, setUsedBy } = useInventory();
|
|
166
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
167
|
+
|
|
168
|
+
// Used by belongs to the main (property) store; its owner sees the column, the filter and bulk edit.
|
|
169
|
+
const isMain = computed(() => props.store.ownerType === "property");
|
|
170
|
+
const usedByOwner = computed(() => isMain.value && props.store.access.canManage);
|
|
171
|
+
const usedByFilter = ref("");
|
|
172
|
+
const selected = ref<string[]>([]);
|
|
173
|
+
const usedByItems = [{ value: USED_BY_ALL, title: USED_BY_ALL_LABEL }, ...USED_BY_OPTIONS];
|
|
174
|
+
const bulk = reactive({ show: false, usedBy: [USED_BY_ALL] as string[], saving: false });
|
|
175
|
+
function openBulk() {
|
|
176
|
+
Object.assign(bulk, { show: true, usedBy: [USED_BY_ALL], saving: false });
|
|
177
|
+
}
|
|
178
|
+
async function saveBulk() {
|
|
179
|
+
bulk.saving = true;
|
|
180
|
+
try {
|
|
181
|
+
const ids = selected.value.map((s: any) => (typeof s === "string" ? s : s._id));
|
|
182
|
+
const res = await setUsedBy(props.store._id, ids, bulk.usedBy);
|
|
183
|
+
notify(`"Used by" set to ${usedByText(bulk.usedBy)} for ${res?.updated ?? ids.length} item(s).`);
|
|
184
|
+
bulk.show = false;
|
|
185
|
+
selected.value = [];
|
|
186
|
+
load();
|
|
187
|
+
} catch (error: any) {
|
|
188
|
+
notify(error?.data?.message || "Could not save.", "error");
|
|
189
|
+
} finally {
|
|
190
|
+
bulk.saving = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
118
193
|
const { getFileUrl } = useFile();
|
|
119
194
|
const { debounce } = useUtils();
|
|
120
195
|
|
|
@@ -135,6 +210,7 @@ const headers = computed(() => [
|
|
|
135
210
|
{ title: "Item", value: "name" },
|
|
136
211
|
{ title: "In store", value: "onHand" },
|
|
137
212
|
{ title: "Low stock alert at", value: "reorderLevel" },
|
|
213
|
+
...(usedByOwner.value ? [{ title: "Used by", value: "usedBy" }] : []),
|
|
138
214
|
...(ctl.value.unitPrice ? [{ title: "Unit price", value: "unitCost" }] : []),
|
|
139
215
|
]);
|
|
140
216
|
|
|
@@ -149,6 +225,7 @@ async function load() {
|
|
|
149
225
|
search: search.value || "",
|
|
150
226
|
lowStock: lowOnly.value || undefined,
|
|
151
227
|
includeInactive: props.store.access.canManage || undefined,
|
|
228
|
+
usedBy: (usedByOwner.value && usedByFilter.value) || undefined,
|
|
152
229
|
});
|
|
153
230
|
items.value = res.items;
|
|
154
231
|
pages.value = res.pages;
|
|
@@ -199,6 +276,7 @@ function done() {
|
|
|
199
276
|
|
|
200
277
|
watch(page, load);
|
|
201
278
|
watch(lowOnly, () => (page.value === 1 ? load() : (page.value = 1)));
|
|
279
|
+
watch(usedByFilter, () => (page.value === 1 ? load() : (page.value = 1)));
|
|
202
280
|
watch(search, debounce(() => (page.value === 1 ? load() : (page.value = 1)), 400));
|
|
203
281
|
watch(() => props.store._id, () => (checkLegacy(), page.value === 1 ? load() : (page.value = 1)));
|
|
204
282
|
onMounted(() => (load(), checkLegacy()));
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
<AppCard v-for="g in groups" :key="g.holder" class="pa-4 mb-3">
|
|
13
13
|
<div class="d-flex align-center mb-2">
|
|
14
14
|
<div class="font-weight-medium">{{ g.holderName || "Unnamed" }}</div>
|
|
15
|
+
<div v-if="g.company || g.serviceLabel" class="text-caption text-medium-emphasis">{{ [g.company, g.serviceLabel].filter(Boolean).join(" · ") }}</div>
|
|
15
16
|
<v-spacer />
|
|
16
17
|
<v-btn v-if="ctl.takeReturn" class="text-none screen-btn-ghost" variant="outlined" size="small" prepend-icon="mdi-keyboard-return" @click="openReturn(g)">Receive return</v-btn>
|
|
17
18
|
</div>
|
|
@@ -47,11 +48,11 @@ const search = ref("");
|
|
|
47
48
|
|
|
48
49
|
const groups = computed(() => {
|
|
49
50
|
const q = (search.value || "").toLowerCase();
|
|
50
|
-
const map = new Map<string, { holder: string; holderName: string; items: TInventoryHolding[] }>();
|
|
51
|
+
const map = new Map<string, { holder: string; holderName: string; company?: string; serviceLabel?: string; items: TInventoryHolding[] }>();
|
|
51
52
|
for (const h of holdings.value) {
|
|
52
53
|
if (q && !h.holderName.toLowerCase().includes(q) && !h.itemName.toLowerCase().includes(q)) continue;
|
|
53
54
|
const key = String(h.holder);
|
|
54
|
-
if (!map.has(key)) map.set(key, { holder: key, holderName: h.holderName, items: [] });
|
|
55
|
+
if (!map.has(key)) map.set(key, { holder: key, holderName: h.holderName, company: (h as any).holderCompany, serviceLabel: (h as any).holderServiceLabel, items: [] });
|
|
55
56
|
map.get(key)!.items.push(h);
|
|
56
57
|
}
|
|
57
58
|
return [...map.values()];
|
|
@@ -17,6 +17,7 @@ export default function useInventory() {
|
|
|
17
17
|
use: "Used",
|
|
18
18
|
return: "Returned",
|
|
19
19
|
adjust: "Stock count",
|
|
20
|
+
handover: "Hand-over",
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
return {
|
|
@@ -29,10 +30,13 @@ export default function useInventory() {
|
|
|
29
30
|
updateStore: (id: string, body: Partial<Pick<TInventoryStore, "name" | "sites" | "showInResidentApp" | "showCostInResidentApp">>) =>
|
|
30
31
|
api<TInventoryStore>(`/stores/${id}`, { method: "PATCH", body }),
|
|
31
32
|
|
|
32
|
-
getItems: (store: string, query: { page?: number; limit?: number; search?: string; lowStock?: boolean; includeInactive?: boolean }) =>
|
|
33
|
+
getItems: (store: string, query: { page?: number; limit?: number; search?: string; lowStock?: boolean; includeInactive?: boolean; usedBy?: string }) =>
|
|
33
34
|
api<{ items: TInventoryItem[]; total: number; lowCount: number; page: number; pages: number }>(`/stores/${store}/items`, { query }),
|
|
34
35
|
createItem: (store: string, body: Record<string, any>) => api(`/stores/${store}/items`, { method: "POST", body }),
|
|
35
36
|
updateItem: (id: string, body: Record<string, any>) => api(`/items/${id}`, { method: "PATCH", body }),
|
|
37
|
+
/** Bulk edit: set "Used by" on several items (store owner). */
|
|
38
|
+
setUsedBy: (store: string, items: string[], usedBy: string[]) =>
|
|
39
|
+
api<{ updated: number }>(`/stores/${store}/items/used-by`, { method: "POST", body: { items, usedBy } }),
|
|
36
40
|
|
|
37
41
|
receive: (store: string, body: Record<string, any>) => api(`/stores/${store}/receive`, { method: "POST", body }),
|
|
38
42
|
count: (store: string, body: { item: string; countedQty: number; reason: string }) =>
|
|
@@ -51,15 +55,26 @@ export default function useInventory() {
|
|
|
51
55
|
|
|
52
56
|
getHoldings: (store: string, holder: string = "me", includeUsedUp = false) =>
|
|
53
57
|
api<TInventoryHolding[]>(`/stores/${store}/holdings`, { query: { holder, ...(includeUsedUp ? { includeUsedUp: true } : {}) } }),
|
|
58
|
+
/** Give to colleague. */
|
|
59
|
+
getHandovers: (store: string, query: { mine?: boolean; status?: string; item?: string; page?: number; limit?: number }) =>
|
|
60
|
+
api<{ items: TInventoryHandover[]; total: number; waitingForMe: number }>(`/stores/${store}/handovers`, { query }),
|
|
61
|
+
createHandover: (store: string, body: { site: string; item: string; to: string; qty: number; note?: string; photos?: string[] }) =>
|
|
62
|
+
api<TInventoryHandover>(`/stores/${store}/handovers`, { method: "POST", body }),
|
|
63
|
+
acceptHandover: (id: string) => api(`/handovers/${id}/accept`, { method: "POST" }),
|
|
64
|
+
declineHandover: (id: string, reason = "") => api(`/handovers/${id}/decline`, { method: "POST", body: { reason } }),
|
|
65
|
+
cancelHandover: (id: string) => api(`/handovers/${id}/cancel`, { method: "POST" }),
|
|
66
|
+
getColleagues: (store: string, search = "") =>
|
|
67
|
+
api<{ _id: string; name: string; company?: string; serviceLabel?: string; label?: string }[]>(`/stores/${store}/colleagues`, { query: { search } }),
|
|
54
68
|
getTransactions: (store: string, query: Record<string, any>) =>
|
|
55
69
|
api<{ items: TInventoryTransaction[]; total: number; page: number; pages: number }>(`/stores/${store}/transactions`, { query }),
|
|
56
70
|
|
|
57
71
|
getUnits: (store: string, site: string, search = "") =>
|
|
58
72
|
api<{ _id: string; name: string; tenantName: string }[]>(`/stores/${store}/units`, { query: { site, search } }),
|
|
59
73
|
getCommonAreas: (store: string, site: string) => api<string[]>(`/stores/${store}/common-areas`, { query: { site } }),
|
|
60
|
-
getPeople: (store: string, search = "") =>
|
|
74
|
+
getPeople: (store: string, search = "") =>
|
|
75
|
+
api<{ _id: string; name: string; orgName: string; email?: string; company?: string; service?: string; serviceLabel?: string; label?: string }[]>(`/stores/${store}/people`, { query: { search } }),
|
|
61
76
|
|
|
62
|
-
getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases", query: TInventoryPeriod & { unchecked?: boolean }) =>
|
|
77
|
+
getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers", query: TInventoryPeriod & { unchecked?: boolean }) =>
|
|
63
78
|
api<{ rows: any[]; totalQty?: number; totalCost?: number }>(`/stores/${store}/reports/${report}`, { query }),
|
|
64
79
|
exportReport: (store: string, report: string, query: TInventoryPeriod & { appSlug?: string }) =>
|
|
65
80
|
api<Blob>(`/stores/${store}/reports/${report}/export`, { query, responseType: "blob" }),
|
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.72.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
|
@@ -23,6 +23,13 @@ declare type TInventoryStore = {
|
|
|
23
23
|
showInResidentApp: boolean;
|
|
24
24
|
showCostInResidentApp: boolean;
|
|
25
25
|
access: TInventoryAccess;
|
|
26
|
+
/** The property manager's store: the site's main store, which providers request from. */
|
|
27
|
+
main?: boolean;
|
|
28
|
+
/** The caller belongs to the organisation that owns (manages) this store. */
|
|
29
|
+
owner?: boolean;
|
|
30
|
+
ownerService?: string | null;
|
|
31
|
+
/** "Property management", "M&E", ... */
|
|
32
|
+
ownerServiceLabel?: string;
|
|
26
33
|
};
|
|
27
34
|
|
|
28
35
|
declare type TInventoryItem = {
|
|
@@ -40,6 +47,8 @@ declare type TInventoryItem = {
|
|
|
40
47
|
onHand: number;
|
|
41
48
|
status: "active" | "inactive";
|
|
42
49
|
lowStock: boolean;
|
|
50
|
+
/** Main store: which service providers see and may request it (["all"] or service keys). */
|
|
51
|
+
usedBy?: string[];
|
|
43
52
|
};
|
|
44
53
|
|
|
45
54
|
declare type TInventoryLine = { item: string; qty: number };
|
|
@@ -80,7 +89,7 @@ declare type TInventoryTransaction = {
|
|
|
80
89
|
item: string;
|
|
81
90
|
itemName: string;
|
|
82
91
|
unit: string;
|
|
83
|
-
type: "opening" | "receive" | "issue" | "use" | "return" | "adjust";
|
|
92
|
+
type: "opening" | "receive" | "issue" | "use" | "return" | "adjust" | "handover";
|
|
84
93
|
qty: number;
|
|
85
94
|
storeChange: number;
|
|
86
95
|
storeBalance: number;
|
|
@@ -102,7 +111,7 @@ declare type TInventoryTransaction = {
|
|
|
102
111
|
createdByName: string;
|
|
103
112
|
};
|
|
104
113
|
|
|
105
|
-
declare type TInventoryPeriod = { from: string; to: string; site?: string; locationType?: string };
|
|
114
|
+
declare type TInventoryPeriod = { from: string; to: string; site?: string; locationType?: string; usedBy?: string };
|
|
106
115
|
|
|
107
116
|
/** An organisation's supplier (shared by its stores). Only the name is required. */
|
|
108
117
|
declare type TInventorySupplier = {
|
|
@@ -116,3 +125,26 @@ declare type TInventorySupplier = {
|
|
|
116
125
|
notes: string;
|
|
117
126
|
status: "active" | "inactive";
|
|
118
127
|
};
|
|
128
|
+
|
|
129
|
+
/** Give to colleague: stock in hand passed to a colleague, pending until they confirm. */
|
|
130
|
+
declare type TInventoryHandover = {
|
|
131
|
+
_id: string;
|
|
132
|
+
store: string;
|
|
133
|
+
site: string;
|
|
134
|
+
item: string;
|
|
135
|
+
itemName: string;
|
|
136
|
+
unit: string;
|
|
137
|
+
qty: number;
|
|
138
|
+
from: string;
|
|
139
|
+
fromName: string;
|
|
140
|
+
fromLabel?: string;
|
|
141
|
+
to: string;
|
|
142
|
+
toName: string;
|
|
143
|
+
toLabel?: string;
|
|
144
|
+
note: string;
|
|
145
|
+
photos: string[];
|
|
146
|
+
status: "pending" | "accepted" | "declined" | "cancelled";
|
|
147
|
+
declineReason?: string;
|
|
148
|
+
createdAt: string;
|
|
149
|
+
decidedAt?: string;
|
|
150
|
+
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* same kind (e.g. staff of several organisations at one property), each one
|
|
5
5
|
* also names its organisation so the buttons can be told apart.
|
|
6
6
|
*/
|
|
7
|
-
type TStoreLike = { ownerType: string; orgName?: string };
|
|
7
|
+
type TStoreLike = { ownerType: string; orgName?: string; main?: boolean; owner?: boolean; ownerServiceLabel?: string };
|
|
8
8
|
|
|
9
9
|
/** "Supplies Winsland House I and Winsland House II" for a store that supplies several sites. */
|
|
10
10
|
export function inventorySuppliesText(names: string[]) {
|
|
@@ -14,11 +14,24 @@ export function inventorySuppliesText(names: string[]) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function inventoryStoreLabel(store: TStoreLike, all: TStoreLike[]) {
|
|
17
|
+
// Owner rule 2026-09-29: the property manager's store is the site's MAIN store,
|
|
18
|
+
// named with who runs it; a provider's own store is "Our store".
|
|
19
|
+
if (store.main !== undefined) {
|
|
20
|
+
if (store.main) return store.orgName ? `Main store · ${store.orgName}` : "Main store";
|
|
21
|
+
const own = all.filter((s) => !s.main).length;
|
|
22
|
+
return own > 1 && store.orgName ? `Our store · ${store.orgName}` : "Our store";
|
|
23
|
+
}
|
|
17
24
|
const kind = store.ownerType === "property" ? "Property store" : "Our store";
|
|
18
25
|
const sameKind = all.filter((s) => s.ownerType === store.ownerType).length;
|
|
19
26
|
return sameKind > 1 && store.orgName ? `${kind} · ${store.orgName}` : kind;
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
/** "Managed by Management Pte Ltd (Property management)" — who runs the store. */
|
|
30
|
+
export function inventoryManagedBy(store: TStoreLike) {
|
|
31
|
+
if (!store.orgName) return "";
|
|
32
|
+
return store.ownerServiceLabel ? `Managed by ${store.orgName} (${store.ownerServiceLabel})` : `Managed by ${store.orgName}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
/**
|
|
23
36
|
* The properties a store can be set to supply, by name. The organisation's own
|
|
24
37
|
* site list does not always contain the store's current property (a manager
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Used by" (owner 2026-09-29): which service providers see and may request an
|
|
3
|
+
* item from the main store. Mirrors API-core `utils/inventory-used-by.ts`.
|
|
4
|
+
* Browser-safe, no Node globals.
|
|
5
|
+
*/
|
|
6
|
+
export const USED_BY_ALL = "all";
|
|
7
|
+
export const USED_BY_ALL_LABEL = "All service providers";
|
|
8
|
+
|
|
9
|
+
export const USED_BY_OPTIONS = [
|
|
10
|
+
{ value: "cleaning", title: "Cleaning" },
|
|
11
|
+
{ value: "security", title: "Security" },
|
|
12
|
+
{ value: "mne", title: "M&E" },
|
|
13
|
+
{ value: "pest", title: "Pest control" },
|
|
14
|
+
{ value: "pool", title: "Pool" },
|
|
15
|
+
{ value: "landscape", title: "Landscape" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const USED_BY_HINT = "Choose who can see and request this item. Leave as All if everyone uses it.";
|
|
19
|
+
|
|
20
|
+
/** Stored/sent value, cleaned: missing, empty or containing "all" means all. */
|
|
21
|
+
export function normaliseUsedBy(value?: readonly string[] | null): string[] {
|
|
22
|
+
const list = (value ?? []).map(String);
|
|
23
|
+
if (!list.length || list.includes(USED_BY_ALL)) return [USED_BY_ALL];
|
|
24
|
+
const keys = USED_BY_OPTIONS.map((o) => o.value).filter((k) => list.includes(k));
|
|
25
|
+
return keys.length ? keys : [USED_BY_ALL];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Chips for one item: nothing for "all" (the usual case says nothing), else one word per service. */
|
|
29
|
+
export function usedByChips(value?: readonly string[] | null): string[] {
|
|
30
|
+
const keys = normaliseUsedBy(value);
|
|
31
|
+
if (keys[0] === USED_BY_ALL) return [];
|
|
32
|
+
return keys.map((k) => USED_BY_OPTIONS.find((o) => o.value === k)!.title);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "All service providers" / "Cleaning, M&E" — for a table cell or a sentence. */
|
|
36
|
+
export function usedByText(value?: readonly string[] | null) {
|
|
37
|
+
const chips = usedByChips(value);
|
|
38
|
+
return chips.length ? chips.join(", ") : USED_BY_ALL_LABEL;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The multi-select's model <-> the stored value. Picking a service clears
|
|
43
|
+
* "All"; picking "All" clears the services; clearing everything means All.
|
|
44
|
+
*/
|
|
45
|
+
export function nextUsedBy(previous: readonly string[], picked: readonly string[]): string[] {
|
|
46
|
+
const added = picked.filter((p) => !previous.includes(p));
|
|
47
|
+
if (added.includes(USED_BY_ALL) || !picked.length) return [USED_BY_ALL];
|
|
48
|
+
const services = picked.filter((p) => p !== USED_BY_ALL);
|
|
49
|
+
return services.length ? services : [USED_BY_ALL];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Filter choices for the owner's stock list and reports. */
|
|
53
|
+
export const USED_BY_FILTERS = [
|
|
54
|
+
{ value: "", title: "Everyone's items" },
|
|
55
|
+
{ value: USED_BY_ALL, title: "For all providers only" },
|
|
56
|
+
...USED_BY_OPTIONS.map((o) => ({ value: o.value, title: `Offered to ${o.title}` })),
|
|
57
|
+
];
|