@7365admin1/layer-common 4.90.2-staging.492 → 4.90.2-staging.494
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 +101 -3
- package/components/InventoryMyStockTab.vue +157 -5
- package/components/InventoryReceiptActions.vue +66 -0
- package/components/InventoryReportsTab.vue +97 -6
- package/components/InventoryRequestsTab.vue +35 -13
- 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 +24 -6
- package/package.json +1 -1
- package/types/inventory.d.ts +39 -2
- package/utils/inventory-ack.ts +148 -0
- package/utils/inventory-usage.ts +16 -1
- package/utils/notification-target.ts +9 -3
|
@@ -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 }}
|
|
@@ -114,7 +136,7 @@
|
|
|
114
136
|
<InventoryMyStockTab :store="store" :site-id="siteId" :sites="storeSites" @changed="refreshCounts" />
|
|
115
137
|
</v-window-item>
|
|
116
138
|
<v-window-item v-if="ctl.tabs.requests" value="requests">
|
|
117
|
-
<InventoryRequestsTab :store="store" :site-id="siteId" @changed="refreshCounts" />
|
|
139
|
+
<InventoryRequestsTab :store="store" :site-id="siteId" :focus-request="focusRequest" @changed="refreshCounts" />
|
|
118
140
|
</v-window-item>
|
|
119
141
|
<v-window-item v-if="ctl.tabs.people" value="technicians">
|
|
120
142
|
<InventoryTechniciansTab :store="store" @changed="refreshCounts" />
|
|
@@ -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 },
|
|
@@ -152,8 +177,10 @@ const movedFromEquipment = useRoute().query.moved === "equipment";
|
|
|
152
177
|
// A notification link (`?storeId=&tab=`, NotificationBell): open that store on that tab, once.
|
|
153
178
|
const linkQuery = useRoute().query;
|
|
154
179
|
const linked = { storeId: String(linkQuery.storeId ?? ""), tab: String(linkQuery.tab ?? "") };
|
|
180
|
+
// `?requestId=` (approved / confirm-received alerts): the Requests tab opens on that request.
|
|
181
|
+
const focusRequest = String(linkQuery.requestId ?? "");
|
|
155
182
|
|
|
156
|
-
const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
183
|
+
const { getHandovers, getStores, getStore, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
157
184
|
const { getSites } = useSite();
|
|
158
185
|
const { requiredRule } = useUtils();
|
|
159
186
|
|
|
@@ -217,7 +244,7 @@ const tabs = computed(() => {
|
|
|
217
244
|
// Hide, never grey: each tab only for the job that uses it (API-core decides `access`).
|
|
218
245
|
...(ctl.value.tabs.mine ? [{ value: "mine", label: "My Stock" }] : []),
|
|
219
246
|
...(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 }] : []),
|
|
247
|
+
...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many, badge: returnsWaiting.value }] : []),
|
|
221
248
|
...(ctl.value.tabs.reports ? [{ value: "reports", label: a.canReports ? "Reports" : "Usage check" }] : []),
|
|
222
249
|
...(!ctl.value.tabs.reports && companyUsage.value ? [{ value: "reports", label: "Company usage" }] : []),
|
|
223
250
|
...(ctl.value.tabs.suppliers ? [{ value: "suppliers", label: "Suppliers" }] : []),
|
|
@@ -244,6 +271,7 @@ async function loadStores() {
|
|
|
244
271
|
loading.value = false;
|
|
245
272
|
}
|
|
246
273
|
refreshCounts();
|
|
274
|
+
loadStoreDetail();
|
|
247
275
|
}
|
|
248
276
|
|
|
249
277
|
/**
|
|
@@ -265,7 +293,38 @@ async function probeCompanyUsage() {
|
|
|
265
293
|
}
|
|
266
294
|
}
|
|
267
295
|
|
|
296
|
+
/** Stock issued to me that I have not confirmed yet (only where the API has the Received step). */
|
|
297
|
+
const toConfirm = ref(0);
|
|
298
|
+
const { currentUser } = useLocalAuth();
|
|
299
|
+
async function refreshToConfirm() {
|
|
300
|
+
const s = store.value;
|
|
301
|
+
toConfirm.value = 0;
|
|
302
|
+
if (!s || !ctl.value.tabs.mine || !inventoryStepsLive(s)) return;
|
|
303
|
+
try {
|
|
304
|
+
const me = String(currentUser.value?._id ?? "");
|
|
305
|
+
const res = await getRequests(s._id, { status: "issued", limit: 100 });
|
|
306
|
+
toConfirm.value = res.items.filter((r) => String(r.requestedBy) === me && (r.ackStatus === "awaiting" || r.ackStatus === "not_received")).length;
|
|
307
|
+
} catch {
|
|
308
|
+
toConfirm.value = 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Returns started by holders, waiting for a store keeper (badge on the staff tab). */
|
|
313
|
+
const returnsWaiting = ref(0);
|
|
314
|
+
async function refreshReturns() {
|
|
315
|
+
const s = store.value;
|
|
316
|
+
returnsWaiting.value = 0;
|
|
317
|
+
if (!s || !ctl.value.takeReturn || !inventoryStepsLive(s)) return;
|
|
318
|
+
try {
|
|
319
|
+
returnsWaiting.value = (await getHandovers(s._id, { status: "pending", limit: 1 }))?.returnsWaiting ?? 0;
|
|
320
|
+
} catch {
|
|
321
|
+
returnsWaiting.value = 0;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
268
325
|
async function refreshCounts() {
|
|
326
|
+
refreshToConfirm();
|
|
327
|
+
refreshReturns();
|
|
269
328
|
if (!store.value?.access.canApprove) return (pendingCount.value = 0);
|
|
270
329
|
try {
|
|
271
330
|
pendingCount.value = (await getRequests(store.value._id, { status: "pending", limit: 1 }))?.pendingCount ?? 0;
|
|
@@ -274,6 +333,43 @@ async function refreshCounts() {
|
|
|
274
333
|
}
|
|
275
334
|
}
|
|
276
335
|
|
|
336
|
+
/** The properties this person works at in this organisation (the same list as the site switcher). */
|
|
337
|
+
const { getAll: getCustomerSites } = useCustomerSite();
|
|
338
|
+
const mySites = ref<{ _id: string; name: string }[]>([]);
|
|
339
|
+
const currentSiteName = computed(() => mySites.value.find((s) => s._id === props.siteId)?.name || storeSites.value.find((s) => s._id === props.siteId)?.name || "this property");
|
|
340
|
+
async function loadMySites() {
|
|
341
|
+
try {
|
|
342
|
+
const res = await getCustomerSites({ org: props.orgId, limit: 100 });
|
|
343
|
+
const seen = new Set<string>();
|
|
344
|
+
mySites.value = (res?.items ?? [])
|
|
345
|
+
.map((i: any) => ({ _id: String(i.site ?? ""), name: String(i.name ?? "") }))
|
|
346
|
+
.filter((s: { _id: string }) => s._id && !seen.has(s._id) && seen.add(s._id));
|
|
347
|
+
} catch {
|
|
348
|
+
mySites.value = [];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const route = useRoute();
|
|
352
|
+
function switchSite(site: string) {
|
|
353
|
+
if (site === props.siteId) return;
|
|
354
|
+
navigateTo({ name: route.name as string, params: { ...route.params, site } });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The store's own settings (the reminder interval also tells the screens the new steps exist on the server). */
|
|
358
|
+
async function loadStoreDetail() {
|
|
359
|
+
const s = store.value;
|
|
360
|
+
if (!s) return;
|
|
361
|
+
try {
|
|
362
|
+
const full = await getStore(s._id);
|
|
363
|
+
if (typeof full?.lowStockReminderDays === "number") {
|
|
364
|
+
s.lowStockReminderDays = full.lowStockReminderDays;
|
|
365
|
+
refreshToConfirm();
|
|
366
|
+
refreshReturns();
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
/* the list's copy is enough to work with */
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
277
373
|
async function loadOrgSites() {
|
|
278
374
|
try {
|
|
279
375
|
const res = await getSites({ orgId: props.orgId, limit: 100 });
|
|
@@ -307,11 +403,13 @@ watch(storeId, () => {
|
|
|
307
403
|
tab.value = wanted && tabs.value.some((t) => t.value === wanted) ? wanted : "stock";
|
|
308
404
|
refreshCounts();
|
|
309
405
|
probeCompanyUsage();
|
|
406
|
+
loadStoreDetail();
|
|
310
407
|
});
|
|
311
408
|
watch(() => props.siteId, loadStores);
|
|
312
409
|
|
|
313
410
|
onMounted(() => {
|
|
314
411
|
loadStores();
|
|
315
412
|
loadOrgSites();
|
|
413
|
+
loadMySites();
|
|
316
414
|
});
|
|
317
415
|
</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,12 +52,26 @@
|
|
|
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>
|
|
41
59
|
</AppCard>
|
|
42
60
|
</template>
|
|
43
61
|
|
|
62
|
+
<!-- Colleagues' open drafts (same company): any technician can take one over and finish it from their own stock. -->
|
|
63
|
+
<template v-if="colleagueDrafts.length">
|
|
64
|
+
<div class="text-subtitle-2 mb-2">Colleagues' jobs in progress ({{ colleagueDrafts.length }})</div>
|
|
65
|
+
<AppCard v-for="d in colleagueDrafts" :key="d._id" class="pa-3 mb-2 d-flex flex-wrap align-center ga-3" style="border-left: 4px solid rgb(var(--v-theme-secondary))">
|
|
66
|
+
<InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
|
|
67
|
+
<div class="flex-grow-1" style="min-width: 200px">
|
|
68
|
+
<div class="font-weight-medium">{{ d.itemName }} × {{ d.qty }} {{ d.unit }}</div>
|
|
69
|
+
<div class="text-body-2">{{ colleagueDraftText(d, standardFormatDateTime) }}</div>
|
|
70
|
+
</div>
|
|
71
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === d._id + 't'" @click="takeOver(d)">Take over</v-btn>
|
|
72
|
+
</AppCard>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
44
75
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
45
76
|
<AppCard v-else-if="!holdings.length" class="pa-6 text-center">
|
|
46
77
|
<div class="text-body-1 mb-1">You are not holding any stock.</div>
|
|
@@ -58,10 +89,13 @@
|
|
|
58
89
|
<div class="font-weight-medium">{{ h.itemName }}</div>
|
|
59
90
|
<div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
|
|
60
91
|
<div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
|
|
92
|
+
<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
93
|
</div>
|
|
62
94
|
<div class="d-flex flex-column ga-1">
|
|
63
95
|
<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
96
|
<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>
|
|
97
|
+
<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>
|
|
98
|
+
<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
99
|
<v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
|
|
66
100
|
</div>
|
|
67
101
|
</AppCard>
|
|
@@ -151,6 +185,45 @@
|
|
|
151
185
|
</div>
|
|
152
186
|
</v-card>
|
|
153
187
|
</v-dialog>
|
|
188
|
+
<v-dialog v-model="ret.show" max-width="480" persistent>
|
|
189
|
+
<v-card v-if="ret.holding" class="screen-modal">
|
|
190
|
+
<v-card-title>Return to store</v-card-title>
|
|
191
|
+
<v-card-text class="screen-modal__body">
|
|
192
|
+
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
193
|
+
{{ 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.
|
|
194
|
+
</p>
|
|
195
|
+
<v-form ref="retForm">
|
|
196
|
+
<InputLabel title="Quantity" required class="mb-1" />
|
|
197
|
+
<v-text-field
|
|
198
|
+
v-model.number="ret.qty"
|
|
199
|
+
type="number"
|
|
200
|
+
min="1"
|
|
201
|
+
density="comfortable"
|
|
202
|
+
:suffix="ret.holding.unit"
|
|
203
|
+
style="max-width: 200px"
|
|
204
|
+
:rules="[retQtyRule]"
|
|
205
|
+
/>
|
|
206
|
+
<InputLabel title="Note (optional)" class="mb-1" />
|
|
207
|
+
<v-text-field v-model="ret.note" density="comfortable" maxlength="500" placeholder="e.g. job cancelled, not needed" />
|
|
208
|
+
</v-form>
|
|
209
|
+
</v-card-text>
|
|
210
|
+
<div class="screen-modal__footer">
|
|
211
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="ret.saving" @click="ret.show = false">Cancel</v-btn>
|
|
212
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="ret.saving" @click="submitReturn">Ask to return</v-btn>
|
|
213
|
+
</div>
|
|
214
|
+
</v-card>
|
|
215
|
+
</v-dialog>
|
|
216
|
+
<InventoryWriteOffDialog
|
|
217
|
+
v-model="wo.show"
|
|
218
|
+
:store="store"
|
|
219
|
+
from="holding"
|
|
220
|
+
:item="String(wo.holding?.item ?? '')"
|
|
221
|
+
:item-name="wo.holding?.itemName ?? ''"
|
|
222
|
+
:unit="wo.holding?.unit ?? ''"
|
|
223
|
+
:max="wo.holding ? freeOf(wo.holding) : 0"
|
|
224
|
+
:site-id="siteId"
|
|
225
|
+
@done="changed"
|
|
226
|
+
/>
|
|
154
227
|
<InventoryLinesDialog v-model="showRequest" mode="request" :store="store" :site-id="siteId" @done="changed" />
|
|
155
228
|
</div>
|
|
156
229
|
</template>
|
|
@@ -161,7 +234,8 @@ import useFile from "../composables/useFile";
|
|
|
161
234
|
import useUtils from "../composables/useUtils";
|
|
162
235
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
163
236
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
164
|
-
import { draftCardText } from "../utils/inventory-usage";
|
|
237
|
+
import { colleagueDraftText, draftCardText } from "../utils/inventory-usage";
|
|
238
|
+
import { ackText, inventoryStepsLive } from "../utils/inventory-ack";
|
|
165
239
|
|
|
166
240
|
const props = defineProps({
|
|
167
241
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -185,6 +259,56 @@ const usage = reactive({ show: false, holding: null as TInventoryHolding | null,
|
|
|
185
259
|
const drafts = ref<TInventoryUsageDraft[]>([]);
|
|
186
260
|
const staleDays = ref(3);
|
|
187
261
|
const history = reactive({ show: false, item: "", title: "", usageOnly: false });
|
|
262
|
+
const stepsLive = computed(() => inventoryStepsLive(props.store));
|
|
263
|
+
|
|
264
|
+
/** Free to use, give, return or write off: not in drafts, not waiting for Received (the API's `free`), not already being given. */
|
|
265
|
+
const freeOf = (h: TInventoryHolding) => Math.max(0, (h.free ?? h.qty) - pendingOf(String(h.item)));
|
|
266
|
+
|
|
267
|
+
// Issued to me and not yet confirmed (or reported not received).
|
|
268
|
+
const toReceive = ref<TInventoryRequest[]>([]);
|
|
269
|
+
const receiveTitle = (r: TInventoryRequest) =>
|
|
270
|
+
`${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)}` : ""}`;
|
|
271
|
+
async function loadToReceive() {
|
|
272
|
+
if (!stepsLive.value) return (toReceive.value = []);
|
|
273
|
+
try {
|
|
274
|
+
const res = await api.getRequests(props.store._id, { status: "issued", limit: 100 });
|
|
275
|
+
toReceive.value = res.items.filter((r) => String(r.requestedBy) === me.value && (r.ackStatus === "awaiting" || r.ackStatus === "not_received"));
|
|
276
|
+
} catch {
|
|
277
|
+
toReceive.value = [];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const retForm = ref();
|
|
282
|
+
const ret = reactive({ show: false, holding: null as TInventoryHolding | null, qty: 1 as number | null, note: "", saving: false });
|
|
283
|
+
const retQtyRule = (v: any) => {
|
|
284
|
+
if (!Number.isInteger(Number(v)) || Number(v) < 1) return "Enter a whole number";
|
|
285
|
+
const max = ret.holding ? freeOf(ret.holding) : 0;
|
|
286
|
+
return Number(v) <= max || `You can return up to ${max}`;
|
|
287
|
+
};
|
|
288
|
+
function openReturn(h: TInventoryHolding) {
|
|
289
|
+
Object.assign(ret, { show: true, holding: h, qty: freeOf(h), note: "", saving: false });
|
|
290
|
+
}
|
|
291
|
+
async function submitReturn() {
|
|
292
|
+
const { valid } = await retForm.value.validate();
|
|
293
|
+
if (!valid || !ret.holding) return;
|
|
294
|
+
ret.saving = true;
|
|
295
|
+
try {
|
|
296
|
+
const site = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
|
|
297
|
+
await api.requestReturn(props.store._id, { site, item: String(ret.holding.item), qty: Number(ret.qty), note: ret.note });
|
|
298
|
+
notify("Return requested. Bring the items to the store keeper; they leave your stock once the keeper accepts them.");
|
|
299
|
+
ret.show = false;
|
|
300
|
+
changed();
|
|
301
|
+
} catch (error: any) {
|
|
302
|
+
notify(error?.data?.message || "Could not request the return.", "error");
|
|
303
|
+
} finally {
|
|
304
|
+
ret.saving = false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const wo = reactive({ show: false, holding: null as TInventoryHolding | null });
|
|
309
|
+
function openWriteOff(h: TInventoryHolding) {
|
|
310
|
+
Object.assign(wo, { show: true, holding: h });
|
|
311
|
+
}
|
|
188
312
|
|
|
189
313
|
function openHistory(h: TInventoryHolding) {
|
|
190
314
|
Object.assign(history, { show: true, item: String(h.item), title: `History — ${h.itemName}`, usageOnly: false });
|
|
@@ -209,6 +333,31 @@ async function loadDrafts() {
|
|
|
209
333
|
drafts.value = [];
|
|
210
334
|
}
|
|
211
335
|
}
|
|
336
|
+
const colleagueDrafts = ref<TInventoryUsageDraft[]>([]);
|
|
337
|
+
async function loadColleagueDrafts() {
|
|
338
|
+
// stepsLive = the API knows ?colleagues (an older API ignores it and returns my own, hence the mine/holder filter too).
|
|
339
|
+
if (!ctl.value.recordUsage || !stepsLive.value) return (colleagueDrafts.value = []);
|
|
340
|
+
try {
|
|
341
|
+
const res = await api.getUsageDrafts(props.store._id, "colleagues");
|
|
342
|
+
colleagueDrafts.value = res.items.filter((d) => !d.mine && String(d.holder) !== me.value);
|
|
343
|
+
} catch {
|
|
344
|
+
colleagueDrafts.value = []; // 403/404: not offered, nothing to show
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function takeOver(d: TInventoryUsageDraft) {
|
|
348
|
+
const who = d.holderName || d.startedByName || "your colleague";
|
|
349
|
+
if (!confirm(`Take over ${who}'s job (${d.qty} ${d.unit ?? ""} ${d.itemName})? You finish it (after photo, submit), and the stock comes from YOUR own stock, not theirs.`)) return;
|
|
350
|
+
acting.value = d._id + "t";
|
|
351
|
+
try {
|
|
352
|
+
await api.takeOverDraft(String(d._id));
|
|
353
|
+
notify("The job is yours now. Finish it under My drafts.");
|
|
354
|
+
changed();
|
|
355
|
+
} catch (error: any) {
|
|
356
|
+
notify(error?.data?.message || "Could not take over the job.", "error");
|
|
357
|
+
} finally {
|
|
358
|
+
acting.value = "";
|
|
359
|
+
}
|
|
360
|
+
}
|
|
212
361
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
213
362
|
|
|
214
363
|
const handovers = ref<TInventoryHandover[]>([]);
|
|
@@ -264,7 +413,7 @@ const give = reactive({ show: false, holding: null as TInventoryHolding | null,
|
|
|
264
413
|
const pendingOf = (item: string) => waiting.value.filter((h) => String(h.item) === item).reduce((s, h) => s + h.qty, 0);
|
|
265
414
|
const giveQtyRule = (v: number | null) => {
|
|
266
415
|
if (!v || v < 1 || !Number.isInteger(Number(v))) return "Enter a whole number";
|
|
267
|
-
const free =
|
|
416
|
+
const free = give.holding ? freeOf(give.holding) : 0;
|
|
268
417
|
return Number(v) <= free || `You can give up to ${Math.max(0, free)}`;
|
|
269
418
|
};
|
|
270
419
|
const colleagues = ref<{ _id: string; name: string; label?: string }[]>([]);
|
|
@@ -325,9 +474,12 @@ function changed() {
|
|
|
325
474
|
load();
|
|
326
475
|
loadHandovers();
|
|
327
476
|
loadDrafts();
|
|
477
|
+
loadColleagueDrafts();
|
|
478
|
+
loadToReceive();
|
|
328
479
|
emit("changed");
|
|
329
480
|
}
|
|
330
481
|
|
|
331
|
-
watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts()));
|
|
332
|
-
|
|
482
|
+
watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts(), loadColleagueDrafts(), loadToReceive()));
|
|
483
|
+
watch(stepsLive, () => (loadToReceive(), loadColleagueDrafts()));
|
|
484
|
+
onMounted(() => (load(), loadHandovers(), loadDrafts(), loadColleagueDrafts(), loadToReceive()));
|
|
333
485
|
</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>
|