@7365admin1/layer-common 4.72.0 → 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 +10 -4
- package/components/InventoryHistoryDialog.vue +205 -0
- package/components/InventoryMain.vue +23 -2
- package/components/InventoryMyStockTab.vue +63 -8
- package/components/InventoryPhotoThumbs.vue +67 -0
- package/components/InventoryReportsTab.vue +154 -28
- package/components/InventoryUsageDialog.vue +119 -28
- package/composables/useInventory.ts +11 -3
- package/package.json +1 -1
- package/types/inventory.d.ts +18 -2
- package/utils/inventory-usage.ts +94 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.73.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cd5501e: Inventory: usage drafts (save the before photo first, add the after photo and submit later; edit or discard with a confirm; My drafts cards with an old-draft warning; "in drafts / free" on holdings); History per item and "My usage" with date, item and place filters; Usage report as the store owner's usage tracking (status incl. in-progress drafts, person, item, place, work order, flags, search; totals by item / person / company; photo thumbnails with times; draft and submit times; how the stock reached the person; Excel with the same filters and photo links; click a person or an item for its full history); a provider's supervisor gets "Company usage" for its own people at the main store.
|
|
8
|
+
|
|
3
9
|
## 4.72.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -1879,11 +1885,11 @@
|
|
|
1879
1885
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1880
1886
|
own rule from two endpoints the console already calls, unprojected:
|
|
1881
1887
|
|
|
1882
|
-
|
|
1883
|
-
|
|
1888
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1889
|
+
GET /api/roles/id/:role that membership's role document
|
|
1884
1890
|
|
|
1885
|
-
|
|
1886
|
-
|
|
1891
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1892
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1887
1893
|
|
|
1888
1894
|
`role.default` is the marker because it is the only property of a platform
|
|
1889
1895
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog v-model="show" max-width="1100" scrollable>
|
|
3
|
+
<v-card class="screen-modal">
|
|
4
|
+
<v-card-title class="d-flex align-center">
|
|
5
|
+
{{ title }}
|
|
6
|
+
<v-spacer />
|
|
7
|
+
<v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="show = false" />
|
|
8
|
+
</v-card-title>
|
|
9
|
+
<v-card-text class="screen-modal__body">
|
|
10
|
+
<p class="text-body-2 text-medium-emphasis mb-3">{{ intro }}</p>
|
|
11
|
+
|
|
12
|
+
<!-- A supervisor opening a person: what they hold now, then everything that happened to their stock. -->
|
|
13
|
+
<template v-if="holder && holder !== 'me' && holder !== selfId && (store.access.canApprove || store.access.canHandle)">
|
|
14
|
+
<div class="text-subtitle-2 mb-1">Holding now</div>
|
|
15
|
+
<v-table density="compact" class="mb-4 border rounded">
|
|
16
|
+
<thead>
|
|
17
|
+
<tr>
|
|
18
|
+
<th>Item</th>
|
|
19
|
+
<th class="text-right">In hand</th>
|
|
20
|
+
<th class="text-right">In drafts</th>
|
|
21
|
+
<th class="text-right">Used this month</th>
|
|
22
|
+
</tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<tr v-for="h in holdings" :key="h._id">
|
|
26
|
+
<td>{{ h.itemName }}</td>
|
|
27
|
+
<td class="text-right">{{ h.qty }} {{ h.unit }}</td>
|
|
28
|
+
<td class="text-right">{{ h.inDrafts || "—" }}</td>
|
|
29
|
+
<td class="text-right">{{ h.usedThisMonth ?? 0 }}</td>
|
|
30
|
+
</tr>
|
|
31
|
+
<tr v-if="!holdings.length">
|
|
32
|
+
<td colspan="4" class="text-center text-medium-emphasis py-3">Not holding any stock now.</td>
|
|
33
|
+
</tr>
|
|
34
|
+
</tbody>
|
|
35
|
+
</v-table>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<div class="d-flex flex-wrap ga-2 mb-2">
|
|
39
|
+
<v-text-field v-model="from" type="date" label="From" density="compact" hide-details style="max-width: 170px" />
|
|
40
|
+
<v-text-field v-model="to" type="date" label="To" density="compact" hide-details style="max-width: 170px" />
|
|
41
|
+
<v-select v-model="type" :items="typeChoices" label="What" density="compact" hide-details style="max-width: 200px" />
|
|
42
|
+
<v-select v-if="!item" v-model="itemPick" :items="[{ value: '', title: 'All items' }, ...itemChoices]" label="Item" density="compact" hide-details style="max-width: 240px" />
|
|
43
|
+
<v-text-field v-model="location" label="Where (unit, tenant or area)" density="compact" hide-details clearable style="max-width: 240px" />
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<v-data-table-server
|
|
47
|
+
:headers="headers"
|
|
48
|
+
:items="rows"
|
|
49
|
+
:items-length="total"
|
|
50
|
+
:loading="loading"
|
|
51
|
+
:items-per-page="limit"
|
|
52
|
+
:page="page"
|
|
53
|
+
density="compact"
|
|
54
|
+
class="border rounded"
|
|
55
|
+
:no-data-text="emptyText"
|
|
56
|
+
@update:page="(p: number) => ((page = p), load())"
|
|
57
|
+
>
|
|
58
|
+
<template #item.date="{ value }">{{ standardFormatDateTime(value) }}</template>
|
|
59
|
+
<template #item.what="{ item }">{{ historyText(item) }}</template>
|
|
60
|
+
<template #item.person="{ item }">{{ item.holderLabel || item.holderName || "—" }}</template>
|
|
61
|
+
<template #item.qty="{ item }">{{ item.qty }} {{ item.unit }}</template>
|
|
62
|
+
<template #item.where="{ item }">{{ whereText(item.location) || (item.type === "handover" ? item.remarks : "") || "—" }}</template>
|
|
63
|
+
<template #item.reference="{ item }">{{ item.type === "handover" ? "—" : item.reference || "—" }}</template>
|
|
64
|
+
<template #item.photos="{ item }">
|
|
65
|
+
<InventoryPhotoThumbs :photos="item.photos" :before-at="item.photosBeforeAt" :after-at="item.photosAfterAt" />
|
|
66
|
+
</template>
|
|
67
|
+
<template #item.by="{ item }">{{ item.createdByLabel || item.createdByName }}</template>
|
|
68
|
+
<template #item.holderBalance="{ value }">{{ value ?? "—" }}</template>
|
|
69
|
+
</v-data-table-server>
|
|
70
|
+
</v-card-text>
|
|
71
|
+
</v-card>
|
|
72
|
+
</v-dialog>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<script setup lang="ts">
|
|
76
|
+
import useInventory from "../composables/useInventory";
|
|
77
|
+
import useUtils from "../composables/useUtils";
|
|
78
|
+
import useLocalAuth from "../composables/useLocalAuth";
|
|
79
|
+
import { historyText, whereText } from "../utils/inventory-usage";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* History of stock (owner 2026-09-29, "where they used their stock"): one person's (`holder`, "me" =
|
|
83
|
+
* the signed-in person), one item's (`item`: its whole chain across people), or both. Received from the
|
|
84
|
+
* store, hand-overs in and out, usage with where + work order + photos, and returns. Drafts are not history.
|
|
85
|
+
*/
|
|
86
|
+
const show = defineModel<boolean>({ default: false });
|
|
87
|
+
const props = defineProps({
|
|
88
|
+
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
89
|
+
holder: { type: String, default: "" },
|
|
90
|
+
item: { type: String, default: "" },
|
|
91
|
+
title: { type: String, default: "History" },
|
|
92
|
+
/** Open on "Used" only (My usage). */
|
|
93
|
+
usageOnly: { type: Boolean, default: false },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const api = useInventory();
|
|
97
|
+
const { standardFormatDateTime } = useUtils();
|
|
98
|
+
const { currentUser } = useLocalAuth();
|
|
99
|
+
const selfId = computed(() => String(currentUser.value?._id ?? ""));
|
|
100
|
+
const rows = ref<TInventoryTransaction[]>([]);
|
|
101
|
+
const holdings = ref<TInventoryHolding[]>([]);
|
|
102
|
+
const total = ref(0);
|
|
103
|
+
const page = ref(1);
|
|
104
|
+
const limit = 50;
|
|
105
|
+
const loading = ref(false);
|
|
106
|
+
const from = ref("");
|
|
107
|
+
const to = ref("");
|
|
108
|
+
const type = ref("");
|
|
109
|
+
const location = ref("");
|
|
110
|
+
const itemPick = ref("");
|
|
111
|
+
const itemChoices = ref<{ value: string; title: string }[]>([]);
|
|
112
|
+
|
|
113
|
+
const typeChoices = [
|
|
114
|
+
{ value: "", title: "Everything" },
|
|
115
|
+
{ value: "use", title: "Used" },
|
|
116
|
+
{ value: "issue", title: "Taken from store" },
|
|
117
|
+
{ value: "handover", title: "Hand-overs" },
|
|
118
|
+
{ value: "return", title: "Given back" },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const intro = computed(() =>
|
|
122
|
+
props.usageOnly
|
|
123
|
+
? "Where the stock was used: date, place, work order and the before / after photos. Filter by date, item or place."
|
|
124
|
+
: props.item && !props.holder
|
|
125
|
+
? "Every movement of this item: into the store, to each person, between colleagues, where it was used and what came back."
|
|
126
|
+
: "Everything that happened to this stock: taken from the store, hand-overs, where it was used (with the work order and photos) and returns.",
|
|
127
|
+
);
|
|
128
|
+
const emptyText = computed(() => (from.value || to.value || type.value || location.value || itemPick.value ? "Nothing matches these filters." : "Nothing recorded yet."));
|
|
129
|
+
const headers = computed(() => [
|
|
130
|
+
{ title: "Date", key: "date", sortable: false },
|
|
131
|
+
{ title: "What happened", key: "what", sortable: false },
|
|
132
|
+
...(props.item ? [] : [{ title: "Item", key: "itemName", sortable: false }]),
|
|
133
|
+
...(props.holder ? [] : [{ title: "Person", key: "person", sortable: false }]),
|
|
134
|
+
{ title: "Qty", key: "qty", sortable: false },
|
|
135
|
+
{ title: "Where", key: "where", sortable: false },
|
|
136
|
+
{ title: "Work order / ref", key: "reference", sortable: false },
|
|
137
|
+
{ title: "Photos", key: "photos", sortable: false },
|
|
138
|
+
{ title: "Recorded by", key: "by", sortable: false },
|
|
139
|
+
...(props.holder ? [{ title: "In hand after", key: "holderBalance", sortable: false }] : []),
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
async function load() {
|
|
143
|
+
loading.value = true;
|
|
144
|
+
try {
|
|
145
|
+
const res = await api.getTransactions(props.store._id, {
|
|
146
|
+
page: page.value,
|
|
147
|
+
limit,
|
|
148
|
+
...(props.holder ? { holder: props.holder } : {}),
|
|
149
|
+
...(props.item || itemPick.value ? { item: props.item || itemPick.value } : {}),
|
|
150
|
+
...(type.value ? { type: type.value } : {}),
|
|
151
|
+
...(location.value ? { location: location.value } : {}),
|
|
152
|
+
...(from.value ? { from: new Date(`${from.value}T00:00:00`).toISOString() } : {}),
|
|
153
|
+
...(to.value ? { to: new Date(`${to.value}T23:59:59.999`).toISOString() } : {}),
|
|
154
|
+
});
|
|
155
|
+
rows.value = res.items;
|
|
156
|
+
total.value = res.total;
|
|
157
|
+
} catch (error: any) {
|
|
158
|
+
rows.value = [];
|
|
159
|
+
total.value = 0;
|
|
160
|
+
notify(error?.data?.message || "Could not load the history.", "error");
|
|
161
|
+
} finally {
|
|
162
|
+
loading.value = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
166
|
+
|
|
167
|
+
async function loadHoldings() {
|
|
168
|
+
holdings.value = [];
|
|
169
|
+
// The API gives another person's holdings to the store's approvers / storekeepers only.
|
|
170
|
+
if (!props.holder || props.holder === "me" || props.holder === selfId.value || !(props.store.access.canApprove || props.store.access.canHandle)) return;
|
|
171
|
+
try {
|
|
172
|
+
holdings.value = await api.getHoldings(props.store._id, props.holder);
|
|
173
|
+
} catch {
|
|
174
|
+
holdings.value = [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let resetting = false;
|
|
179
|
+
watch([from, to, type, location, itemPick], () => {
|
|
180
|
+
if (resetting) return;
|
|
181
|
+
page.value = 1;
|
|
182
|
+
load();
|
|
183
|
+
});
|
|
184
|
+
watch(show, async (open) => {
|
|
185
|
+
if (!open) return;
|
|
186
|
+
resetting = true;
|
|
187
|
+
page.value = 1;
|
|
188
|
+
from.value = "";
|
|
189
|
+
to.value = "";
|
|
190
|
+
location.value = "";
|
|
191
|
+
itemPick.value = "";
|
|
192
|
+
type.value = props.usageOnly ? "use" : "";
|
|
193
|
+
await nextTick();
|
|
194
|
+
resetting = false;
|
|
195
|
+
load();
|
|
196
|
+
loadHoldings();
|
|
197
|
+
if (!props.item) {
|
|
198
|
+
try {
|
|
199
|
+
itemChoices.value = (await api.getItems(props.store._id, { limit: 200 })).items.map((i) => ({ value: i._id, title: i.name }));
|
|
200
|
+
} catch {
|
|
201
|
+
itemChoices.value = [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
</script>
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
<v-window-item v-if="ctl.tabs.people" value="technicians">
|
|
120
120
|
<InventoryTechniciansTab :store="store" @changed="refreshCounts" />
|
|
121
121
|
</v-window-item>
|
|
122
|
-
<v-window-item v-if="ctl.tabs.reports" value="reports">
|
|
122
|
+
<v-window-item v-if="ctl.tabs.reports || companyUsage" value="reports">
|
|
123
123
|
<InventoryReportsTab :store="store" :sites="storeSites" />
|
|
124
124
|
</v-window-item>
|
|
125
125
|
<v-window-item v-if="ctl.tabs.suppliers" value="suppliers">
|
|
@@ -150,7 +150,7 @@ const props = defineProps({
|
|
|
150
150
|
|
|
151
151
|
const movedFromEquipment = useRoute().query.moved === "equipment";
|
|
152
152
|
|
|
153
|
-
const { getStores, createStore: apiCreateStore, getRequests } = useInventory();
|
|
153
|
+
const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
154
154
|
const { getSites } = useSite();
|
|
155
155
|
const { requiredRule } = useUtils();
|
|
156
156
|
|
|
@@ -216,6 +216,7 @@ const tabs = computed(() => {
|
|
|
216
216
|
...(ctl.value.tabs.requests ? [{ value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 }] : []),
|
|
217
217
|
...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many }] : []),
|
|
218
218
|
...(ctl.value.tabs.reports ? [{ value: "reports", label: a.canReports ? "Reports" : "Usage check" }] : []),
|
|
219
|
+
...(!ctl.value.tabs.reports && companyUsage.value ? [{ value: "reports", label: "Company usage" }] : []),
|
|
219
220
|
...(ctl.value.tabs.suppliers ? [{ value: "suppliers", label: "Suppliers" }] : []),
|
|
220
221
|
...(ctl.value.tabs.settings ? [{ value: "settings", label: "Settings" }] : []),
|
|
221
222
|
];
|
|
@@ -239,6 +240,25 @@ async function loadStores() {
|
|
|
239
240
|
refreshCounts();
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Another company's supervisor at the main store may track its OWN people's usage (the API answers the usage
|
|
245
|
+
* report for them, scoped). The store's access flags do not say so, so ask once per store.
|
|
246
|
+
* ponytail: one probe request per store switch; add a flag to the store's access if the API grows one.
|
|
247
|
+
*/
|
|
248
|
+
const companyUsage = ref(false);
|
|
249
|
+
async function probeCompanyUsage() {
|
|
250
|
+
companyUsage.value = false;
|
|
251
|
+
const s = store.value;
|
|
252
|
+
if (!s || ctl.value.tabs.reports || !s.main || s.owner !== false) return;
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
try {
|
|
255
|
+
await getReport(s._id, "usage", { from: now, to: now });
|
|
256
|
+
companyUsage.value = true;
|
|
257
|
+
} catch {
|
|
258
|
+
companyUsage.value = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
242
262
|
async function refreshCounts() {
|
|
243
263
|
if (!store.value?.access.canApprove) return (pendingCount.value = 0);
|
|
244
264
|
try {
|
|
@@ -277,6 +297,7 @@ async function createStore() {
|
|
|
277
297
|
watch(storeId, () => {
|
|
278
298
|
tab.value = "stock";
|
|
279
299
|
refreshCounts();
|
|
300
|
+
probeCompanyUsage();
|
|
280
301
|
});
|
|
281
302
|
watch(() => props.siteId, loadStores);
|
|
282
303
|
|
|
@@ -22,6 +22,25 @@
|
|
|
22
22
|
<v-btn class="text-none" variant="text" size="small" :loading="acting === h._id + 'c'" @click="cancelHandover(h)">Cancel</v-btn>
|
|
23
23
|
</AppCard>
|
|
24
24
|
|
|
25
|
+
<!-- Usage drafts: before photo saved, job not finished yet. Nothing is taken off the stock until submitted. -->
|
|
26
|
+
<template v-if="drafts.length">
|
|
27
|
+
<div class="text-subtitle-2 mb-2">My drafts — in progress ({{ drafts.length }})</div>
|
|
28
|
+
<AppCard
|
|
29
|
+
v-for="d in drafts"
|
|
30
|
+
:key="d._id"
|
|
31
|
+
class="pa-3 mb-2 d-flex flex-wrap align-center ga-3"
|
|
32
|
+
:style="{ borderLeft: `4px solid rgb(var(--v-theme-${d.stale ? 'error' : 'info'}))` }"
|
|
33
|
+
>
|
|
34
|
+
<InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
|
|
35
|
+
<div class="flex-grow-1" style="min-width: 200px">
|
|
36
|
+
<div class="font-weight-medium">{{ d.itemName }} × {{ d.qty }} {{ d.unit }}<span v-if="d.reference" class="text-medium-emphasis"> · {{ d.reference }}</span></div>
|
|
37
|
+
<div class="text-body-2">{{ draftCardText(d, standardFormatDateTime, staleDays).line }}</div>
|
|
38
|
+
<div v-if="d.stale" class="text-caption text-error">{{ draftCardText(d, standardFormatDateTime, staleDays).warning }}</div>
|
|
39
|
+
</div>
|
|
40
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" size="small" @click="continueDraft(d)">{{ d.photos?.after?.length ? "Review and submit" : "Add after photo" }}</v-btn>
|
|
41
|
+
</AppCard>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
25
44
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
26
45
|
<AppCard v-else-if="!holdings.length" class="pa-6 text-center">
|
|
27
46
|
<div class="text-body-1 mb-1">You are not holding any stock.</div>
|
|
@@ -38,16 +57,22 @@
|
|
|
38
57
|
<div class="flex-grow-1">
|
|
39
58
|
<div class="font-weight-medium">{{ h.itemName }}</div>
|
|
40
59
|
<div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
|
|
60
|
+
<div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
|
|
41
61
|
</div>
|
|
42
62
|
<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>
|
|
63
|
+
<v-btn v-if="(h.free ?? h.qty) > 0 && ctl.recordUsage" class="text-none screen-btn-primary" variant="flat" size="small" @click="use(h)">Record usage</v-btn>
|
|
44
64
|
<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>
|
|
65
|
+
<v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
|
|
45
66
|
</div>
|
|
46
67
|
</AppCard>
|
|
47
68
|
</v-col>
|
|
48
69
|
</v-row>
|
|
49
70
|
|
|
50
|
-
<div class="
|
|
71
|
+
<div class="d-flex align-center mt-6 mb-2">
|
|
72
|
+
<div class="text-subtitle-2">My recent activity</div>
|
|
73
|
+
<v-spacer />
|
|
74
|
+
<v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-map-marker-check-outline" @click="openMyUsage">My usage — where I used my stock</v-btn>
|
|
75
|
+
</div>
|
|
51
76
|
<v-table density="compact">
|
|
52
77
|
<thead>
|
|
53
78
|
<tr>
|
|
@@ -76,7 +101,8 @@
|
|
|
76
101
|
</tbody>
|
|
77
102
|
</v-table>
|
|
78
103
|
|
|
79
|
-
<InventoryUsageDialog v-model="usage.show" :store="store" :site-id="siteId" :holding="usage.holding" :sites="sites" @done="changed" />
|
|
104
|
+
<InventoryUsageDialog v-model="usage.show" :store="store" :site-id="siteId" :holding="usage.holding" :draft="usage.draft" :sites="sites" @done="changed" />
|
|
105
|
+
<InventoryHistoryDialog v-model="history.show" :store="store" :holder="me" :item="history.item" :title="history.title" :usage-only="history.usageOnly" />
|
|
80
106
|
<v-dialog v-model="give.show" max-width="520" persistent>
|
|
81
107
|
<v-card class="screen-modal">
|
|
82
108
|
<v-card-title class="d-flex align-center">
|
|
@@ -135,6 +161,7 @@ import useFile from "../composables/useFile";
|
|
|
135
161
|
import useUtils from "../composables/useUtils";
|
|
136
162
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
137
163
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
164
|
+
import { draftCardText } from "../utils/inventory-usage";
|
|
138
165
|
|
|
139
166
|
const props = defineProps({
|
|
140
167
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -147,14 +174,41 @@ const emit = defineEmits(["changed"]);
|
|
|
147
174
|
const api = useInventory();
|
|
148
175
|
const { typeLabels } = api;
|
|
149
176
|
const { getFileUrl } = useFile();
|
|
150
|
-
const { standardFormatDate } = useUtils();
|
|
177
|
+
const { standardFormatDate, standardFormatDateTime } = useUtils();
|
|
151
178
|
const { currentUser } = useLocalAuth();
|
|
152
179
|
|
|
153
180
|
const holdings = ref<TInventoryHolding[]>([]);
|
|
154
181
|
const activity = ref<TInventoryTransaction[]>([]);
|
|
155
182
|
const loading = ref(false);
|
|
156
183
|
const showRequest = ref(false);
|
|
157
|
-
const usage = reactive({ show: false, holding: null as TInventoryHolding | null });
|
|
184
|
+
const usage = reactive({ show: false, holding: null as TInventoryHolding | null, draft: null as TInventoryUsageDraft | null });
|
|
185
|
+
const drafts = ref<TInventoryUsageDraft[]>([]);
|
|
186
|
+
const staleDays = ref(3);
|
|
187
|
+
const history = reactive({ show: false, item: "", title: "", usageOnly: false });
|
|
188
|
+
|
|
189
|
+
function openHistory(h: TInventoryHolding) {
|
|
190
|
+
Object.assign(history, { show: true, item: String(h.item), title: `History — ${h.itemName}`, usageOnly: false });
|
|
191
|
+
}
|
|
192
|
+
function openMyUsage() {
|
|
193
|
+
Object.assign(history, { show: true, item: "", title: "My usage", usageOnly: true });
|
|
194
|
+
}
|
|
195
|
+
function continueDraft(d: TInventoryUsageDraft) {
|
|
196
|
+
// The draft's own item in hand (its qty is reserved there); a stand-in if the list has not loaded it.
|
|
197
|
+
const holding =
|
|
198
|
+
holdings.value.find((h) => String(h.item) === String(d.item)) ??
|
|
199
|
+
({ _id: d._id, item: d.item, itemName: d.itemName, unit: d.unit, photo: "", holder: "", holderName: "", qty: d.qty, inDrafts: d.qty } as TInventoryHolding);
|
|
200
|
+
Object.assign(usage, { show: true, holding, draft: d });
|
|
201
|
+
}
|
|
202
|
+
async function loadDrafts() {
|
|
203
|
+
if (!ctl.value.recordUsage) return;
|
|
204
|
+
try {
|
|
205
|
+
const res = await api.getUsageDrafts(props.store._id);
|
|
206
|
+
drafts.value = res.items;
|
|
207
|
+
staleDays.value = res.staleAfterDays ?? 3;
|
|
208
|
+
} catch {
|
|
209
|
+
drafts.value = [];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
158
212
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
159
213
|
|
|
160
214
|
const handovers = ref<TInventoryHandover[]>([]);
|
|
@@ -264,15 +318,16 @@ async function load() {
|
|
|
264
318
|
}
|
|
265
319
|
|
|
266
320
|
function use(h: TInventoryHolding) {
|
|
267
|
-
Object.assign(usage, { show: true, holding: h });
|
|
321
|
+
Object.assign(usage, { show: true, holding: h, draft: null });
|
|
268
322
|
}
|
|
269
323
|
|
|
270
324
|
function changed() {
|
|
271
325
|
load();
|
|
272
326
|
loadHandovers();
|
|
327
|
+
loadDrafts();
|
|
273
328
|
emit("changed");
|
|
274
329
|
}
|
|
275
330
|
|
|
276
|
-
watch(() => props.store._id, () => (load(), loadHandovers()));
|
|
277
|
-
onMounted(() => (load(), loadHandovers()));
|
|
331
|
+
watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts()));
|
|
332
|
+
onMounted(() => (load(), loadHandovers(), loadDrafts()));
|
|
278
333
|
</script>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="list.length" class="d-flex flex-wrap ga-1 py-1">
|
|
3
|
+
<button v-for="ph in list" :key="ph.id + ph.label" type="button" class="inventory-thumb" :title="ph.title" :aria-label="`Open ${ph.title}`" @click="open = ph">
|
|
4
|
+
<v-img :src="getFileUrl(ph.id)" :width="size" :height="size" cover class="rounded" :alt="ph.title" />
|
|
5
|
+
<span class="inventory-thumb__tag">{{ ph.label }}</span>
|
|
6
|
+
</button>
|
|
7
|
+
</div>
|
|
8
|
+
<span v-else class="text-medium-emphasis">—</span>
|
|
9
|
+
<v-dialog :model-value="!!open" max-width="820" @update:model-value="open = null">
|
|
10
|
+
<v-card v-if="open" class="screen-modal">
|
|
11
|
+
<v-card-title class="d-flex align-center">
|
|
12
|
+
{{ open.title }}
|
|
13
|
+
<v-spacer />
|
|
14
|
+
<v-btn icon="mdi-close" variant="text" aria-label="Close" @click="open = null" />
|
|
15
|
+
</v-card-title>
|
|
16
|
+
<v-card-text>
|
|
17
|
+
<v-img :src="getFileUrl(open.id)" max-height="70vh" contain />
|
|
18
|
+
<div class="d-flex mt-2">
|
|
19
|
+
<a :href="getFileUrl(open.id)" target="_blank" rel="noopener" class="text-body-2">Open full size</a>
|
|
20
|
+
</div>
|
|
21
|
+
</v-card-text>
|
|
22
|
+
</v-card>
|
|
23
|
+
</v-dialog>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
import useFile from "../composables/useFile";
|
|
28
|
+
import useUtils from "../composables/useUtils";
|
|
29
|
+
|
|
30
|
+
/** Before / after (and other) photos of a usage or hand-over: small thumbnails, click to enlarge with the time taken. */
|
|
31
|
+
const props = defineProps({
|
|
32
|
+
photos: { type: Object as PropType<{ before?: string[]; after?: string[]; other?: string[] } | undefined>, default: undefined },
|
|
33
|
+
beforeAt: { type: String as PropType<string | null | undefined>, default: null },
|
|
34
|
+
afterAt: { type: String as PropType<string | null | undefined>, default: null },
|
|
35
|
+
size: { type: Number, default: 36 },
|
|
36
|
+
});
|
|
37
|
+
const { getFileUrl } = useFile();
|
|
38
|
+
const { standardFormatDateTime } = useUtils();
|
|
39
|
+
const open = ref<{ id: string; label: string; title: string } | null>(null);
|
|
40
|
+
const at = (d?: string | null) => (d ? ` — taken ${standardFormatDateTime(d)}` : "");
|
|
41
|
+
const list = computed(() => [
|
|
42
|
+
...(props.photos?.before ?? []).map((id) => ({ id, label: "Before", title: `Before photo${at(props.beforeAt)}` })),
|
|
43
|
+
...(props.photos?.after ?? []).map((id) => ({ id, label: "After", title: `After photo${at(props.afterAt)}` })),
|
|
44
|
+
...(props.photos?.other ?? []).map((id) => ({ id, label: "Photo", title: "Photo" })),
|
|
45
|
+
]);
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<style scoped>
|
|
49
|
+
.inventory-thumb {
|
|
50
|
+
position: relative;
|
|
51
|
+
border: 0;
|
|
52
|
+
padding: 0;
|
|
53
|
+
background: none;
|
|
54
|
+
cursor: zoom-in;
|
|
55
|
+
}
|
|
56
|
+
.inventory-thumb__tag {
|
|
57
|
+
position: absolute;
|
|
58
|
+
left: 2px;
|
|
59
|
+
bottom: 2px;
|
|
60
|
+
font-size: 9px;
|
|
61
|
+
line-height: 1;
|
|
62
|
+
padding: 1px 3px;
|
|
63
|
+
border-radius: 3px;
|
|
64
|
+
background: rgba(0, 0, 0, 0.6);
|
|
65
|
+
color: #fff;
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
@@ -6,7 +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
|
+
<v-btn v-if="ctl.fullReports || ctl.review" value="handovers" class="text-none">Hand-overs</v-btn>
|
|
10
10
|
</v-btn-toggle>
|
|
11
11
|
<v-select
|
|
12
12
|
v-if="report === 'purchases'"
|
|
@@ -60,21 +60,36 @@
|
|
|
60
60
|
hide-details
|
|
61
61
|
style="max-width: 260px"
|
|
62
62
|
/>
|
|
63
|
-
<v-checkbox v-if="report === 'usage'" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
|
|
63
|
+
<v-checkbox v-if="report === 'usage' && canCheck" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
|
|
64
64
|
<v-spacer />
|
|
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">
|
|
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">
|
|
66
66
|
Export Excel
|
|
67
67
|
</v-btn>
|
|
68
68
|
</div>
|
|
69
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
|
+
|
|
70
84
|
<p class="text-caption text-medium-emphasis mb-2">{{ help[report] }}</p>
|
|
71
85
|
<p v-if="report === 'movement' && siteName" class="text-caption text-medium-emphasis mb-2">
|
|
72
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.
|
|
73
87
|
</p>
|
|
74
88
|
|
|
75
|
-
<div v-if="report === 'usage' &&
|
|
89
|
+
<div v-if="report === 'usage' && shownRows.length" class="d-flex flex-wrap align-center ga-3 mb-2">
|
|
76
90
|
<div class="text-body-2 font-weight-medium">
|
|
77
|
-
{{
|
|
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>
|
|
78
93
|
</div>
|
|
79
94
|
<v-spacer />
|
|
80
95
|
<template v-if="canCheck">
|
|
@@ -88,6 +103,19 @@
|
|
|
88
103
|
</template>
|
|
89
104
|
</div>
|
|
90
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
|
+
|
|
91
119
|
<template v-if="report === 'purchases' && rows.length">
|
|
92
120
|
<div class="text-body-2 font-weight-medium mb-2">Total purchases {{ money(totals.cost) }}</div>
|
|
93
121
|
<v-row dense class="mb-2">
|
|
@@ -121,11 +149,12 @@
|
|
|
121
149
|
:items="shownRows"
|
|
122
150
|
:loading="loading"
|
|
123
151
|
:show-select="report === 'usage' && canCheck"
|
|
152
|
+
:item-selectable="(r: any) => r.status !== 'draft'"
|
|
124
153
|
item-value="_id"
|
|
125
154
|
density="compact"
|
|
126
155
|
:items-per-page="50"
|
|
127
156
|
class="border rounded"
|
|
128
|
-
no-data-text="
|
|
157
|
+
:no-data-text="noDataText"
|
|
129
158
|
>
|
|
130
159
|
<template #item.date="{ value }">{{ standardFormatDate(value) }}</template>
|
|
131
160
|
<template #item.createdAt="{ value }">{{ standardFormatDate(value) }}</template>
|
|
@@ -142,14 +171,31 @@
|
|
|
142
171
|
<template #item.receivedCost="{ value }">{{ money(value) }}</template>
|
|
143
172
|
<template #item.usedCost="{ value }">{{ money(value) }}</template>
|
|
144
173
|
<template #item.siteUsedCost="{ value }">{{ money(value) }}</template>
|
|
145
|
-
<!-- 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). -->
|
|
146
175
|
<template #item.photos="{ item }">
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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>
|
|
151
182
|
</div>
|
|
152
|
-
|
|
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>
|
|
195
|
+
</div>
|
|
196
|
+
</template>
|
|
197
|
+
<template #item.path="{ item }">
|
|
198
|
+
<span class="text-caption">{{ usagePathText(item.path, standardFormatDate) || "—" }}</span>
|
|
153
199
|
</template>
|
|
154
200
|
<template #item.checkedByName="{ item }">
|
|
155
201
|
<v-chip v-if="item.checkedByName" size="x-small" color="success" variant="tonal">{{ item.checkedByName }}</v-chip>
|
|
@@ -159,6 +205,7 @@
|
|
|
159
205
|
<span :class="item.reorderLevel && item.closing <= item.reorderLevel ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ item.closing }}</span>
|
|
160
206
|
</template>
|
|
161
207
|
</v-data-table>
|
|
208
|
+
<InventoryHistoryDialog v-model="drill.show" :store="store" :holder="drill.holder" :item="drill.item" :title="drill.title" />
|
|
162
209
|
</div>
|
|
163
210
|
</template>
|
|
164
211
|
|
|
@@ -166,9 +213,9 @@
|
|
|
166
213
|
import useInventory, { saveBlob } from "../composables/useInventory";
|
|
167
214
|
import useWorkerTerm, { workerAppKey } from "../composables/useWorkerTerm";
|
|
168
215
|
import useUtils from "../composables/useUtils";
|
|
169
|
-
import useFile from "../composables/useFile";
|
|
170
216
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
171
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";
|
|
172
219
|
|
|
173
220
|
const props = defineProps({
|
|
174
221
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -177,12 +224,7 @@ const props = defineProps({
|
|
|
177
224
|
const ctl = computed(() => inventoryControls(props.store.access));
|
|
178
225
|
|
|
179
226
|
const api = useInventory();
|
|
180
|
-
const { standardFormatDate } = useUtils();
|
|
181
|
-
const { getFileUrl } = useFile();
|
|
182
|
-
const usagePhotos = (row: any) => [
|
|
183
|
-
...(row.photos?.before ?? []).map((id: string) => ({ id, label: "Before" })),
|
|
184
|
-
...(row.photos?.after ?? []).map((id: string) => ({ id, label: "After" })),
|
|
185
|
-
];
|
|
227
|
+
const { standardFormatDate, standardFormatDateTime } = useUtils();
|
|
186
228
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
187
229
|
|
|
188
230
|
const worker = useWorkerTerm({ providers: true });
|
|
@@ -197,6 +239,26 @@ const site = ref("");
|
|
|
197
239
|
const locationType = ref("");
|
|
198
240
|
const uncheckedOnly = ref(false);
|
|
199
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
|
+
}
|
|
200
262
|
const rows = ref<any[]>([]);
|
|
201
263
|
const selected = ref<string[]>([]);
|
|
202
264
|
const purchases = reactive({ byMonth: [] as { month: string; deliveries: number; totalCost: number }[], bySupplier: [] as { supplier: string; deliveries: number; totalCost: number }[] });
|
|
@@ -214,7 +276,7 @@ const barWidth = (v: number) => {
|
|
|
214
276
|
|
|
215
277
|
const help: Record<string, string> = {
|
|
216
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.",
|
|
217
|
-
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.",
|
|
218
280
|
technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
|
|
219
281
|
purchases: "Every delivery received, and opening stock entered with new items (Type: Opening stock), with totals by month and by supplier.",
|
|
220
282
|
handovers: "Stock passed from one person to a colleague: who gave it, who received it, and whether they confirmed.",
|
|
@@ -228,7 +290,43 @@ const serviceFilter = ref("");
|
|
|
228
290
|
const serviceChoices = computed(() =>
|
|
229
291
|
[...new Set(rows.value.map(companyOf).filter(Boolean))].sort().map((v) => ({ value: v, title: v })),
|
|
230
292
|
);
|
|
231
|
-
const shownRows = computed(() =>
|
|
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
|
+
);
|
|
232
330
|
|
|
233
331
|
const headers: Record<string, any[]> = {
|
|
234
332
|
movement: [
|
|
@@ -247,6 +345,7 @@ const headers: Record<string, any[]> = {
|
|
|
247
345
|
],
|
|
248
346
|
usage: [
|
|
249
347
|
{ title: "Date", key: "date" },
|
|
348
|
+
{ title: "Status", key: "state", sortable: false },
|
|
250
349
|
{ title: "Property", key: "siteName" },
|
|
251
350
|
{ title: "Where", key: "locationType" },
|
|
252
351
|
{ title: "Unit / Area", key: "location" },
|
|
@@ -255,12 +354,13 @@ const headers: Record<string, any[]> = {
|
|
|
255
354
|
{ title: "Qty", key: "qty" },
|
|
256
355
|
{ title: "Unit price", key: "unitCost" },
|
|
257
356
|
{ title: "Total", key: "totalCost" },
|
|
258
|
-
{ title: worker.one, key: "technician" },
|
|
259
|
-
{ title: "
|
|
260
|
-
{ title: "
|
|
261
|
-
{ title: "
|
|
262
|
-
{ 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" },
|
|
263
362
|
{ title: "Reported by", key: "reportedBy" },
|
|
363
|
+
{ title: "Remarks", key: "remarks" },
|
|
264
364
|
{ title: "Checked by", key: "checkedByName" },
|
|
265
365
|
],
|
|
266
366
|
technicians: [
|
|
@@ -326,9 +426,27 @@ function period(): TInventoryPeriod & { unchecked?: boolean } {
|
|
|
326
426
|
...(report.value === "usage" && locationType.value ? { locationType: locationType.value } : {}),
|
|
327
427
|
...(report.value === "usage" && uncheckedOnly.value ? { unchecked: true } : {}),
|
|
328
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
|
+
: {}),
|
|
329
432
|
};
|
|
330
433
|
}
|
|
331
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
|
+
|
|
332
450
|
async function load() {
|
|
333
451
|
if (!month.value) return;
|
|
334
452
|
loading.value = true;
|
|
@@ -338,6 +456,8 @@ async function load() {
|
|
|
338
456
|
rows.value = res.rows;
|
|
339
457
|
totals.qty = res.totalQty ?? 0;
|
|
340
458
|
totals.cost = res.totalCost ?? 0;
|
|
459
|
+
scope.value = res.scope ?? "all";
|
|
460
|
+
staleDays.value = res.staleAfterDays ?? 3;
|
|
341
461
|
purchases.byMonth = res.byMonth ?? [];
|
|
342
462
|
purchases.bySupplier = res.bySupplier ?? [];
|
|
343
463
|
} catch (error: any) {
|
|
@@ -365,7 +485,7 @@ async function setChecked(checked: boolean) {
|
|
|
365
485
|
async function exportExcel() {
|
|
366
486
|
exporting.value = true;
|
|
367
487
|
try {
|
|
368
|
-
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 });
|
|
369
489
|
saveBlob(blob, `${props.store.name} - ${report.value === "technicians" ? worker.many : report.value} - ${month.value}.xlsx`);
|
|
370
490
|
} catch {
|
|
371
491
|
notify("Could not export the report.", "error");
|
|
@@ -374,7 +494,13 @@ async function exportExcel() {
|
|
|
374
494
|
}
|
|
375
495
|
}
|
|
376
496
|
|
|
377
|
-
watch([report, month, span, site, locationType, uncheckedOnly, usedBy, () => 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
|
+
});
|
|
378
504
|
watch(report, () => (serviceFilter.value = ""));
|
|
379
505
|
onMounted(load);
|
|
380
506
|
</script>
|
|
@@ -2,24 +2,30 @@
|
|
|
2
2
|
<v-dialog v-model="show" max-width="620" persistent scrollable>
|
|
3
3
|
<v-card v-if="holding" class="screen-modal">
|
|
4
4
|
<v-card-title class="d-flex align-center">
|
|
5
|
-
Record Usage
|
|
5
|
+
{{ draft ? "Finish usage (draft)" : "Record Usage" }}
|
|
6
6
|
<v-spacer />
|
|
7
7
|
<v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="show = false" />
|
|
8
8
|
</v-card-title>
|
|
9
9
|
<v-card-text class="screen-modal__body">
|
|
10
10
|
<div class="font-weight-medium">{{ holding.itemName }}</div>
|
|
11
|
-
<div class="text-body-2 text-medium-emphasis mb-4">
|
|
11
|
+
<div class="text-body-2 text-medium-emphasis mb-4">
|
|
12
|
+
You have {{ holding.qty }} {{ holding.unit }} in hand<template v-if="otherDrafts"> · {{ otherDrafts }} already in your other drafts</template>.
|
|
13
|
+
<template v-if="!draft"> Took the before photo but the job is not done yet? Save a draft now and finish it later.</template>
|
|
14
|
+
</div>
|
|
15
|
+
<v-alert v-if="draft" type="info" variant="tonal" density="compact" class="mb-4">
|
|
16
|
+
Saved as a draft {{ draft.draftedAt ? standardFormatDateTime(draft.draftedAt) : "" }}. Nothing has been taken off your stock yet — add the after photo and submit when the job is done.
|
|
17
|
+
</v-alert>
|
|
12
18
|
<v-form ref="form">
|
|
13
19
|
<InputLabel title="How many did you use?" required class="mb-1" />
|
|
14
20
|
<v-text-field
|
|
15
21
|
v-model.number="qty"
|
|
16
22
|
type="number"
|
|
17
23
|
min="1"
|
|
18
|
-
:max="
|
|
24
|
+
:max="maxQty"
|
|
19
25
|
:suffix="holding.unit"
|
|
20
26
|
density="comfortable"
|
|
21
27
|
style="max-width: 200px"
|
|
22
|
-
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <=
|
|
28
|
+
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= maxQty) || `1 to ${maxQty}`]"
|
|
23
29
|
/>
|
|
24
30
|
|
|
25
31
|
<InputLabel title="Where?" required class="mb-1" />
|
|
@@ -74,15 +80,17 @@
|
|
|
74
80
|
<v-col cols="12" sm="6">
|
|
75
81
|
<InputLabel title="Photo before" required class="mb-1" />
|
|
76
82
|
<InventoryPhotoInput v-model="photosBefore" :max="3" />
|
|
83
|
+
<div v-if="draft?.photosBeforeAt" class="text-caption text-medium-emphasis mt-1">Taken {{ standardFormatDateTime(draft.photosBeforeAt) }}</div>
|
|
77
84
|
</v-col>
|
|
78
85
|
<v-col cols="12" sm="6">
|
|
79
86
|
<InputLabel title="Photo after" required class="mb-1" />
|
|
80
87
|
<InventoryPhotoInput v-model="photosAfter" :max="3" />
|
|
88
|
+
<div v-if="draft?.photosAfterAt" class="text-caption text-medium-emphasis mt-1">Taken {{ standardFormatDateTime(draft.photosAfterAt) }}</div>
|
|
81
89
|
</v-col>
|
|
82
90
|
</v-row>
|
|
83
91
|
<div v-if="photoError" class="text-error text-body-2 mt-1" role="alert">{{ photoError }}</div>
|
|
84
92
|
|
|
85
|
-
<InputLabel title="Date used" class="mb-1 mt-3" />
|
|
93
|
+
<InputLabel :title="draft ? 'Date of the job (used when you submit)' : 'Date used'" class="mb-1 mt-3" />
|
|
86
94
|
<v-text-field v-model="date" type="date" :max="today" density="comfortable" style="max-width: 200px" />
|
|
87
95
|
<InputLabel title="Fault reported by (optional)" class="mb-1" />
|
|
88
96
|
<v-text-field v-model="reportedBy" density="comfortable" placeholder="e.g. tenant, security guard" maxlength="120" />
|
|
@@ -91,8 +99,21 @@
|
|
|
91
99
|
</v-form>
|
|
92
100
|
</v-card-text>
|
|
93
101
|
<div class="screen-modal__footer">
|
|
94
|
-
<v-btn class="
|
|
95
|
-
<v-
|
|
102
|
+
<v-btn v-if="draft" class="text-none" variant="text" color="error" :disabled="!!saving" @click="confirmDiscard = true">Discard draft</v-btn>
|
|
103
|
+
<v-spacer v-if="draft" />
|
|
104
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="!!saving" @click="show = false">Cancel</v-btn>
|
|
105
|
+
<v-btn class="screen-btn-ghost text-none" variant="outlined" prepend-icon="mdi-content-save-outline" :loading="saving === 'draft'" :disabled="!!saving" @click="saveDraft">Save draft</v-btn>
|
|
106
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="saving === 'submit'" :disabled="!!saving" @click="save">{{ draft ? "Submit usage" : "Save usage" }}</v-btn>
|
|
107
|
+
</div>
|
|
108
|
+
</v-card>
|
|
109
|
+
</v-dialog>
|
|
110
|
+
<v-dialog v-model="confirmDiscard" max-width="420">
|
|
111
|
+
<v-card class="screen-modal">
|
|
112
|
+
<v-card-title>Discard this draft?</v-card-title>
|
|
113
|
+
<v-card-text>The draft and its photos are removed. Nothing was taken off your stock, so your stock stays as it is.</v-card-text>
|
|
114
|
+
<div class="screen-modal__footer">
|
|
115
|
+
<v-btn class="screen-btn-ghost" variant="outlined" @click="confirmDiscard = false">Keep it</v-btn>
|
|
116
|
+
<v-btn color="error" variant="flat" :loading="saving === 'discard'" @click="discard">Discard</v-btn>
|
|
96
117
|
</div>
|
|
97
118
|
</v-card>
|
|
98
119
|
</v-dialog>
|
|
@@ -107,16 +128,24 @@ const props = defineProps({
|
|
|
107
128
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
108
129
|
siteId: { type: String, required: true },
|
|
109
130
|
holding: { type: Object as PropType<TInventoryHolding | null>, default: null },
|
|
131
|
+
/** An existing draft to finish (edit, add the after photo, submit or discard). */
|
|
132
|
+
draft: { type: Object as PropType<TInventoryUsageDraft | null>, default: null },
|
|
110
133
|
sites: { type: Array as PropType<{ _id: string; name: string }[]>, default: () => [] },
|
|
111
134
|
});
|
|
112
135
|
const emit = defineEmits(["done"]);
|
|
113
136
|
|
|
114
137
|
const api = useInventory();
|
|
115
|
-
const { requiredRule, debounce } = useUtils();
|
|
138
|
+
const { requiredRule, debounce, standardFormatDateTime } = useUtils();
|
|
116
139
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
117
140
|
|
|
118
141
|
const form = ref();
|
|
119
|
-
const saving = ref(
|
|
142
|
+
const saving = ref<"" | "draft" | "submit" | "discard">("");
|
|
143
|
+
const confirmDiscard = ref(false);
|
|
144
|
+
// Stock already promised to the person's OTHER drafts; this draft's own qty is free for it.
|
|
145
|
+
const otherDrafts = computed(() => Math.max(0, (props.holding?.inDrafts ?? 0) - (props.draft?.qty ?? 0)));
|
|
146
|
+
const maxQty = computed(() => Math.max(1, (props.holding?.qty ?? 0) - otherDrafts.value));
|
|
147
|
+
const USAGE_DRAFT_NEEDS_BEFORE = "Take the before photo to save a draft.";
|
|
148
|
+
let prefilling = false;
|
|
120
149
|
const today = new Date().toLocaleDateString("en-CA");
|
|
121
150
|
const qty = ref<number | null>(1);
|
|
122
151
|
const where = ref<"tenant_unit" | "common_area">("tenant_unit");
|
|
@@ -159,38 +188,81 @@ async function loadAreas() {
|
|
|
159
188
|
}
|
|
160
189
|
}
|
|
161
190
|
|
|
191
|
+
function body() {
|
|
192
|
+
return {
|
|
193
|
+
item: props.holding!.item,
|
|
194
|
+
qty: Number(qty.value),
|
|
195
|
+
locationType: where.value,
|
|
196
|
+
...(where.value === "tenant_unit" ? { unit: unit.value } : { area: area.value }),
|
|
197
|
+
reference: reference.value,
|
|
198
|
+
photosBefore: photosBefore.value,
|
|
199
|
+
photosAfter: photosAfter.value,
|
|
200
|
+
remarks: remarks.value,
|
|
201
|
+
reportedBy: reportedBy.value,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function finished(text: string) {
|
|
206
|
+
notify(text);
|
|
207
|
+
show.value = false;
|
|
208
|
+
emit("done");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Save draft: before photo required, after photo optional; nothing is deducted. */
|
|
212
|
+
async function saveDraft() {
|
|
213
|
+
const { valid } = await form.value.validate();
|
|
214
|
+
photoError.value = photosBefore.value.length ? "" : USAGE_DRAFT_NEEDS_BEFORE;
|
|
215
|
+
if (!valid || photoError.value || !props.holding) return;
|
|
216
|
+
saving.value = "draft";
|
|
217
|
+
try {
|
|
218
|
+
if (props.draft) await api.updateUsageDraft(props.draft._id, body());
|
|
219
|
+
else await api.createUsageDraft(props.store._id, { site: site.value, ...body() });
|
|
220
|
+
finished("Draft saved. Add the after photo and submit it when the job is done — it is under My drafts.");
|
|
221
|
+
} catch (error: any) {
|
|
222
|
+
notify(error?.data?.message || "Could not save the draft.", "error");
|
|
223
|
+
} finally {
|
|
224
|
+
saving.value = "";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
162
228
|
async function save() {
|
|
163
229
|
const { valid } = await form.value.validate();
|
|
164
230
|
// Owner decision 2026-09-27: a before AND an after photo on every usage record (the API refuses without).
|
|
165
231
|
photoError.value = photosBefore.value.length && photosAfter.value.length ? "" : USAGE_PHOTOS_REQUIRED;
|
|
166
232
|
if (!valid || photoError.value || !props.holding) return;
|
|
167
|
-
saving.value =
|
|
233
|
+
saving.value = "submit";
|
|
168
234
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
photosBefore: photosBefore.value,
|
|
178
|
-
photosAfter: photosAfter.value,
|
|
179
|
-
remarks: remarks.value,
|
|
180
|
-
reportedBy: reportedBy.value,
|
|
181
|
-
});
|
|
182
|
-
notify("Usage saved.");
|
|
183
|
-
show.value = false;
|
|
184
|
-
emit("done");
|
|
235
|
+
if (props.draft) {
|
|
236
|
+
// Keep any edits, then submit: the stock comes off now.
|
|
237
|
+
await api.updateUsageDraft(props.draft._id, body());
|
|
238
|
+
await api.submitUsageDraft(props.draft._id, { photosAfter: photosAfter.value, date: pickedDate(date.value) });
|
|
239
|
+
} else {
|
|
240
|
+
await api.recordUsage(props.store._id, { site: site.value, ...body(), date: pickedDate(date.value) });
|
|
241
|
+
}
|
|
242
|
+
finished("Usage saved.");
|
|
185
243
|
} catch (error: any) {
|
|
186
244
|
notify(error?.data?.message || "Could not save the usage.", "error");
|
|
187
245
|
} finally {
|
|
188
|
-
saving.value =
|
|
246
|
+
saving.value = "";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function discard() {
|
|
251
|
+
if (!props.draft) return;
|
|
252
|
+
saving.value = "discard";
|
|
253
|
+
try {
|
|
254
|
+
await api.discardUsageDraft(props.draft._id);
|
|
255
|
+
confirmDiscard.value = false;
|
|
256
|
+
finished("Draft discarded.");
|
|
257
|
+
} catch (error: any) {
|
|
258
|
+
notify(error?.data?.message || "Could not discard the draft.", "error");
|
|
259
|
+
} finally {
|
|
260
|
+
saving.value = "";
|
|
189
261
|
}
|
|
190
262
|
}
|
|
191
263
|
|
|
192
264
|
watch(site, () => {
|
|
193
|
-
unit.value = "";
|
|
265
|
+
if (!prefilling) unit.value = "";
|
|
194
266
|
searchUnits("");
|
|
195
267
|
loadAreas();
|
|
196
268
|
});
|
|
@@ -212,6 +284,25 @@ watch(show, (open) => {
|
|
|
212
284
|
reportedBy.value = "";
|
|
213
285
|
date.value = today;
|
|
214
286
|
site.value = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
|
|
287
|
+
const d = props.draft;
|
|
288
|
+
if (d) {
|
|
289
|
+
// Finish a draft: everything as saved; the site cannot change.
|
|
290
|
+
prefilling = true;
|
|
291
|
+
site.value = String(d.site ?? site.value);
|
|
292
|
+
qty.value = d.qty;
|
|
293
|
+
where.value = d.location?.type === "common_area" ? "common_area" : "tenant_unit";
|
|
294
|
+
area.value = d.location?.area ?? "";
|
|
295
|
+
unit.value = String(d.location?.unit ?? "");
|
|
296
|
+
if (unit.value) units.value = [{ _id: unit.value, name: d.location?.unitName ?? "", tenantName: d.location?.tenantName ?? "" }];
|
|
297
|
+
reference.value = d.reference ?? "";
|
|
298
|
+
remarks.value = d.remarks ?? "";
|
|
299
|
+
reportedBy.value = d.reportedBy ?? "";
|
|
300
|
+
photosBefore.value = [...(d.photos?.before ?? [])];
|
|
301
|
+
photosAfter.value = [...(d.photos?.after ?? [])];
|
|
302
|
+
nextTick(() => (prefilling = false));
|
|
303
|
+
loadAreas();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
215
306
|
searchUnits("");
|
|
216
307
|
loadAreas();
|
|
217
308
|
});
|
|
@@ -15,6 +15,7 @@ export default function useInventory() {
|
|
|
15
15
|
receive: "Received",
|
|
16
16
|
issue: "Issued",
|
|
17
17
|
use: "Used",
|
|
18
|
+
use_draft: "Draft",
|
|
18
19
|
return: "Returned",
|
|
19
20
|
adjust: "Stock count",
|
|
20
21
|
handover: "Hand-over",
|
|
@@ -44,6 +45,13 @@ export default function useInventory() {
|
|
|
44
45
|
issue: (store: string, body: Record<string, any>) => api(`/stores/${store}/issue`, { method: "POST", body }),
|
|
45
46
|
receiveReturn: (store: string, body: Record<string, any>) => api(`/stores/${store}/returns`, { method: "POST", body }),
|
|
46
47
|
recordUsage: (store: string, body: Record<string, any>) => api(`/stores/${store}/usage`, { method: "POST", body }),
|
|
48
|
+
/** Usage drafts: before photo now, after photo and submit after the job (stock is deducted on submit). */
|
|
49
|
+
getUsageDrafts: (store: string, everyone = false) =>
|
|
50
|
+
api<{ items: TInventoryUsageDraft[]; staleAfterDays: number }>(`/stores/${store}/usage-drafts`, { query: everyone ? { everyone: true } : {} }),
|
|
51
|
+
createUsageDraft: (store: string, body: Record<string, any>) => api<TInventoryUsageDraft>(`/stores/${store}/usage-drafts`, { method: "POST", body }),
|
|
52
|
+
updateUsageDraft: (id: string, body: Record<string, any>) => api<TInventoryUsageDraft>(`/usage-drafts/${id}`, { method: "PATCH", body }),
|
|
53
|
+
discardUsageDraft: (id: string) => api(`/usage-drafts/${id}/discard`, { method: "POST" }),
|
|
54
|
+
submitUsageDraft: (id: string, body: { photosAfter?: string[]; date?: string }) => api<TInventoryTransaction>(`/usage-drafts/${id}/submit`, { method: "POST", body }),
|
|
47
55
|
|
|
48
56
|
getRequests: (store: string, query: { page?: number; limit?: number; status?: string; mine?: boolean }) =>
|
|
49
57
|
api<{ items: TInventoryRequest[]; total: number; pendingCount: number; page: number; pages: number }>(`/stores/${store}/requests`, { query }),
|
|
@@ -74,9 +82,9 @@ export default function useInventory() {
|
|
|
74
82
|
getPeople: (store: string, search = "") =>
|
|
75
83
|
api<{ _id: string; name: string; orgName: string; email?: string; company?: string; service?: string; serviceLabel?: string; label?: string }[]>(`/stores/${store}/people`, { query: { search } }),
|
|
76
84
|
|
|
77
|
-
getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers", query: TInventoryPeriod &
|
|
78
|
-
api<{ rows: any[]; totalQty?: number; totalCost?: number }>(`/stores/${store}/reports/${report}`, { query }),
|
|
79
|
-
exportReport: (store: string, report: string, query: TInventoryPeriod &
|
|
85
|
+
getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers", query: TInventoryPeriod & Record<string, any>) =>
|
|
86
|
+
api<{ rows: any[]; totalQty?: number; totalCost?: number; scope?: "all" | "own_company"; staleAfterDays?: number }>(`/stores/${store}/reports/${report}`, { query }),
|
|
87
|
+
exportReport: (store: string, report: string, query: TInventoryPeriod & Record<string, any>) =>
|
|
80
88
|
api<Blob>(`/stores/${store}/reports/${report}/export`, { query, responseType: "blob" }),
|
|
81
89
|
checkUsage: (store: string, ids: string[], checked: boolean) =>
|
|
82
90
|
api<{ updated: number }>(`/stores/${store}/usage/check`, { method: "POST", body: { ids, checked } }),
|
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.73.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
|
@@ -82,6 +82,11 @@ declare type TInventoryHolding = {
|
|
|
82
82
|
packing?: string;
|
|
83
83
|
/** Used by this holder since the start of the month (Singapore time). */
|
|
84
84
|
usedThisMonth?: number;
|
|
85
|
+
holderLabel?: string;
|
|
86
|
+
/** How many sit in the holder's own usage drafts (not deducted until submitted), and what is still free. */
|
|
87
|
+
inDrafts?: number;
|
|
88
|
+
draftCount?: number;
|
|
89
|
+
free?: number;
|
|
85
90
|
};
|
|
86
91
|
|
|
87
92
|
declare type TInventoryTransaction = {
|
|
@@ -89,7 +94,7 @@ declare type TInventoryTransaction = {
|
|
|
89
94
|
item: string;
|
|
90
95
|
itemName: string;
|
|
91
96
|
unit: string;
|
|
92
|
-
type: "opening" | "receive" | "issue" | "use" | "return" | "adjust" | "handover";
|
|
97
|
+
type: "opening" | "receive" | "issue" | "use" | "return" | "adjust" | "handover" | "use_draft";
|
|
93
98
|
qty: number;
|
|
94
99
|
storeChange: number;
|
|
95
100
|
storeBalance: number;
|
|
@@ -97,7 +102,7 @@ declare type TInventoryTransaction = {
|
|
|
97
102
|
holderChange?: number;
|
|
98
103
|
holderBalance?: number;
|
|
99
104
|
site?: string;
|
|
100
|
-
location?: { type: "common_area" | "tenant_unit"; area?: string; unitName?: string; tenantName?: string };
|
|
105
|
+
location?: { type: "common_area" | "tenant_unit"; area?: string; unit?: string; unitName?: string; tenantName?: string };
|
|
101
106
|
reference?: string;
|
|
102
107
|
supplier?: string;
|
|
103
108
|
unitCost: number;
|
|
@@ -109,8 +114,19 @@ declare type TInventoryTransaction = {
|
|
|
109
114
|
receivedBy?: string;
|
|
110
115
|
date: string;
|
|
111
116
|
createdByName: string;
|
|
117
|
+
holder?: string;
|
|
118
|
+
holderLabel?: string;
|
|
119
|
+
createdByLabel?: string;
|
|
120
|
+
/** Usage saved as a draft first. */
|
|
121
|
+
draftedAt?: string;
|
|
122
|
+
photosBeforeAt?: string | null;
|
|
123
|
+
photosAfterAt?: string | null;
|
|
124
|
+
submittedAt?: string;
|
|
112
125
|
};
|
|
113
126
|
|
|
127
|
+
/** A usage record saved with its before photo, waiting for the after photo (no stock deducted yet). */
|
|
128
|
+
declare type TInventoryUsageDraft = TInventoryTransaction & { type: "use_draft"; stale: boolean; mine: boolean; clientRef?: string };
|
|
129
|
+
|
|
114
130
|
declare type TInventoryPeriod = { from: string; to: string; site?: string; locationType?: string; usedBy?: string };
|
|
115
131
|
|
|
116
132
|
/** An organisation's supplier (shared by its stores). Only the name is required. */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain words for usage drafts, usage history and the store owner's usage
|
|
3
|
+
* tracking (owner 2026-09-29). Used by the Record Usage dialog, My Stock and
|
|
4
|
+
* the Usage report. Pure, so it is tested without a browser.
|
|
5
|
+
*/
|
|
6
|
+
type TLocation = { type?: string; area?: string; unitName?: string; tenantName?: string } | undefined;
|
|
7
|
+
type TDraft = { location?: TLocation; photos?: { before?: string[]; after?: string[] }; photosBeforeAt?: string | null; stale?: boolean };
|
|
8
|
+
type TStep = { type: "issued" | "handed_over"; to: string; by: string; date: string };
|
|
9
|
+
|
|
10
|
+
export const USAGE_FLAG_WORDS: Record<string, string> = {
|
|
11
|
+
no_work_order: "No work order",
|
|
12
|
+
old_draft: "Draft waiting too long",
|
|
13
|
+
above_typical: "More than usual",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const USAGE_STATUS_CHOICES = [
|
|
17
|
+
{ value: "submitted", title: "Submitted" },
|
|
18
|
+
{ value: "draft", title: "In progress (drafts)" },
|
|
19
|
+
{ value: "all", title: "All" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** "Unit Tower A 05-12 (Tenant Co)" or the common area as typed. */
|
|
23
|
+
export function whereText(location: TLocation) {
|
|
24
|
+
if (!location) return "";
|
|
25
|
+
if (location.type === "tenant_unit") return `Unit ${location.unitName ?? ""}${location.tenantName ? ` (${location.tenantName})` : ""}`.trim();
|
|
26
|
+
return location.area ?? "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** "Before photo taken 10:32 · Unit 05-12 · Add the after photo to finish" — the draft card. */
|
|
30
|
+
export function draftCardText(d: TDraft, time: (iso: string) => string, staleDays = 3) {
|
|
31
|
+
const taken = d.photosBeforeAt ? `Before photo taken ${time(d.photosBeforeAt)}` : "Before photo taken";
|
|
32
|
+
const next = d.photos?.after?.length ? "Ready to submit" : "Add the after photo to finish";
|
|
33
|
+
return {
|
|
34
|
+
line: [taken, whereText(d.location), next].filter(Boolean).join(" · "),
|
|
35
|
+
warning: d.stale ? `Waiting more than ${staleDays} days — finish or discard it` : "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "Issued to Ali by Keeper (29/09) → Handed to Bala by Ali (29/09)". */
|
|
40
|
+
export function usagePathText(steps: TStep[] | undefined, day: (iso: string) => string) {
|
|
41
|
+
if (!steps?.length) return "";
|
|
42
|
+
return steps.map((s) => `${s.type === "issued" ? "Issued to" : "Handed to"} ${s.to} by ${s.by} (${day(s.date)})`).join(" → ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Query for the report/export: empty filters are left out, so the server applies no filter. */
|
|
46
|
+
export function usageQuery(filters: Record<string, string | boolean | undefined | null>) {
|
|
47
|
+
return Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== "" && v !== undefined && v !== null && v !== false));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Totals per item / person / company, of submitted records only (drafts are not usage yet). */
|
|
51
|
+
export function usageTotals(rows: { status?: string; qty: number; totalCost?: number; unit?: string; itemName?: string; holderLabel?: string; technician?: string; holderCompany?: string }[]) {
|
|
52
|
+
const group = (label: (r: (typeof rows)[number]) => string) => {
|
|
53
|
+
const map = new Map<string, { label: string; records: number; qty: number; totalCost: number; unit: string }>();
|
|
54
|
+
for (const r of rows) {
|
|
55
|
+
if (r.status === "draft") continue;
|
|
56
|
+
const k = label(r);
|
|
57
|
+
const t = map.get(k) ?? { label: k, records: 0, qty: 0, totalCost: 0, unit: r.unit ?? "" };
|
|
58
|
+
t.records++;
|
|
59
|
+
t.qty += r.qty;
|
|
60
|
+
t.totalCost = Math.round((t.totalCost + (r.totalCost ?? 0)) * 100) / 100;
|
|
61
|
+
// Different units are not added up in words.
|
|
62
|
+
if (t.unit !== (r.unit ?? "")) t.unit = "";
|
|
63
|
+
map.set(k, t);
|
|
64
|
+
}
|
|
65
|
+
return [...map.values()].sort((a, b) => b.records - a.records || a.label.localeCompare(b.label));
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
byItem: group((r) => r.itemName ?? ""),
|
|
69
|
+
byPerson: group((r) => r.holderLabel || r.technician || ""),
|
|
70
|
+
byCompany: group((r) => r.holderCompany || "(company not known)"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What a history row says happened, from the person's side. */
|
|
75
|
+
export function historyText(t: { type: string; holderChange?: number }) {
|
|
76
|
+
switch (t.type) {
|
|
77
|
+
case "issue":
|
|
78
|
+
return "Taken from store";
|
|
79
|
+
case "return":
|
|
80
|
+
return "Given back to store";
|
|
81
|
+
case "handover":
|
|
82
|
+
return (t.holderChange ?? 0) < 0 ? "Given to colleague" : "Received from colleague";
|
|
83
|
+
case "use":
|
|
84
|
+
return "Used";
|
|
85
|
+
case "opening":
|
|
86
|
+
return "Opening stock";
|
|
87
|
+
case "receive":
|
|
88
|
+
return "Received into store";
|
|
89
|
+
case "adjust":
|
|
90
|
+
return "Stock count";
|
|
91
|
+
default:
|
|
92
|
+
return t.type;
|
|
93
|
+
}
|
|
94
|
+
}
|