@7365admin1/layer-common 4.95.0 → 4.97.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 +20 -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/components/VehicleQrStickerDialog.vue +64 -4
- 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
- package/utils/vehicle-qr-design.ts +304 -20
- package/utils/vehicle-qr-sticker.ts +126 -20
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<v-btn-toggle v-model="status" mandatory density="comfortable" variant="outlined" divided>
|
|
5
5
|
<v-btn value="pending" class="text-none">Waiting</v-btn>
|
|
6
6
|
<v-btn value="issued" class="text-none">Approved</v-btn>
|
|
7
|
+
<v-btn v-if="stepsLive" value="confirm" class="text-none">Waiting for Received</v-btn>
|
|
7
8
|
<v-btn value="rejected" class="text-none">Rejected</v-btn>
|
|
8
9
|
<v-btn value="" class="text-none">All</v-btn>
|
|
9
10
|
</v-btn-toggle>
|
|
@@ -24,14 +25,20 @@
|
|
|
24
25
|
|
|
25
26
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
26
27
|
<div v-else-if="!requests.length" class="text-center text-medium-emphasis py-10">
|
|
27
|
-
{{ search ? `No requests match "${search}". Try another word, or pick "All" to look through every status.` : status === "pending" ? (store.access.canApprove ? "No requests waiting for approval." : "You have no requests waiting. Use Request Stock to ask the store for items.") : "No requests to show." }}
|
|
28
|
+
{{ search ? `No requests match "${search}". Try another word, or pick "All" to look through every status.` : status === "confirm" ? "Nothing is waiting for anyone to tap Received." : status === "pending" ? (store.access.canApprove ? "No requests waiting for approval." : "You have no requests waiting. Use Request Stock to ask the store for items.") : "No requests to show." }}
|
|
28
29
|
</div>
|
|
29
30
|
|
|
30
|
-
<AppCard
|
|
31
|
+
<AppCard
|
|
32
|
+
v-for="r in requests"
|
|
33
|
+
:key="r._id"
|
|
34
|
+
class="pa-4 mb-3"
|
|
35
|
+
:style="r._id === focusRequest ? { borderLeft: '4px solid rgb(var(--v-theme-primary))' } : undefined"
|
|
36
|
+
>
|
|
31
37
|
<div class="d-flex flex-wrap align-center ga-2 mb-2">
|
|
32
38
|
<div class="font-weight-medium">{{ r.requestedByLabel || r.requestedByName || "Someone" }}</div>
|
|
33
39
|
<div class="text-caption text-medium-emphasis">{{ standardFormatDateTime(r.createdAt) }}</div>
|
|
34
40
|
<v-chip size="small" :color="chip[r.status].color" variant="tonal">{{ chip[r.status].label }}</v-chip>
|
|
41
|
+
<v-chip v-if="r.status === 'issued' && r.ackStatus && r.ackStatus !== 'received'" size="small" :color="ACK_CHIP[r.ackStatus].color" variant="tonal">{{ ACK_CHIP[r.ackStatus].label }}</v-chip>
|
|
35
42
|
<!-- One store can supply several properties: say which one this request is for. -->
|
|
36
43
|
<v-chip v-if="store.sites.length > 1 && r.siteName" size="small" variant="outlined" prepend-icon="mdi-office-building-outline">For {{ r.siteName }}</v-chip>
|
|
37
44
|
<v-spacer />
|
|
@@ -39,6 +46,7 @@
|
|
|
39
46
|
<v-btn v-if="ctl.review" class="text-none screen-btn-primary" variant="flat" @click="openDecision(r)">Review</v-btn>
|
|
40
47
|
<v-btn v-else-if="isMine(r)" class="text-none screen-btn-ghost" variant="outlined" :loading="busyId === r._id" @click="cancel(r)">Cancel</v-btn>
|
|
41
48
|
</template>
|
|
49
|
+
<InventoryReceiptActions v-if="r.status === 'issued' && isMine(r) && (r.ackStatus === 'awaiting' || r.ackStatus === 'not_received')" :request="r" @done="changed" />
|
|
42
50
|
</div>
|
|
43
51
|
<div v-for="l in r.lines" :key="String(l.item)" class="text-body-2">
|
|
44
52
|
{{ requestLineText(r.status, l) }}
|
|
@@ -47,6 +55,9 @@
|
|
|
47
55
|
<div v-if="r.decidedByName" class="text-caption mt-1">
|
|
48
56
|
{{ requestOutcomeText(r, standardFormatDateTime) }}
|
|
49
57
|
</div>
|
|
58
|
+
<div v-if="ackText(r, isMine(r))" class="text-body-2 mt-1" :class="r.ackStatus === 'not_received' ? 'text-error' : r.ackStatus === 'awaiting' ? 'text-warning' : 'text-medium-emphasis'">
|
|
59
|
+
{{ ackText(r, isMine(r)) }}
|
|
60
|
+
</div>
|
|
50
61
|
</AppCard>
|
|
51
62
|
|
|
52
63
|
<div class="text-center" v-if="page < pages">
|
|
@@ -62,11 +73,11 @@
|
|
|
62
73
|
</v-card-title>
|
|
63
74
|
<v-card-text class="screen-modal__body">
|
|
64
75
|
<p class="text-body-2 text-medium-emphasis mb-3">Hand over the items, then approve. You can give less than asked.</p>
|
|
65
|
-
<v-form ref="decisionForm">
|
|
76
|
+
<v-form ref="decisionForm" validate-on="blur invalid-input">
|
|
66
77
|
<div v-for="l in decision.lines" :key="l.item" class="d-flex align-center ga-3 mb-1">
|
|
67
78
|
<div class="flex-grow-1">
|
|
68
79
|
<div class="font-weight-medium">{{ l.itemName }}</div>
|
|
69
|
-
<div class="text-caption text-medium-emphasis">Asked {{ l.qty }} ·
|
|
80
|
+
<div class="text-caption text-medium-emphasis">Asked {{ l.qty }} · You can give up to {{ qtyUnit(l.onHand ?? "…", l.unit) }}{{ l.reservedQty ? ` (${l.reservedQty} set aside for this request)` : "" }}</div>
|
|
70
81
|
</div>
|
|
71
82
|
<v-text-field
|
|
72
83
|
v-model.number="l.give"
|
|
@@ -76,7 +87,7 @@
|
|
|
76
87
|
label="Give"
|
|
77
88
|
density="comfortable"
|
|
78
89
|
style="max-width: 120px"
|
|
79
|
-
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= l.qty) || `0 to ${l.qty}`, (v: any) => l.onHand === undefined || Number(v) <= l.onHand || `Only ${l.onHand}
|
|
90
|
+
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= l.qty) || `0 to ${l.qty}`, (v: any) => l.onHand === undefined || Number(v) <= l.onHand || `Only ${l.onHand} available`]"
|
|
80
91
|
/>
|
|
81
92
|
</div>
|
|
82
93
|
<InputLabel title="Remarks" class="mb-1 mt-2" />
|
|
@@ -100,10 +111,13 @@ import useUtils from "../composables/useUtils";
|
|
|
100
111
|
import { REQUEST_STATUS, requestLineText, requestOutcomeText } from "../utils/inventory-request-text";
|
|
101
112
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
102
113
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
114
|
+
import { ACK_CHIP, ackText, approveCap, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
103
115
|
|
|
104
116
|
const props = defineProps({
|
|
105
117
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
106
118
|
siteId: { type: String, required: true },
|
|
119
|
+
/** A request to open first (notification link). Shown on top, marked, in the "All" list. */
|
|
120
|
+
focusRequest: { type: String, default: "" },
|
|
107
121
|
});
|
|
108
122
|
const ctl = computed(() => inventoryControls(props.store.access));
|
|
109
123
|
const emit = defineEmits(["changed"]);
|
|
@@ -114,8 +128,9 @@ const { currentUser } = useLocalAuth();
|
|
|
114
128
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
115
129
|
|
|
116
130
|
const chip = REQUEST_STATUS;
|
|
131
|
+
const stepsLive = computed(() => inventoryStepsLive(props.store));
|
|
117
132
|
|
|
118
|
-
const status = ref("pending");
|
|
133
|
+
const status = ref(props.focusRequest ? "" : "pending");
|
|
119
134
|
const search = ref("");
|
|
120
135
|
const requests = ref<TInventoryRequest[]>([]);
|
|
121
136
|
const page = ref(1);
|
|
@@ -129,8 +144,14 @@ const isMine = (r: TInventoryRequest) => String(r.requestedBy) === String(curren
|
|
|
129
144
|
async function load(p = 1) {
|
|
130
145
|
loading.value = true;
|
|
131
146
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
147
|
+
// "Waiting for Received" is approved requests whose person has not confirmed yet.
|
|
148
|
+
const confirm = status.value === "confirm";
|
|
149
|
+
const res = await api.getRequests(props.store._id, { page: p, limit: confirm ? 100 : 20, status: confirm ? "issued" : status.value || undefined, search: search.value?.trim() || undefined });
|
|
150
|
+
const got = confirm ? res.items.filter((r) => r.ackStatus === "awaiting" || r.ackStatus === "not_received") : res.items;
|
|
151
|
+
// ponytail: the linked request is found in the first page of "All" (the newest 20); an older one just shows the list.
|
|
152
|
+
const focused = p === 1 && props.focusRequest ? got.find((r) => r._id === props.focusRequest) : undefined;
|
|
153
|
+
const rows = focused ? [focused, ...got.filter((r) => r !== focused)] : got;
|
|
154
|
+
requests.value = p === 1 ? rows : [...requests.value, ...got];
|
|
134
155
|
page.value = p;
|
|
135
156
|
pages.value = res.pages;
|
|
136
157
|
} catch {
|
|
@@ -146,7 +167,7 @@ function changed() {
|
|
|
146
167
|
}
|
|
147
168
|
|
|
148
169
|
async function cancel(r: TInventoryRequest) {
|
|
149
|
-
if (!confirm("Cancel this request? The store keeper will no longer see it.")) return;
|
|
170
|
+
if (!confirm("Cancel this request? The store keeper will no longer see it, and anything set aside for it goes back to the store.")) return;
|
|
150
171
|
busyId.value = r._id;
|
|
151
172
|
try {
|
|
152
173
|
await api.cancelRequest(r._id);
|
|
@@ -163,7 +184,7 @@ const decisionForm = ref();
|
|
|
163
184
|
const decision = reactive({
|
|
164
185
|
show: false,
|
|
165
186
|
request: null as TInventoryRequest | null,
|
|
166
|
-
lines: [] as { item: string; itemName: string; unit: string; qty: number; give: number; onHand?: number }[],
|
|
187
|
+
lines: [] as { item: string; itemName: string; unit: string; qty: number; give: number; onHand?: number; reservedQty?: number }[],
|
|
167
188
|
remarks: "",
|
|
168
189
|
busy: "" as "" | "approve" | "reject",
|
|
169
190
|
});
|
|
@@ -174,13 +195,14 @@ async function openDecision(r: TInventoryRequest) {
|
|
|
174
195
|
request: r,
|
|
175
196
|
remarks: "",
|
|
176
197
|
busy: "",
|
|
177
|
-
lines: r.lines.map((l) => ({ item: String(l.item), itemName: l.itemName, unit: l.unit, qty: l.qty, give: l.qty, onHand: undefined })),
|
|
198
|
+
lines: r.lines.map((l) => ({ item: String(l.item), itemName: l.itemName, unit: l.unit, qty: l.qty, give: l.qty, onHand: undefined, reservedQty: l.reservedQty })),
|
|
178
199
|
});
|
|
179
200
|
try {
|
|
180
201
|
const res = await api.getItems(props.store._id, { limit: 200, includeInactive: true });
|
|
181
202
|
for (const line of decision.lines) {
|
|
182
203
|
const found = res.items.find((i) => i._id === line.item);
|
|
183
|
-
|
|
204
|
+
// What is free, plus what this request already set aside for itself.
|
|
205
|
+
line.onHand = approveCap(found, line.reservedQty ?? 0) ?? 0;
|
|
184
206
|
line.give = Math.min(line.qty, line.onHand);
|
|
185
207
|
}
|
|
186
208
|
} catch {
|
|
@@ -197,7 +219,7 @@ async function approve() {
|
|
|
197
219
|
lines: decision.lines.map((l) => ({ item: l.item, qty: Number(l.give) })),
|
|
198
220
|
remarks: decision.remarks,
|
|
199
221
|
});
|
|
200
|
-
notify("Approved. The stock is now with the requester.");
|
|
222
|
+
notify(stepsLive.value ? "Approved. The requester is asked to tap Received once they have the items." : "Approved. The stock is now with the requester.");
|
|
201
223
|
decision.show = false;
|
|
202
224
|
changed();
|
|
203
225
|
} catch (error: any) {
|
|
@@ -213,7 +235,7 @@ async function reject() {
|
|
|
213
235
|
decision.busy = "reject";
|
|
214
236
|
try {
|
|
215
237
|
await api.rejectRequest(decision.request._id, decision.remarks.trim());
|
|
216
|
-
notify("Request rejected.");
|
|
238
|
+
notify(stepsLive.value ? "Request rejected. Anything set aside for it is back in the store." : "Request rejected.");
|
|
217
239
|
decision.show = false;
|
|
218
240
|
changed();
|
|
219
241
|
} catch (error: any) {
|
|
@@ -21,6 +21,26 @@
|
|
|
21
21
|
/>
|
|
22
22
|
</template>
|
|
23
23
|
|
|
24
|
+
<template v-if="stepsLive">
|
|
25
|
+
<v-divider class="my-4" />
|
|
26
|
+
<div class="text-subtitle-1 mb-1">Low stock reminders</div>
|
|
27
|
+
<p class="text-body-2 text-medium-emphasis mb-2">
|
|
28
|
+
When an item goes low, the people who manage this store get an alert. Choose how often to remind them until someone records a PO number for it.
|
|
29
|
+
</p>
|
|
30
|
+
<v-text-field
|
|
31
|
+
v-model.number="reminderDays"
|
|
32
|
+
type="number"
|
|
33
|
+
min="0"
|
|
34
|
+
max="30"
|
|
35
|
+
label="Remind every (days)"
|
|
36
|
+
density="comfortable"
|
|
37
|
+
style="max-width: 220px"
|
|
38
|
+
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= 30) || 'A whole number from 0 to 30']"
|
|
39
|
+
:hint="`How often to remind (days). 0 = no reminders. ${reminderDaysText(reminderDays)}`"
|
|
40
|
+
persistent-hint
|
|
41
|
+
/>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
24
44
|
<v-divider class="my-4" />
|
|
25
45
|
<div class="text-subtitle-1 mb-1">Service365 app</div>
|
|
26
46
|
<p class="text-body-2 text-medium-emphasis mb-2">
|
|
@@ -40,6 +60,7 @@
|
|
|
40
60
|
import useInventory from "../composables/useInventory";
|
|
41
61
|
import useUtils from "../composables/useUtils";
|
|
42
62
|
import { inventorySiteOptions } from "../utils/inventory-store-label";
|
|
63
|
+
import { inventoryStepsLive, reminderDaysText } from "../utils/inventory-ack";
|
|
43
64
|
|
|
44
65
|
const props = defineProps({
|
|
45
66
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -57,6 +78,8 @@ const name = ref("");
|
|
|
57
78
|
const sites = ref<string[]>([]);
|
|
58
79
|
const showInResidentApp = ref(false);
|
|
59
80
|
const showCostInResidentApp = ref(false);
|
|
81
|
+
const reminderDays = ref<number>(1);
|
|
82
|
+
const stepsLive = computed(() => inventoryStepsLive(props.store));
|
|
60
83
|
|
|
61
84
|
const siteItems = computed(() => inventorySiteOptions(props.orgSites, props.store));
|
|
62
85
|
|
|
@@ -65,6 +88,7 @@ function reset() {
|
|
|
65
88
|
sites.value = [...props.store.sites];
|
|
66
89
|
showInResidentApp.value = props.store.showInResidentApp;
|
|
67
90
|
showCostInResidentApp.value = props.store.showCostInResidentApp;
|
|
91
|
+
reminderDays.value = props.store.lowStockReminderDays ?? 1;
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
async function save() {
|
|
@@ -77,6 +101,8 @@ async function save() {
|
|
|
77
101
|
sites: sites.value,
|
|
78
102
|
showInResidentApp: showInResidentApp.value,
|
|
79
103
|
showCostInResidentApp: showInResidentApp.value && showCostInResidentApp.value,
|
|
104
|
+
// Only to an API that has reminders (an older one refuses the unknown field).
|
|
105
|
+
...(stepsLive.value ? { lowStockReminderDays: Number(reminderDays.value) } : {}),
|
|
80
106
|
});
|
|
81
107
|
notify("Settings saved.");
|
|
82
108
|
emit("saved");
|
|
@@ -87,5 +113,5 @@ async function save() {
|
|
|
87
113
|
}
|
|
88
114
|
}
|
|
89
115
|
|
|
90
|
-
watch(() => props.store, reset, { immediate: true });
|
|
116
|
+
watch(() => [props.store, props.store.lowStockReminderDays], reset, { immediate: true });
|
|
91
117
|
</script>
|
|
@@ -96,10 +96,17 @@
|
|
|
96
96
|
</div>
|
|
97
97
|
</template>
|
|
98
98
|
<template #item.onHand="{ item }">
|
|
99
|
-
|
|
100
|
-
<span class="text-
|
|
99
|
+
<!-- Owner 2026-10-03: what is requested comes off this number at once (set aside), so everyone sees what is really left. -->
|
|
100
|
+
<span :class="item.lowStock ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ qtyUnit(availableQty(item), item.unit) }}</span>
|
|
101
101
|
<v-chip v-if="item.lowStock" size="x-small" color="error" variant="tonal" class="ml-2">Low</v-chip>
|
|
102
102
|
<v-chip v-if="item.status === 'inactive'" size="x-small" variant="tonal" class="ml-2">Not in use</v-chip>
|
|
103
|
+
<div v-if="item.reserved" class="text-caption text-medium-emphasis">+ {{ item.reserved }} set aside for requests (still on the shelf)</div>
|
|
104
|
+
<div v-if="item.awaitingReceipt" class="text-caption text-medium-emphasis">{{ item.awaitingReceipt }} handed out, waiting for Received</div>
|
|
105
|
+
<!-- Acknowledge / PO details are for the people who handle the store; a technician sees only the Low chip. -->
|
|
106
|
+
<div v-if="item.lowStockAlert && canAckLow" class="text-caption text-error" @click.stop>
|
|
107
|
+
{{ lowStockAlertText(item.lowStockAlert, standardFormatDate) }}
|
|
108
|
+
<a v-if="canAckLow && !item.lowStockAlert.poNumber" href="#" class="text-primary ml-1" @click.prevent.stop="openAck(item)">Acknowledge</a>
|
|
109
|
+
</div>
|
|
103
110
|
</template>
|
|
104
111
|
<template #item.reorderLevel="{ value }">{{ value || "—" }}</template>
|
|
105
112
|
<template #item.usedBy="{ item }">{{ usedByText(item.usedBy) }}</template>
|
|
@@ -143,6 +150,23 @@
|
|
|
143
150
|
</div>
|
|
144
151
|
</v-card>
|
|
145
152
|
</v-dialog>
|
|
153
|
+
<v-dialog v-model="ack.show" max-width="480">
|
|
154
|
+
<v-card v-if="ack.item" class="screen-modal">
|
|
155
|
+
<v-card-title>Low stock: {{ ack.item.name }}</v-card-title>
|
|
156
|
+
<v-card-text class="screen-modal__body">
|
|
157
|
+
<p class="text-body-2 mb-3">
|
|
158
|
+
{{ qtyUnit(availableQty(ack.item), ack.item.unit) }} left (alert at {{ ack.item.reorderLevel }}). Record the PO number once you have ordered more:
|
|
159
|
+
the reminders then stop. Without a PO number, we note that you have seen it and the reminders carry on.
|
|
160
|
+
</p>
|
|
161
|
+
<InputLabel title="PO number (optional)" class="mb-1" />
|
|
162
|
+
<v-text-field v-model="ack.poNumber" density="comfortable" maxlength="100" placeholder="e.g. PO-0412" />
|
|
163
|
+
</v-card-text>
|
|
164
|
+
<div class="screen-modal__footer">
|
|
165
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="ack.saving" @click="ack.show = false">Cancel</v-btn>
|
|
166
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="ack.saving" @click="saveAck">{{ ack.poNumber.trim() ? "Save PO and stop reminders" : "Mark as seen" }}</v-btn>
|
|
167
|
+
</div>
|
|
168
|
+
</v-card>
|
|
169
|
+
</v-dialog>
|
|
146
170
|
<InventoryItemDetail v-model="detail.show" :store="store" :item="detail.item" @edit="editItem" @done="done" />
|
|
147
171
|
</div>
|
|
148
172
|
</template>
|
|
@@ -154,6 +178,7 @@ import useUtils from "../composables/useUtils";
|
|
|
154
178
|
import { inventoryItemMeta } from "../utils/inventory-store-label";
|
|
155
179
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
156
180
|
import { hasLegacyEquipmentToCopy } from "../utils/inventory-legacy";
|
|
181
|
+
import { availableQty, lowStockAlertText, qtyUnit } from "../utils/inventory-ack";
|
|
157
182
|
import { nextUsedBy, USED_BY_ALL, USED_BY_ALL_LABEL, USED_BY_FILTERS, USED_BY_HINT, USED_BY_OPTIONS, usedByChips, usedByText } from "../utils/inventory-used-by";
|
|
158
183
|
|
|
159
184
|
const props = defineProps({
|
|
@@ -164,7 +189,30 @@ const props = defineProps({
|
|
|
164
189
|
const ctl = computed(() => inventoryControls(props.store.access));
|
|
165
190
|
const emit = defineEmits(["changed"]);
|
|
166
191
|
|
|
167
|
-
const { getItems, getLegacyEquipment, setUsedBy } = useInventory();
|
|
192
|
+
const { getItems, getLegacyEquipment, setUsedBy, acknowledgeLowStock } = useInventory();
|
|
193
|
+
const { standardFormatDate } = useUtils();
|
|
194
|
+
|
|
195
|
+
// Low stock acknowledge (client 2026-10-02): store managers, approvers and store keepers.
|
|
196
|
+
const canAckLow = computed(() => ctl.value.addItems || ctl.value.review || ctl.value.receive);
|
|
197
|
+
const ack = reactive({ show: false, item: null as TInventoryItem | null, poNumber: "", saving: false });
|
|
198
|
+
function openAck(item: TInventoryItem) {
|
|
199
|
+
Object.assign(ack, { show: true, item, poNumber: "", saving: false });
|
|
200
|
+
}
|
|
201
|
+
async function saveAck() {
|
|
202
|
+
if (!ack.item) return;
|
|
203
|
+
ack.saving = true;
|
|
204
|
+
try {
|
|
205
|
+
const po = ack.poNumber.trim();
|
|
206
|
+
await acknowledgeLowStock(ack.item._id, po);
|
|
207
|
+
notify(po ? `PO ${po} recorded for ${ack.item.name}. The low stock reminders have stopped.` : `Noted that you have seen ${ack.item.name} is low. Reminders continue until a PO number is recorded.`);
|
|
208
|
+
ack.show = false;
|
|
209
|
+
load();
|
|
210
|
+
} catch (error: any) {
|
|
211
|
+
notify(error?.data?.message || "Could not save. Please try again.", "error");
|
|
212
|
+
} finally {
|
|
213
|
+
ack.saving = false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
168
216
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
169
217
|
|
|
170
218
|
// Used by belongs to the main (property) store; its owner sees the column, the filter and bulk edit.
|
|
@@ -210,7 +258,7 @@ const pageRange = computed(() => (total.value ? `${(page.value - 1) * limit + 1}
|
|
|
210
258
|
|
|
211
259
|
const headers = computed(() => [
|
|
212
260
|
{ title: "Item", value: "name" },
|
|
213
|
-
{ title: "
|
|
261
|
+
{ title: "Available", value: "onHand" },
|
|
214
262
|
{ title: "Low stock alert at", value: "reorderLevel" },
|
|
215
263
|
...(usedByOwner.value ? [{ title: "Used by", value: "usedBy" }] : []),
|
|
216
264
|
...(ctl.value.unitPrice ? [{ title: "Unit price", value: "unitCost" }] : []),
|
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
<v-text-field v-model="search" density="compact" placeholder="Search name or item" clearable hide-details append-inner-icon="mdi-magnify" style="max-width: 280px" />
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
|
+
<!-- Returns the holder started (owner 2026-10-03): accept once the items are back on the shelf. -->
|
|
10
|
+
<AppCard v-for="h in returns" :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
|
+
<v-icon icon="mdi-keyboard-return" color="warning" />
|
|
12
|
+
<div class="flex-grow-1" style="min-width: 220px">
|
|
13
|
+
<div class="font-weight-medium">{{ h.fromLabel || h.fromName }} wants to return {{ qtyUnit(h.qty, h.unit) }} {{ h.itemName }}</div>
|
|
14
|
+
<div class="text-caption text-medium-emphasis">{{ standardFormatDate(h.createdAt) }}{{ h.note ? ` · "${h.note}"` : "" }} · Accept once you have the items back; they go back into the store.</div>
|
|
15
|
+
</div>
|
|
16
|
+
<v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" :disabled="!!acting" @click="openDecline(h)">Not received</v-btn>
|
|
17
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === h._id" :disabled="!!acting" @click="acceptReturn(h)">Accept return</v-btn>
|
|
18
|
+
</AppCard>
|
|
19
|
+
|
|
9
20
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
10
21
|
<div v-else-if="!groups.length" class="text-center text-medium-emphasis py-10">Nobody is holding stock from this store.</div>
|
|
11
22
|
|
|
@@ -17,7 +28,9 @@
|
|
|
17
28
|
<v-btn v-if="ctl.takeReturn" class="text-none screen-btn-ghost" variant="outlined" size="small" prepend-icon="mdi-keyboard-return" @click="openReturn(g)">Receive return</v-btn>
|
|
18
29
|
</div>
|
|
19
30
|
<div class="d-flex flex-wrap ga-2">
|
|
20
|
-
<v-chip v-for="h in g.items" :key="h._id" variant="tonal">
|
|
31
|
+
<v-chip v-for="h in g.items" :key="h._id" variant="tonal">
|
|
32
|
+
{{ h.itemName }}: {{ qtyUnit(h.qty, h.unit) }} left · {{ h.usedThisMonth ?? 0 }} used this month{{ h.unacknowledged ? ` · ${h.unacknowledged} not yet confirmed as received` : "" }}
|
|
33
|
+
</v-chip>
|
|
21
34
|
</div>
|
|
22
35
|
</AppCard>
|
|
23
36
|
|
|
@@ -30,18 +43,77 @@
|
|
|
30
43
|
:holdings="ret.items"
|
|
31
44
|
@done="changed"
|
|
32
45
|
/>
|
|
46
|
+
<v-dialog v-model="decline.show" max-width="480">
|
|
47
|
+
<v-card v-if="decline.handover" class="screen-modal">
|
|
48
|
+
<v-card-title>Return not received</v-card-title>
|
|
49
|
+
<v-card-text class="screen-modal__body">
|
|
50
|
+
<p class="text-body-2 mb-3">The {{ decline.handover.itemName }} stays with {{ decline.handover.fromName }}. Tell them why, so they know what to do.</p>
|
|
51
|
+
<v-textarea v-model="decline.reason" rows="2" auto-grow density="comfortable" maxlength="500" placeholder="e.g. nothing was brought to the store" />
|
|
52
|
+
</v-card-text>
|
|
53
|
+
<div class="screen-modal__footer">
|
|
54
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="!!acting" @click="decline.show = false">Cancel</v-btn>
|
|
55
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="!!acting" @click="declineReturn">Send</v-btn>
|
|
56
|
+
</div>
|
|
57
|
+
</v-card>
|
|
58
|
+
</v-dialog>
|
|
33
59
|
</div>
|
|
34
60
|
</template>
|
|
35
61
|
|
|
36
62
|
<script setup lang="ts">
|
|
63
|
+
import { qtyUnit } from "../utils/inventory-ack";
|
|
37
64
|
import useInventory from "../composables/useInventory";
|
|
38
65
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
66
|
+
import useUtils from "../composables/useUtils";
|
|
39
67
|
|
|
40
68
|
const props = defineProps({ store: { type: Object as PropType<TInventoryStore>, required: true } });
|
|
41
69
|
const ctl = computed(() => inventoryControls(props.store.access));
|
|
42
70
|
const emit = defineEmits(["changed"]);
|
|
43
71
|
|
|
44
|
-
const { getHoldings } = useInventory();
|
|
72
|
+
const { getHoldings, getHandovers, acceptHandover, declineHandover } = useInventory();
|
|
73
|
+
const { standardFormatDate } = useUtils();
|
|
74
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
75
|
+
|
|
76
|
+
const returns = ref<TInventoryHandover[]>([]);
|
|
77
|
+
const acting = ref("");
|
|
78
|
+
const decline = reactive({ show: false, handover: null as TInventoryHandover | null, reason: "" });
|
|
79
|
+
async function loadReturns() {
|
|
80
|
+
if (!ctl.value.takeReturn) return (returns.value = []);
|
|
81
|
+
try {
|
|
82
|
+
returns.value = (await getHandovers(props.store._id, { status: "pending", limit: 50 })).items.filter((h) => h.kind === "return");
|
|
83
|
+
} catch {
|
|
84
|
+
returns.value = [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function acceptReturn(h: TInventoryHandover) {
|
|
88
|
+
acting.value = h._id;
|
|
89
|
+
try {
|
|
90
|
+
await acceptHandover(h._id);
|
|
91
|
+
notify(`${qtyUnit(h.qty, h.unit)} ${h.itemName} is back in the store.`);
|
|
92
|
+
changed();
|
|
93
|
+
} catch (error: any) {
|
|
94
|
+
notify(error?.data?.message || "Could not accept the return.", "error");
|
|
95
|
+
} finally {
|
|
96
|
+
acting.value = "";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function openDecline(h: TInventoryHandover) {
|
|
100
|
+
Object.assign(decline, { show: true, handover: h, reason: "" });
|
|
101
|
+
}
|
|
102
|
+
async function declineReturn() {
|
|
103
|
+
const h = decline.handover;
|
|
104
|
+
if (!h) return;
|
|
105
|
+
acting.value = h._id;
|
|
106
|
+
try {
|
|
107
|
+
await declineHandover(h._id, decline.reason.trim());
|
|
108
|
+
notify(`Return not accepted. The ${h.itemName} stays with ${h.fromName}, who has been told.`);
|
|
109
|
+
decline.show = false;
|
|
110
|
+
changed();
|
|
111
|
+
} catch (error: any) {
|
|
112
|
+
notify(error?.data?.message || "Could not update the return.", "error");
|
|
113
|
+
} finally {
|
|
114
|
+
acting.value = "";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
45
117
|
const holdings = ref<TInventoryHolding[]>([]);
|
|
46
118
|
const loading = ref(false);
|
|
47
119
|
const search = ref("");
|
|
@@ -76,9 +148,10 @@ function openReturn(g: { holder: string; holderName: string; items: TInventoryHo
|
|
|
76
148
|
|
|
77
149
|
function changed() {
|
|
78
150
|
load();
|
|
151
|
+
loadReturns();
|
|
79
152
|
emit("changed");
|
|
80
153
|
}
|
|
81
154
|
|
|
82
|
-
watch(() => props.store._id, load);
|
|
83
|
-
onMounted(load);
|
|
155
|
+
watch(() => props.store._id, () => (load(), loadReturns()));
|
|
156
|
+
onMounted(() => (load(), loadReturns()));
|
|
84
157
|
</script>
|
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
<v-card-text class="screen-modal__body">
|
|
10
10
|
<div class="font-weight-medium">{{ holding.itemName }}</div>
|
|
11
11
|
<div class="text-body-2 text-medium-emphasis mb-4">
|
|
12
|
-
You have {{ holding.qty
|
|
12
|
+
You have {{ qtyUnit(holding.qty, holding.unit) }} in hand<template v-if="otherDrafts"> · {{ otherDrafts }} already in your other drafts</template>.
|
|
13
13
|
<template v-if="!draft"> Took the before photo but the job is not done yet? Save a draft now and finish it later.</template>
|
|
14
14
|
</div>
|
|
15
15
|
<v-alert v-if="draft" type="info" variant="tonal" density="compact" class="mb-4">
|
|
16
16
|
Saved as a draft {{ draft.draftedAt ? standardFormatDateTime(draft.draftedAt) : "" }}. Nothing has been taken off your stock yet — add the after photo and submit when the job is done.
|
|
17
17
|
</v-alert>
|
|
18
|
-
<v-form ref="form">
|
|
18
|
+
<v-form ref="form" validate-on="blur invalid-input">
|
|
19
19
|
<InputLabel title="How many did you use?" required class="mb-1" />
|
|
20
20
|
<v-text-field
|
|
21
21
|
v-model.number="qty"
|
|
@@ -92,6 +92,18 @@
|
|
|
92
92
|
|
|
93
93
|
<InputLabel :title="draft ? 'Date of the job (used when you submit)' : 'Date used'" class="mb-1 mt-3" />
|
|
94
94
|
<v-text-field v-model="date" type="date" :max="today" density="comfortable" style="max-width: 200px" />
|
|
95
|
+
<template v-if="stepsLive">
|
|
96
|
+
<InputLabel title="Also attended (optional)" class="mb-1" />
|
|
97
|
+
<v-text-field
|
|
98
|
+
v-model="attendedWith"
|
|
99
|
+
density="comfortable"
|
|
100
|
+
maxlength="200"
|
|
101
|
+
placeholder="e.g. Ahmad, Kumar"
|
|
102
|
+
hint="Colleagues who did this job with you. The stock still comes from your own stock."
|
|
103
|
+
persistent-hint
|
|
104
|
+
class="mb-2"
|
|
105
|
+
/>
|
|
106
|
+
</template>
|
|
95
107
|
<InputLabel title="Fault reported by (optional)" class="mb-1" />
|
|
96
108
|
<v-text-field v-model="reportedBy" density="comfortable" placeholder="e.g. tenant, security guard" maxlength="120" />
|
|
97
109
|
<InputLabel title="Remarks (optional)" class="mb-1" />
|
|
@@ -122,6 +134,7 @@
|
|
|
122
134
|
<script setup lang="ts">
|
|
123
135
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
124
136
|
import useUtils from "../composables/useUtils";
|
|
137
|
+
import { inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
125
138
|
|
|
126
139
|
const show = defineModel<boolean>({ default: false });
|
|
127
140
|
const props = defineProps({
|
|
@@ -155,6 +168,8 @@ const area = ref("");
|
|
|
155
168
|
const reference = ref("");
|
|
156
169
|
const remarks = ref("");
|
|
157
170
|
const reportedBy = ref("");
|
|
171
|
+
const attendedWith = ref("");
|
|
172
|
+
const stepsLive = computed(() => inventoryStepsLive(props.store));
|
|
158
173
|
const date = ref(today);
|
|
159
174
|
const photosBefore = ref<string[]>([]);
|
|
160
175
|
const photosAfter = ref<string[]>([]);
|
|
@@ -199,6 +214,8 @@ function body() {
|
|
|
199
214
|
photosAfter: photosAfter.value,
|
|
200
215
|
remarks: remarks.value,
|
|
201
216
|
reportedBy: reportedBy.value,
|
|
217
|
+
// Only to an API that knows it (an older one refuses an unknown field).
|
|
218
|
+
...(stepsLive.value && attendedWith.value.trim() ? { attendedWith: attendedWith.value.trim() } : {}),
|
|
202
219
|
};
|
|
203
220
|
}
|
|
204
221
|
|
|
@@ -282,6 +299,7 @@ watch(show, (open) => {
|
|
|
282
299
|
reference.value = "";
|
|
283
300
|
remarks.value = "";
|
|
284
301
|
reportedBy.value = "";
|
|
302
|
+
attendedWith.value = "";
|
|
285
303
|
date.value = today;
|
|
286
304
|
site.value = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
|
|
287
305
|
const d = props.draft;
|
|
@@ -297,6 +315,7 @@ watch(show, (open) => {
|
|
|
297
315
|
reference.value = d.reference ?? "";
|
|
298
316
|
remarks.value = d.remarks ?? "";
|
|
299
317
|
reportedBy.value = d.reportedBy ?? "";
|
|
318
|
+
attendedWith.value = d.attendedWith ?? "";
|
|
300
319
|
photosBefore.value = [...(d.photos?.before ?? [])];
|
|
301
320
|
photosAfter.value = [...(d.photos?.after ?? [])];
|
|
302
321
|
nextTick(() => (prefilling = false));
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog v-model="show" max-width="560" persistent scrollable>
|
|
3
|
+
<v-card class="screen-modal">
|
|
4
|
+
<v-card-title class="d-flex align-center">
|
|
5
|
+
Write off {{ itemName }}
|
|
6
|
+
<v-spacer />
|
|
7
|
+
<v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="show = false" />
|
|
8
|
+
</v-card-title>
|
|
9
|
+
<v-card-text class="screen-modal__body">
|
|
10
|
+
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
11
|
+
{{
|
|
12
|
+
from === "store"
|
|
13
|
+
? `For stock on the store shelf that can no longer be used. ${qtyUnit(max, unit)} on the shelf.`
|
|
14
|
+
: `For stock in your hands that is used up, spoilt, damaged or lost. It does not go back to the store and is not counted as usage. You can write off up to ${qtyUnit(max, unit)}.`
|
|
15
|
+
}}
|
|
16
|
+
</p>
|
|
17
|
+
<!-- Errors once a field is left or Write off is pressed, never from opening the form. -->
|
|
18
|
+
<v-form ref="form" validate-on="blur invalid-input">
|
|
19
|
+
<InputLabel title="How many?" required class="mb-1" />
|
|
20
|
+
<v-text-field
|
|
21
|
+
v-model.number="qty"
|
|
22
|
+
type="number"
|
|
23
|
+
min="1"
|
|
24
|
+
:max="max"
|
|
25
|
+
density="comfortable"
|
|
26
|
+
:suffix="unit"
|
|
27
|
+
style="max-width: 200px"
|
|
28
|
+
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1) || 'Enter a whole number', (v: any) => Number(v) <= max || `You can write off up to ${max}`]"
|
|
29
|
+
/>
|
|
30
|
+
<InputLabel title="Why?" required class="mb-1" />
|
|
31
|
+
<v-chip-group v-model="reason" :mandatory="from !== 'store'" selected-class="text-primary" class="mb-2">
|
|
32
|
+
<v-chip v-for="r in WRITE_OFF_REASONS" :key="r.value" :value="r.value" variant="outlined" filter>{{ r.title }}</v-chip>
|
|
33
|
+
</v-chip-group>
|
|
34
|
+
<InputLabel :title="reason === 'other' ? 'What happened?' : 'Remarks (optional)'" :required="reason === 'other'" class="mb-1" />
|
|
35
|
+
<v-textarea v-model="remarks" rows="2" auto-grow density="comfortable" maxlength="1000" placeholder="e.g. fell into the drain at block 3" />
|
|
36
|
+
<InputLabel title="Photos (optional)" class="mb-1" />
|
|
37
|
+
<InventoryPhotoInput v-model="photos" :max="5" />
|
|
38
|
+
<v-alert v-if="problem" type="warning" variant="tonal" density="compact" class="mt-2">{{ problem }}</v-alert>
|
|
39
|
+
</v-form>
|
|
40
|
+
</v-card-text>
|
|
41
|
+
<div class="screen-modal__footer">
|
|
42
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="saving" @click="show = false">Cancel</v-btn>
|
|
43
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="saving" @click="save">Write off</v-btn>
|
|
44
|
+
</div>
|
|
45
|
+
</v-card>
|
|
46
|
+
</v-dialog>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
import useInventory from "../composables/useInventory";
|
|
51
|
+
import { qtyUnit, WRITE_OFF_REASONS, writeOffDefaultReason, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
|
|
52
|
+
|
|
53
|
+
/** Write-off (owner 2026-10-03): from the person's own stock (`holding`) or, for a store keeper, from the shelf (`store`). */
|
|
54
|
+
const show = defineModel<boolean>({ default: false });
|
|
55
|
+
const props = defineProps({
|
|
56
|
+
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
57
|
+
from: { type: String as PropType<"holding" | "store">, default: "holding" },
|
|
58
|
+
item: { type: String, default: "" },
|
|
59
|
+
itemName: { type: String, default: "" },
|
|
60
|
+
unit: { type: String, default: "" },
|
|
61
|
+
max: { type: Number, default: 0 },
|
|
62
|
+
siteId: { type: String, default: "" },
|
|
63
|
+
});
|
|
64
|
+
const emit = defineEmits(["done"]);
|
|
65
|
+
|
|
66
|
+
const api = useInventory();
|
|
67
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
68
|
+
|
|
69
|
+
const form = ref();
|
|
70
|
+
const qty = ref<number | null>(1);
|
|
71
|
+
const reason = ref<string>(writeOffDefaultReason(props.from));
|
|
72
|
+
const remarks = ref("");
|
|
73
|
+
const photos = ref<string[]>([]);
|
|
74
|
+
const saving = ref(false);
|
|
75
|
+
const tried = ref(false);
|
|
76
|
+
const problem = computed(() => (tried.value ? writeOffProblem({ reason: reason.value ?? "", remarks: remarks.value }) : ""));
|
|
77
|
+
|
|
78
|
+
async function save() {
|
|
79
|
+
tried.value = true;
|
|
80
|
+
const { valid } = await form.value.validate();
|
|
81
|
+
if (!valid || writeOffProblem({ reason: reason.value ?? "", remarks: remarks.value })) return;
|
|
82
|
+
saving.value = true;
|
|
83
|
+
try {
|
|
84
|
+
await api.writeOff(props.store._id, {
|
|
85
|
+
from: props.from,
|
|
86
|
+
...(props.siteId && props.store.sites.includes(props.siteId) ? { site: props.siteId } : {}),
|
|
87
|
+
item: props.item,
|
|
88
|
+
qty: Number(qty.value),
|
|
89
|
+
reason: reason.value,
|
|
90
|
+
remarks: remarks.value.trim(),
|
|
91
|
+
photos: photos.value,
|
|
92
|
+
});
|
|
93
|
+
notify(`${qtyUnit(qty.value, props.unit)} of ${props.itemName} written off as ${writeOffLabel(reason.value).toLowerCase()}.${props.from === "holding" ? " The store keeper has been told." : ""}`);
|
|
94
|
+
show.value = false;
|
|
95
|
+
emit("done");
|
|
96
|
+
} catch (error: any) {
|
|
97
|
+
notify(error?.data?.message || "Could not write it off. Please try again.", "error");
|
|
98
|
+
} finally {
|
|
99
|
+
saving.value = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
watch(show, (open) => {
|
|
104
|
+
if (!open) return;
|
|
105
|
+
form.value?.resetValidation();
|
|
106
|
+
qty.value = 1;
|
|
107
|
+
reason.value = writeOffDefaultReason(props.from);
|
|
108
|
+
remarks.value = "";
|
|
109
|
+
photos.value = [];
|
|
110
|
+
tried.value = false;
|
|
111
|
+
});
|
|
112
|
+
</script>
|