@7365admin1/layer-common 4.90.2-staging.492 → 4.90.2-staging.493
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/components/InventoryHistoryDialog.vue +8 -1
- package/components/InventoryItemDetail.vue +17 -0
- package/components/InventoryLinesDialog.vue +26 -4
- package/components/InventoryMain.vue +98 -2
- package/components/InventoryMyStockTab.vue +117 -4
- package/components/InventoryReceiptActions.vue +66 -0
- package/components/InventoryReportsTab.vue +97 -6
- package/components/InventoryRequestsTab.vue +23 -11
- package/components/InventorySettingsTab.vue +27 -1
- package/components/InventoryStockTab.vue +51 -3
- package/components/InventoryTechniciansTab.vue +76 -4
- package/components/InventoryUsageDialog.vue +19 -0
- package/components/InventoryWriteOffDialog.vue +110 -0
- package/composables/useInventory.ts +21 -4
- package/package.json +1 -1
- package/types/inventory.d.ts +39 -2
- package/utils/inventory-ack.ts +148 -0
- package/utils/inventory-usage.ts +5 -1
|
@@ -64,7 +64,12 @@
|
|
|
64
64
|
<template #item.photos="{ item }">
|
|
65
65
|
<InventoryPhotoThumbs :photos="item.photos" :before-at="item.photosBeforeAt" :after-at="item.photosAfterAt" />
|
|
66
66
|
</template>
|
|
67
|
-
<template #item.by="{ item }">
|
|
67
|
+
<template #item.by="{ item }">
|
|
68
|
+
{{ item.createdByLabel || item.createdByName }}
|
|
69
|
+
<div v-if="item.attendedWith" class="text-caption text-medium-emphasis">Also attended: {{ item.attendedWith }}</div>
|
|
70
|
+
<div v-if="item.startedByName" class="text-caption text-medium-emphasis">Started by {{ item.startedByName }}</div>
|
|
71
|
+
<div v-if="item.type === 'write_off' && item.remarks" class="text-caption text-medium-emphasis">"{{ item.remarks }}"</div>
|
|
72
|
+
</template>
|
|
68
73
|
<template #item.holderBalance="{ value }">{{ value ?? "—" }}</template>
|
|
69
74
|
</v-data-table-server>
|
|
70
75
|
</v-card-text>
|
|
@@ -77,6 +82,7 @@ import useInventory from "../composables/useInventory";
|
|
|
77
82
|
import useUtils from "../composables/useUtils";
|
|
78
83
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
79
84
|
import { historyText, whereText } from "../utils/inventory-usage";
|
|
85
|
+
import { inventoryStepsLive } from "../utils/inventory-ack";
|
|
80
86
|
|
|
81
87
|
/**
|
|
82
88
|
* History of stock (owner 2026-09-29, "where they used their stock"): one person's (`holder`, "me" =
|
|
@@ -116,6 +122,7 @@ const typeChoices = [
|
|
|
116
122
|
{ value: "issue", title: "Taken from store" },
|
|
117
123
|
{ value: "handover", title: "Hand-overs" },
|
|
118
124
|
{ value: "return", title: "Given back" },
|
|
125
|
+
...(inventoryStepsLive(props.store) ? [{ value: "write_off", title: "Written off" }] : []),
|
|
119
126
|
];
|
|
120
127
|
|
|
121
128
|
const intro = computed(() =>
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
<v-img v-if="item.photo" :src="getFileUrl(item.photo)" width="120" height="120" cover class="rounded flex-grow-0" />
|
|
12
12
|
<div class="flex-grow-1">
|
|
13
13
|
<div class="text-h5 mb-1" :class="item.lowStock ? 'text-error' : ''">{{ item.onHand }} {{ item.unit }} in store</div>
|
|
14
|
+
<div v-if="item.reserved" class="text-body-2 mb-1">{{ availableText(item) }} (the rest is set aside for requests waiting for approval)</div>
|
|
15
|
+
<div v-if="item.lowStockAlert" class="text-body-2 text-error mb-1">{{ lowStockAlertText(item.lowStockAlert, standardFormatDate) }}</div>
|
|
14
16
|
<div class="text-body-2 text-medium-emphasis">
|
|
15
17
|
{{ [inventoryItemMeta(item), item.reorderLevel ? `Alert at ${item.reorderLevel}` : "", item.unitCost !== undefined ? `$${Number(item.unitCost).toFixed(2)} each` : ""].filter(Boolean).join(" · ") }}
|
|
16
18
|
</div>
|
|
@@ -21,7 +23,18 @@
|
|
|
21
23
|
<div class="d-flex flex-wrap ga-2 mb-4" v-if="ctl.editItem || ctl.count">
|
|
22
24
|
<v-btn v-if="ctl.editItem" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-pencil-outline" @click="emit('edit', item)">Edit item</v-btn>
|
|
23
25
|
<v-btn v-if="ctl.count" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-counter" @click="counting = !counting">Stock count</v-btn>
|
|
26
|
+
<v-btn v-if="ctl.count && stepsLive && item.onHand > 0" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-delete-alert-outline" @click="writingOff = true">Write off</v-btn>
|
|
24
27
|
</div>
|
|
28
|
+
<InventoryWriteOffDialog
|
|
29
|
+
v-model="writingOff"
|
|
30
|
+
:store="store"
|
|
31
|
+
from="store"
|
|
32
|
+
:item="item._id"
|
|
33
|
+
:item-name="item.name"
|
|
34
|
+
:unit="item.unit"
|
|
35
|
+
:max="item.onHand"
|
|
36
|
+
@done="(show = false), emit('done')"
|
|
37
|
+
/>
|
|
25
38
|
|
|
26
39
|
<v-form v-if="counting" ref="countForm" class="mb-4">
|
|
27
40
|
<AppCard class="pa-4">
|
|
@@ -80,6 +93,7 @@ import useUtils from "../composables/useUtils";
|
|
|
80
93
|
import { inventoryItemMeta } from "../utils/inventory-store-label";
|
|
81
94
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
82
95
|
import { receiptRefText } from "../utils/inventory-receiving";
|
|
96
|
+
import { availableText, inventoryStepsLive, lowStockAlertText, writeOffLabel } from "../utils/inventory-ack";
|
|
83
97
|
|
|
84
98
|
const show = defineModel<boolean>({ default: false });
|
|
85
99
|
const props = defineProps({
|
|
@@ -103,6 +117,8 @@ const counted = ref<number | null>(null);
|
|
|
103
117
|
const reason = ref("");
|
|
104
118
|
const saving = ref(false);
|
|
105
119
|
const countForm = ref();
|
|
120
|
+
const writingOff = ref(false);
|
|
121
|
+
const stepsLive = computed(() => inventoryStepsLive(props.store));
|
|
106
122
|
|
|
107
123
|
const formatDate = (d: string) => standardFormatDate(d);
|
|
108
124
|
|
|
@@ -121,6 +137,7 @@ function describe(t: TInventoryTransaction) {
|
|
|
121
137
|
t.reference || "",
|
|
122
138
|
);
|
|
123
139
|
if (t.type === "adjust") parts.push(t.remarks || "");
|
|
140
|
+
if (t.type === "write_off") parts.push(`${t.qty} ${writeOffLabel(t.writeOffReason).toLowerCase()}`, (t.holderChange ?? 0) < 0 ? `by ${who}` : "from the shelf", t.remarks || "");
|
|
124
141
|
if (t.type === "opening") parts.push(t.supplier || "", t.reference ? `PO ${t.reference}` : "", t.receivedBy ? `received by ${t.receivedBy}` : "", t.remarks || "");
|
|
125
142
|
return parts.filter(Boolean).join(" · ");
|
|
126
143
|
}
|
|
@@ -170,6 +170,7 @@
|
|
|
170
170
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
171
171
|
import useUtils from "../composables/useUtils";
|
|
172
172
|
import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
|
|
173
|
+
import { availableQty, availableText as availableTextOf } from "../utils/inventory-ack";
|
|
173
174
|
import { RECEIVING_TYPES, receivingFields, receivingProblem, type TReceivingType } from "../utils/inventory-receiving";
|
|
174
175
|
|
|
175
176
|
type TMode = "receive" | "issue" | "request" | "return";
|
|
@@ -217,7 +218,25 @@ const COPY: Record<TMode, { title: string; help: string; button: string; success
|
|
|
217
218
|
success: "Stock returned to the store.",
|
|
218
219
|
},
|
|
219
220
|
};
|
|
220
|
-
|
|
221
|
+
// The API sets requested stock aside at once (owner 2026-10-03) when its items carry `available`.
|
|
222
|
+
const reserving = computed(() => itemChoices.value.some((i) => i.available !== undefined));
|
|
223
|
+
const copy = computed(() => {
|
|
224
|
+
const c = COPY[props.mode];
|
|
225
|
+
if (!reserving.value) return c;
|
|
226
|
+
if (props.mode === "request")
|
|
227
|
+
return {
|
|
228
|
+
...c,
|
|
229
|
+
help: "Ask the store for what you need, up to what is available (shown under each item). The quantity is set aside for you as soon as you send it, so nobody else can take it. If the request is rejected or cancelled, it goes back to the store.",
|
|
230
|
+
success: "Request sent. The items are set aside for you until the store keeper approves. Then open My Requests and tap Received once you have them.",
|
|
231
|
+
};
|
|
232
|
+
if (props.mode === "issue")
|
|
233
|
+
return {
|
|
234
|
+
...c,
|
|
235
|
+
help: "Hand stock to someone at the store counter. It leaves the store at once. They tap Received in their app to confirm they got it, and can use it after that.",
|
|
236
|
+
success: "Stock issued. The person is asked to tap Received to confirm they got it.",
|
|
237
|
+
};
|
|
238
|
+
return c;
|
|
239
|
+
});
|
|
221
240
|
|
|
222
241
|
const form = ref();
|
|
223
242
|
const saving = ref(false);
|
|
@@ -254,12 +273,15 @@ const choices = computed<any[]>(() =>
|
|
|
254
273
|
const siteChoices = computed(() => (props.sites.length ? props.sites : props.store.sites.map((_id) => ({ _id, name: _id === props.siteId ? "This property" : "Other property" }))));
|
|
255
274
|
|
|
256
275
|
const unitOf = (id: string) => choices.value.find((c) => c._id === id)?.unit ?? "";
|
|
257
|
-
const availableOf = (id: string) =>
|
|
276
|
+
const availableOf = (id: string) => {
|
|
277
|
+
const c = choices.value.find((x) => x._id === id);
|
|
278
|
+
return c && (props.mode === "request" || props.mode === "issue") ? availableQty(c) : c?.onHand;
|
|
279
|
+
};
|
|
258
280
|
|
|
259
281
|
function availableText(item: any) {
|
|
260
282
|
if (props.mode === "return") return `In hand: ${item.onHand} ${item.unit}`;
|
|
261
283
|
if (props.mode === "receive") return `In store: ${item.onHand} ${item.unit}`;
|
|
262
|
-
return
|
|
284
|
+
return availableTextOf(item);
|
|
263
285
|
}
|
|
264
286
|
|
|
265
287
|
function qtyRule(line: TRow) {
|
|
@@ -270,7 +292,7 @@ function qtyRule(line: TRow) {
|
|
|
270
292
|
const total = rows.value.filter((r) => r.item === line.item).reduce((s, r) => s + Number(r.qty || 0), 0);
|
|
271
293
|
// Requests too (client 2026-10-02): nobody can ask for more than the store has; the server refuses it as well.
|
|
272
294
|
if (available !== undefined && total > available)
|
|
273
|
-
return available > 0 ? `Only ${available} ${unitOf(line.item)}
|
|
295
|
+
return available > 0 ? `Only ${available} ${unitOf(line.item)} available — ${props.mode === "request" ? "ask for" : "give"} ${available} or fewer` : "None available right now";
|
|
274
296
|
return true;
|
|
275
297
|
};
|
|
276
298
|
}
|
|
@@ -34,6 +34,22 @@
|
|
|
34
34
|
Equipment is now in Inventory.
|
|
35
35
|
</v-alert>
|
|
36
36
|
|
|
37
|
+
<!-- Client 2026-10-02 (Winsland): each property keeps its own stock, so say plainly whose stock this is. -->
|
|
38
|
+
<v-alert v-if="mySites.length > 1" type="info" variant="tonal" density="compact" class="mb-4" icon="mdi-office-building-marker-outline">
|
|
39
|
+
<div class="d-flex flex-wrap align-center ga-2">
|
|
40
|
+
<span>You are viewing <strong>{{ currentSiteName }}</strong>'s inventory.</span>
|
|
41
|
+
<v-spacer />
|
|
42
|
+
<v-menu>
|
|
43
|
+
<template #activator="{ props: menu }">
|
|
44
|
+
<v-btn v-bind="menu" size="small" variant="outlined" class="text-none" append-icon="mdi-chevron-down">Switch property</v-btn>
|
|
45
|
+
</template>
|
|
46
|
+
<v-list density="compact" max-height="320">
|
|
47
|
+
<v-list-item v-for="s in mySites" :key="s._id" :title="s.name" :active="s._id === siteId" @click="switchSite(s._id)" />
|
|
48
|
+
</v-list>
|
|
49
|
+
</v-menu>
|
|
50
|
+
</div>
|
|
51
|
+
</v-alert>
|
|
52
|
+
|
|
37
53
|
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
|
|
38
54
|
|
|
39
55
|
<AppCard v-else-if="loadError" class="pa-6">
|
|
@@ -99,6 +115,12 @@
|
|
|
99
115
|
<v-btn size="small" variant="flat" color="warning" class="text-none" @click="tab = 'requests'">Review requests</v-btn>
|
|
100
116
|
</div>
|
|
101
117
|
</v-alert>
|
|
118
|
+
<v-alert v-if="toConfirm > 0 && tab !== 'mine'" type="warning" variant="tonal" density="compact" class="mb-4" icon="mdi-package-variant-closed-check">
|
|
119
|
+
<div class="d-flex align-center flex-wrap ga-2">
|
|
120
|
+
<span>{{ toConfirm === 1 ? "1 delivery is" : `${toConfirm} deliveries are` }} waiting for you to tap Received. You can use the items after that.</span>
|
|
121
|
+
<v-btn size="small" variant="flat" color="warning" class="text-none" @click="tab = 'mine'">Open My Stock</v-btn>
|
|
122
|
+
</div>
|
|
123
|
+
</v-alert>
|
|
102
124
|
<v-tabs v-model="tab" color="primary" class="mb-4" show-arrows>
|
|
103
125
|
<v-tab v-for="t in tabs" :key="t.value" :value="t.value" class="text-none">
|
|
104
126
|
{{ t.label }}
|
|
@@ -139,9 +161,12 @@
|
|
|
139
161
|
import useInventory from "../composables/useInventory";
|
|
140
162
|
import useWorkerTerm from "../composables/useWorkerTerm";
|
|
141
163
|
import useSite from "../composables/useSite";
|
|
164
|
+
import useCustomerSite from "../composables/useCustomerSite";
|
|
142
165
|
import useUtils from "../composables/useUtils";
|
|
143
166
|
import { inventoryManagedBy, inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
|
|
144
167
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
168
|
+
import { inventoryStepsLive } from "../utils/inventory-ack";
|
|
169
|
+
import useLocalAuth from "../composables/useLocalAuth";
|
|
145
170
|
|
|
146
171
|
const props = defineProps({
|
|
147
172
|
orgId: { type: String, required: true },
|
|
@@ -153,7 +178,7 @@ const movedFromEquipment = useRoute().query.moved === "equipment";
|
|
|
153
178
|
const linkQuery = useRoute().query;
|
|
154
179
|
const linked = { storeId: String(linkQuery.storeId ?? ""), tab: String(linkQuery.tab ?? "") };
|
|
155
180
|
|
|
156
|
-
const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
181
|
+
const { getHandovers, getStores, getStore, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
157
182
|
const { getSites } = useSite();
|
|
158
183
|
const { requiredRule } = useUtils();
|
|
159
184
|
|
|
@@ -217,7 +242,7 @@ const tabs = computed(() => {
|
|
|
217
242
|
// Hide, never grey: each tab only for the job that uses it (API-core decides `access`).
|
|
218
243
|
...(ctl.value.tabs.mine ? [{ value: "mine", label: "My Stock" }] : []),
|
|
219
244
|
...(ctl.value.tabs.requests ? [{ value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 }] : []),
|
|
220
|
-
...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many }] : []),
|
|
245
|
+
...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many, badge: returnsWaiting.value }] : []),
|
|
221
246
|
...(ctl.value.tabs.reports ? [{ value: "reports", label: a.canReports ? "Reports" : "Usage check" }] : []),
|
|
222
247
|
...(!ctl.value.tabs.reports && companyUsage.value ? [{ value: "reports", label: "Company usage" }] : []),
|
|
223
248
|
...(ctl.value.tabs.suppliers ? [{ value: "suppliers", label: "Suppliers" }] : []),
|
|
@@ -244,6 +269,7 @@ async function loadStores() {
|
|
|
244
269
|
loading.value = false;
|
|
245
270
|
}
|
|
246
271
|
refreshCounts();
|
|
272
|
+
loadStoreDetail();
|
|
247
273
|
}
|
|
248
274
|
|
|
249
275
|
/**
|
|
@@ -265,7 +291,38 @@ async function probeCompanyUsage() {
|
|
|
265
291
|
}
|
|
266
292
|
}
|
|
267
293
|
|
|
294
|
+
/** Stock issued to me that I have not confirmed yet (only where the API has the Received step). */
|
|
295
|
+
const toConfirm = ref(0);
|
|
296
|
+
const { currentUser } = useLocalAuth();
|
|
297
|
+
async function refreshToConfirm() {
|
|
298
|
+
const s = store.value;
|
|
299
|
+
toConfirm.value = 0;
|
|
300
|
+
if (!s || !ctl.value.tabs.mine || !inventoryStepsLive(s)) return;
|
|
301
|
+
try {
|
|
302
|
+
const me = String(currentUser.value?._id ?? "");
|
|
303
|
+
const res = await getRequests(s._id, { status: "issued", limit: 100 });
|
|
304
|
+
toConfirm.value = res.items.filter((r) => String(r.requestedBy) === me && (r.ackStatus === "awaiting" || r.ackStatus === "not_received")).length;
|
|
305
|
+
} catch {
|
|
306
|
+
toConfirm.value = 0;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Returns started by holders, waiting for a store keeper (badge on the staff tab). */
|
|
311
|
+
const returnsWaiting = ref(0);
|
|
312
|
+
async function refreshReturns() {
|
|
313
|
+
const s = store.value;
|
|
314
|
+
returnsWaiting.value = 0;
|
|
315
|
+
if (!s || !ctl.value.takeReturn || !inventoryStepsLive(s)) return;
|
|
316
|
+
try {
|
|
317
|
+
returnsWaiting.value = (await getHandovers(s._id, { status: "pending", limit: 1 }))?.returnsWaiting ?? 0;
|
|
318
|
+
} catch {
|
|
319
|
+
returnsWaiting.value = 0;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
268
323
|
async function refreshCounts() {
|
|
324
|
+
refreshToConfirm();
|
|
325
|
+
refreshReturns();
|
|
269
326
|
if (!store.value?.access.canApprove) return (pendingCount.value = 0);
|
|
270
327
|
try {
|
|
271
328
|
pendingCount.value = (await getRequests(store.value._id, { status: "pending", limit: 1 }))?.pendingCount ?? 0;
|
|
@@ -274,6 +331,43 @@ async function refreshCounts() {
|
|
|
274
331
|
}
|
|
275
332
|
}
|
|
276
333
|
|
|
334
|
+
/** The properties this person works at in this organisation (the same list as the site switcher). */
|
|
335
|
+
const { getAll: getCustomerSites } = useCustomerSite();
|
|
336
|
+
const mySites = ref<{ _id: string; name: string }[]>([]);
|
|
337
|
+
const currentSiteName = computed(() => mySites.value.find((s) => s._id === props.siteId)?.name || storeSites.value.find((s) => s._id === props.siteId)?.name || "this property");
|
|
338
|
+
async function loadMySites() {
|
|
339
|
+
try {
|
|
340
|
+
const res = await getCustomerSites({ org: props.orgId, limit: 100 });
|
|
341
|
+
const seen = new Set<string>();
|
|
342
|
+
mySites.value = (res?.items ?? [])
|
|
343
|
+
.map((i: any) => ({ _id: String(i.site ?? ""), name: String(i.name ?? "") }))
|
|
344
|
+
.filter((s: { _id: string }) => s._id && !seen.has(s._id) && seen.add(s._id));
|
|
345
|
+
} catch {
|
|
346
|
+
mySites.value = [];
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const route = useRoute();
|
|
350
|
+
function switchSite(site: string) {
|
|
351
|
+
if (site === props.siteId) return;
|
|
352
|
+
navigateTo({ name: route.name as string, params: { ...route.params, site } });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The store's own settings (the reminder interval also tells the screens the new steps exist on the server). */
|
|
356
|
+
async function loadStoreDetail() {
|
|
357
|
+
const s = store.value;
|
|
358
|
+
if (!s) return;
|
|
359
|
+
try {
|
|
360
|
+
const full = await getStore(s._id);
|
|
361
|
+
if (typeof full?.lowStockReminderDays === "number") {
|
|
362
|
+
s.lowStockReminderDays = full.lowStockReminderDays;
|
|
363
|
+
refreshToConfirm();
|
|
364
|
+
refreshReturns();
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
/* the list's copy is enough to work with */
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
277
371
|
async function loadOrgSites() {
|
|
278
372
|
try {
|
|
279
373
|
const res = await getSites({ orgId: props.orgId, limit: 100 });
|
|
@@ -307,11 +401,13 @@ watch(storeId, () => {
|
|
|
307
401
|
tab.value = wanted && tabs.value.some((t) => t.value === wanted) ? wanted : "stock";
|
|
308
402
|
refreshCounts();
|
|
309
403
|
probeCompanyUsage();
|
|
404
|
+
loadStoreDetail();
|
|
310
405
|
});
|
|
311
406
|
watch(() => props.siteId, loadStores);
|
|
312
407
|
|
|
313
408
|
onMounted(() => {
|
|
314
409
|
loadStores();
|
|
315
410
|
loadOrgSites();
|
|
411
|
+
loadMySites();
|
|
316
412
|
});
|
|
317
413
|
</script>
|
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
<v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">Request Stock</v-btn>
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
|
+
<!-- Issued to me, waiting for me to tap Received (owner 2026-10-03). Reminded daily until I do. -->
|
|
10
|
+
<AppCard
|
|
11
|
+
v-for="r in toReceive"
|
|
12
|
+
:key="r._id"
|
|
13
|
+
class="pa-4 mb-3 d-flex flex-wrap align-center ga-3"
|
|
14
|
+
:style="{ borderLeft: `4px solid rgb(var(--v-theme-${r.ackStatus === 'not_received' ? 'error' : 'warning'}))` }"
|
|
15
|
+
>
|
|
16
|
+
<v-icon :icon="r.ackStatus === 'not_received' ? 'mdi-package-variant-remove' : 'mdi-package-variant-closed-check'" :color="r.ackStatus === 'not_received' ? 'error' : 'warning'" />
|
|
17
|
+
<div class="flex-grow-1" style="min-width: 220px">
|
|
18
|
+
<div class="font-weight-medium">{{ receiveTitle(r) }}</div>
|
|
19
|
+
<div class="text-body-2">{{ ackText(r, true) }}</div>
|
|
20
|
+
</div>
|
|
21
|
+
<InventoryReceiptActions :request="r" @done="changed" />
|
|
22
|
+
</AppCard>
|
|
23
|
+
|
|
9
24
|
<!-- Give to colleague: what waits for me to confirm, and what I gave that waits for them. -->
|
|
10
25
|
<AppCard v-for="h in toConfirm" :key="h._id" class="pa-4 mb-3 d-flex flex-wrap align-center ga-3" style="border-left: 4px solid rgb(var(--v-theme-warning))">
|
|
11
26
|
<v-icon icon="mdi-hand-coin-outline" color="warning" />
|
|
@@ -18,7 +33,9 @@
|
|
|
18
33
|
</AppCard>
|
|
19
34
|
<AppCard v-for="h in waiting" :key="h._id" class="pa-3 mb-3 d-flex flex-wrap align-center ga-3">
|
|
20
35
|
<v-icon icon="mdi-timer-sand" color="medium-emphasis" />
|
|
21
|
-
<div class="flex-grow-1 text-body-2">
|
|
36
|
+
<div class="flex-grow-1 text-body-2">
|
|
37
|
+
{{ h.kind === "return" ? `Returning ${h.qty} ${h.unit} ${h.itemName} to ${h.toName || "the store"}. Hand it to the store keeper; it leaves your stock once they accept it.` : `Waiting for ${h.toName} to confirm ${h.qty} ${h.unit} ${h.itemName}.` }}
|
|
38
|
+
</div>
|
|
22
39
|
<v-btn class="text-none" variant="text" size="small" :loading="acting === h._id + 'c'" @click="cancelHandover(h)">Cancel</v-btn>
|
|
23
40
|
</AppCard>
|
|
24
41
|
|
|
@@ -35,6 +52,7 @@
|
|
|
35
52
|
<div class="flex-grow-1" style="min-width: 200px">
|
|
36
53
|
<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
54
|
<div class="text-body-2">{{ draftCardText(d, standardFormatDateTime, staleDays).line }}</div>
|
|
55
|
+
<div v-if="d.startedByName" class="text-caption text-medium-emphasis">Started by {{ d.startedByName }}{{ d.takenOverAt ? `, taken over by you ${standardFormatDateTime(d.takenOverAt)}` : "" }}. The stock comes from your own stock when you submit.</div>
|
|
38
56
|
<div v-if="d.stale" class="text-caption text-error">{{ draftCardText(d, standardFormatDateTime, staleDays).warning }}</div>
|
|
39
57
|
</div>
|
|
40
58
|
<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>
|
|
@@ -58,10 +76,13 @@
|
|
|
58
76
|
<div class="font-weight-medium">{{ h.itemName }}</div>
|
|
59
77
|
<div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
|
|
60
78
|
<div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
|
|
79
|
+
<div v-if="h.unacknowledged" class="text-caption text-warning">{{ h.unacknowledged }} waiting for you to tap Received (above) before you can use them</div>
|
|
61
80
|
</div>
|
|
62
81
|
<div class="d-flex flex-column ga-1">
|
|
63
82
|
<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>
|
|
64
83
|
<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>
|
|
84
|
+
<v-btn v-if="stepsLive && freeOf(h) > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openReturn(h)">Return to store</v-btn>
|
|
85
|
+
<v-btn v-if="stepsLive && ctl.recordUsage && freeOf(h) > 0" class="text-none" variant="text" size="small" prepend-icon="mdi-delete-alert-outline" @click="openWriteOff(h)">Write off</v-btn>
|
|
65
86
|
<v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
|
|
66
87
|
</div>
|
|
67
88
|
</AppCard>
|
|
@@ -151,6 +172,45 @@
|
|
|
151
172
|
</div>
|
|
152
173
|
</v-card>
|
|
153
174
|
</v-dialog>
|
|
175
|
+
<v-dialog v-model="ret.show" max-width="480" persistent>
|
|
176
|
+
<v-card v-if="ret.holding" class="screen-modal">
|
|
177
|
+
<v-card-title>Return to store</v-card-title>
|
|
178
|
+
<v-card-text class="screen-modal__body">
|
|
179
|
+
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
180
|
+
{{ ret.holding.itemName }}: you can return up to {{ freeOf(ret.holding) }} {{ ret.holding.unit }}. The store keeper is told; it goes back into the store once they accept it.
|
|
181
|
+
</p>
|
|
182
|
+
<v-form ref="retForm">
|
|
183
|
+
<InputLabel title="Quantity" required class="mb-1" />
|
|
184
|
+
<v-text-field
|
|
185
|
+
v-model.number="ret.qty"
|
|
186
|
+
type="number"
|
|
187
|
+
min="1"
|
|
188
|
+
density="comfortable"
|
|
189
|
+
:suffix="ret.holding.unit"
|
|
190
|
+
style="max-width: 200px"
|
|
191
|
+
:rules="[retQtyRule]"
|
|
192
|
+
/>
|
|
193
|
+
<InputLabel title="Note (optional)" class="mb-1" />
|
|
194
|
+
<v-text-field v-model="ret.note" density="comfortable" maxlength="500" placeholder="e.g. job cancelled, not needed" />
|
|
195
|
+
</v-form>
|
|
196
|
+
</v-card-text>
|
|
197
|
+
<div class="screen-modal__footer">
|
|
198
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="ret.saving" @click="ret.show = false">Cancel</v-btn>
|
|
199
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="ret.saving" @click="submitReturn">Ask to return</v-btn>
|
|
200
|
+
</div>
|
|
201
|
+
</v-card>
|
|
202
|
+
</v-dialog>
|
|
203
|
+
<InventoryWriteOffDialog
|
|
204
|
+
v-model="wo.show"
|
|
205
|
+
:store="store"
|
|
206
|
+
from="holding"
|
|
207
|
+
:item="String(wo.holding?.item ?? '')"
|
|
208
|
+
:item-name="wo.holding?.itemName ?? ''"
|
|
209
|
+
:unit="wo.holding?.unit ?? ''"
|
|
210
|
+
:max="wo.holding ? freeOf(wo.holding) : 0"
|
|
211
|
+
:site-id="siteId"
|
|
212
|
+
@done="changed"
|
|
213
|
+
/>
|
|
154
214
|
<InventoryLinesDialog v-model="showRequest" mode="request" :store="store" :site-id="siteId" @done="changed" />
|
|
155
215
|
</div>
|
|
156
216
|
</template>
|
|
@@ -162,6 +222,7 @@ import useUtils from "../composables/useUtils";
|
|
|
162
222
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
163
223
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
164
224
|
import { draftCardText } from "../utils/inventory-usage";
|
|
225
|
+
import { ackText, inventoryStepsLive } from "../utils/inventory-ack";
|
|
165
226
|
|
|
166
227
|
const props = defineProps({
|
|
167
228
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -185,6 +246,56 @@ const usage = reactive({ show: false, holding: null as TInventoryHolding | null,
|
|
|
185
246
|
const drafts = ref<TInventoryUsageDraft[]>([]);
|
|
186
247
|
const staleDays = ref(3);
|
|
187
248
|
const history = reactive({ show: false, item: "", title: "", usageOnly: false });
|
|
249
|
+
const stepsLive = computed(() => inventoryStepsLive(props.store));
|
|
250
|
+
|
|
251
|
+
/** Free to use, give, return or write off: not in drafts, not waiting for Received (the API's `free`), not already being given. */
|
|
252
|
+
const freeOf = (h: TInventoryHolding) => Math.max(0, (h.free ?? h.qty) - pendingOf(String(h.item)));
|
|
253
|
+
|
|
254
|
+
// Issued to me and not yet confirmed (or reported not received).
|
|
255
|
+
const toReceive = ref<TInventoryRequest[]>([]);
|
|
256
|
+
const receiveTitle = (r: TInventoryRequest) =>
|
|
257
|
+
`${r.decidedByName || "The store"} issued you ${r.lines.filter((l) => l.issuedQty > 0).map((l) => `${l.issuedQty} ${l.unit} ${l.itemName}`).join(", ")}${r.decidedAt ? ` on ${standardFormatDate(r.decidedAt)}` : ""}`;
|
|
258
|
+
async function loadToReceive() {
|
|
259
|
+
if (!stepsLive.value) return (toReceive.value = []);
|
|
260
|
+
try {
|
|
261
|
+
const res = await api.getRequests(props.store._id, { status: "issued", limit: 100 });
|
|
262
|
+
toReceive.value = res.items.filter((r) => String(r.requestedBy) === me.value && (r.ackStatus === "awaiting" || r.ackStatus === "not_received"));
|
|
263
|
+
} catch {
|
|
264
|
+
toReceive.value = [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const retForm = ref();
|
|
269
|
+
const ret = reactive({ show: false, holding: null as TInventoryHolding | null, qty: 1 as number | null, note: "", saving: false });
|
|
270
|
+
const retQtyRule = (v: any) => {
|
|
271
|
+
if (!Number.isInteger(Number(v)) || Number(v) < 1) return "Enter a whole number";
|
|
272
|
+
const max = ret.holding ? freeOf(ret.holding) : 0;
|
|
273
|
+
return Number(v) <= max || `You can return up to ${max}`;
|
|
274
|
+
};
|
|
275
|
+
function openReturn(h: TInventoryHolding) {
|
|
276
|
+
Object.assign(ret, { show: true, holding: h, qty: freeOf(h), note: "", saving: false });
|
|
277
|
+
}
|
|
278
|
+
async function submitReturn() {
|
|
279
|
+
const { valid } = await retForm.value.validate();
|
|
280
|
+
if (!valid || !ret.holding) return;
|
|
281
|
+
ret.saving = true;
|
|
282
|
+
try {
|
|
283
|
+
const site = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
|
|
284
|
+
await api.requestReturn(props.store._id, { site, item: String(ret.holding.item), qty: Number(ret.qty), note: ret.note });
|
|
285
|
+
notify("Return requested. Bring the items to the store keeper; they leave your stock once the keeper accepts them.");
|
|
286
|
+
ret.show = false;
|
|
287
|
+
changed();
|
|
288
|
+
} catch (error: any) {
|
|
289
|
+
notify(error?.data?.message || "Could not request the return.", "error");
|
|
290
|
+
} finally {
|
|
291
|
+
ret.saving = false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const wo = reactive({ show: false, holding: null as TInventoryHolding | null });
|
|
296
|
+
function openWriteOff(h: TInventoryHolding) {
|
|
297
|
+
Object.assign(wo, { show: true, holding: h });
|
|
298
|
+
}
|
|
188
299
|
|
|
189
300
|
function openHistory(h: TInventoryHolding) {
|
|
190
301
|
Object.assign(history, { show: true, item: String(h.item), title: `History — ${h.itemName}`, usageOnly: false });
|
|
@@ -264,7 +375,7 @@ const give = reactive({ show: false, holding: null as TInventoryHolding | null,
|
|
|
264
375
|
const pendingOf = (item: string) => waiting.value.filter((h) => String(h.item) === item).reduce((s, h) => s + h.qty, 0);
|
|
265
376
|
const giveQtyRule = (v: number | null) => {
|
|
266
377
|
if (!v || v < 1 || !Number.isInteger(Number(v))) return "Enter a whole number";
|
|
267
|
-
const free =
|
|
378
|
+
const free = give.holding ? freeOf(give.holding) : 0;
|
|
268
379
|
return Number(v) <= free || `You can give up to ${Math.max(0, free)}`;
|
|
269
380
|
};
|
|
270
381
|
const colleagues = ref<{ _id: string; name: string; label?: string }[]>([]);
|
|
@@ -325,9 +436,11 @@ function changed() {
|
|
|
325
436
|
load();
|
|
326
437
|
loadHandovers();
|
|
327
438
|
loadDrafts();
|
|
439
|
+
loadToReceive();
|
|
328
440
|
emit("changed");
|
|
329
441
|
}
|
|
330
442
|
|
|
331
|
-
watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts()));
|
|
332
|
-
|
|
443
|
+
watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts(), loadToReceive()));
|
|
444
|
+
watch(stepsLive, loadToReceive);
|
|
445
|
+
onMounted(() => (load(), loadHandovers(), loadDrafts(), loadToReceive()));
|
|
333
446
|
</script>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- The person the stock was issued to confirms it reached them (owner 2026-10-03). -->
|
|
3
|
+
<div class="d-flex flex-wrap ga-2">
|
|
4
|
+
<v-btn v-if="request.ackStatus === 'awaiting'" class="text-none screen-btn-ghost" variant="outlined" size="small" :disabled="!!busy" @click="openNotReceived">Not received</v-btn>
|
|
5
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="busy === 'received'" :disabled="!!busy" @click="received">Received</v-btn>
|
|
6
|
+
|
|
7
|
+
<v-dialog v-model="nr.show" max-width="480">
|
|
8
|
+
<v-card class="screen-modal">
|
|
9
|
+
<v-card-title>Not received</v-card-title>
|
|
10
|
+
<v-card-text class="screen-modal__body">
|
|
11
|
+
<p class="text-body-2 mb-3">Tell the store keeper what happened. They are told at once and will follow up with you.</p>
|
|
12
|
+
<v-textarea v-model="nr.reason" rows="2" auto-grow density="comfortable" maxlength="500" placeholder="e.g. nobody handed it to me, only 2 of the 5 were in the bag" />
|
|
13
|
+
</v-card-text>
|
|
14
|
+
<div class="screen-modal__footer">
|
|
15
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="busy === 'not'" @click="nr.show = false">Cancel</v-btn>
|
|
16
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="busy === 'not'" :disabled="!nr.reason.trim()" @click="notReceived">Send to store keeper</v-btn>
|
|
17
|
+
</div>
|
|
18
|
+
</v-card>
|
|
19
|
+
</v-dialog>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import useInventory from "../composables/useInventory";
|
|
25
|
+
|
|
26
|
+
const props = defineProps({ request: { type: Object as PropType<TInventoryRequest>, required: true } });
|
|
27
|
+
const emit = defineEmits(["done"]);
|
|
28
|
+
|
|
29
|
+
const api = useInventory();
|
|
30
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
31
|
+
const busy = ref<"" | "received" | "not">("");
|
|
32
|
+
const nr = reactive({ show: false, reason: "" });
|
|
33
|
+
|
|
34
|
+
const items = () => props.request.lines.filter((l) => l.issuedQty > 0).map((l) => `${l.issuedQty} ${l.unit} ${l.itemName}`).join(", ");
|
|
35
|
+
|
|
36
|
+
function openNotReceived() {
|
|
37
|
+
Object.assign(nr, { show: true, reason: "" });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function received() {
|
|
41
|
+
busy.value = "received";
|
|
42
|
+
try {
|
|
43
|
+
await api.acknowledgeRequest(props.request._id);
|
|
44
|
+
notify(`Thank you. ${items()} ${props.request.lines.length === 1 ? "is" : "are"} now in My Stock, ready to use.`);
|
|
45
|
+
emit("done");
|
|
46
|
+
} catch (error: any) {
|
|
47
|
+
notify(error?.data?.message || "Could not save. Please try again.", "error");
|
|
48
|
+
} finally {
|
|
49
|
+
busy.value = "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function notReceived() {
|
|
54
|
+
busy.value = "not";
|
|
55
|
+
try {
|
|
56
|
+
await api.notReceived(props.request._id, nr.reason.trim());
|
|
57
|
+
notify("Sent. The store keeper will check and follow up with you.");
|
|
58
|
+
nr.show = false;
|
|
59
|
+
emit("done");
|
|
60
|
+
} catch (error: any) {
|
|
61
|
+
notify(error?.data?.message || "Could not send. Please try again.", "error");
|
|
62
|
+
} finally {
|
|
63
|
+
busy.value = "";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
</script>
|