@7365admin1/layer-common 4.71.2 → 4.73.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/InventoryHistoryDialog.vue +205 -0
- package/components/InventoryItemDetail.vue +7 -4
- package/components/InventoryItemDialog.vue +21 -1
- package/components/InventoryLinesDialog.vue +25 -3
- package/components/InventoryMain.vue +49 -3
- package/components/InventoryMyStockTab.vue +227 -10
- package/components/InventoryPhotoThumbs.vue +67 -0
- package/components/InventoryReportsTab.vue +208 -26
- package/components/InventoryRequestsTab.vue +2 -2
- package/components/InventoryStockTab.vue +79 -1
- package/components/InventoryTechniciansTab.vue +3 -2
- package/components/InventoryUsageDialog.vue +119 -28
- package/composables/useInventory.ts +28 -5
- package/package.json +1 -1
- package/types/inventory.d.ts +51 -3
- package/utils/inventory-store-label.ts +14 -1
- package/utils/inventory-usage.ts +94 -0
- package/utils/inventory-used-by.ts +57 -0
|
@@ -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 v-if="ctl.fullReports || ctl.review" 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,21 +42,54 @@
|
|
|
41
42
|
hide-details
|
|
42
43
|
style="max-width: 260px"
|
|
43
44
|
/>
|
|
44
|
-
<v-
|
|
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
|
+
/>
|
|
63
|
+
<v-checkbox v-if="report === 'usage' && canCheck" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
|
|
45
64
|
<v-spacer />
|
|
46
|
-
<v-btn v-if="ctl.fullReports" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-file-excel-outline" :loading="exporting" @click="exportExcel">
|
|
65
|
+
<v-btn v-if="ctl.fullReports || report === 'usage'" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-file-excel-outline" :loading="exporting" @click="exportExcel">
|
|
47
66
|
Export Excel
|
|
48
67
|
</v-btn>
|
|
49
68
|
</div>
|
|
50
69
|
|
|
70
|
+
<!-- Usage tracking (owner 2026-09-29): who used what from this store, where, for which work order, with photos. -->
|
|
71
|
+
<div v-if="report === 'usage'" class="d-flex flex-wrap ga-2 align-center mb-3">
|
|
72
|
+
<v-select v-model="status" :items="USAGE_STATUS_CHOICES" label="Status" density="compact" hide-details style="max-width: 200px" />
|
|
73
|
+
<v-select v-model="person" :items="[{ value: '', title: 'Everyone' }, ...personChoices]" label="Person" density="compact" hide-details style="max-width: 280px" />
|
|
74
|
+
<v-select v-model="itemFilter" :items="[{ value: '', title: 'All items' }, ...itemChoices]" label="Item" density="compact" hide-details style="max-width: 220px" />
|
|
75
|
+
<v-text-field v-model="locationText" label="Where (unit, tenant or area)" density="compact" hide-details clearable style="max-width: 220px" />
|
|
76
|
+
<v-text-field v-model="referenceText" label="Work order / ref" density="compact" hide-details clearable style="max-width: 180px" />
|
|
77
|
+
<v-select v-model="flag" :items="flagChoices" label="Needs a look" density="compact" hide-details style="max-width: 220px" />
|
|
78
|
+
<v-text-field v-model="search" label="Search" prepend-inner-icon="mdi-magnify" density="compact" hide-details clearable style="max-width: 220px" />
|
|
79
|
+
</div>
|
|
80
|
+
<v-alert v-if="report === 'usage' && scope === 'own_company'" type="info" variant="tonal" density="compact" class="mb-2">
|
|
81
|
+
You see your own company's people only. The store's owner sees everyone's.
|
|
82
|
+
</v-alert>
|
|
83
|
+
|
|
51
84
|
<p class="text-caption text-medium-emphasis mb-2">{{ help[report] }}</p>
|
|
52
85
|
<p v-if="report === 'movement' && siteName" class="text-caption text-medium-emphasis mb-2">
|
|
53
86
|
Start, received and end are the whole store's (shared by {{ sites.map((s) => s.name).join(" and ") }}). The last columns show what was issued for {{ siteName }} and used there.
|
|
54
87
|
</p>
|
|
55
88
|
|
|
56
|
-
<div v-if="report === 'usage' &&
|
|
89
|
+
<div v-if="report === 'usage' && shownRows.length" class="d-flex flex-wrap align-center ga-3 mb-2">
|
|
57
90
|
<div class="text-body-2 font-weight-medium">
|
|
58
|
-
{{
|
|
91
|
+
<template v-if="usageSummary.records">{{ usageSummary.records }} record(s) · {{ usageSummary.qty }} item(s) used · Total cost {{ money(usageSummary.cost) }}</template>
|
|
92
|
+
<template v-if="usageSummary.drafts">{{ usageSummary.records ? " · " : "" }}{{ usageSummary.drafts }} in progress (drafts — not counted as usage until submitted)</template>
|
|
59
93
|
</div>
|
|
60
94
|
<v-spacer />
|
|
61
95
|
<template v-if="canCheck">
|
|
@@ -69,6 +103,19 @@
|
|
|
69
103
|
</template>
|
|
70
104
|
</div>
|
|
71
105
|
|
|
106
|
+
<v-row v-if="report === 'usage' && usageSummary.records" dense class="mb-2">
|
|
107
|
+
<v-col v-for="box in totalBoxes" :key="box.title" cols="12" md="4">
|
|
108
|
+
<AppCard class="pa-3">
|
|
109
|
+
<div class="text-subtitle-2 mb-2">{{ box.title }}</div>
|
|
110
|
+
<div v-for="t in box.rows.slice(0, 6)" :key="t.label" class="d-flex justify-space-between text-body-2 mb-1 ga-2">
|
|
111
|
+
<span class="text-truncate">{{ t.label }}</span>
|
|
112
|
+
<span class="text-no-wrap">{{ t.records }} × · {{ t.qty }}{{ t.unit ? ` ${t.unit}` : "" }} · {{ money(t.totalCost) }}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div v-if="box.rows.length > 6" class="text-caption text-medium-emphasis">and {{ box.rows.length - 6 }} more (all in the Excel file)</div>
|
|
115
|
+
</AppCard>
|
|
116
|
+
</v-col>
|
|
117
|
+
</v-row>
|
|
118
|
+
|
|
72
119
|
<template v-if="report === 'purchases' && rows.length">
|
|
73
120
|
<div class="text-body-2 font-weight-medium mb-2">Total purchases {{ money(totals.cost) }}</div>
|
|
74
121
|
<v-row dense class="mb-2">
|
|
@@ -99,16 +146,22 @@
|
|
|
99
146
|
<v-data-table
|
|
100
147
|
v-model="selected"
|
|
101
148
|
:headers="tableHeaders"
|
|
102
|
-
:items="
|
|
149
|
+
:items="shownRows"
|
|
103
150
|
:loading="loading"
|
|
104
151
|
:show-select="report === 'usage' && canCheck"
|
|
152
|
+
:item-selectable="(r: any) => r.status !== 'draft'"
|
|
105
153
|
item-value="_id"
|
|
106
154
|
density="compact"
|
|
107
155
|
:items-per-page="50"
|
|
108
156
|
class="border rounded"
|
|
109
|
-
no-data-text="
|
|
157
|
+
:no-data-text="noDataText"
|
|
110
158
|
>
|
|
111
159
|
<template #item.date="{ value }">{{ standardFormatDate(value) }}</template>
|
|
160
|
+
<template #item.createdAt="{ value }">{{ standardFormatDate(value) }}</template>
|
|
161
|
+
<template #item.decidedAt="{ value }">{{ value ? standardFormatDate(value) : "—" }}</template>
|
|
162
|
+
<template #item.status="{ value }">
|
|
163
|
+
<v-chip size="x-small" variant="tonal" :color="value === 'accepted' ? 'success' : value === 'pending' ? 'warning' : undefined">{{ handoverStatus[value] ?? value }}</v-chip>
|
|
164
|
+
</template>
|
|
112
165
|
<template #item.locationType="{ value }">{{ value === "tenant_unit" ? "Tenant unit" : "Common area" }}</template>
|
|
113
166
|
<template #item.unitCost="{ value }">{{ money(value) }}</template>
|
|
114
167
|
<template #item.totalCost="{ value }">{{ money(value) }}</template>
|
|
@@ -118,14 +171,31 @@
|
|
|
118
171
|
<template #item.receivedCost="{ value }">{{ money(value) }}</template>
|
|
119
172
|
<template #item.usedCost="{ value }">{{ money(value) }}</template>
|
|
120
173
|
<template #item.siteUsedCost="{ value }">{{ money(value) }}</template>
|
|
121
|
-
<!-- The supervisor checks usage against the technician's before/after photos. -->
|
|
174
|
+
<!-- The supervisor checks usage against the technician's before/after photos (click to enlarge, with the time taken). -->
|
|
122
175
|
<template #item.photos="{ item }">
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
176
|
+
<InventoryPhotoThumbs :photos="item.photos" :before-at="item.photosBeforeAt" :after-at="item.photosAfterAt" />
|
|
177
|
+
</template>
|
|
178
|
+
<template #item.state="{ item }">
|
|
179
|
+
<v-chip size="x-small" variant="tonal" :color="item.status === 'draft' ? 'info' : 'success'">{{ item.status === "draft" ? "In progress" : "Submitted" }}</v-chip>
|
|
180
|
+
<div v-for="f in item.flags ?? []" :key="f" class="mt-1">
|
|
181
|
+
<v-chip size="x-small" variant="tonal" color="warning">{{ flagWords[f] ?? f }}</v-chip>
|
|
182
|
+
</div>
|
|
183
|
+
</template>
|
|
184
|
+
<template #item.technician="{ item }">
|
|
185
|
+
<a href="#" class="text-primary" @click.prevent="openPerson(item)">{{ item.holderLabel || item.technician }}</a>
|
|
186
|
+
</template>
|
|
187
|
+
<template #item.itemName="{ item }">
|
|
188
|
+
<a v-if="report === 'usage' && scope === 'all'" href="#" class="text-primary" @click.prevent="openItem(item)">{{ item.itemName }}</a>
|
|
189
|
+
<span v-else>{{ item.itemName }}</span>
|
|
190
|
+
</template>
|
|
191
|
+
<template #item.times="{ item }">
|
|
192
|
+
<div class="text-caption text-no-wrap">
|
|
193
|
+
<div v-if="item.draftedAt">Draft {{ standardFormatDateTime(item.draftedAt) }}</div>
|
|
194
|
+
<div v-if="item.submittedAt">Submitted {{ standardFormatDateTime(item.submittedAt) }}</div>
|
|
127
195
|
</div>
|
|
128
|
-
|
|
196
|
+
</template>
|
|
197
|
+
<template #item.path="{ item }">
|
|
198
|
+
<span class="text-caption">{{ usagePathText(item.path, standardFormatDate) || "—" }}</span>
|
|
129
199
|
</template>
|
|
130
200
|
<template #item.checkedByName="{ item }">
|
|
131
201
|
<v-chip v-if="item.checkedByName" size="x-small" color="success" variant="tonal">{{ item.checkedByName }}</v-chip>
|
|
@@ -135,6 +205,7 @@
|
|
|
135
205
|
<span :class="item.reorderLevel && item.closing <= item.reorderLevel ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ item.closing }}</span>
|
|
136
206
|
</template>
|
|
137
207
|
</v-data-table>
|
|
208
|
+
<InventoryHistoryDialog v-model="drill.show" :store="store" :holder="drill.holder" :item="drill.item" :title="drill.title" />
|
|
138
209
|
</div>
|
|
139
210
|
</template>
|
|
140
211
|
|
|
@@ -142,8 +213,9 @@
|
|
|
142
213
|
import useInventory, { saveBlob } from "../composables/useInventory";
|
|
143
214
|
import useWorkerTerm, { workerAppKey } from "../composables/useWorkerTerm";
|
|
144
215
|
import useUtils from "../composables/useUtils";
|
|
145
|
-
import useFile from "../composables/useFile";
|
|
146
216
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
217
|
+
import { USED_BY_FILTERS } from "../utils/inventory-used-by";
|
|
218
|
+
import { USAGE_FLAG_WORDS, USAGE_STATUS_CHOICES, usagePathText, usageQuery, usageTotals } from "../utils/inventory-usage";
|
|
147
219
|
|
|
148
220
|
const props = defineProps({
|
|
149
221
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -152,12 +224,7 @@ const props = defineProps({
|
|
|
152
224
|
const ctl = computed(() => inventoryControls(props.store.access));
|
|
153
225
|
|
|
154
226
|
const api = useInventory();
|
|
155
|
-
const { standardFormatDate } = useUtils();
|
|
156
|
-
const { getFileUrl } = useFile();
|
|
157
|
-
const usagePhotos = (row: any) => [
|
|
158
|
-
...(row.photos?.before ?? []).map((id: string) => ({ id, label: "Before" })),
|
|
159
|
-
...(row.photos?.after ?? []).map((id: string) => ({ id, label: "After" })),
|
|
160
|
-
];
|
|
227
|
+
const { standardFormatDate, standardFormatDateTime } = useUtils();
|
|
161
228
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
162
229
|
|
|
163
230
|
const worker = useWorkerTerm({ providers: true });
|
|
@@ -166,11 +233,32 @@ const appSlug = workerAppKey();
|
|
|
166
233
|
const now = new Date();
|
|
167
234
|
const month = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
|
|
168
235
|
// An approver without `view-reports` gets only the usage check screen.
|
|
169
|
-
const report = ref<"movement" | "usage" | "technicians" | "purchases">(ctl.value.fullReports ? "movement" : "usage");
|
|
236
|
+
const report = ref<"movement" | "usage" | "technicians" | "purchases" | "handovers">(ctl.value.fullReports ? "movement" : "usage");
|
|
170
237
|
const span = ref<"month" | "year">("year");
|
|
171
238
|
const site = ref("");
|
|
172
239
|
const locationType = ref("");
|
|
173
240
|
const uncheckedOnly = ref(false);
|
|
241
|
+
const usedBy = ref("");
|
|
242
|
+
// Usage tracking filters. Status / place / work order / flag / search go to the server; person and item
|
|
243
|
+
// narrow the loaded rows (their choices come from the rows).
|
|
244
|
+
const status = ref("submitted");
|
|
245
|
+
const person = ref("");
|
|
246
|
+
const itemFilter = ref("");
|
|
247
|
+
const locationText = ref("");
|
|
248
|
+
const referenceText = ref("");
|
|
249
|
+
const flag = ref("");
|
|
250
|
+
const search = ref("");
|
|
251
|
+
const scope = ref<"all" | "own_company">("all");
|
|
252
|
+
const staleDays = ref(3);
|
|
253
|
+
const flagWords = computed(() => ({ ...USAGE_FLAG_WORDS, old_draft: `Draft older than ${staleDays.value} days` }) as Record<string, string>);
|
|
254
|
+
const flagChoices = computed(() => [{ value: "", title: "Everything" }, ...Object.entries(flagWords.value).map(([value, title]) => ({ value, title }))]);
|
|
255
|
+
const drill = reactive({ show: false, holder: "", item: "", title: "" });
|
|
256
|
+
function openPerson(r: any) {
|
|
257
|
+
Object.assign(drill, { show: true, holder: String(r.holder ?? ""), item: "", title: r.holderLabel || r.technician });
|
|
258
|
+
}
|
|
259
|
+
function openItem(r: any) {
|
|
260
|
+
Object.assign(drill, { show: true, holder: "", item: String(r.item ?? ""), title: `${r.itemName} — where it went` });
|
|
261
|
+
}
|
|
174
262
|
const rows = ref<any[]>([]);
|
|
175
263
|
const selected = ref<string[]>([]);
|
|
176
264
|
const purchases = reactive({ byMonth: [] as { month: string; deliveries: number; totalCost: number }[], bySupplier: [] as { supplier: string; deliveries: number; totalCost: number }[] });
|
|
@@ -188,11 +276,58 @@ const barWidth = (v: number) => {
|
|
|
188
276
|
|
|
189
277
|
const help: Record<string, string> = {
|
|
190
278
|
movement: "For each item: stock at the start of the month, what came in (deliveries and opening stock), what was issued and returned, count differences, and stock at the end.",
|
|
191
|
-
usage: "Every
|
|
279
|
+
usage: "Every usage of this store's items, by anyone: who (company and service), where, which work order, before and after photos with their times, and how the stock reached them. Click a person or an item to see its full history. Use it for tenant billing; tick records and mark them checked once verified.",
|
|
192
280
|
technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
|
|
193
281
|
purchases: "Every delivery received, and opening stock entered with new items (Type: Opening stock), with totals by month and by supplier.",
|
|
282
|
+
handovers: "Stock passed from one person to a colleague: who gave it, who received it, and whether they confirmed.",
|
|
194
283
|
};
|
|
195
284
|
|
|
285
|
+
const handoverStatus: Record<string, string> = { pending: "Waiting to confirm", accepted: "Confirmed", declined: "Declined", cancelled: "Cancelled" };
|
|
286
|
+
|
|
287
|
+
// "Company · Service" of the person on each row, for the filter (usage, staff and hand-over reports).
|
|
288
|
+
const companyOf = (r: any) => [r.holderCompany ?? r.fromCompany, r.holderServiceLabel ?? r.fromServiceLabel].filter(Boolean).join(" · ");
|
|
289
|
+
const serviceFilter = ref("");
|
|
290
|
+
const serviceChoices = computed(() =>
|
|
291
|
+
[...new Set(rows.value.map(companyOf).filter(Boolean))].sort().map((v) => ({ value: v, title: v })),
|
|
292
|
+
);
|
|
293
|
+
const shownRows = computed(() =>
|
|
294
|
+
rows.value.filter(
|
|
295
|
+
(r) =>
|
|
296
|
+
(!serviceFilter.value || companyOf(r) === serviceFilter.value || [r.toCompany, r.toServiceLabel].filter(Boolean).join(" · ") === serviceFilter.value) &&
|
|
297
|
+
(report.value !== "usage" || ((!person.value || String(r.holder) === person.value) && (!itemFilter.value || String(r.item) === itemFilter.value))),
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
const choices = (key: (r: any) => string, label: (r: any) => string) =>
|
|
301
|
+
report.value !== "usage"
|
|
302
|
+
? []
|
|
303
|
+
: [...new Map(rows.value.map((r) => [key(r), String(label(r) ?? "")])).entries()].map(([value, title]) => ({ value, title })).sort((a, b) => a.title.localeCompare(b.title));
|
|
304
|
+
const personChoices = computed(() => choices((r) => String(r.holder), (r) => r.holderLabel || r.technician));
|
|
305
|
+
const itemChoices = computed(() => choices((r) => String(r.item), (r) => r.itemName));
|
|
306
|
+
const usageSummary = computed(() => {
|
|
307
|
+
const done = shownRows.value.filter((r) => r.status !== "draft");
|
|
308
|
+
return {
|
|
309
|
+
records: done.length,
|
|
310
|
+
qty: done.reduce((sum, r) => sum + r.qty, 0),
|
|
311
|
+
cost: Math.round(done.reduce((sum, r) => sum + (r.totalCost ?? 0), 0) * 100) / 100,
|
|
312
|
+
drafts: shownRows.value.length - done.length,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
const totalBoxes = computed(() => {
|
|
316
|
+
const t = usageTotals(shownRows.value);
|
|
317
|
+
return [
|
|
318
|
+
{ title: "By item", rows: t.byItem },
|
|
319
|
+
{ title: "By person", rows: t.byPerson },
|
|
320
|
+
{ title: "By company", rows: t.byCompany },
|
|
321
|
+
];
|
|
322
|
+
});
|
|
323
|
+
const noDataText = computed(() =>
|
|
324
|
+
report.value !== "usage"
|
|
325
|
+
? "Nothing recorded in this period."
|
|
326
|
+
: status.value === "draft"
|
|
327
|
+
? "No usage in progress. Drafts appear here once someone saves a before photo."
|
|
328
|
+
: "No usage recorded for these filters in this month.",
|
|
329
|
+
);
|
|
330
|
+
|
|
196
331
|
const headers: Record<string, any[]> = {
|
|
197
332
|
movement: [
|
|
198
333
|
{ title: "Item", key: "name" },
|
|
@@ -210,6 +345,7 @@ const headers: Record<string, any[]> = {
|
|
|
210
345
|
],
|
|
211
346
|
usage: [
|
|
212
347
|
{ title: "Date", key: "date" },
|
|
348
|
+
{ title: "Status", key: "state", sortable: false },
|
|
213
349
|
{ title: "Property", key: "siteName" },
|
|
214
350
|
{ title: "Where", key: "locationType" },
|
|
215
351
|
{ title: "Unit / Area", key: "location" },
|
|
@@ -218,23 +354,41 @@ const headers: Record<string, any[]> = {
|
|
|
218
354
|
{ title: "Qty", key: "qty" },
|
|
219
355
|
{ title: "Unit price", key: "unitCost" },
|
|
220
356
|
{ title: "Total", key: "totalCost" },
|
|
221
|
-
{ title: worker.one, key: "technician" },
|
|
222
|
-
{ title: "Photos", key: "photos", sortable: false },
|
|
223
|
-
{ title: "
|
|
357
|
+
{ title: worker.one, key: "technician", minWidth: "200px" },
|
|
358
|
+
{ title: "Photos", key: "photos", sortable: false, minWidth: "124px" },
|
|
359
|
+
{ title: "Work order / ref", key: "reference", minWidth: "130px" },
|
|
360
|
+
{ title: "Saved / submitted", key: "times", sortable: false },
|
|
361
|
+
{ title: "Came from", key: "path", sortable: false, minWidth: "220px" },
|
|
224
362
|
{ title: "Reported by", key: "reportedBy" },
|
|
363
|
+
{ title: "Remarks", key: "remarks" },
|
|
225
364
|
{ title: "Checked by", key: "checkedByName" },
|
|
226
365
|
],
|
|
227
366
|
technicians: [
|
|
228
367
|
{ title: "Name", key: "technician" },
|
|
368
|
+
{ title: "Company", key: "holderCompany" },
|
|
369
|
+
{ title: "Service", key: "holderServiceLabel" },
|
|
229
370
|
{ title: "Item", key: "itemName" },
|
|
230
371
|
{ title: "Unit", key: "unit" },
|
|
231
372
|
{ title: "Holding at start", key: "opening" },
|
|
232
373
|
{ title: "Taken", key: "taken" },
|
|
374
|
+
{ title: "From colleagues", key: "gotFromColleagues" },
|
|
233
375
|
{ title: "Used", key: "used" },
|
|
234
376
|
{ title: "Returned", key: "returned" },
|
|
377
|
+
{ title: "To colleagues", key: "gaveToColleagues" },
|
|
235
378
|
{ title: "Holding at end", key: "closing" },
|
|
236
379
|
{ title: "In hand now", key: "inHandNow" },
|
|
237
380
|
],
|
|
381
|
+
handovers: [
|
|
382
|
+
{ title: "Date", key: "createdAt" },
|
|
383
|
+
{ title: "Item", key: "itemName" },
|
|
384
|
+
{ title: "Qty", key: "qty" },
|
|
385
|
+
{ title: "Unit", key: "unit" },
|
|
386
|
+
{ title: "Given by", key: "fromLabel" },
|
|
387
|
+
{ title: "Given to", key: "toLabel" },
|
|
388
|
+
{ title: "Status", key: "status" },
|
|
389
|
+
{ title: "Confirmed / declined", key: "decidedAt" },
|
|
390
|
+
{ title: "Note", key: "note" },
|
|
391
|
+
],
|
|
238
392
|
purchases: [
|
|
239
393
|
{ title: "Date", key: "date" },
|
|
240
394
|
{ title: "Type", key: "kind" },
|
|
@@ -271,9 +425,28 @@ function period(): TInventoryPeriod & { unchecked?: boolean } {
|
|
|
271
425
|
...((report.value === "usage" || report.value === "movement") && site.value ? { site: site.value } : {}),
|
|
272
426
|
...(report.value === "usage" && locationType.value ? { locationType: locationType.value } : {}),
|
|
273
427
|
...(report.value === "usage" && uncheckedOnly.value ? { unchecked: true } : {}),
|
|
428
|
+
...(usedBy.value ? { usedBy: usedBy.value } : {}),
|
|
429
|
+
...(report.value === "usage"
|
|
430
|
+
? usageQuery({ status: status.value === "submitted" ? "" : status.value, location: locationText.value, reference: referenceText.value, flag: flag.value, search: search.value })
|
|
431
|
+
: {}),
|
|
274
432
|
};
|
|
275
433
|
}
|
|
276
434
|
|
|
435
|
+
/** The export carries the person / item / company picked on screen too. */
|
|
436
|
+
function exportFilters() {
|
|
437
|
+
if (report.value !== "usage") return {};
|
|
438
|
+
const pick = rows.value.find((r) => companyOf(r) === serviceFilter.value);
|
|
439
|
+
const base = String(useRuntimeConfig().public.API_DO_STORAGE_ENDPOINT ?? "");
|
|
440
|
+
return usageQuery({
|
|
441
|
+
holder: person.value,
|
|
442
|
+
item: itemFilter.value,
|
|
443
|
+
company: serviceFilter.value ? pick?.holderCompany : "",
|
|
444
|
+
service: serviceFilter.value ? pick?.holderService : "",
|
|
445
|
+
// Photo links in the file open the photo itself.
|
|
446
|
+
photoBase: base.startsWith("https://") ? base : "",
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
277
450
|
async function load() {
|
|
278
451
|
if (!month.value) return;
|
|
279
452
|
loading.value = true;
|
|
@@ -283,6 +456,8 @@ async function load() {
|
|
|
283
456
|
rows.value = res.rows;
|
|
284
457
|
totals.qty = res.totalQty ?? 0;
|
|
285
458
|
totals.cost = res.totalCost ?? 0;
|
|
459
|
+
scope.value = res.scope ?? "all";
|
|
460
|
+
staleDays.value = res.staleAfterDays ?? 3;
|
|
286
461
|
purchases.byMonth = res.byMonth ?? [];
|
|
287
462
|
purchases.bySupplier = res.bySupplier ?? [];
|
|
288
463
|
} catch (error: any) {
|
|
@@ -310,7 +485,7 @@ async function setChecked(checked: boolean) {
|
|
|
310
485
|
async function exportExcel() {
|
|
311
486
|
exporting.value = true;
|
|
312
487
|
try {
|
|
313
|
-
const blob = await api.exportReport(props.store._id, report.value, { ...period(), appSlug });
|
|
488
|
+
const blob = await api.exportReport(props.store._id, report.value, { ...period(), ...exportFilters(), appSlug });
|
|
314
489
|
saveBlob(blob, `${props.store.name} - ${report.value === "technicians" ? worker.many : report.value} - ${month.value}.xlsx`);
|
|
315
490
|
} catch {
|
|
316
491
|
notify("Could not export the report.", "error");
|
|
@@ -319,6 +494,13 @@ async function exportExcel() {
|
|
|
319
494
|
}
|
|
320
495
|
}
|
|
321
496
|
|
|
322
|
-
watch([report, month, span, site, locationType, uncheckedOnly, () => props.store._id], load);
|
|
497
|
+
watch([report, month, span, site, locationType, uncheckedOnly, usedBy, status, flag, () => props.store._id], load);
|
|
498
|
+
// Typed filters wait until the typing stops.
|
|
499
|
+
let typing: ReturnType<typeof setTimeout> | undefined;
|
|
500
|
+
watch([locationText, referenceText, search], () => {
|
|
501
|
+
clearTimeout(typing);
|
|
502
|
+
typing = setTimeout(load, 400);
|
|
503
|
+
});
|
|
504
|
+
watch(report, () => (serviceFilter.value = ""));
|
|
323
505
|
onMounted(load);
|
|
324
506
|
</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()];
|