@7365admin1/layer-common 4.90.2-staging.490 → 4.90.2-staging.492
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/components/VehicleQrStickerDialog.vue +83 -16
- 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
- package/utils/vehicle-qr-design.ts +268 -0
- package/utils/vehicle-qr-sticker.ts +26 -15
|
@@ -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);
|
|
@@ -76,7 +76,34 @@
|
|
|
76
76
|
Expired, blocklisted and inactive vehicles start unticked. Each sticker has a cut-off strip under the
|
|
77
77
|
dashed line saying which unit and owner it is for - cut it off before pasting the sticker on the car.
|
|
78
78
|
</p>
|
|
79
|
-
<
|
|
79
|
+
<div class="text-subtitle-2 mt-3 mb-1">Sticker design</div>
|
|
80
|
+
<p class="text-caption text-medium-emphasis mb-2">
|
|
81
|
+
Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365, and the
|
|
82
|
+
QR code stays the same size. Your choice is remembered on this computer.
|
|
83
|
+
</p>
|
|
84
|
+
<v-row dense data-test="vehicle-qr-design">
|
|
85
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
86
|
+
<v-select v-model="design.dots" :items="DOT_OPTIONS" label="QR dots" density="compact" hide-details />
|
|
87
|
+
</v-col>
|
|
88
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
89
|
+
<v-select v-model="design.eyes" :items="EYE_OPTIONS" label="QR corners" density="compact" hide-details />
|
|
90
|
+
</v-col>
|
|
91
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
92
|
+
<v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
|
|
93
|
+
hide-details />
|
|
94
|
+
</v-col>
|
|
95
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
96
|
+
<v-select v-model="design.frame" :items="FRAME_OPTIONS" label="Sticker shape" density="compact" hide-details />
|
|
97
|
+
</v-col>
|
|
98
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
99
|
+
<v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
|
|
100
|
+
hide-details />
|
|
101
|
+
</v-col>
|
|
102
|
+
<v-col cols="12" sm="6" md="4" lg="auto" class="d-flex align-center">
|
|
103
|
+
<v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
|
|
104
|
+
</v-col>
|
|
105
|
+
</v-row>
|
|
106
|
+
<iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
|
|
80
107
|
</template>
|
|
81
108
|
</v-card-text>
|
|
82
109
|
|
|
@@ -114,6 +141,18 @@ import {
|
|
|
114
141
|
type StickerItem,
|
|
115
142
|
type StickerPlateRow,
|
|
116
143
|
} from "../utils/vehicle-qr-sticker";
|
|
144
|
+
import {
|
|
145
|
+
DEFAULT_STICKER_DESIGN,
|
|
146
|
+
DOT_OPTIONS,
|
|
147
|
+
EYE_OPTIONS,
|
|
148
|
+
FRAME_OPTIONS,
|
|
149
|
+
LABEL_OPTIONS,
|
|
150
|
+
QR_ECC,
|
|
151
|
+
STICKER_THEMES,
|
|
152
|
+
normalizeDesign,
|
|
153
|
+
stickerFaceSvg,
|
|
154
|
+
type StickerDesign,
|
|
155
|
+
} from "../utils/vehicle-qr-design";
|
|
117
156
|
|
|
118
157
|
const props = defineProps({
|
|
119
158
|
modelValue: { type: Boolean, default: false },
|
|
@@ -143,6 +182,29 @@ const error = ref("");
|
|
|
143
182
|
const docHtml = ref("");
|
|
144
183
|
const frame = ref<HTMLIFrameElement | null>(null);
|
|
145
184
|
const fromReissue = computed(() => props.vehicleIds.length > 0);
|
|
185
|
+
const siteName = ref("");
|
|
186
|
+
|
|
187
|
+
// The last design chosen in this browser. Storage can be blocked (private
|
|
188
|
+
// window): then it is simply the standard look each time.
|
|
189
|
+
const DESIGN_KEY = "vehicleQrStickerDesign";
|
|
190
|
+
function loadDesign(): StickerDesign {
|
|
191
|
+
try {
|
|
192
|
+
return normalizeDesign(JSON.parse(localStorage.getItem(DESIGN_KEY) || "null"));
|
|
193
|
+
} catch {
|
|
194
|
+
return { ...DEFAULT_STICKER_DESIGN };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const design = reactive<StickerDesign>(loadDesign());
|
|
198
|
+
watch(design, (d) => {
|
|
199
|
+
try {
|
|
200
|
+
localStorage.setItem(DESIGN_KEY, JSON.stringify(d));
|
|
201
|
+
} catch {
|
|
202
|
+
/* not saved - nothing else to do */
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
function resetDesign() {
|
|
206
|
+
Object.assign(design, DEFAULT_STICKER_DESIGN);
|
|
207
|
+
}
|
|
146
208
|
|
|
147
209
|
const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
|
|
148
210
|
const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
|
|
@@ -203,6 +265,7 @@ async function goReview() {
|
|
|
203
265
|
const { ids, dropped: over } = idsForRequest(selected.value);
|
|
204
266
|
const res = await getVehicleQrStickers(props.site, ids);
|
|
205
267
|
items.value = (res?.items ?? []) as StickerItem[];
|
|
268
|
+
siteName.value = String((res as any)?.site?.name ?? "");
|
|
206
269
|
missing.value = res?.missing ?? [];
|
|
207
270
|
dropped.value = over;
|
|
208
271
|
ticked.value = defaultStickerIds(items.value);
|
|
@@ -214,21 +277,25 @@ async function goReview() {
|
|
|
214
277
|
}
|
|
215
278
|
}
|
|
216
279
|
|
|
217
|
-
// The same document is the preview and the print job.
|
|
218
|
-
// 4-module quiet zone
|
|
219
|
-
|
|
220
|
-
watch(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
280
|
+
// The same document is the preview and the print job. The QR content, error
|
|
281
|
+
// correction (Q), 4-module quiet zone and 32 mm size are the same on every
|
|
282
|
+
// design; only the look of the dots, corners, colours and frame changes.
|
|
283
|
+
watch(
|
|
284
|
+
[items, ticked, design, siteName],
|
|
285
|
+
() => {
|
|
286
|
+
const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
|
|
287
|
+
if (!chosen.length) {
|
|
288
|
+
docHtml.value = "";
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const look = normalizeDesign(design);
|
|
292
|
+
const faces = chosen.map((i) =>
|
|
293
|
+
stickerFaceSvg(QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules, look),
|
|
294
|
+
);
|
|
295
|
+
docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value });
|
|
296
|
+
},
|
|
297
|
+
{ deep: true },
|
|
298
|
+
);
|
|
232
299
|
|
|
233
300
|
function print() {
|
|
234
301
|
const win = frame.value?.contentWindow;
|
|
@@ -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.492",
|
|
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
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vehicle QR sticker designs - the sticker face (frame + QR) as plain shapes.
|
|
3
|
+
*
|
|
4
|
+
* The QR library only gives us the dark/light module grid; we draw it here so
|
|
5
|
+
* the look can change while the code itself does not: same payload, same error
|
|
6
|
+
* correction (Q), a 4-module quiet zone and a 32 mm QR on every design. The
|
|
7
|
+
* shapes are kept as data (`stickerFacePaints`) so the test can draw them to
|
|
8
|
+
* pixels and decode them - every design must still scan. Colours are dark ink
|
|
9
|
+
* on light paper only; the test checks each theme's contrast.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type QrMatrix = { size: number; get(row: number, col: number): number | boolean };
|
|
13
|
+
|
|
14
|
+
export type DotShape = "square" | "rounded" | "dots";
|
|
15
|
+
export type EyeShape = "square" | "rounded" | "circle";
|
|
16
|
+
export type FrameShape = "rounded" | "square" | "soft" | "arch" | "ticket";
|
|
17
|
+
export type LabelChoice = "app" | "verify" | "site" | "none";
|
|
18
|
+
|
|
19
|
+
export type StickerTheme = { id: string; title: string; ink: string; eye: string; paper: string };
|
|
20
|
+
|
|
21
|
+
export type StickerDesign = { dots: DotShape; eyes: EyeShape; theme: string; frame: FrameShape; label: LabelChoice };
|
|
22
|
+
|
|
23
|
+
export const DOT_OPTIONS: { value: DotShape; title: string }[] = [
|
|
24
|
+
{ value: "square", title: "Squares (classic)" },
|
|
25
|
+
{ value: "rounded", title: "Rounded squares" },
|
|
26
|
+
{ value: "dots", title: "Round dots" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const EYE_OPTIONS: { value: EyeShape; title: string }[] = [
|
|
30
|
+
{ value: "square", title: "Square" },
|
|
31
|
+
{ value: "rounded", title: "Rounded" },
|
|
32
|
+
{ value: "circle", title: "Circle" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const FRAME_OPTIONS: { value: FrameShape; title: string }[] = [
|
|
36
|
+
{ value: "rounded", title: "Rounded corners" },
|
|
37
|
+
{ value: "square", title: "Square corners" },
|
|
38
|
+
{ value: "soft", title: "Extra round" },
|
|
39
|
+
{ value: "arch", title: "Badge (round top)" },
|
|
40
|
+
{ value: "ticket", title: "Ticket (cut corners)" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const LABEL_OPTIONS: { value: LabelChoice; title: string }[] = [
|
|
44
|
+
{ value: "app", title: "Scan with iSecure365 / iMA365" },
|
|
45
|
+
{ value: "verify", title: "Scan to verify vehicle" },
|
|
46
|
+
{ value: "site", title: "Site name" },
|
|
47
|
+
{ value: "none", title: "No extra words" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Dark ink on light paper only - never inverted, never pale. Checked by test.
|
|
51
|
+
export const STICKER_THEMES: StickerTheme[] = [
|
|
52
|
+
{ id: "black", title: "Black on white", ink: "#000000", eye: "#000000", paper: "#ffffff" },
|
|
53
|
+
{ id: "navy", title: "Navy blue", ink: "#0b2545", eye: "#0b2545", paper: "#ffffff" },
|
|
54
|
+
{ id: "navy-red", title: "Navy with red corners", ink: "#0b2545", eye: "#8b1a1a", paper: "#ffffff" },
|
|
55
|
+
{ id: "green", title: "Dark green on mint", ink: "#14462b", eye: "#14462b", paper: "#f2faf4" },
|
|
56
|
+
{ id: "maroon", title: "Maroon on cream", ink: "#5c0f1d", eye: "#5c0f1d", paper: "#fff8ee" },
|
|
57
|
+
{ id: "purple", title: "Deep purple", ink: "#33105c", eye: "#33105c", paper: "#ffffff" },
|
|
58
|
+
{ id: "charcoal", title: "Charcoal on cream", ink: "#222222", eye: "#222222", paper: "#fffbea" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_STICKER_DESIGN: StickerDesign = {
|
|
62
|
+
dots: "square",
|
|
63
|
+
eyes: "square",
|
|
64
|
+
theme: "black",
|
|
65
|
+
frame: "rounded",
|
|
66
|
+
label: "app",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A saved or typed-in design with anything unknown replaced by the default. */
|
|
70
|
+
export function normalizeDesign(raw: unknown): StickerDesign {
|
|
71
|
+
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
|
72
|
+
const pick = <T extends string>(v: unknown, list: { value: T }[], d: T): T =>
|
|
73
|
+
list.some((o) => o.value === v) ? (v as T) : d;
|
|
74
|
+
const d = DEFAULT_STICKER_DESIGN;
|
|
75
|
+
return {
|
|
76
|
+
dots: pick(r.dots, DOT_OPTIONS, d.dots),
|
|
77
|
+
eyes: pick(r.eyes, EYE_OPTIONS, d.eyes),
|
|
78
|
+
theme: STICKER_THEMES.some((t) => t.id === r.theme) ? String(r.theme) : d.theme,
|
|
79
|
+
frame: pick(r.frame, FRAME_OPTIONS, d.frame),
|
|
80
|
+
label: pick(r.label, LABEL_OPTIONS, d.label),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function stickerTheme(id: string): StickerTheme {
|
|
85
|
+
return STICKER_THEMES.find((t) => t.id === id) ?? STICKER_THEMES[0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** WCAG relative luminance of "#rrggbb". */
|
|
89
|
+
export function stickerLuminance(hex: string): number {
|
|
90
|
+
const n = parseInt(hex.slice(1), 16);
|
|
91
|
+
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
92
|
+
return 0.2126 * lin(((n >> 16) & 255) / 255) + 0.7152 * lin(((n >> 8) & 255) / 255) + 0.0722 * lin((n & 255) / 255);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function stickerContrast(a: string, b: string): number {
|
|
96
|
+
const [hi, lo] = [stickerLuminance(a), stickerLuminance(b)].sort((x, y) => y - x);
|
|
97
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- geometry (all in mm; the card is 47 x 58) ----------------------------
|
|
101
|
+
|
|
102
|
+
export const CARD_W = 47;
|
|
103
|
+
export const CARD_H = 58;
|
|
104
|
+
export const QR_MM = 32; // the whole QR including its quiet zone
|
|
105
|
+
export const QUIET_MODULES = 4;
|
|
106
|
+
/** Error correction on every design (no centre logo, so Q stays enough). */
|
|
107
|
+
export const QR_ECC = "Q";
|
|
108
|
+
const STROKE = 0.35;
|
|
109
|
+
|
|
110
|
+
/** A rectangle with per-corner radii [tl, tr, br, bl], or concave cut corners. */
|
|
111
|
+
export type Box = { t: "box"; x: number; y: number; w: number; h: number; r?: number[]; notch?: number };
|
|
112
|
+
export type Circle = { t: "circle"; cx: number; cy: number; r: number };
|
|
113
|
+
export type Shape = Box | Circle;
|
|
114
|
+
/** A filled shape; `hole` is cut out of it (rings, the frame line). */
|
|
115
|
+
export type Paint = { shape: Shape; hole?: Shape; fill: string };
|
|
116
|
+
|
|
117
|
+
/** Top of the QR on the card: lower on the badge so the round top keeps clear of the code. */
|
|
118
|
+
export function qrTop(frame: FrameShape): number {
|
|
119
|
+
return frame === "arch" ? 7 : 2.5;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cardBox(frame: FrameShape, inset: number): Box {
|
|
123
|
+
const x = inset;
|
|
124
|
+
const w = CARD_W - inset * 2;
|
|
125
|
+
const h = CARD_H - inset * 2;
|
|
126
|
+
const r = (v: number) => Math.max(0, v - inset);
|
|
127
|
+
switch (frame) {
|
|
128
|
+
case "square":
|
|
129
|
+
return { t: "box", x, y: x, w, h, r: [r(0.8), r(0.8), r(0.8), r(0.8)] };
|
|
130
|
+
case "soft":
|
|
131
|
+
return { t: "box", x, y: x, w, h, r: [r(8), r(8), r(8), r(8)] };
|
|
132
|
+
case "arch":
|
|
133
|
+
return { t: "box", x, y: x, w, h, r: [w / 2, w / 2, r(3), r(3)] };
|
|
134
|
+
case "ticket":
|
|
135
|
+
return { t: "box", x, y: x, w, h, notch: 4 + inset };
|
|
136
|
+
default:
|
|
137
|
+
return { t: "box", x, y: x, w, h, r: [r(3), r(3), r(3), r(3)] };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isFinder(n: number, row: number, col: number): boolean {
|
|
142
|
+
return (row < 7 && col < 7) || (row < 7 && col >= n - 7) || (row >= n - 7 && col < 7);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every shape on the sticker face, painted in order. */
|
|
146
|
+
export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Paint[] {
|
|
147
|
+
const theme = stickerTheme(design.theme);
|
|
148
|
+
const n = matrix.size;
|
|
149
|
+
const m = QR_MM / (n + QUIET_MODULES * 2);
|
|
150
|
+
const ox = (CARD_W - QR_MM) / 2 + QUIET_MODULES * m;
|
|
151
|
+
const oy = qrTop(design.frame) + QUIET_MODULES * m;
|
|
152
|
+
const paints: Paint[] = [
|
|
153
|
+
{ shape: cardBox(design.frame, 0), fill: theme.paper },
|
|
154
|
+
{ shape: cardBox(design.frame, 0), hole: cardBox(design.frame, STROKE), fill: theme.ink },
|
|
155
|
+
];
|
|
156
|
+
const box = (x: number, y: number, w: number, h: number, rad = 0): Box => ({ t: "box", x, y, w, h, r: [rad, rad, rad, rad] });
|
|
157
|
+
|
|
158
|
+
for (let row = 0; row < n; row++) {
|
|
159
|
+
for (let col = 0; col < n; col++) {
|
|
160
|
+
if (!matrix.get(row, col) || isFinder(n, row, col)) continue;
|
|
161
|
+
const x = ox + col * m;
|
|
162
|
+
const y = oy + row * m;
|
|
163
|
+
if (design.dots === "dots") {
|
|
164
|
+
paints.push({ shape: { t: "circle", cx: x + m / 2, cy: y + m / 2, r: m * 0.45 }, fill: theme.ink });
|
|
165
|
+
} else if (design.dots === "rounded") {
|
|
166
|
+
paints.push({ shape: box(x, y, m, m, m * 0.3), fill: theme.ink });
|
|
167
|
+
} else {
|
|
168
|
+
// ponytail: one rect per horizontal run, so adjacent squares never show a hairline seam
|
|
169
|
+
let end = col;
|
|
170
|
+
while (end + 1 < n && matrix.get(row, end + 1) && !isFinder(n, row, end + 1)) end++;
|
|
171
|
+
paints.push({ shape: box(x, y, (end - col + 1) * m, m), fill: theme.ink });
|
|
172
|
+
col = end;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const [r0, c0] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
|
|
178
|
+
const x = ox + c0 * m;
|
|
179
|
+
const y = oy + r0 * m;
|
|
180
|
+
if (design.eyes === "circle") {
|
|
181
|
+
const cx = x + 3.5 * m;
|
|
182
|
+
const cy = y + 3.5 * m;
|
|
183
|
+
paints.push({ shape: { t: "circle", cx, cy, r: 3.5 * m }, hole: { t: "circle", cx, cy, r: 2.5 * m }, fill: theme.eye });
|
|
184
|
+
paints.push({ shape: { t: "circle", cx, cy, r: 1.5 * m }, fill: theme.eye });
|
|
185
|
+
} else {
|
|
186
|
+
const round = design.eyes === "rounded";
|
|
187
|
+
paints.push({
|
|
188
|
+
shape: box(x, y, 7 * m, 7 * m, round ? 2 * m : 0),
|
|
189
|
+
hole: box(x + m, y + m, 5 * m, 5 * m, round ? 1.2 * m : 0),
|
|
190
|
+
fill: theme.eye,
|
|
191
|
+
});
|
|
192
|
+
paints.push({ shape: box(x + 2 * m, y + 2 * m, 3 * m, 3 * m, round ? 0.9 * m : 0), fill: theme.eye });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return paints;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- drawing ---------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
const f = (v: number) => String(Math.round(v * 1000) / 1000);
|
|
201
|
+
|
|
202
|
+
function boxPath(b: Box): string {
|
|
203
|
+
const { x, y, w, h } = b;
|
|
204
|
+
if (b.notch) {
|
|
205
|
+
const k = b.notch;
|
|
206
|
+
return (
|
|
207
|
+
`M${f(x + k)} ${f(y)}H${f(x + w - k)}A${f(k)} ${f(k)} 0 0 0 ${f(x + w)} ${f(y + k)}` +
|
|
208
|
+
`V${f(y + h - k)}A${f(k)} ${f(k)} 0 0 0 ${f(x + w - k)} ${f(y + h)}` +
|
|
209
|
+
`H${f(x + k)}A${f(k)} ${f(k)} 0 0 0 ${f(x)} ${f(y + h - k)}` +
|
|
210
|
+
`V${f(y + k)}A${f(k)} ${f(k)} 0 0 0 ${f(x + k)} ${f(y)}Z`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const [tl, tr, br, bl] = b.r ?? [0, 0, 0, 0];
|
|
214
|
+
const arc = (r: number, ex: number, ey: number) => (r ? `A${f(r)} ${f(r)} 0 0 1 ${f(ex)} ${f(ey)}` : "");
|
|
215
|
+
return (
|
|
216
|
+
`M${f(x + tl)} ${f(y)}H${f(x + w - tr)}${arc(tr, x + w, y + tr)}` +
|
|
217
|
+
`V${f(y + h - br)}${arc(br, x + w - br, y + h)}` +
|
|
218
|
+
`H${f(x + bl)}${arc(bl, x, y + h - bl)}` +
|
|
219
|
+
`V${f(y + tl)}${arc(tl, x + tl, y)}Z`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function shapePath(s: Shape): string {
|
|
224
|
+
if (s.t === "box") return boxPath(s);
|
|
225
|
+
const { cx, cy, r } = s;
|
|
226
|
+
return `M${f(cx - r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx + r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx - r)} ${f(cy)}Z`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The face as one SVG, 47 x 58 mm. Runs of same-colour shapes share one path. */
|
|
230
|
+
export function paintsToSvg(paints: Paint[]): string {
|
|
231
|
+
const out: string[] = [];
|
|
232
|
+
let fill = "";
|
|
233
|
+
let d = "";
|
|
234
|
+
const flush = () => d && out.push(`<path fill="${fill}" d="${d}"/>`);
|
|
235
|
+
for (const p of paints) {
|
|
236
|
+
if (p.hole) {
|
|
237
|
+
// A ring is its own evenodd path, so its hole never cuts a neighbour.
|
|
238
|
+
flush();
|
|
239
|
+
d = "";
|
|
240
|
+
out.push(`<path fill="${p.fill}" fill-rule="evenodd" d="${shapePath(p.shape)}${shapePath(p.hole)}"/>`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (p.fill !== fill) {
|
|
244
|
+
flush();
|
|
245
|
+
fill = p.fill;
|
|
246
|
+
d = "";
|
|
247
|
+
}
|
|
248
|
+
d += shapePath(p.shape);
|
|
249
|
+
}
|
|
250
|
+
flush();
|
|
251
|
+
return (
|
|
252
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CARD_W} ${CARD_H}" width="${CARD_W}mm" height="${CARD_H}mm">` +
|
|
253
|
+
out.join("") +
|
|
254
|
+
`</svg>`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign): string {
|
|
259
|
+
return paintsToSvg(stickerFacePaints(matrix, design));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The words under the plate for this design ("" = none). */
|
|
263
|
+
export function stickerLabel(design: StickerDesign, siteName = ""): string {
|
|
264
|
+
if (design.label === "none") return "";
|
|
265
|
+
if (design.label === "verify") return "Scan to verify vehicle";
|
|
266
|
+
if (design.label === "site") return String(siteName ?? "").trim();
|
|
267
|
+
return "Scan with iSecure365 / iMA365";
|
|
268
|
+
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* pasted on the car.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { DEFAULT_STICKER_DESIGN, QR_MM, qrTop, stickerLabel, stickerTheme, type StickerDesign } from "./vehicle-qr-design.ts";
|
|
12
|
+
|
|
11
13
|
/** The server's most vehicles per request (POST /api/vehicles/qr/stickers). */
|
|
12
14
|
export const STICKER_MAX_PER_REQUEST = 500;
|
|
13
15
|
|
|
@@ -146,13 +148,13 @@ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family:
|
|
|
146
148
|
.page { display: grid; grid-template-columns: repeat(${STICKER_COLUMNS}, 47mm); gap: 2mm; justify-content: center; align-content: start; width: 198mm; height: 285mm; margin: 0 auto; break-after: page; }
|
|
147
149
|
.page:last-child { break-after: auto; }
|
|
148
150
|
.cell { width: 47mm; break-inside: avoid; }
|
|
149
|
-
.card { width: 47mm; height: 58mm;
|
|
150
|
-
.
|
|
151
|
-
.
|
|
151
|
+
.card { position: relative; width: 47mm; height: 58mm; }
|
|
152
|
+
.face, .face svg { position: absolute; inset: 0; width: 47mm; height: 58mm; display: block; }
|
|
153
|
+
.words { position: absolute; left: 3mm; right: 3mm; bottom: 2mm; display: flex; flex-direction: column; align-items: center; justify-content: space-between; text-align: center; }
|
|
152
154
|
.plate { font-size: 13pt; font-weight: 800; letter-spacing: 0.3mm; line-height: 1.1; }
|
|
153
|
-
.hint { font-size: 5.8pt;
|
|
154
|
-
.brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt;
|
|
155
|
-
.mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm;
|
|
155
|
+
.hint { font-size: 5.8pt; line-height: 1.2; opacity: 0.85; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
|
156
|
+
.brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt; border-top: 0.2mm solid #ccc; width: 100%; padding-top: 1mm; }
|
|
157
|
+
.mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm; color: #fff; font-weight: 800; font-size: 5pt; line-height: 3.2mm; text-align: center; }
|
|
156
158
|
.cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top: 1mm; font-size: 6.5pt; line-height: 1.25; color: #111; min-height: 8mm; }
|
|
157
159
|
.cut .owner { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
158
160
|
.cut .note { color: #666; font-size: 5.5pt; }
|
|
@@ -160,20 +162,29 @@ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family:
|
|
|
160
162
|
`;
|
|
161
163
|
|
|
162
164
|
/**
|
|
163
|
-
* One full HTML document: A4 sheets of stickers. `
|
|
164
|
-
* `items` (
|
|
165
|
-
* owner name is typed by a person.
|
|
165
|
+
* One full HTML document: A4 sheets of stickers. `faceSvgs` is parallel to
|
|
166
|
+
* `items` (each sticker's frame + QR, already drawn for the chosen design).
|
|
167
|
+
* Every value is HTML-escaped - an owner or site name is typed by a person.
|
|
166
168
|
*/
|
|
167
|
-
export function buildStickerDocument(
|
|
169
|
+
export function buildStickerDocument(
|
|
170
|
+
items: StickerItem[],
|
|
171
|
+
faceSvgs: string[],
|
|
172
|
+
opts: { design?: StickerDesign; siteName?: string } = {},
|
|
173
|
+
): string {
|
|
174
|
+
const design = opts.design ?? DEFAULT_STICKER_DESIGN;
|
|
175
|
+
const { ink } = stickerTheme(design.theme);
|
|
176
|
+
const label = stickerLabel(design, opts.siteName);
|
|
177
|
+
const wordsTop = qrTop(design.frame) + QR_MM + 0.8;
|
|
168
178
|
const cells = items.map((item, i) => {
|
|
169
179
|
const { place, owner } = installerLine(item);
|
|
170
180
|
return (
|
|
171
|
-
`<div class="cell"><div class="card">` +
|
|
172
|
-
`<div class="
|
|
181
|
+
`<div class="cell"><div class="card" style="color:${ink}">` +
|
|
182
|
+
`<div class="face">${faceSvgs[i] ?? ""}</div>` +
|
|
183
|
+
`<div class="words" style="top:${wordsTop}mm">` +
|
|
173
184
|
`<div class="plate">${escapeHtml(item.plateNumber)}</div>` +
|
|
174
|
-
`<div class="hint"
|
|
175
|
-
`<div class="brand"><span class="mark">7</span>Powered by Seven365</div>` +
|
|
176
|
-
`</div>` +
|
|
185
|
+
(label ? `<div class="hint">${escapeHtml(label)}</div>` : "") +
|
|
186
|
+
`<div class="brand"><span class="mark" style="background:${ink}">7</span>Powered by Seven365</div>` +
|
|
187
|
+
`</div></div>` +
|
|
177
188
|
`<div class="cut"><div><b>${escapeHtml(place || "-")}</b></div>` +
|
|
178
189
|
`<div class="owner">Owner: ${escapeHtml(owner || "-")}</div>` +
|
|
179
190
|
`<div class="note">Installer: cut here, then paste on this car</div></div></div>`
|