@7365admin1/layer-common 4.95.0 → 4.96.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 +14 -4
- package/components/InventoryHistoryDialog.vue +10 -3
- package/components/InventoryItemDetail.vue +18 -1
- package/components/InventoryItemDialog.vue +1 -1
- package/components/InventoryLinesDialog.vue +35 -9
- package/components/InventoryMain.vue +107 -5
- package/components/InventoryMyStockTab.vue +167 -12
- package/components/InventoryPoDialog.vue +2 -1
- package/components/InventoryReceiptActions.vue +67 -0
- package/components/InventoryReportsTab.vue +97 -6
- package/components/InventoryRequestsTab.vue +36 -14
- package/components/InventorySettingsTab.vue +27 -1
- package/components/InventoryStockTab.vue +52 -4
- package/components/InventoryTechniciansTab.vue +77 -4
- package/components/InventoryUsageDialog.vue +21 -2
- package/components/InventoryWriteOffDialog.vue +112 -0
- package/composables/useInventory.ts +24 -6
- package/package.json +1 -1
- package/types/inventory.d.ts +39 -2
- package/utils/inventory-ack.ts +172 -0
- package/utils/inventory-request-text.ts +5 -3
- package/utils/inventory-usage.ts +16 -1
- package/utils/notification-target.ts +9 -3
|
@@ -6,11 +6,26 @@
|
|
|
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" />
|
|
12
27
|
<div class="flex-grow-1">
|
|
13
|
-
<div class="font-weight-medium">{{ h.fromLabel || h.fromName }} is giving you {{ h.qty
|
|
28
|
+
<div class="font-weight-medium">{{ h.fromLabel || h.fromName }} is giving you {{ qtyUnit(h.qty, h.unit) }} {{ h.itemName }}</div>
|
|
14
29
|
<div class="text-caption text-medium-emphasis">{{ standardFormatDate(h.createdAt) }}{{ h.note ? ` · ${h.note}` : "" }} · Confirm once you have it in hand.</div>
|
|
15
30
|
</div>
|
|
16
31
|
<v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" :loading="acting === h._id + 'd'" @click="decide(h, false)">Decline</v-btn>
|
|
@@ -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 ${qtyUnit(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 ${qtyUnit(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
|
|
|
@@ -33,14 +50,29 @@
|
|
|
33
50
|
>
|
|
34
51
|
<InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
|
|
35
52
|
<div class="flex-grow-1" style="min-width: 200px">
|
|
36
|
-
<div class="font-weight-medium">{{ d.itemName }} × {{ d.qty
|
|
53
|
+
<div class="font-weight-medium">{{ d.itemName }} × {{ qtyUnit(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="colleagueDraftsOffered">
|
|
64
|
+
<div class="text-subtitle-2 mb-2">Colleagues' jobs in progress ({{ colleagueDrafts.length }})</div>
|
|
65
|
+
<div v-if="!colleagueDrafts.length" class="text-body-2 text-medium-emphasis mb-3">No unfinished jobs from your colleagues right now.</div>
|
|
66
|
+
<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))">
|
|
67
|
+
<InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
|
|
68
|
+
<div class="flex-grow-1" style="min-width: 200px">
|
|
69
|
+
<div class="font-weight-medium">{{ d.itemName }} × {{ qtyUnit(d.qty, d.unit) }}</div>
|
|
70
|
+
<div class="text-body-2">{{ colleagueDraftText(d, standardFormatDateTime) }}</div>
|
|
71
|
+
</div>
|
|
72
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === d._id + 't'" @click="takeOver(d)">Take over</v-btn>
|
|
73
|
+
</AppCard>
|
|
74
|
+
</template>
|
|
75
|
+
|
|
44
76
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
45
77
|
<AppCard v-else-if="!holdings.length" class="pa-6 text-center">
|
|
46
78
|
<div class="text-body-1 mb-1">You are not holding any stock.</div>
|
|
@@ -56,12 +88,15 @@
|
|
|
56
88
|
</v-avatar>
|
|
57
89
|
<div class="flex-grow-1">
|
|
58
90
|
<div class="font-weight-medium">{{ h.itemName }}</div>
|
|
59
|
-
<div class="text-body-2">{{ h.qty
|
|
91
|
+
<div class="text-body-2">{{ qtyUnit(h.qty, h.unit) }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
|
|
60
92
|
<div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
|
|
93
|
+
<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
94
|
</div>
|
|
62
95
|
<div class="d-flex flex-column ga-1">
|
|
63
96
|
<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
|
-
<v-btn v-if="h
|
|
97
|
+
<v-btn v-if="freeOf(h) > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openGive(h)">Give to colleague</v-btn>
|
|
98
|
+
<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>
|
|
99
|
+
<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
100
|
<v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
|
|
66
101
|
</div>
|
|
67
102
|
</AppCard>
|
|
@@ -112,9 +147,9 @@
|
|
|
112
147
|
</v-card-title>
|
|
113
148
|
<v-card-text class="screen-modal__body">
|
|
114
149
|
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
115
|
-
{{ give.holding?.itemName }}:
|
|
150
|
+
{{ give.holding?.itemName }}: {{ holdText(give.holding) }}. It moves to your colleague once they confirm they received it.
|
|
116
151
|
</p>
|
|
117
|
-
<v-form ref="giveForm">
|
|
152
|
+
<v-form ref="giveForm" validate-on="blur invalid-input">
|
|
118
153
|
<InputLabel title="Colleague" required class="mb-1" />
|
|
119
154
|
<v-autocomplete
|
|
120
155
|
v-model="give.to"
|
|
@@ -125,6 +160,7 @@
|
|
|
125
160
|
placeholder="Search a name"
|
|
126
161
|
density="comfortable"
|
|
127
162
|
no-filter
|
|
163
|
+
no-data-text="No colleague from your company works at this property."
|
|
128
164
|
:rules="[(v: string) => !!v || 'Choose who you are giving it to']"
|
|
129
165
|
@update:search="searchColleagues"
|
|
130
166
|
/>
|
|
@@ -151,6 +187,45 @@
|
|
|
151
187
|
</div>
|
|
152
188
|
</v-card>
|
|
153
189
|
</v-dialog>
|
|
190
|
+
<v-dialog v-model="ret.show" max-width="480" persistent>
|
|
191
|
+
<v-card v-if="ret.holding" class="screen-modal">
|
|
192
|
+
<v-card-title>Return to store</v-card-title>
|
|
193
|
+
<v-card-text class="screen-modal__body">
|
|
194
|
+
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
195
|
+
{{ ret.holding.itemName }}: you can return up to {{ qtyUnit(freeOf(ret.holding), ret.holding.unit) }}. The store keeper is told; it goes back into the store once they accept it.
|
|
196
|
+
</p>
|
|
197
|
+
<v-form ref="retForm" validate-on="blur invalid-input">
|
|
198
|
+
<InputLabel title="Quantity" required class="mb-1" />
|
|
199
|
+
<v-text-field
|
|
200
|
+
v-model.number="ret.qty"
|
|
201
|
+
type="number"
|
|
202
|
+
min="1"
|
|
203
|
+
density="comfortable"
|
|
204
|
+
:suffix="ret.holding.unit"
|
|
205
|
+
style="max-width: 200px"
|
|
206
|
+
:rules="[retQtyRule]"
|
|
207
|
+
/>
|
|
208
|
+
<InputLabel title="Note (optional)" class="mb-1" />
|
|
209
|
+
<v-text-field v-model="ret.note" density="comfortable" maxlength="500" placeholder="e.g. job cancelled, not needed" />
|
|
210
|
+
</v-form>
|
|
211
|
+
</v-card-text>
|
|
212
|
+
<div class="screen-modal__footer">
|
|
213
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="ret.saving" @click="ret.show = false">Cancel</v-btn>
|
|
214
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="ret.saving" @click="submitReturn">Ask to return</v-btn>
|
|
215
|
+
</div>
|
|
216
|
+
</v-card>
|
|
217
|
+
</v-dialog>
|
|
218
|
+
<InventoryWriteOffDialog
|
|
219
|
+
v-model="wo.show"
|
|
220
|
+
:store="store"
|
|
221
|
+
from="holding"
|
|
222
|
+
:item="String(wo.holding?.item ?? '')"
|
|
223
|
+
:item-name="wo.holding?.itemName ?? ''"
|
|
224
|
+
:unit="wo.holding?.unit ?? ''"
|
|
225
|
+
:max="wo.holding ? freeOf(wo.holding) : 0"
|
|
226
|
+
:site-id="siteId"
|
|
227
|
+
@done="changed"
|
|
228
|
+
/>
|
|
154
229
|
<InventoryLinesDialog v-model="showRequest" mode="request" :store="store" :site-id="siteId" @done="changed" />
|
|
155
230
|
</div>
|
|
156
231
|
</template>
|
|
@@ -161,7 +236,8 @@ import useFile from "../composables/useFile";
|
|
|
161
236
|
import useUtils from "../composables/useUtils";
|
|
162
237
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
163
238
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
164
|
-
import { draftCardText } from "../utils/inventory-usage";
|
|
239
|
+
import { colleagueDraftText, draftCardText } from "../utils/inventory-usage";
|
|
240
|
+
import { ackText, holdText, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
165
241
|
|
|
166
242
|
const props = defineProps({
|
|
167
243
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -185,6 +261,56 @@ const usage = reactive({ show: false, holding: null as TInventoryHolding | null,
|
|
|
185
261
|
const drafts = ref<TInventoryUsageDraft[]>([]);
|
|
186
262
|
const staleDays = ref(3);
|
|
187
263
|
const history = reactive({ show: false, item: "", title: "", usageOnly: false });
|
|
264
|
+
const stepsLive = computed(() => inventoryStepsLive(props.store));
|
|
265
|
+
|
|
266
|
+
/** Free to use, give, return or write off: not in drafts, not waiting for Received (the API's `free`), not already being given. */
|
|
267
|
+
const freeOf = (h: TInventoryHolding) => Math.max(0, (h.free ?? h.qty) - pendingOf(String(h.item)));
|
|
268
|
+
|
|
269
|
+
// Issued to me and not yet confirmed (or reported not received).
|
|
270
|
+
const toReceive = ref<TInventoryRequest[]>([]);
|
|
271
|
+
const receiveTitle = (r: TInventoryRequest) =>
|
|
272
|
+
`${r.decidedByName || "The store"} issued you ${r.lines.filter((l) => l.issuedQty > 0).map((l) => `${qtyUnit(l.issuedQty, l.unit)} ${l.itemName}`).join(", ")}${r.decidedAt ? ` on ${standardFormatDate(r.decidedAt)}` : ""}`;
|
|
273
|
+
async function loadToReceive() {
|
|
274
|
+
if (!stepsLive.value) return (toReceive.value = []);
|
|
275
|
+
try {
|
|
276
|
+
const res = await api.getRequests(props.store._id, { status: "issued", limit: 100 });
|
|
277
|
+
toReceive.value = res.items.filter((r) => String(r.requestedBy) === me.value && (r.ackStatus === "awaiting" || r.ackStatus === "not_received"));
|
|
278
|
+
} catch {
|
|
279
|
+
toReceive.value = [];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const retForm = ref();
|
|
284
|
+
const ret = reactive({ show: false, holding: null as TInventoryHolding | null, qty: 1 as number | null, note: "", saving: false });
|
|
285
|
+
const retQtyRule = (v: any) => {
|
|
286
|
+
if (!Number.isInteger(Number(v)) || Number(v) < 1) return "Enter a whole number";
|
|
287
|
+
const max = ret.holding ? freeOf(ret.holding) : 0;
|
|
288
|
+
return Number(v) <= max || `You can return up to ${max}`;
|
|
289
|
+
};
|
|
290
|
+
function openReturn(h: TInventoryHolding) {
|
|
291
|
+
Object.assign(ret, { show: true, holding: h, qty: freeOf(h), note: "", saving: false });
|
|
292
|
+
}
|
|
293
|
+
async function submitReturn() {
|
|
294
|
+
const { valid } = await retForm.value.validate();
|
|
295
|
+
if (!valid || !ret.holding) return;
|
|
296
|
+
ret.saving = true;
|
|
297
|
+
try {
|
|
298
|
+
const site = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
|
|
299
|
+
await api.requestReturn(props.store._id, { site, item: String(ret.holding.item), qty: Number(ret.qty), note: ret.note });
|
|
300
|
+
notify("Return requested. Bring the items to the store keeper; they leave your stock once the keeper accepts them.");
|
|
301
|
+
ret.show = false;
|
|
302
|
+
changed();
|
|
303
|
+
} catch (error: any) {
|
|
304
|
+
notify(error?.data?.message || "Could not request the return.", "error");
|
|
305
|
+
} finally {
|
|
306
|
+
ret.saving = false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const wo = reactive({ show: false, holding: null as TInventoryHolding | null });
|
|
311
|
+
function openWriteOff(h: TInventoryHolding) {
|
|
312
|
+
Object.assign(wo, { show: true, holding: h });
|
|
313
|
+
}
|
|
188
314
|
|
|
189
315
|
function openHistory(h: TInventoryHolding) {
|
|
190
316
|
Object.assign(history, { show: true, item: String(h.item), title: `History — ${h.itemName}`, usageOnly: false });
|
|
@@ -209,6 +335,32 @@ async function loadDrafts() {
|
|
|
209
335
|
drafts.value = [];
|
|
210
336
|
}
|
|
211
337
|
}
|
|
338
|
+
const colleagueDrafts = ref<TInventoryUsageDraft[]>([]);
|
|
339
|
+
const colleagueDraftsOffered = computed(() => ctl.value.recordUsage && stepsLive.value);
|
|
340
|
+
async function loadColleagueDrafts() {
|
|
341
|
+
// stepsLive = the API knows ?colleagues (an older API ignores it and returns my own, hence the mine/holder filter too).
|
|
342
|
+
if (!ctl.value.recordUsage || !stepsLive.value) return (colleagueDrafts.value = []);
|
|
343
|
+
try {
|
|
344
|
+
const res = await api.getUsageDrafts(props.store._id, "colleagues");
|
|
345
|
+
colleagueDrafts.value = res.items.filter((d) => !d.mine && String(d.holder) !== me.value);
|
|
346
|
+
} catch {
|
|
347
|
+
colleagueDrafts.value = []; // 403/404: not offered, nothing to show
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async function takeOver(d: TInventoryUsageDraft) {
|
|
351
|
+
const who = d.holderName || d.startedByName || "your colleague";
|
|
352
|
+
if (!confirm(`Take over ${who}'s job (${qtyUnit(d.qty, d.unit)} ${d.itemName})? You finish it (after photo, submit), and the stock comes from YOUR own stock, not theirs.`)) return;
|
|
353
|
+
acting.value = d._id + "t";
|
|
354
|
+
try {
|
|
355
|
+
await api.takeOverDraft(String(d._id));
|
|
356
|
+
notify("The job is yours now. Finish it under My drafts.");
|
|
357
|
+
changed();
|
|
358
|
+
} catch (error: any) {
|
|
359
|
+
notify(error?.data?.message || "Could not take over the job.", "error");
|
|
360
|
+
} finally {
|
|
361
|
+
acting.value = "";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
212
364
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
213
365
|
|
|
214
366
|
const handovers = ref<TInventoryHandover[]>([]);
|
|
@@ -237,7 +389,7 @@ async function decide(h: TInventoryHandover, accept: boolean) {
|
|
|
237
389
|
try {
|
|
238
390
|
if (accept) await api.acceptHandover(h._id);
|
|
239
391
|
else await api.declineHandover(h._id, "");
|
|
240
|
-
notify(accept ? `You received ${h.qty
|
|
392
|
+
notify(accept ? `You received ${qtyUnit(h.qty, h.unit)} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
|
|
241
393
|
changed();
|
|
242
394
|
} catch (error: any) {
|
|
243
395
|
notify(error?.data?.message || "Could not update the hand-over.", "error");
|
|
@@ -264,7 +416,7 @@ const give = reactive({ show: false, holding: null as TInventoryHolding | null,
|
|
|
264
416
|
const pendingOf = (item: string) => waiting.value.filter((h) => String(h.item) === item).reduce((s, h) => s + h.qty, 0);
|
|
265
417
|
const giveQtyRule = (v: number | null) => {
|
|
266
418
|
if (!v || v < 1 || !Number.isInteger(Number(v))) return "Enter a whole number";
|
|
267
|
-
const free =
|
|
419
|
+
const free = give.holding ? freeOf(give.holding) : 0;
|
|
268
420
|
return Number(v) <= free || `You can give up to ${Math.max(0, free)}`;
|
|
269
421
|
};
|
|
270
422
|
const colleagues = ref<{ _id: string; name: string; label?: string }[]>([]);
|
|
@@ -325,9 +477,12 @@ function changed() {
|
|
|
325
477
|
load();
|
|
326
478
|
loadHandovers();
|
|
327
479
|
loadDrafts();
|
|
480
|
+
loadColleagueDrafts();
|
|
481
|
+
loadToReceive();
|
|
328
482
|
emit("changed");
|
|
329
483
|
}
|
|
330
484
|
|
|
331
|
-
watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts()));
|
|
332
|
-
|
|
485
|
+
watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts(), loadColleagueDrafts(), loadToReceive()));
|
|
486
|
+
watch(stepsLive, () => (loadToReceive(), loadColleagueDrafts()));
|
|
487
|
+
onMounted(() => (load(), loadHandovers(), loadDrafts(), loadColleagueDrafts(), loadToReceive()));
|
|
333
488
|
</script>
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
:aria-label="`${l.received} of ${l.ordered} received`"
|
|
41
41
|
/>
|
|
42
42
|
<div v-for="d in l.deliveries" :key="d._id" class="text-caption text-medium-emphasis">
|
|
43
|
-
{{ standardFormatDate(d.date) }} · {{ d.qty
|
|
43
|
+
{{ standardFormatDate(d.date) }} · {{ qtyUnit(d.qty, l.unit) }} · {{ deliveryText(d) }}
|
|
44
44
|
</div>
|
|
45
45
|
</AppCard>
|
|
46
46
|
</template>
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
</template>
|
|
51
51
|
|
|
52
52
|
<script setup lang="ts">
|
|
53
|
+
import { qtyUnit } from "../utils/inventory-ack";
|
|
53
54
|
import useInventory from "../composables/useInventory";
|
|
54
55
|
import useUtils from "../composables/useUtils";
|
|
55
56
|
import { poLineText, receivingLabel } from "../utils/inventory-receiving";
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { qtyUnit } from "../utils/inventory-ack";
|
|
25
|
+
import useInventory from "../composables/useInventory";
|
|
26
|
+
|
|
27
|
+
const props = defineProps({ request: { type: Object as PropType<TInventoryRequest>, required: true } });
|
|
28
|
+
const emit = defineEmits(["done"]);
|
|
29
|
+
|
|
30
|
+
const api = useInventory();
|
|
31
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
32
|
+
const busy = ref<"" | "received" | "not">("");
|
|
33
|
+
const nr = reactive({ show: false, reason: "" });
|
|
34
|
+
|
|
35
|
+
const items = () => props.request.lines.filter((l) => l.issuedQty > 0).map((l) => `${qtyUnit(l.issuedQty, l.unit)} ${l.itemName}`).join(", ");
|
|
36
|
+
|
|
37
|
+
function openNotReceived() {
|
|
38
|
+
Object.assign(nr, { show: true, reason: "" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function received() {
|
|
42
|
+
busy.value = "received";
|
|
43
|
+
try {
|
|
44
|
+
await api.acknowledgeRequest(props.request._id);
|
|
45
|
+
notify(`Thank you. ${items()} ${props.request.lines.length === 1 ? "is" : "are"} now in My Stock, ready to use.`);
|
|
46
|
+
emit("done");
|
|
47
|
+
} catch (error: any) {
|
|
48
|
+
notify(error?.data?.message || "Could not save. Please try again.", "error");
|
|
49
|
+
} finally {
|
|
50
|
+
busy.value = "";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function notReceived() {
|
|
55
|
+
busy.value = "not";
|
|
56
|
+
try {
|
|
57
|
+
await api.notReceived(props.request._id, nr.reason.trim());
|
|
58
|
+
notify("Sent. The store keeper will check and follow up with you.");
|
|
59
|
+
nr.show = false;
|
|
60
|
+
emit("done");
|
|
61
|
+
} catch (error: any) {
|
|
62
|
+
notify(error?.data?.message || "Could not send. Please try again.", "error");
|
|
63
|
+
} finally {
|
|
64
|
+
busy.value = "";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
@@ -7,6 +7,7 @@
|
|
|
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
9
|
<v-btn v-if="ctl.fullReports || ctl.review" value="handovers" class="text-none">Hand-overs</v-btn>
|
|
10
|
+
<v-btn v-if="stepsLive && (ctl.fullReports || ctl.review)" value="reconciliation" class="text-none">Reconciliation</v-btn>
|
|
10
11
|
</v-btn-toggle>
|
|
11
12
|
<v-select
|
|
12
13
|
v-if="report === 'purchases'"
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
/>
|
|
22
23
|
<v-text-field v-model="month" type="month" :label="report === 'purchases' && span === 'year' ? 'Up to month' : 'Month'" density="compact" hide-details style="max-width: 180px" />
|
|
23
24
|
<v-select
|
|
24
|
-
v-if="(report === 'usage' || report === 'movement') && sites.length > 1"
|
|
25
|
+
v-if="(report === 'usage' || report === 'movement' || report === 'reconciliation') && sites.length > 1"
|
|
25
26
|
v-model="site"
|
|
26
27
|
:items="[{ _id: '', name: 'All properties' }, ...sites]"
|
|
27
28
|
item-title="name"
|
|
@@ -60,6 +61,7 @@
|
|
|
60
61
|
hide-details
|
|
61
62
|
style="max-width: 260px"
|
|
62
63
|
/>
|
|
64
|
+
<v-text-field v-if="report === 'reconciliation'" v-model="itemText" label="Item" prepend-inner-icon="mdi-magnify" density="compact" hide-details clearable style="max-width: 220px" />
|
|
63
65
|
<v-checkbox v-if="report === 'usage' && canCheck" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
|
|
64
66
|
<v-spacer />
|
|
65
67
|
<v-btn v-if="report === 'purchases'" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-clipboard-search-outline" @click="openPo('')">
|
|
@@ -146,7 +148,35 @@
|
|
|
146
148
|
</v-row>
|
|
147
149
|
</template>
|
|
148
150
|
|
|
151
|
+
<!-- Reconciliation (client 2026-10-02): per item, where every unit went, and whether it tallies. -->
|
|
152
|
+
<template v-if="report === 'reconciliation'">
|
|
153
|
+
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
154
|
+
<div v-else-if="!reconRows.length" class="text-center text-medium-emphasis py-10">Nothing moved in this period.</div>
|
|
155
|
+
<v-alert v-if="!loading && reconRows.length && !reconFull" type="info" variant="tonal" density="compact" class="mb-2">
|
|
156
|
+
You see your own company's people only. The store's owner sees the store's side too.
|
|
157
|
+
</v-alert>
|
|
158
|
+
<v-alert v-if="!loading && notTallying" type="error" variant="tonal" density="compact" class="mb-2">
|
|
159
|
+
{{ notTallying === 1 ? "1 item does not" : `${notTallying} items do not` }} tally. They are marked in red below.
|
|
160
|
+
</v-alert>
|
|
161
|
+
<AppCard v-for="r in reconRows" :key="String(r.item)" class="pa-4 mb-3" :style="r.check && !r.check.ok ? 'border-left: 4px solid rgb(var(--v-theme-error))' : ''">
|
|
162
|
+
<div class="d-flex flex-wrap align-center ga-2 mb-1">
|
|
163
|
+
<div class="font-weight-medium">{{ r.name }}</div>
|
|
164
|
+
<div class="text-caption text-medium-emphasis">{{ [r.code, r.unit].filter(Boolean).join(" · ") }}</div>
|
|
165
|
+
<v-spacer />
|
|
166
|
+
<v-chip v-if="r.check" size="small" variant="tonal" :color="r.check.ok ? 'success' : 'error'">{{ r.check.ok ? "Tallies" : "Does not tally" }}</v-chip>
|
|
167
|
+
</div>
|
|
168
|
+
<div v-if="r.store" class="text-body-2 mb-1">
|
|
169
|
+
<strong>Store:</strong> {{ reconStoreText(r) }}
|
|
170
|
+
</div>
|
|
171
|
+
<div v-for="p in r.people" :key="String(p.holder)" class="text-body-2">
|
|
172
|
+
<strong>{{ p.holderLabel || p.name }}:</strong> {{ reconciliationPersonText(p, r.unit) }}
|
|
173
|
+
</div>
|
|
174
|
+
<div v-if="r.check" class="text-caption mt-1" :class="r.check.ok ? 'text-medium-emphasis' : 'text-error'">{{ reconciliationCheckText(r.check) }}</div>
|
|
175
|
+
</AppCard>
|
|
176
|
+
</template>
|
|
177
|
+
|
|
149
178
|
<v-data-table
|
|
179
|
+
v-else
|
|
150
180
|
v-model="selected"
|
|
151
181
|
:headers="tableHeaders"
|
|
152
182
|
:items="shownRows"
|
|
@@ -190,6 +220,9 @@
|
|
|
190
220
|
</template>
|
|
191
221
|
<template #item.state="{ item }">
|
|
192
222
|
<v-chip size="x-small" variant="tonal" :color="item.status === 'draft' ? 'info' : 'success'">{{ item.status === "draft" ? "In progress" : "Submitted" }}</v-chip>
|
|
223
|
+
<div v-if="item.status === 'draft' && stepsLive && ctl.recordUsage && String(item.holder) !== me" class="mt-1">
|
|
224
|
+
<v-btn size="x-small" variant="outlined" class="text-none" :loading="takingOver === item._id" @click="takeOver(item)">Take over</v-btn>
|
|
225
|
+
</div>
|
|
193
226
|
<div v-for="f in item.flags ?? []" :key="f" class="mt-1">
|
|
194
227
|
<v-chip size="x-small" variant="tonal" color="warning">{{ flagWords[f] ?? f }}</v-chip>
|
|
195
228
|
</div>
|
|
@@ -227,10 +260,12 @@
|
|
|
227
260
|
import useInventory, { saveBlob } from "../composables/useInventory";
|
|
228
261
|
import useWorkerTerm, { workerAppKey } from "../composables/useWorkerTerm";
|
|
229
262
|
import useUtils from "../composables/useUtils";
|
|
263
|
+
import useLocalAuth from "../composables/useLocalAuth";
|
|
230
264
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
231
265
|
import { USED_BY_FILTERS } from "../utils/inventory-used-by";
|
|
232
266
|
import { USAGE_FLAG_WORDS, USAGE_STATUS_CHOICES, usagePathText, usageQuery, usageTotals } from "../utils/inventory-usage";
|
|
233
267
|
import { receivingLabel } from "../utils/inventory-receiving";
|
|
268
|
+
import { inventoryStepsLive, qtyUnit, reconciliationCheckText, reconciliationPersonText, writeOffReasonText } from "../utils/inventory-ack";
|
|
234
269
|
|
|
235
270
|
const props = defineProps({
|
|
236
271
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -248,7 +283,8 @@ const appSlug = workerAppKey();
|
|
|
248
283
|
const now = new Date();
|
|
249
284
|
const month = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
|
|
250
285
|
// An approver without `view-reports` gets only the usage check screen.
|
|
251
|
-
const
|
|
286
|
+
const stepsLive = computed(() => inventoryStepsLive(props.store));
|
|
287
|
+
const report = ref<"movement" | "usage" | "technicians" | "purchases" | "handovers" | "reconciliation">(ctl.value.fullReports ? "movement" : "usage");
|
|
252
288
|
const span = ref<"month" | "year">("year");
|
|
253
289
|
const site = ref("");
|
|
254
290
|
const locationType = ref("");
|
|
@@ -297,8 +333,35 @@ const help: Record<string, string> = {
|
|
|
297
333
|
technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
|
|
298
334
|
purchases: "Every delivery received — by PO, DO or cash — and opening stock entered with new items, with totals by month and by supplier. Click a PO number (or Check a PO) to see ordered against received.",
|
|
299
335
|
handovers: "Stock passed from one person to a colleague: who gave it, who received it, and whether they confirmed.",
|
|
336
|
+
reconciliation:
|
|
337
|
+
"For each item: what came in (PO, DO, cash, opening stock), what the store issued, and for each person what they took, used (and where), wrote off, gave to colleagues, returned and still hold. Every item should tally; anything that does not is marked red.",
|
|
300
338
|
};
|
|
301
339
|
|
|
340
|
+
// Reconciliation rows, narrowed by the Item box.
|
|
341
|
+
const itemText = ref("");
|
|
342
|
+
const reconFull = ref(true);
|
|
343
|
+
const reconRows = computed(() => {
|
|
344
|
+
if (report.value !== "reconciliation") return [];
|
|
345
|
+
const q = (itemText.value || "").trim().toLowerCase();
|
|
346
|
+
return rows.value.filter((r) => !q || String(r.name ?? "").toLowerCase().includes(q) || String(r.code ?? "").toLowerCase().includes(q));
|
|
347
|
+
});
|
|
348
|
+
const notTallying = computed(() => reconRows.value.filter((r) => r.check && !r.check.ok).length);
|
|
349
|
+
function reconStoreText(r: any) {
|
|
350
|
+
const s = r.store;
|
|
351
|
+
const got = [s.received.po && `${s.received.po} by PO`, s.received.do && `${s.received.do} by DO`, s.received.cash && `${s.received.cash} by cash`, s.received.opening && `${s.received.opening} opening stock`].filter(Boolean).join(", ");
|
|
352
|
+
return [
|
|
353
|
+
`start ${s.opening}`,
|
|
354
|
+
`received ${s.received.total}${got ? ` (${got})` : ""}`,
|
|
355
|
+
`issued ${s.issued}`,
|
|
356
|
+
s.returned ? `returned ${s.returned}` : "",
|
|
357
|
+
s.adjusted ? `stock count ${s.adjusted > 0 ? "+" : ""}${s.adjusted}` : "",
|
|
358
|
+
s.writtenOff ? `written off from the shelf ${s.writtenOff}` : "",
|
|
359
|
+
`end ${qtyUnit(s.closing, r.unit)}`,
|
|
360
|
+
]
|
|
361
|
+
.filter(Boolean)
|
|
362
|
+
.join(" · ");
|
|
363
|
+
}
|
|
364
|
+
|
|
302
365
|
const handoverStatus: Record<string, string> = { pending: "Waiting to confirm", accepted: "Confirmed", declined: "Declined", cancelled: "Cancelled" };
|
|
303
366
|
|
|
304
367
|
// "Company · Service" of the person on each row, for the filter (usage, staff and hand-over reports).
|
|
@@ -359,6 +422,7 @@ const headers: Record<string, any[]> = {
|
|
|
359
422
|
{ title: "End", key: "closing" },
|
|
360
423
|
{ title: "Used", key: "usedByTechnicians" },
|
|
361
424
|
{ title: "Usage cost", key: "usedCost" },
|
|
425
|
+
{ title: "Written off", key: "writtenOffAll" },
|
|
362
426
|
],
|
|
363
427
|
usage: [
|
|
364
428
|
{ title: "Date", key: "date" },
|
|
@@ -392,6 +456,7 @@ const headers: Record<string, any[]> = {
|
|
|
392
456
|
{ title: "Used", key: "used" },
|
|
393
457
|
{ title: "Returned", key: "returned" },
|
|
394
458
|
{ title: "To colleagues", key: "gaveToColleagues" },
|
|
459
|
+
{ title: "Written off", key: "writtenOffText" },
|
|
395
460
|
{ title: "Holding at end", key: "closing" },
|
|
396
461
|
{ title: "In hand now", key: "inHandNow" },
|
|
397
462
|
],
|
|
@@ -424,15 +489,17 @@ const headers: Record<string, any[]> = {
|
|
|
424
489
|
};
|
|
425
490
|
|
|
426
491
|
const siteName = computed(() => (site.value ? props.sites.find((s) => s._id === site.value)?.name ?? "" : ""));
|
|
492
|
+
// The "Written off" columns only from an API that records write-offs.
|
|
493
|
+
const baseHeaders = (key: string) => (stepsLive.value ? headers[key] : (headers[key] ?? []).filter((h) => h.key !== "writtenOffAll" && h.key !== "writtenOffText"));
|
|
427
494
|
const tableHeaders = computed(() =>
|
|
428
495
|
report.value === "movement" && siteName.value
|
|
429
496
|
? [
|
|
430
|
-
...
|
|
497
|
+
...baseHeaders("movement"),
|
|
431
498
|
{ title: `Issued for ${siteName.value}`, key: "siteIssued" },
|
|
432
499
|
{ title: `Used at ${siteName.value}`, key: "siteUsed" },
|
|
433
500
|
{ title: `Usage cost at ${siteName.value}`, key: "siteUsedCost" },
|
|
434
501
|
]
|
|
435
|
-
:
|
|
502
|
+
: baseHeaders(report.value),
|
|
436
503
|
);
|
|
437
504
|
|
|
438
505
|
function period(): TInventoryPeriod & { unchecked?: boolean } {
|
|
@@ -442,7 +509,7 @@ function period(): TInventoryPeriod & { unchecked?: boolean } {
|
|
|
442
509
|
return {
|
|
443
510
|
from: from.toISOString(),
|
|
444
511
|
to: to.toISOString(),
|
|
445
|
-
...((report.value === "usage" || report.value === "movement") && site.value ? { site: site.value } : {}),
|
|
512
|
+
...((report.value === "usage" || report.value === "movement" || report.value === "reconciliation") && site.value ? { site: site.value } : {}),
|
|
446
513
|
...(report.value === "usage" && locationType.value ? { locationType: locationType.value } : {}),
|
|
447
514
|
...(report.value === "usage" && uncheckedOnly.value ? { unchecked: true } : {}),
|
|
448
515
|
...(usedBy.value ? { usedBy: usedBy.value } : {}),
|
|
@@ -473,7 +540,13 @@ async function load() {
|
|
|
473
540
|
selected.value = [];
|
|
474
541
|
try {
|
|
475
542
|
const res: any = await api.getReport(props.store._id, report.value, period());
|
|
476
|
-
|
|
543
|
+
// Written off: the store shelf plus people's own stock (movement); "5 used up, 2 spoilt" (staff).
|
|
544
|
+
rows.value = (res.rows ?? []).map((r: any) => ({
|
|
545
|
+
...r,
|
|
546
|
+
...(r.writtenOff !== undefined || r.writtenOffByTechnicians !== undefined ? { writtenOffAll: (r.writtenOff ?? 0) + (r.writtenOffByTechnicians ?? 0) } : {}),
|
|
547
|
+
...(report.value === "technicians" ? { writtenOffText: writeOffReasonText(r.writtenOffByReason) || (r.writtenOff ? String(r.writtenOff) : "") } : {}),
|
|
548
|
+
}));
|
|
549
|
+
reconFull.value = res.full !== false;
|
|
477
550
|
totals.qty = res.totalQty ?? 0;
|
|
478
551
|
totals.cost = res.totalCost ?? 0;
|
|
479
552
|
scope.value = res.scope ?? "all";
|
|
@@ -488,6 +561,24 @@ async function load() {
|
|
|
488
561
|
}
|
|
489
562
|
}
|
|
490
563
|
|
|
564
|
+
// Shift hand-over (client 2026-10-02): a colleague finishes a draft someone else started, from their own stock.
|
|
565
|
+
const { currentUser } = useLocalAuth();
|
|
566
|
+
const me = computed(() => String(currentUser.value?._id ?? ""));
|
|
567
|
+
const takingOver = ref("");
|
|
568
|
+
async function takeOver(r: any) {
|
|
569
|
+
if (!confirm(`Take over ${r.technician || "your colleague"}'s draft for ${qtyUnit(r.qty, r.unit)} ${r.itemName}? You finish it (after photo, submit), and the stock comes from YOUR own stock, not theirs.`)) return;
|
|
570
|
+
takingOver.value = r._id;
|
|
571
|
+
try {
|
|
572
|
+
await api.takeOverDraft(String(r._id));
|
|
573
|
+
notify("The draft is yours now. Finish it from My Stock > My drafts.");
|
|
574
|
+
await load();
|
|
575
|
+
} catch (error: any) {
|
|
576
|
+
notify(error?.data?.message || "Could not take over the draft.", "error");
|
|
577
|
+
} finally {
|
|
578
|
+
takingOver.value = "";
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
491
582
|
async function setChecked(checked: boolean) {
|
|
492
583
|
if (!selected.value.length) return;
|
|
493
584
|
checking.value = checked ? "on" : "off";
|