@7365admin1/layer-common 4.90.2-staging.490 → 4.90.2-staging.491
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/InventoryItemDetail.vue +2 -1
- package/components/InventoryLinesDialog.vue +91 -13
- package/components/InventoryPhotoInput.vue +44 -6
- package/components/InventoryPoDialog.vue +111 -0
- package/components/InventoryReportsTab.vue +23 -3
- package/components/InventoryRequestsTab.vue +27 -4
- package/components/InventoryStockTab.vue +3 -0
- package/composables/useInventory.ts +4 -1
- package/package.json +1 -1
- package/types/inventory.d.ts +27 -0
- package/utils/inventory-receiving.ts +90 -0
|
@@ -79,6 +79,7 @@ import useFile from "../composables/useFile";
|
|
|
79
79
|
import useUtils from "../composables/useUtils";
|
|
80
80
|
import { inventoryItemMeta } from "../utils/inventory-store-label";
|
|
81
81
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
82
|
+
import { receiptRefText } from "../utils/inventory-receiving";
|
|
82
83
|
|
|
83
84
|
const show = defineModel<boolean>({ default: false });
|
|
84
85
|
const props = defineProps({
|
|
@@ -107,7 +108,7 @@ const formatDate = (d: string) => standardFormatDate(d);
|
|
|
107
108
|
|
|
108
109
|
function describe(t: TInventoryTransaction) {
|
|
109
110
|
const parts: string[] = [];
|
|
110
|
-
if (t.type === "receive") parts.push(t.supplier || "", t
|
|
111
|
+
if (t.type === "receive") parts.push(t.supplier || "", receiptRefText(t));
|
|
111
112
|
const who = (t as any).holderLabel || t.holderName;
|
|
112
113
|
if (t.type === "issue") parts.push(`${t.qty} to ${who}`, t.reference || "");
|
|
113
114
|
if (t.type === "return") parts.push(`${t.qty} from ${who}`);
|
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
<v-card-text class="screen-modal__body">
|
|
11
11
|
<p class="text-body-2 text-medium-emphasis mb-4">{{ copy.help }}</p>
|
|
12
12
|
<v-form ref="form">
|
|
13
|
+
<template v-if="mode === 'receive'">
|
|
14
|
+
<InputLabel title="How did this stock come in?" required class="mb-1" />
|
|
15
|
+
<v-btn-toggle v-model="receivingType" mandatory divided variant="outlined" density="comfortable" class="mb-1 receiving-type">
|
|
16
|
+
<v-btn v-for="t in RECEIVING_TYPES" :key="t.value" :value="t.value" class="text-none">{{ t.title }}</v-btn>
|
|
17
|
+
</v-btn-toggle>
|
|
18
|
+
<p class="text-caption text-medium-emphasis mb-3">{{ receivingHelp }}</p>
|
|
19
|
+
</template>
|
|
13
20
|
<template v-if="mode === 'issue' || mode === 'return'">
|
|
14
21
|
<InputLabel :title="mode === 'issue' ? 'Give to' : 'Returned by'" required class="mb-1" />
|
|
15
22
|
<v-autocomplete
|
|
@@ -65,6 +72,18 @@
|
|
|
65
72
|
:rules="[qtyRule(line)]"
|
|
66
73
|
:suffix="unitOf(line.item)"
|
|
67
74
|
/>
|
|
75
|
+
<v-text-field
|
|
76
|
+
v-if="mode === 'receive' && receivingType !== 'CASH'"
|
|
77
|
+
v-model.number="line.ordered"
|
|
78
|
+
type="number"
|
|
79
|
+
min="1"
|
|
80
|
+
label="Ordered (optional)"
|
|
81
|
+
density="comfortable"
|
|
82
|
+
style="max-width: 150px"
|
|
83
|
+
:rules="[orderedRule]"
|
|
84
|
+
hint="Qty on the PO"
|
|
85
|
+
persistent-hint
|
|
86
|
+
/>
|
|
68
87
|
<v-text-field
|
|
69
88
|
v-if="mode === 'receive'"
|
|
70
89
|
v-model.number="line.unitCost"
|
|
@@ -89,18 +108,35 @@
|
|
|
89
108
|
<InputLabel title="Supplier" class="mb-1" />
|
|
90
109
|
<InventorySupplierField v-if="show" v-model="supplier" :store-id="store._id" />
|
|
91
110
|
</v-col>
|
|
92
|
-
<v-col cols="12" md="
|
|
93
|
-
<InputLabel title="PO number" class="mb-1" />
|
|
94
|
-
<v-text-field v-model="reference" density="comfortable" />
|
|
95
|
-
</v-col>
|
|
96
|
-
<v-col cols="12" md="3">
|
|
111
|
+
<v-col cols="12" md="6">
|
|
97
112
|
<InputLabel title="Date received" class="mb-1" />
|
|
98
113
|
<v-text-field v-model="date" type="date" :max="today" density="comfortable" />
|
|
99
114
|
</v-col>
|
|
115
|
+
<v-col v-if="receivingType !== 'CASH'" cols="12" md="4">
|
|
116
|
+
<InputLabel :title="receivingType === 'PO' ? 'PO number' : 'PO number (optional)'" :required="receivingType === 'PO'" class="mb-1" />
|
|
117
|
+
<v-text-field v-model="reference" density="comfortable" placeholder="e.g. PO-0412" />
|
|
118
|
+
</v-col>
|
|
119
|
+
<template v-if="receivingType === 'DO'">
|
|
120
|
+
<v-col cols="12" sm="6" md="4">
|
|
121
|
+
<InputLabel title="DO number" required class="mb-1" />
|
|
122
|
+
<v-text-field v-model="doNumber" density="comfortable" />
|
|
123
|
+
</v-col>
|
|
124
|
+
<v-col cols="12" sm="6" md="4">
|
|
125
|
+
<InputLabel title="DO date" required class="mb-1" />
|
|
126
|
+
<v-text-field v-model="doDate" type="date" :max="today" density="comfortable" />
|
|
127
|
+
</v-col>
|
|
128
|
+
</template>
|
|
129
|
+
<v-col v-if="receivingType === 'CASH'" cols="12" md="6">
|
|
130
|
+
<InputLabel title="Receipt number (optional)" class="mb-1" />
|
|
131
|
+
<v-text-field v-model="receiptNumber" density="comfortable" placeholder="From the shop receipt" />
|
|
132
|
+
</v-col>
|
|
100
133
|
<v-col cols="12">
|
|
101
|
-
<InputLabel title="Delivery
|
|
134
|
+
<InputLabel title="Delivery photos (optional)" class="mb-1" />
|
|
102
135
|
<InventoryPhotoInput v-model="photos" />
|
|
103
136
|
</v-col>
|
|
137
|
+
<v-col v-if="receivingError" cols="12">
|
|
138
|
+
<v-alert type="warning" variant="tonal" density="compact">{{ receivingError }}</v-alert>
|
|
139
|
+
</v-col>
|
|
104
140
|
</v-row>
|
|
105
141
|
|
|
106
142
|
<template v-if="mode === 'request' || mode === 'issue'">
|
|
@@ -134,9 +170,10 @@
|
|
|
134
170
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
135
171
|
import useUtils from "../composables/useUtils";
|
|
136
172
|
import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
|
|
173
|
+
import { RECEIVING_TYPES, receivingFields, receivingProblem, type TReceivingType } from "../utils/inventory-receiving";
|
|
137
174
|
|
|
138
175
|
type TMode = "receive" | "issue" | "request" | "return";
|
|
139
|
-
type TRow = { item: string; qty: number | null; unitCost?: number | null };
|
|
176
|
+
type TRow = { item: string; qty: number | null; unitCost?: number | null; ordered?: number | null };
|
|
140
177
|
|
|
141
178
|
const show = defineModel<boolean>({ default: false });
|
|
142
179
|
const props = defineProps({
|
|
@@ -169,7 +206,7 @@ const COPY: Record<TMode, { title: string; help: string; button: string; success
|
|
|
169
206
|
},
|
|
170
207
|
request: {
|
|
171
208
|
title: "Request Stock",
|
|
172
|
-
help: "Ask the store for the items you need. You will see them under My Stock once the store keeper approves.",
|
|
209
|
+
help: "Ask the store for the items you need — up to what the store has (shown under each item). You will see them under My Stock once the store keeper approves.",
|
|
173
210
|
button: "Send request",
|
|
174
211
|
success: "Request sent to the store.",
|
|
175
212
|
},
|
|
@@ -184,7 +221,7 @@ const copy = computed(() => COPY[props.mode]);
|
|
|
184
221
|
|
|
185
222
|
const form = ref();
|
|
186
223
|
const saving = ref(false);
|
|
187
|
-
const blank = (): TRow => ({ item: "", qty: null, unitCost: null });
|
|
224
|
+
const blank = (): TRow => ({ item: "", qty: null, unitCost: null, ordered: null });
|
|
188
225
|
const rows = ref<TRow[]>([blank()]);
|
|
189
226
|
const supplier = ref("");
|
|
190
227
|
const reference = ref("");
|
|
@@ -194,6 +231,19 @@ const today = new Date().toLocaleDateString("en-CA");
|
|
|
194
231
|
const date = ref(today);
|
|
195
232
|
const holder = ref("");
|
|
196
233
|
const site = ref(props.siteId);
|
|
234
|
+
// Receiving (client 2026-10-02): PO / DO / cash, each with its own paperwork.
|
|
235
|
+
const receivingType = ref<TReceivingType>("PO");
|
|
236
|
+
const doNumber = ref("");
|
|
237
|
+
const doDate = ref("");
|
|
238
|
+
const receiptNumber = ref("");
|
|
239
|
+
const tried = ref(false);
|
|
240
|
+
const receivingHelp = computed(() => RECEIVING_TYPES.find((t) => t.value === receivingType.value)?.help ?? "");
|
|
241
|
+
const hasOrdered = (r: TRow) => r.ordered !== null && r.ordered !== undefined && r.ordered !== ("" as any);
|
|
242
|
+
const receivingCheck = () =>
|
|
243
|
+
receivingProblem({ type: receivingType.value, poNumber: reference.value, doNumber: doNumber.value, doDate: doDate.value, anyOrdered: receivingType.value !== "CASH" && rows.value.some(hasOrdered) });
|
|
244
|
+
/** Shown once they have tried to save, so the form does not open with a warning. */
|
|
245
|
+
const receivingError = computed(() => (props.mode === "receive" && tried.value ? receivingCheck() : ""));
|
|
246
|
+
const orderedRule = (v: number | null | string) => v === null || v === "" || (Number.isInteger(Number(v)) && Number(v) >= 1) || "Whole number";
|
|
197
247
|
|
|
198
248
|
const itemChoices = ref<TInventoryItem[]>([]);
|
|
199
249
|
const choices = computed<any[]>(() =>
|
|
@@ -218,7 +268,9 @@ function qtyRule(line: TRow) {
|
|
|
218
268
|
if (props.mode === "receive") return true;
|
|
219
269
|
const available = availableOf(line.item);
|
|
220
270
|
const total = rows.value.filter((r) => r.item === line.item).reduce((s, r) => s + Number(r.qty || 0), 0);
|
|
221
|
-
|
|
271
|
+
// Requests too (client 2026-10-02): nobody can ask for more than the store has; the server refuses it as well.
|
|
272
|
+
if (available !== undefined && total > available)
|
|
273
|
+
return available > 0 ? `Only ${available} ${unitOf(line.item)} in the store — ask for ${available} or fewer` : "None left in the store";
|
|
222
274
|
return true;
|
|
223
275
|
};
|
|
224
276
|
}
|
|
@@ -266,16 +318,29 @@ const searchPeople = debounce(async (q: string) => {
|
|
|
266
318
|
}, 300);
|
|
267
319
|
|
|
268
320
|
async function submit() {
|
|
321
|
+
tried.value = true;
|
|
269
322
|
const { valid } = await form.value.validate();
|
|
270
|
-
if (!valid) return;
|
|
323
|
+
if (!valid || (props.mode === "receive" && receivingCheck())) return;
|
|
271
324
|
const lines = rows.value.filter((r) => r.item && r.qty).map((r) => ({ item: r.item, qty: Number(r.qty) }));
|
|
272
325
|
saving.value = true;
|
|
273
326
|
try {
|
|
274
327
|
if (props.mode === "receive") {
|
|
275
328
|
await api.receive(props.store._id, {
|
|
276
|
-
lines: rows.value.map((r) => ({
|
|
329
|
+
lines: rows.value.map((r) => ({
|
|
330
|
+
item: r.item,
|
|
331
|
+
qty: Number(r.qty),
|
|
332
|
+
...(r.unitCost !== null && r.unitCost !== undefined && r.unitCost !== ("" as any) ? { unitCost: Number(r.unitCost) } : {}),
|
|
333
|
+
...(receivingType.value !== "CASH" && hasOrdered(r) ? { poOrderedQty: Number(r.ordered) } : {}),
|
|
334
|
+
})),
|
|
277
335
|
supplier: supplier.value,
|
|
278
|
-
|
|
336
|
+
...receivingFields({
|
|
337
|
+
type: receivingType.value,
|
|
338
|
+
poNumber: reference.value,
|
|
339
|
+
doNumber: doNumber.value,
|
|
340
|
+
// The server refuses a future time: today's DO date is "now", an earlier one noon that day.
|
|
341
|
+
doDateIso: doDate.value ? (pickedDate(doDate.value) ?? new Date().toISOString()) : undefined,
|
|
342
|
+
receiptNumber: receiptNumber.value,
|
|
343
|
+
}),
|
|
279
344
|
date: pickedDate(date.value),
|
|
280
345
|
photos: photos.value,
|
|
281
346
|
remarks: remarks.value,
|
|
@@ -307,7 +372,20 @@ watch(show, (open) => {
|
|
|
307
372
|
date.value = today;
|
|
308
373
|
holder.value = "";
|
|
309
374
|
site.value = props.siteId;
|
|
375
|
+
receivingType.value = "PO";
|
|
376
|
+
doNumber.value = "";
|
|
377
|
+
doDate.value = "";
|
|
378
|
+
receiptNumber.value = "";
|
|
379
|
+
tried.value = false;
|
|
310
380
|
if (props.mode !== "return") loadItems();
|
|
311
381
|
if (props.mode === "issue") searchPeople("");
|
|
312
382
|
});
|
|
313
383
|
</script>
|
|
384
|
+
|
|
385
|
+
<style scoped>
|
|
386
|
+
/* Three choices side by side on a computer, wrapping on a phone. */
|
|
387
|
+
.receiving-type {
|
|
388
|
+
height: auto !important;
|
|
389
|
+
flex-wrap: wrap;
|
|
390
|
+
}
|
|
391
|
+
</style>
|
|
@@ -16,16 +16,18 @@
|
|
|
16
16
|
{{ label }}
|
|
17
17
|
</v-btn>
|
|
18
18
|
</div>
|
|
19
|
-
<input ref="picker" type="file" accept="
|
|
20
|
-
<div
|
|
19
|
+
<input ref="picker" type="file" :accept="PHOTO_ACCEPT" multiple hidden @change="upload" />
|
|
20
|
+
<div class="text-caption text-medium-emphasis mt-1">{{ hint }}</div>
|
|
21
|
+
<div v-if="error" class="text-caption text-error mt-1" role="alert">{{ error }}</div>
|
|
21
22
|
</div>
|
|
22
23
|
</template>
|
|
23
24
|
|
|
24
25
|
<script setup lang="ts">
|
|
25
26
|
import useFile from "../composables/useFile";
|
|
27
|
+
import { photoCheck, scaledSize, PHOTO_ACCEPT, SHRINK_STEPS, PHOTO_MAX_BYTES } from "../utils/inventory-receiving";
|
|
26
28
|
|
|
27
29
|
const model = defineModel<string[]>({ default: () => [] });
|
|
28
|
-
defineProps({
|
|
30
|
+
const props = defineProps({
|
|
29
31
|
label: { type: String, default: "Add photo" },
|
|
30
32
|
max: { type: Number, default: 5 },
|
|
31
33
|
});
|
|
@@ -35,20 +37,56 @@ const picker = ref<HTMLInputElement>();
|
|
|
35
37
|
const uploading = ref(false);
|
|
36
38
|
const error = ref("");
|
|
37
39
|
|
|
40
|
+
const hint = computed(() => `JPG, PNG or WebP, up to ${props.max} photo${props.max === 1 ? "" : "s"}, 5 MB each. Bigger photos are made smaller for you.`);
|
|
41
|
+
|
|
42
|
+
/** Redraw a too-big photo smaller as a JPEG (no library: the browser's canvas). Null when it cannot get under 5 MB. */
|
|
43
|
+
async function shrink(file: File): Promise<File | null> {
|
|
44
|
+
try {
|
|
45
|
+
const bitmap = await createImageBitmap(file);
|
|
46
|
+
for (const step of SHRINK_STEPS) {
|
|
47
|
+
const { width, height } = scaledSize(bitmap.width, bitmap.height, step.side);
|
|
48
|
+
const canvas = document.createElement("canvas");
|
|
49
|
+
Object.assign(canvas, { width, height });
|
|
50
|
+
const ctx = canvas.getContext("2d")!;
|
|
51
|
+
ctx.fillStyle = "#fff"; // a see-through PNG would turn black as a JPEG
|
|
52
|
+
ctx.fillRect(0, 0, width, height);
|
|
53
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
54
|
+
const blob = await new Promise<Blob | null>((done) => canvas.toBlob(done, "image/jpeg", step.quality));
|
|
55
|
+
if (blob && blob.size <= PHOTO_MAX_BYTES) return new File([blob], file.name.replace(/\.[^.]+$/, "") + ".jpg", { type: "image/jpeg" });
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
/* the browser could not read it */
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
38
63
|
async function upload(event: Event) {
|
|
39
64
|
const files = Array.from((event.target as HTMLInputElement).files ?? []);
|
|
40
65
|
(event.target as HTMLInputElement).value = "";
|
|
41
66
|
if (!files.length) return;
|
|
42
67
|
uploading.value = true;
|
|
43
|
-
|
|
68
|
+
const problems: string[] = [];
|
|
69
|
+
const room = props.max - model.value.length;
|
|
70
|
+
if (files.length > room) problems.push(`Only ${props.max} photo${props.max === 1 ? "" : "s"} can be added here, so the first ${room} were kept.`);
|
|
44
71
|
try {
|
|
45
|
-
for (const
|
|
72
|
+
for (const picked of files.slice(0, room)) {
|
|
73
|
+
const check = photoCheck(picked);
|
|
74
|
+
let file: File | null = picked;
|
|
75
|
+
if (check === "shrink") {
|
|
76
|
+
file = await shrink(picked);
|
|
77
|
+
if (!file) problems.push(`"${picked.name}" is too big (over 5 MB) and could not be made smaller. Please pick another photo.`);
|
|
78
|
+
} else if (check !== "ok") {
|
|
79
|
+
problems.push(check);
|
|
80
|
+
file = null;
|
|
81
|
+
}
|
|
82
|
+
if (!file) continue;
|
|
46
83
|
const res = await addFile(file);
|
|
47
84
|
if (res?.id) model.value = [...model.value, res.id as string];
|
|
48
85
|
}
|
|
49
86
|
} catch {
|
|
50
|
-
|
|
87
|
+
problems.push("A photo could not be uploaded. Please check your connection and try again.");
|
|
51
88
|
} finally {
|
|
89
|
+
error.value = problems.join(" ");
|
|
52
90
|
uploading.value = false;
|
|
53
91
|
}
|
|
54
92
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog v-model="show" max-width="720" scrollable>
|
|
3
|
+
<v-card class="screen-modal">
|
|
4
|
+
<v-card-title class="d-flex align-center">
|
|
5
|
+
Check a purchase order
|
|
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
|
+
|
|
10
|
+
<v-card-text class="screen-modal__body">
|
|
11
|
+
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
12
|
+
Type a PO number to see, for each item, how many were ordered, how many have arrived so far (over every delivery), and what is still to come.
|
|
13
|
+
</p>
|
|
14
|
+
<v-form class="d-flex ga-2 align-start mb-4" @submit.prevent="lookUp">
|
|
15
|
+
<v-text-field v-model="po" placeholder="e.g. PO-0412" density="comfortable" prepend-inner-icon="mdi-magnify" hide-details autofocus class="flex-grow-1" />
|
|
16
|
+
<v-btn type="submit" class="text-none screen-btn-primary" variant="flat" :loading="loading" :disabled="!po.trim()" style="height: 48px">Look up</v-btn>
|
|
17
|
+
</v-form>
|
|
18
|
+
|
|
19
|
+
<div v-if="error" class="text-body-2 text-error">{{ error }}</div>
|
|
20
|
+
<div v-else-if="result && !result.lines.length" class="text-center text-medium-emphasis py-6">
|
|
21
|
+
Nothing has been received against PO "{{ result.po }}" in this store yet. Check the number, or record the delivery with Receive Stock.
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<template v-else-if="result">
|
|
25
|
+
<div class="text-subtitle-2 mb-2">PO {{ result.po }} — {{ result.lines.length }} item{{ result.lines.length === 1 ? "" : "s" }}</div>
|
|
26
|
+
<AppCard v-for="l in result.lines" :key="l.item" class="pa-3 mb-2">
|
|
27
|
+
<div class="d-flex flex-wrap align-center ga-2">
|
|
28
|
+
<div class="font-weight-medium">{{ l.itemName }}</div>
|
|
29
|
+
<v-spacer />
|
|
30
|
+
<v-chip size="small" variant="tonal" :color="chip(l).color">{{ chip(l).label }}</v-chip>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="text-body-2 mt-1">{{ poLineText(l) }}</div>
|
|
33
|
+
<v-progress-linear
|
|
34
|
+
v-if="l.ordered"
|
|
35
|
+
:model-value="Math.min(100, (l.received / l.ordered) * 100)"
|
|
36
|
+
:color="chip(l).color"
|
|
37
|
+
height="6"
|
|
38
|
+
rounded
|
|
39
|
+
class="my-2"
|
|
40
|
+
:aria-label="`${l.received} of ${l.ordered} received`"
|
|
41
|
+
/>
|
|
42
|
+
<div v-for="d in l.deliveries" :key="d._id" class="text-caption text-medium-emphasis">
|
|
43
|
+
{{ standardFormatDate(d.date) }} · {{ d.qty }} {{ l.unit }} · {{ deliveryText(d) }}
|
|
44
|
+
</div>
|
|
45
|
+
</AppCard>
|
|
46
|
+
</template>
|
|
47
|
+
</v-card-text>
|
|
48
|
+
</v-card>
|
|
49
|
+
</v-dialog>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<script setup lang="ts">
|
|
53
|
+
import useInventory from "../composables/useInventory";
|
|
54
|
+
import useUtils from "../composables/useUtils";
|
|
55
|
+
import { poLineText, receivingLabel } from "../utils/inventory-receiving";
|
|
56
|
+
|
|
57
|
+
const show = defineModel<boolean>({ default: false });
|
|
58
|
+
const props = defineProps({
|
|
59
|
+
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
60
|
+
/** Open straight on this PO (a PO number clicked in a report). */
|
|
61
|
+
initialPo: { type: String, default: "" },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const api = useInventory();
|
|
65
|
+
const { standardFormatDate } = useUtils();
|
|
66
|
+
|
|
67
|
+
const po = ref("");
|
|
68
|
+
const loading = ref(false);
|
|
69
|
+
const error = ref("");
|
|
70
|
+
const result = ref<{ po: string; lines: TInventoryPoLine[] } | null>(null);
|
|
71
|
+
|
|
72
|
+
const chip = (l: TInventoryPoLine) =>
|
|
73
|
+
l.ordered === null
|
|
74
|
+
? { label: "Ordered qty not recorded", color: undefined }
|
|
75
|
+
: l.outstanding
|
|
76
|
+
? { label: `${l.outstanding} to come`, color: "warning" }
|
|
77
|
+
: { label: "Complete", color: "success" };
|
|
78
|
+
|
|
79
|
+
const deliveryText = (d: TInventoryPoLine["deliveries"][number]) =>
|
|
80
|
+
[
|
|
81
|
+
receivingLabel(d.receivingType),
|
|
82
|
+
d.doNumber ? `DO ${d.doNumber}${d.doDate ? ` (${standardFormatDate(d.doDate)})` : ""}` : "",
|
|
83
|
+
d.receiptNumber ? `Receipt ${d.receiptNumber}` : "",
|
|
84
|
+
d.supplier,
|
|
85
|
+
d.receivedBy ? `received by ${d.receivedBy}` : "",
|
|
86
|
+
]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(" · ");
|
|
89
|
+
|
|
90
|
+
async function lookUp() {
|
|
91
|
+
if (!po.value.trim()) return;
|
|
92
|
+
loading.value = true;
|
|
93
|
+
error.value = "";
|
|
94
|
+
try {
|
|
95
|
+
result.value = await api.getPurchaseOrder(props.store._id, po.value.trim());
|
|
96
|
+
} catch (e: any) {
|
|
97
|
+
result.value = null;
|
|
98
|
+
error.value = e?.data?.message || "Could not look up this PO. Please try again.";
|
|
99
|
+
} finally {
|
|
100
|
+
loading.value = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
watch(show, (open) => {
|
|
105
|
+
if (!open) return;
|
|
106
|
+
po.value = props.initialPo;
|
|
107
|
+
result.value = null;
|
|
108
|
+
error.value = "";
|
|
109
|
+
if (po.value) lookUp();
|
|
110
|
+
});
|
|
111
|
+
</script>
|
|
@@ -62,6 +62,9 @@
|
|
|
62
62
|
/>
|
|
63
63
|
<v-checkbox v-if="report === 'usage' && canCheck" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
|
|
64
64
|
<v-spacer />
|
|
65
|
+
<v-btn v-if="report === 'purchases'" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-clipboard-search-outline" @click="openPo('')">
|
|
66
|
+
Check a PO
|
|
67
|
+
</v-btn>
|
|
65
68
|
<v-btn v-if="ctl.fullReports || report === 'usage'" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-file-excel-outline" :loading="exporting" @click="exportExcel">
|
|
66
69
|
Export Excel
|
|
67
70
|
</v-btn>
|
|
@@ -169,6 +172,16 @@
|
|
|
169
172
|
{{ item.received }}<span v-if="item.receivedOpening" class="d-block text-caption text-medium-emphasis">incl. {{ item.receivedOpening }} opening stock</span>
|
|
170
173
|
</template>
|
|
171
174
|
<template #item.receivedCost="{ value }">{{ money(value) }}</template>
|
|
175
|
+
<template #item.receivingType="{ item }">{{ item.kind === "Opening stock" ? "Opening stock" : receivingLabel(item.receivingType) }}</template>
|
|
176
|
+
<template #item.reference="{ item }">
|
|
177
|
+
<a v-if="report === 'purchases' && item.reference" href="#" class="text-primary" title="See ordered vs received for this PO" @click.prevent="openPo(item.reference)">{{ item.reference }}</a>
|
|
178
|
+
<span v-else>{{ item.reference || "—" }}</span>
|
|
179
|
+
</template>
|
|
180
|
+
<template #item.doNumber="{ item }">
|
|
181
|
+
<span v-if="item.doNumber">{{ item.doNumber }}<span v-if="item.doDate" class="d-block text-caption text-medium-emphasis">{{ standardFormatDate(item.doDate) }}</span></span>
|
|
182
|
+
<span v-else>—</span>
|
|
183
|
+
</template>
|
|
184
|
+
<template #item.poOrderedQty="{ value }">{{ value ?? "—" }}</template>
|
|
172
185
|
<template #item.usedCost="{ value }">{{ money(value) }}</template>
|
|
173
186
|
<template #item.siteUsedCost="{ value }">{{ money(value) }}</template>
|
|
174
187
|
<!-- The supervisor checks usage against the technician's before/after photos (click to enlarge, with the time taken). -->
|
|
@@ -206,6 +219,7 @@
|
|
|
206
219
|
</template>
|
|
207
220
|
</v-data-table>
|
|
208
221
|
<InventoryHistoryDialog v-model="drill.show" :store="store" :holder="drill.holder" :item="drill.item" :title="drill.title" />
|
|
222
|
+
<InventoryPoDialog v-model="po.show" :store="store" :initial-po="po.number" />
|
|
209
223
|
</div>
|
|
210
224
|
</template>
|
|
211
225
|
|
|
@@ -216,6 +230,7 @@ import useUtils from "../composables/useUtils";
|
|
|
216
230
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
217
231
|
import { USED_BY_FILTERS } from "../utils/inventory-used-by";
|
|
218
232
|
import { USAGE_FLAG_WORDS, USAGE_STATUS_CHOICES, usagePathText, usageQuery, usageTotals } from "../utils/inventory-usage";
|
|
233
|
+
import { receivingLabel } from "../utils/inventory-receiving";
|
|
219
234
|
|
|
220
235
|
const props = defineProps({
|
|
221
236
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -253,6 +268,8 @@ const staleDays = ref(3);
|
|
|
253
268
|
const flagWords = computed(() => ({ ...USAGE_FLAG_WORDS, old_draft: `Draft older than ${staleDays.value} days` }) as Record<string, string>);
|
|
254
269
|
const flagChoices = computed(() => [{ value: "", title: "Everything" }, ...Object.entries(flagWords.value).map(([value, title]) => ({ value, title }))]);
|
|
255
270
|
const drill = reactive({ show: false, holder: "", item: "", title: "" });
|
|
271
|
+
const po = reactive({ show: false, number: "" });
|
|
272
|
+
const openPo = (number: string) => Object.assign(po, { show: true, number });
|
|
256
273
|
function openPerson(r: any) {
|
|
257
274
|
Object.assign(drill, { show: true, holder: String(r.holder ?? ""), item: "", title: r.holderLabel || r.technician });
|
|
258
275
|
}
|
|
@@ -278,7 +295,7 @@ const help: Record<string, string> = {
|
|
|
278
295
|
movement: "For each item: stock at the start of the month, what came in (deliveries and opening stock), what was issued and returned, count differences, and stock at the end.",
|
|
279
296
|
usage: "Every usage of this store's items, by anyone: who (company and service), where, which work order, before and after photos with their times, and how the stock reached them. Click a person or an item to see its full history. Use it for tenant billing; tick records and mark them checked once verified.",
|
|
280
297
|
technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
|
|
281
|
-
purchases: "Every delivery received, and opening stock entered with new items
|
|
298
|
+
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.",
|
|
282
299
|
handovers: "Stock passed from one person to a colleague: who gave it, who received it, and whether they confirmed.",
|
|
283
300
|
};
|
|
284
301
|
|
|
@@ -391,12 +408,15 @@ const headers: Record<string, any[]> = {
|
|
|
391
408
|
],
|
|
392
409
|
purchases: [
|
|
393
410
|
{ title: "Date", key: "date" },
|
|
394
|
-
{ title: "Type", key: "
|
|
411
|
+
{ title: "Type", key: "receivingType" },
|
|
395
412
|
{ title: "Supplier", key: "supplier" },
|
|
396
413
|
{ title: "PO No.", key: "reference" },
|
|
414
|
+
{ title: "DO No. / date", key: "doNumber" },
|
|
415
|
+
{ title: "Receipt No.", key: "receiptNumber" },
|
|
397
416
|
{ title: "Item", key: "itemName" },
|
|
398
417
|
{ title: "Unit", key: "unit" },
|
|
399
|
-
{ title: "
|
|
418
|
+
{ title: "Ordered", key: "poOrderedQty" },
|
|
419
|
+
{ title: "Received", key: "qty" },
|
|
400
420
|
{ title: "Unit price", key: "unitCost" },
|
|
401
421
|
{ title: "Total", key: "totalCost" },
|
|
402
422
|
{ title: "Received by", key: "receivedBy" },
|
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
<v-btn value="rejected" class="text-none">Rejected</v-btn>
|
|
8
8
|
<v-btn value="" class="text-none">All</v-btn>
|
|
9
9
|
</v-btn-toggle>
|
|
10
|
+
<v-text-field
|
|
11
|
+
v-model="search"
|
|
12
|
+
placeholder="Search item, person, reference or remarks"
|
|
13
|
+
prepend-inner-icon="mdi-magnify"
|
|
14
|
+
density="compact"
|
|
15
|
+
hide-details
|
|
16
|
+
clearable
|
|
17
|
+
class="request-search"
|
|
18
|
+
/>
|
|
10
19
|
<v-spacer />
|
|
11
20
|
<v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">
|
|
12
21
|
Request Stock
|
|
@@ -15,7 +24,7 @@
|
|
|
15
24
|
|
|
16
25
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
17
26
|
<div v-else-if="!requests.length" class="text-center text-medium-emphasis py-10">
|
|
18
|
-
{{ 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." }}
|
|
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." }}
|
|
19
28
|
</div>
|
|
20
29
|
|
|
21
30
|
<AppCard v-for="r in requests" :key="r._id" class="pa-4 mb-3">
|
|
@@ -63,11 +72,11 @@
|
|
|
63
72
|
v-model.number="l.give"
|
|
64
73
|
type="number"
|
|
65
74
|
min="0"
|
|
66
|
-
:max="l.qty"
|
|
75
|
+
:max="Math.min(l.qty, l.onHand ?? l.qty)"
|
|
67
76
|
label="Give"
|
|
68
77
|
density="comfortable"
|
|
69
78
|
style="max-width: 120px"
|
|
70
|
-
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= l.qty) || `0 to ${l.qty}`]"
|
|
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} in store`]"
|
|
71
80
|
/>
|
|
72
81
|
</div>
|
|
73
82
|
<InputLabel title="Remarks" class="mb-1 mt-2" />
|
|
@@ -107,6 +116,7 @@ const notify = inject<(text: string, color?: string) => void>("inventoryNotify",
|
|
|
107
116
|
const chip = REQUEST_STATUS;
|
|
108
117
|
|
|
109
118
|
const status = ref("pending");
|
|
119
|
+
const search = ref("");
|
|
110
120
|
const requests = ref<TInventoryRequest[]>([]);
|
|
111
121
|
const page = ref(1);
|
|
112
122
|
const pages = ref(0);
|
|
@@ -119,7 +129,7 @@ const isMine = (r: TInventoryRequest) => String(r.requestedBy) === String(curren
|
|
|
119
129
|
async function load(p = 1) {
|
|
120
130
|
loading.value = true;
|
|
121
131
|
try {
|
|
122
|
-
const res = await api.getRequests(props.store._id, { page: p, limit: 20, status: status.value || undefined });
|
|
132
|
+
const res = await api.getRequests(props.store._id, { page: p, limit: 20, status: status.value || undefined, search: search.value?.trim() || undefined });
|
|
123
133
|
requests.value = p === 1 ? res.items : [...requests.value, ...res.items];
|
|
124
134
|
page.value = p;
|
|
125
135
|
pages.value = res.pages;
|
|
@@ -214,6 +224,19 @@ async function reject() {
|
|
|
214
224
|
}
|
|
215
225
|
|
|
216
226
|
watch(status, () => load(1));
|
|
227
|
+
// Typed search waits until the typing stops.
|
|
228
|
+
let typing: ReturnType<typeof setTimeout> | undefined;
|
|
229
|
+
watch(search, () => {
|
|
230
|
+
clearTimeout(typing);
|
|
231
|
+
typing = setTimeout(() => load(1), 400);
|
|
232
|
+
});
|
|
217
233
|
watch(() => props.store._id, () => load(1));
|
|
218
234
|
onMounted(() => load(1));
|
|
219
235
|
</script>
|
|
236
|
+
|
|
237
|
+
<style scoped>
|
|
238
|
+
.request-search {
|
|
239
|
+
min-width: 220px;
|
|
240
|
+
max-width: 340px;
|
|
241
|
+
}
|
|
242
|
+
</style>
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
<v-list density="compact">
|
|
18
18
|
<v-list-item prepend-icon="mdi-truck-delivery-outline" title="Record a delivery" @click="open('receive')" />
|
|
19
19
|
<v-list-item prepend-icon="mdi-file-excel-outline" title="Import deliveries from Excel" @click="showReceiveImport = true" />
|
|
20
|
+
<v-list-item prepend-icon="mdi-clipboard-search-outline" title="Check a purchase order (PO)" @click="showPo = true" />
|
|
20
21
|
</v-list>
|
|
21
22
|
</v-menu>
|
|
22
23
|
<v-btn v-if="ctl.issue && !storeEmpty" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-hand-extended-outline" @click="open('issue')">
|
|
@@ -117,6 +118,7 @@
|
|
|
117
118
|
<InventoryItemDialog v-model="itemDialog.show" :store="store" :item="itemDialog.item" @done="done" />
|
|
118
119
|
<InventoryImportDialog v-model="showImport" :store="store" @done="done" />
|
|
119
120
|
<InventoryImportDialog v-model="showReceiveImport" :store="store" kind="receipts" @done="done" />
|
|
121
|
+
<InventoryPoDialog v-model="showPo" :store="store" />
|
|
120
122
|
<InventoryLegacyImportDialog v-model="showLegacy" :store="store" @done="done" />
|
|
121
123
|
<v-dialog v-model="bulk.show" max-width="480">
|
|
122
124
|
<v-card class="screen-modal">
|
|
@@ -256,6 +258,7 @@ function openItem(item: TInventoryItem) {
|
|
|
256
258
|
|
|
257
259
|
const showImport = ref(false);
|
|
258
260
|
const showReceiveImport = ref(false);
|
|
261
|
+
const showPo = ref(false);
|
|
259
262
|
const showLegacy = ref(false);
|
|
260
263
|
// Offer the old Equipment copy only where this store's sites have old records not yet copied.
|
|
261
264
|
const legacyToCopy = ref(false);
|
|
@@ -53,7 +53,7 @@ export default function useInventory() {
|
|
|
53
53
|
discardUsageDraft: (id: string) => api(`/usage-drafts/${id}/discard`, { method: "POST" }),
|
|
54
54
|
submitUsageDraft: (id: string, body: { photosAfter?: string[]; date?: string }) => api<TInventoryTransaction>(`/usage-drafts/${id}/submit`, { method: "POST", body }),
|
|
55
55
|
|
|
56
|
-
getRequests: (store: string, query: { page?: number; limit?: number; status?: string; mine?: boolean }) =>
|
|
56
|
+
getRequests: (store: string, query: { page?: number; limit?: number; status?: string; mine?: boolean; search?: string }) =>
|
|
57
57
|
api<{ items: TInventoryRequest[]; total: number; pendingCount: number; page: number; pages: number }>(`/stores/${store}/requests`, { query }),
|
|
58
58
|
createRequest: (store: string, body: Record<string, any>) => api(`/stores/${store}/requests`, { method: "POST", body }),
|
|
59
59
|
approveRequest: (id: string, body: { lines?: TInventoryLine[]; remarks?: string }) =>
|
|
@@ -84,6 +84,9 @@ export default function useInventory() {
|
|
|
84
84
|
|
|
85
85
|
getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers", query: TInventoryPeriod & Record<string, any>) =>
|
|
86
86
|
api<{ rows: any[]; totalQty?: number; totalCost?: number; scope?: "all" | "own_company"; staleAfterDays?: number }>(`/stores/${store}/reports/${report}`, { query }),
|
|
87
|
+
/** One PO across all its deliveries: ordered vs received per item (report viewers and store keepers). */
|
|
88
|
+
getPurchaseOrder: (store: string, po: string) =>
|
|
89
|
+
api<{ po: string; lines: TInventoryPoLine[] }>(`/stores/${store}/reports/purchase-order`, { query: { po } }),
|
|
87
90
|
exportReport: (store: string, report: string, query: TInventoryPeriod & Record<string, any>) =>
|
|
88
91
|
api<Blob>(`/stores/${store}/reports/${report}/export`, { query, responseType: "blob" }),
|
|
89
92
|
checkUsage: (store: string, ids: string[], checked: boolean) =>
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.90.2-staging.
|
|
5
|
+
"version": "4.90.2-staging.491",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
package/types/inventory.d.ts
CHANGED
|
@@ -105,6 +105,12 @@ declare type TInventoryTransaction = {
|
|
|
105
105
|
location?: { type: "common_area" | "tenant_unit"; area?: string; unit?: string; unitName?: string; tenantName?: string };
|
|
106
106
|
reference?: string;
|
|
107
107
|
supplier?: string;
|
|
108
|
+
/** Receiving (2026-10): missing on older rows = PO. */
|
|
109
|
+
receivingType?: "PO" | "DO" | "CASH";
|
|
110
|
+
doNumber?: string;
|
|
111
|
+
doDate?: string | null;
|
|
112
|
+
receiptNumber?: string;
|
|
113
|
+
poOrderedQty?: number | null;
|
|
108
114
|
unitCost: number;
|
|
109
115
|
totalCost: number;
|
|
110
116
|
photos?: { before: string[]; after: string[]; other: string[] };
|
|
@@ -164,3 +170,24 @@ declare type TInventoryHandover = {
|
|
|
164
170
|
createdAt: string;
|
|
165
171
|
decidedAt?: string;
|
|
166
172
|
};
|
|
173
|
+
|
|
174
|
+
/** GET /stores/:id/reports/purchase-order: one item of a PO, ordered (null = not recorded) vs received. */
|
|
175
|
+
declare type TInventoryPoLine = {
|
|
176
|
+
item: string;
|
|
177
|
+
itemName: string;
|
|
178
|
+
unit: string;
|
|
179
|
+
ordered: number | null;
|
|
180
|
+
received: number;
|
|
181
|
+
outstanding: number | null;
|
|
182
|
+
deliveries: {
|
|
183
|
+
_id: string;
|
|
184
|
+
date: string;
|
|
185
|
+
qty: number;
|
|
186
|
+
receivingType: "PO" | "DO" | "CASH";
|
|
187
|
+
doNumber: string;
|
|
188
|
+
doDate: string | null;
|
|
189
|
+
receiptNumber: string;
|
|
190
|
+
supplier: string;
|
|
191
|
+
receivedBy: string;
|
|
192
|
+
}[];
|
|
193
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receiving (client meeting 2026-10-02): how a delivery came in, and the limits on its photos.
|
|
3
|
+
* The server (API-core `receiveSchema`) is the gate; these keep the form from sending what it refuses.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type TReceivingType = "PO" | "DO" | "CASH";
|
|
7
|
+
|
|
8
|
+
export const RECEIVING_TYPES: { value: TReceivingType; title: string; help: string }[] = [
|
|
9
|
+
{ value: "PO", title: "Purchase order (PO)", help: "Goods ordered with a PO. Enter the PO number so you can track what has arrived against what was ordered." },
|
|
10
|
+
{ value: "DO", title: "Delivery order (DO)", help: "Goods came with a supplier's delivery order. The DO number and DO date are needed; add the PO number too if there is one." },
|
|
11
|
+
{ value: "CASH", title: "Cash purchase", help: "Bought and paid on the spot. Enter the shop receipt number if there is one." },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** Old rows carry no type: they were PO receipts. */
|
|
15
|
+
export const receivingLabel = (t?: string | null) => ({ PO: "PO", DO: "DO", CASH: "Cash" })[String(t || "PO").toUpperCase() as TReceivingType] ?? "PO";
|
|
16
|
+
|
|
17
|
+
/** "PO PO-0412 · DO D-77 (1 Oct)" — the paperwork a delivery came with, for history lines. */
|
|
18
|
+
export function receiptRefText(
|
|
19
|
+
t: { receivingType?: string | null; reference?: string; doNumber?: string; receiptNumber?: string },
|
|
20
|
+
) {
|
|
21
|
+
return [
|
|
22
|
+
t.reference ? `PO ${t.reference}` : "",
|
|
23
|
+
t.doNumber ? `DO ${t.doNumber}` : "",
|
|
24
|
+
t.receiptNumber ? `Receipt ${t.receiptNumber}` : "",
|
|
25
|
+
String(t.receivingType || "").toUpperCase() === "CASH" && !t.receiptNumber ? "Cash purchase" : "",
|
|
26
|
+
]
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join(" · ");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** "60 of 100 received · 40 still to come" / "60 received (ordered quantity not recorded)". */
|
|
32
|
+
export function poLineText(l: { ordered: number | null; received: number; outstanding: number | null; unit?: string }) {
|
|
33
|
+
const u = l.unit ? ` ${l.unit}` : "";
|
|
34
|
+
if (l.ordered === null || l.ordered === undefined) return `${l.received}${u} received (ordered quantity not recorded)`;
|
|
35
|
+
const rest = l.outstanding ? `${l.outstanding}${u} still to come` : l.received > l.ordered ? `${l.received - l.ordered}${u} more than ordered` : "all received";
|
|
36
|
+
return `${l.received} of ${l.ordered}${u} received · ${rest}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What the form must still have before a receipt of this type can be sent ("" = ready). */
|
|
40
|
+
export function receivingProblem(f: { type: TReceivingType; poNumber: string; doNumber: string; doDate: string; anyOrdered: boolean }) {
|
|
41
|
+
if (f.type === "PO" && !f.poNumber.trim()) return "Enter the PO number.";
|
|
42
|
+
if (f.type === "DO" && !f.doNumber.trim()) return "Enter the DO number for a delivery order (DO) receipt.";
|
|
43
|
+
if (f.type === "DO" && !f.doDate) return "Enter the DO date.";
|
|
44
|
+
if (f.anyOrdered && !f.poNumber.trim()) return "Enter the PO number to record the quantity ordered.";
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The type-specific part of the receive body: only the fields that belong to the chosen type. */
|
|
49
|
+
export function receivingFields(f: { type: TReceivingType; poNumber: string; doNumber: string; doDateIso?: string; receiptNumber: string }) {
|
|
50
|
+
return {
|
|
51
|
+
receivingType: f.type,
|
|
52
|
+
reference: f.type === "CASH" ? "" : f.poNumber.trim(),
|
|
53
|
+
...(f.type === "DO" ? { doNumber: f.doNumber.trim(), ...(f.doDateIso ? { doDate: f.doDateIso } : {}) } : {}),
|
|
54
|
+
...(f.type === "CASH" && f.receiptNumber.trim() ? { receiptNumber: f.receiptNumber.trim() } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Photos: what the server accepts (JPEG / PNG / WebP / iPhone HEIC), 5 MB each. */
|
|
59
|
+
export const PHOTO_MAX_BYTES = 5 * 1024 * 1024;
|
|
60
|
+
export const PHOTO_ACCEPT = "image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.heif";
|
|
61
|
+
const PHOTO_EXT = /\.(jpe?g|png|webp|heic|heif)$/i;
|
|
62
|
+
/** Types a browser canvas can redraw smaller (not HEIC). */
|
|
63
|
+
const SHRINKABLE = ["image/jpeg", "image/png", "image/webp"];
|
|
64
|
+
|
|
65
|
+
const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What to do with a picked file: "ok", "shrink" (too big but the browser can make it smaller),
|
|
69
|
+
* or a sentence telling the person why it cannot be used.
|
|
70
|
+
*/
|
|
71
|
+
export function photoCheck(file: { name: string; type: string; size: number }): "ok" | "shrink" | string {
|
|
72
|
+
const typeOk = /^image\/(jpeg|png|webp|heic|heif)$/i.test(file.type) || (!file.type && PHOTO_EXT.test(file.name));
|
|
73
|
+
if (!typeOk) return `"${file.name}" is not a photo we can use. Please pick a JPG, PNG or WebP picture.`;
|
|
74
|
+
if (file.size <= PHOTO_MAX_BYTES) return "ok";
|
|
75
|
+
if (SHRINKABLE.includes(file.type.toLowerCase())) return "shrink";
|
|
76
|
+
return `"${file.name}" is ${mb(file.size)} MB. Each photo must be 5 MB or smaller — please take it at a lower size or pick another.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Long side of a shrunk photo, and JPEG qualities to try in turn until it fits. */
|
|
80
|
+
export const SHRINK_STEPS = [
|
|
81
|
+
{ side: 2560, quality: 0.85 },
|
|
82
|
+
{ side: 1920, quality: 0.8 },
|
|
83
|
+
{ side: 1600, quality: 0.7 },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/** Size of a photo scaled so its longer side is at most `side` (never enlarged). */
|
|
87
|
+
export function scaledSize(width: number, height: number, side: number) {
|
|
88
|
+
const k = Math.min(1, side / Math.max(width, height));
|
|
89
|
+
return { width: Math.round(width * k), height: Math.round(height * k) };
|
|
90
|
+
}
|