@7365admin1/layer-common 4.92.0 → 4.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -4
- package/components/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/InvitationMain.vue +120 -21
- package/composables/useInventory.ts +4 -1
- package/composables/useVerification.ts +3 -0
- package/middleware/01.auth.ts +3 -7
- package/package.json +1 -1
- package/types/inventory.d.ts +27 -0
- package/utils/inventory-receiving.ts +90 -0
- package/utils/invite-group.ts +61 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.94.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8c5c478: Inventory: receiving by PO, DO or cash (DO number and date, receipt number, ordered quantity per line), a "Check a PO" view of ordered vs received, a search box on the requests list, requests and approvals capped at what the store has, and photo picking limited to JPG/PNG/WebP at 5 MB each (bigger photos are shrunk in the browser).
|
|
8
|
+
|
|
9
|
+
## 4.93.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- b763487: Invitations list shows ONE row per multi-site invitation (e-mail, role, date, "N sites" with a details dialog listing every site and its status). Row actions: Resend (one request, one e-mail for the whole group), Cancel all, and Cancel one site inside the details. Old single-site invitations look and behave as before. Asks the API for `group=true`; against an API that does not know it, falls back to the plain per-site list. Same component on the org console and every app's Invitations page.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 31c71b3: The auth middleware no longer saves `?email=` as a browser-wide `service-provider-email` cookie (30 days). Nothing reads it any more, and a leftover from another account sent the org app's sign-in to that account's create page. Redirect-to-sign-in behaviour is unchanged.
|
|
18
|
+
|
|
3
19
|
## 4.92.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
|
@@ -2044,11 +2060,11 @@
|
|
|
2044
2060
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
2045
2061
|
own rule from two endpoints the console already calls, unprojected:
|
|
2046
2062
|
|
|
2047
|
-
|
|
2048
|
-
|
|
2063
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
2064
|
+
GET /api/roles/id/:role that membership's role document
|
|
2049
2065
|
|
|
2050
|
-
|
|
2051
|
-
|
|
2066
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
2067
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
2052
2068
|
|
|
2053
2069
|
`role.default` is the marker because it is the only property of a platform
|
|
2054
2070
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -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);
|
|
@@ -61,19 +61,37 @@
|
|
|
61
61
|
<template #item.createdAt="{ item }">
|
|
62
62
|
{{ item.createdAt ? formatDate(item.createdAt) : "" }}
|
|
63
63
|
</template>
|
|
64
|
+
<template #item.siteLabel="{ item }">
|
|
65
|
+
<a
|
|
66
|
+
v-if="siteCountOf(item) > 1"
|
|
67
|
+
href="#"
|
|
68
|
+
class="text-primary"
|
|
69
|
+
:title="item.sites?.map((s) => s.siteName).join(', ')"
|
|
70
|
+
@click.prevent="detailItem = item"
|
|
71
|
+
>
|
|
72
|
+
{{ siteLabel(item) }}
|
|
73
|
+
</a>
|
|
74
|
+
<span v-else>{{ siteLabel(item) }}</span>
|
|
75
|
+
</template>
|
|
64
76
|
<template #item.action-table="{ item }">
|
|
65
77
|
<v-menu
|
|
66
|
-
:close-on-content-click="
|
|
78
|
+
:close-on-content-click="true"
|
|
67
79
|
offset-y
|
|
68
|
-
width="
|
|
69
|
-
v-if="props.cancelInvitation"
|
|
80
|
+
width="170"
|
|
81
|
+
v-if="item.status === 'pending' && (props.cancelInvitation || props.inviteMember)"
|
|
70
82
|
>
|
|
71
83
|
<template v-slot:activator="{ props }">
|
|
72
84
|
<v-icon v-bind="props">mdi-dots-vertical</v-icon>
|
|
73
85
|
</template>
|
|
74
86
|
<v-list>
|
|
75
|
-
<v-list-item
|
|
76
|
-
|
|
87
|
+
<v-list-item v-if="props.inviteMember" @click="onResend(item)">
|
|
88
|
+
Resend
|
|
89
|
+
</v-list-item>
|
|
90
|
+
<v-list-item
|
|
91
|
+
v-if="props.cancelInvitation"
|
|
92
|
+
@click="openConfirmDialog(groupIds(item))"
|
|
93
|
+
>
|
|
94
|
+
{{ siteCountOf(item) > 1 ? "Cancel all" : "Cancel Invite" }}
|
|
77
95
|
</v-list-item>
|
|
78
96
|
</v-list>
|
|
79
97
|
</v-menu>
|
|
@@ -85,6 +103,34 @@
|
|
|
85
103
|
</AppCard>
|
|
86
104
|
</v-col>
|
|
87
105
|
|
|
106
|
+
<v-dialog :model-value="!!detailItem" max-width="520" @update:model-value="detailItem = null">
|
|
107
|
+
<v-card v-if="detailItem" class="pa-4">
|
|
108
|
+
<div class="text-h6 mb-1">{{ siteLabel(detailItem) }}</div>
|
|
109
|
+
<div class="text-caption mb-3">
|
|
110
|
+
{{ detailItem.email }} - one invitation e-mail; accepting it adds every site below.
|
|
111
|
+
</div>
|
|
112
|
+
<v-list density="compact" style="max-height: 50vh; overflow-y: auto">
|
|
113
|
+
<v-list-item v-for="s in detailItem.sites" :key="s._id" :title="s.siteName || 'Site'">
|
|
114
|
+
<template #append>
|
|
115
|
+
<span class="text-caption me-3">{{ formatStatus(s.status || "") }}</span>
|
|
116
|
+
<v-btn
|
|
117
|
+
v-if="s.status === 'pending' && props.cancelInvitation"
|
|
118
|
+
size="small"
|
|
119
|
+
variant="text"
|
|
120
|
+
color="error"
|
|
121
|
+
@click="openConfirmDialog([s._id])"
|
|
122
|
+
>
|
|
123
|
+
Cancel
|
|
124
|
+
</v-btn>
|
|
125
|
+
</template>
|
|
126
|
+
</v-list-item>
|
|
127
|
+
</v-list>
|
|
128
|
+
<div class="d-flex justify-end mt-3">
|
|
129
|
+
<v-btn variant="text" @click="detailItem = null">Close</v-btn>
|
|
130
|
+
</div>
|
|
131
|
+
</v-card>
|
|
132
|
+
</v-dialog>
|
|
133
|
+
|
|
88
134
|
<ConfirmDialog
|
|
89
135
|
v-model="confirmDialog"
|
|
90
136
|
:loading="cancelLoading"
|
|
@@ -95,8 +141,12 @@
|
|
|
95
141
|
</template>
|
|
96
142
|
<template #description>
|
|
97
143
|
<p class="text-subtitle-2">
|
|
98
|
-
|
|
99
|
-
|
|
144
|
+
{{
|
|
145
|
+
selectedInviteIds.length > 1
|
|
146
|
+
? `Are you sure you want to cancel all ${selectedInviteIds.length} site invitations?`
|
|
147
|
+
: "Are you sure you want to cancel this invitation?"
|
|
148
|
+
}}
|
|
149
|
+
This action cannot be undone.
|
|
100
150
|
</p>
|
|
101
151
|
</template>
|
|
102
152
|
<template #footer>
|
|
@@ -140,6 +190,13 @@
|
|
|
140
190
|
<script setup lang="ts">
|
|
141
191
|
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
142
192
|
import { invitationsEmptyText } from "../utils/invitations-empty-text";
|
|
193
|
+
import {
|
|
194
|
+
groupIds,
|
|
195
|
+
resendArgs,
|
|
196
|
+
siteCountOf,
|
|
197
|
+
siteLabel,
|
|
198
|
+
} from "../utils/invite-group";
|
|
199
|
+
import useUser from "../composables/useUser";
|
|
143
200
|
|
|
144
201
|
/** The same two tabs, same labels, same routes. */
|
|
145
202
|
const invitationTabs = [
|
|
@@ -205,7 +262,8 @@ const headers = computed(() => {
|
|
|
205
262
|
},
|
|
206
263
|
{
|
|
207
264
|
title: "Site Invited To",
|
|
208
|
-
value: "
|
|
265
|
+
value: "siteLabel",
|
|
266
|
+
sortable: false,
|
|
209
267
|
},
|
|
210
268
|
{
|
|
211
269
|
title: "Status",
|
|
@@ -214,7 +272,7 @@ const headers = computed(() => {
|
|
|
214
272
|
|
|
215
273
|
];
|
|
216
274
|
|
|
217
|
-
if (props.status === "pending" && props.cancelInvitation) {
|
|
275
|
+
if (props.status === "pending" && (props.cancelInvitation || props.inviteMember)) {
|
|
218
276
|
baseHeaders.push({
|
|
219
277
|
title: "Action",
|
|
220
278
|
value: "action-table",
|
|
@@ -226,6 +284,7 @@ const headers = computed(() => {
|
|
|
226
284
|
|
|
227
285
|
const { getVerifications: _getVerifications, cancelUserInvitation } =
|
|
228
286
|
useVerification();
|
|
287
|
+
const { inviteMemberClient } = useUser();
|
|
229
288
|
|
|
230
289
|
const page = ref(1);
|
|
231
290
|
const pages = ref(0);
|
|
@@ -255,8 +314,8 @@ const {
|
|
|
255
314
|
pageRange: string;
|
|
256
315
|
}>(
|
|
257
316
|
`get-verifications-${props.app}-${props.org}-${props.siteId}-${props.status}`,
|
|
258
|
-
() =>
|
|
259
|
-
|
|
317
|
+
async () => {
|
|
318
|
+
const query = {
|
|
260
319
|
page: page.value,
|
|
261
320
|
status: props.status,
|
|
262
321
|
search: headerSearch.value,
|
|
@@ -271,7 +330,15 @@ const {
|
|
|
271
330
|
// caller reaches it (owner report 2026-09-28: LakeGarden's invitations
|
|
272
331
|
// showed on every Wing Tai site). The org console passes no site.
|
|
273
332
|
siteId: props.siteId,
|
|
274
|
-
}
|
|
333
|
+
};
|
|
334
|
+
// One row per multi-site invitation. An API without `group` refuses the
|
|
335
|
+
// key: fall back to the plain list (one row per site, as before).
|
|
336
|
+
try {
|
|
337
|
+
return await _getVerifications({ ...query, group: true });
|
|
338
|
+
} catch {
|
|
339
|
+
return await _getVerifications(query);
|
|
340
|
+
}
|
|
341
|
+
},
|
|
275
342
|
{
|
|
276
343
|
watch: [() => props.status]
|
|
277
344
|
}
|
|
@@ -309,31 +376,63 @@ watch([headerSearch, () => props.status], () => {
|
|
|
309
376
|
});
|
|
310
377
|
|
|
311
378
|
const confirmDialog = ref(false);
|
|
312
|
-
const
|
|
379
|
+
const selectedInviteIds = ref<string[]>([]);
|
|
313
380
|
const cancelLoading = ref(false);
|
|
381
|
+
const detailItem = ref<any>(null);
|
|
382
|
+
const resending = ref(false);
|
|
314
383
|
|
|
315
|
-
function openConfirmDialog(
|
|
316
|
-
|
|
384
|
+
function openConfirmDialog(ids: string[]) {
|
|
385
|
+
selectedInviteIds.value = ids;
|
|
317
386
|
confirmDialog.value = true;
|
|
318
387
|
}
|
|
319
388
|
|
|
320
389
|
async function onConfirmCancel() {
|
|
321
|
-
if (!
|
|
390
|
+
if (!selectedInviteIds.value.length) return;
|
|
322
391
|
|
|
323
392
|
cancelLoading.value = true;
|
|
324
393
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
394
|
+
let res: Record<string, any> = {};
|
|
395
|
+
for (const id of selectedInviteIds.value) {
|
|
396
|
+
res = await cancelUserInvitation(id);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
showMessage(
|
|
400
|
+
selectedInviteIds.value.length > 1
|
|
401
|
+
? "All site invitations cancelled"
|
|
402
|
+
: res.message || "Invitation cancelled successfully",
|
|
403
|
+
"success",
|
|
404
|
+
);
|
|
405
|
+
detailItem.value = null;
|
|
328
406
|
await getVerifications();
|
|
329
407
|
confirmDialog.value = false;
|
|
330
|
-
|
|
408
|
+
selectedInviteIds.value = [];
|
|
331
409
|
} catch (error: any) {
|
|
332
|
-
showMessage(error?.message || "Failed to cancel invitation", "error");
|
|
410
|
+
showMessage(error?.data?.message || error?.message || "Failed to cancel invitation", "error");
|
|
411
|
+
await getVerifications();
|
|
333
412
|
}
|
|
334
413
|
cancelLoading.value = false;
|
|
335
414
|
}
|
|
336
415
|
|
|
416
|
+
// ONE request = ONE e-mail for the whole group (the server re-uses the
|
|
417
|
+
// pending rows and names every site).
|
|
418
|
+
async function onResend(item: any) {
|
|
419
|
+
const args = resendArgs(item);
|
|
420
|
+
if (!args) {
|
|
421
|
+
showMessage("This invitation cannot be re-sent from here.", "error");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (resending.value) return;
|
|
425
|
+
resending.value = true;
|
|
426
|
+
try {
|
|
427
|
+
await inviteMemberClient(args);
|
|
428
|
+
showMessage("Invitation re-sent (one e-mail).", "success");
|
|
429
|
+
await getVerifications();
|
|
430
|
+
} catch (error: any) {
|
|
431
|
+
showMessage(error?.data?.message || error?.message || "Failed to resend invitation", "error");
|
|
432
|
+
}
|
|
433
|
+
resending.value = false;
|
|
434
|
+
}
|
|
435
|
+
|
|
337
436
|
function handleSuccess(closeForm: boolean) {
|
|
338
437
|
getVerifications();
|
|
339
438
|
if (closeForm) {
|
|
@@ -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) =>
|
|
@@ -11,6 +11,8 @@ export default function useVerification() {
|
|
|
11
11
|
limit = 20,
|
|
12
12
|
// "seven365": only the invitations Seven365 sent (the console's lists).
|
|
13
13
|
origin = "",
|
|
14
|
+
// true: one entry per multi-site invitation group, with its `sites`.
|
|
15
|
+
group = false,
|
|
14
16
|
} = {}): Promise<{
|
|
15
17
|
items: TMiniVerification[];
|
|
16
18
|
pages: number;
|
|
@@ -22,6 +24,7 @@ export default function useVerification() {
|
|
|
22
24
|
status, search, page, type, email, app, siteId, orgId, limit,
|
|
23
25
|
// Sent only when asked for: an older API refuses a key it does not know.
|
|
24
26
|
...(origin && { origin }),
|
|
27
|
+
...(group && { group: "true" }),
|
|
25
28
|
},
|
|
26
29
|
});
|
|
27
30
|
}
|
package/middleware/01.auth.ts
CHANGED
|
@@ -4,13 +4,9 @@ export default defineNuxtRouteMiddleware(async (route) => {
|
|
|
4
4
|
// Get access token from cookies
|
|
5
5
|
const sid = useCookie("sid", cookieConfig).value;
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (serviceProviderEmail) {
|
|
11
|
-
useCookie("service-provider-email", cookieConfig).value =
|
|
12
|
-
serviceProviderEmail;
|
|
13
|
-
}
|
|
7
|
+
// No longer saves `?email=` as a browser-wide `service-provider-email` cookie
|
|
8
|
+
// (30 days): nothing reads it, and a leftover from another account sent the
|
|
9
|
+
// org app's sign-in to that account's create page.
|
|
14
10
|
|
|
15
11
|
if (!sid) {
|
|
16
12
|
// Redirect to login page if no access token
|
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.
|
|
5
|
+
"version": "4.94.0",
|
|
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,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invitations list, multi-site invitations (owner request 2026-10-02): the
|
|
3
|
+
* server (`GET /api/verifications?group=true`) returns ONE entry per invitation
|
|
4
|
+
* group with its `sites`; an old single-site invitation is a group of one.
|
|
5
|
+
*/
|
|
6
|
+
export type InviteSiteRow = {
|
|
7
|
+
_id: string;
|
|
8
|
+
siteId?: string;
|
|
9
|
+
siteName?: string;
|
|
10
|
+
status?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type InviteEntry = {
|
|
14
|
+
_id?: string;
|
|
15
|
+
email?: string;
|
|
16
|
+
metadata?: Record<string, any>;
|
|
17
|
+
sites?: InviteSiteRow[];
|
|
18
|
+
siteCount?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function siteCountOf(item: InviteEntry): number {
|
|
22
|
+
return item.siteCount ?? item.sites?.length ?? 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** "LakeGarden" for one site, "21 sites" for a group (details list them all). */
|
|
26
|
+
export function siteLabel(item: InviteEntry): string {
|
|
27
|
+
const n = siteCountOf(item);
|
|
28
|
+
if (n > 1) return `${n} sites`;
|
|
29
|
+
return item.sites?.[0]?.siteName || item.metadata?.siteName || "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every verification id the entry stands for (Cancel all). */
|
|
33
|
+
export function groupIds(item: InviteEntry): string[] {
|
|
34
|
+
const ids = item.sites?.length
|
|
35
|
+
? item.sites.map((s) => s._id)
|
|
36
|
+
: [item._id ?? ""];
|
|
37
|
+
return ids.filter(Boolean).map(String);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Arguments of `inviteMemberClient` that re-send the invitation: ONE request,
|
|
42
|
+
* so ONE e-mail for the whole group. `null` when it cannot be rebuilt (no org).
|
|
43
|
+
*/
|
|
44
|
+
export function resendArgs(item: InviteEntry) {
|
|
45
|
+
const m = item.metadata ?? {};
|
|
46
|
+
if (!item.email || !m.org) return null;
|
|
47
|
+
const sites = (item.sites ?? [])
|
|
48
|
+
.map((s) => s.siteId)
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.map(String);
|
|
51
|
+
return {
|
|
52
|
+
email: item.email,
|
|
53
|
+
app: m.app ?? "",
|
|
54
|
+
role: m.role ? String(m.role) : "",
|
|
55
|
+
name: m.name ?? "",
|
|
56
|
+
org: String(m.org),
|
|
57
|
+
sites: sites.length > 1 ? sites : [],
|
|
58
|
+
site: sites.length === 1 ? sites[0] : m.siteId ? String(m.siteId) : "",
|
|
59
|
+
siteName: item.sites?.[0]?.siteName || m.siteName || "",
|
|
60
|
+
};
|
|
61
|
+
}
|