@7365admin1/layer-common 4.93.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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 4.93.0
4
10
 
5
11
  ### Minor Changes
@@ -2054,11 +2060,11 @@
2054
2060
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
2055
2061
  own rule from two endpoints the console already calls, unprojected:
2056
2062
 
2057
- GET /api/members/user/:user/app/admin the Seven365 staff membership
2058
- GET /api/roles/id/:role that membership's role document
2063
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
2064
+ GET /api/roles/id/:role that membership's role document
2059
2065
 
2060
- owner = member.type === "admin" && role.type === "admin" && role.default === true
2061
- staff = member.type === "admin" && role.type === "admin"
2066
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
2067
+ staff = member.type === "admin" && role.type === "admin"
2062
2068
 
2063
2069
  `role.default` is the marker because it is the only property of a platform
2064
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.reference ? `PO ${t.reference}` : "");
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="3">
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 photo (optional)" class="mb-1" />
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
- if (props.mode !== "request" && available !== undefined && total > available) return `Only ${available} available`;
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) => ({ item: r.item, qty: Number(r.qty), ...(r.unitCost !== null && r.unitCost !== undefined && r.unitCost !== ("" as any) ? { unitCost: Number(r.unitCost) } : {}) })),
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
- reference: reference.value,
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="image/*" multiple hidden @change="upload" />
20
- <div v-if="error" class="text-caption text-error mt-1">{{ error }}</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
- error.value = "";
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 file of files.slice(0, 5 - model.value.length)) {
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
- error.value = "A photo could not be uploaded. Please try again.";
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 (Type: Opening stock), with totals by month and by supplier.",
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: "kind" },
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: "Qty", key: "qty" },
418
+ { title: "Ordered", key: "poOrderedQty" },
419
+ { title: "Received", key: "qty" },
400
420
  { title: "Unit price", key: "unitCost" },
401
421
  { title: "Total", key: "totalCost" },
402
422
  { title: "Received by", key: "receivedBy" },
@@ -7,6 +7,15 @@
7
7
  <v-btn value="rejected" class="text-none">Rejected</v-btn>
8
8
  <v-btn value="" class="text-none">All</v-btn>
9
9
  </v-btn-toggle>
10
+ <v-text-field
11
+ v-model="search"
12
+ placeholder="Search item, person, reference or remarks"
13
+ prepend-inner-icon="mdi-magnify"
14
+ density="compact"
15
+ hide-details
16
+ clearable
17
+ class="request-search"
18
+ />
10
19
  <v-spacer />
11
20
  <v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">
12
21
  Request Stock
@@ -15,7 +24,7 @@
15
24
 
16
25
  <v-progress-linear v-if="loading" indeterminate color="primary" />
17
26
  <div v-else-if="!requests.length" class="text-center text-medium-emphasis py-10">
18
- {{ status === "pending" ? (store.access.canApprove ? "No requests waiting for approval." : "You have no requests waiting. Use Request Stock to ask the store for items.") : "No requests to show." }}
27
+ {{ search ? `No requests match "${search}". Try another word, or pick "All" to look through every status.` : status === "pending" ? (store.access.canApprove ? "No requests waiting for approval." : "You have no requests waiting. Use Request Stock to ask the store for items.") : "No requests to show." }}
19
28
  </div>
20
29
 
21
30
  <AppCard v-for="r in requests" :key="r._id" class="pa-4 mb-3">
@@ -63,11 +72,11 @@
63
72
  v-model.number="l.give"
64
73
  type="number"
65
74
  min="0"
66
- :max="l.qty"
75
+ :max="Math.min(l.qty, l.onHand ?? l.qty)"
67
76
  label="Give"
68
77
  density="comfortable"
69
78
  style="max-width: 120px"
70
- :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= l.qty) || `0 to ${l.qty}`]"
79
+ :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= l.qty) || `0 to ${l.qty}`, (v: any) => l.onHand === undefined || Number(v) <= l.onHand || `Only ${l.onHand} in store`]"
71
80
  />
72
81
  </div>
73
82
  <InputLabel title="Remarks" class="mb-1 mt-2" />
@@ -107,6 +116,7 @@ const notify = inject<(text: string, color?: string) => void>("inventoryNotify",
107
116
  const chip = REQUEST_STATUS;
108
117
 
109
118
  const status = ref("pending");
119
+ const search = ref("");
110
120
  const requests = ref<TInventoryRequest[]>([]);
111
121
  const page = ref(1);
112
122
  const pages = ref(0);
@@ -119,7 +129,7 @@ const isMine = (r: TInventoryRequest) => String(r.requestedBy) === String(curren
119
129
  async function load(p = 1) {
120
130
  loading.value = true;
121
131
  try {
122
- const res = await api.getRequests(props.store._id, { page: p, limit: 20, status: status.value || undefined });
132
+ const res = await api.getRequests(props.store._id, { page: p, limit: 20, status: status.value || undefined, search: search.value?.trim() || undefined });
123
133
  requests.value = p === 1 ? res.items : [...requests.value, ...res.items];
124
134
  page.value = p;
125
135
  pages.value = res.pages;
@@ -214,6 +224,19 @@ async function reject() {
214
224
  }
215
225
 
216
226
  watch(status, () => load(1));
227
+ // Typed search waits until the typing stops.
228
+ let typing: ReturnType<typeof setTimeout> | undefined;
229
+ watch(search, () => {
230
+ clearTimeout(typing);
231
+ typing = setTimeout(() => load(1), 400);
232
+ });
217
233
  watch(() => props.store._id, () => load(1));
218
234
  onMounted(() => load(1));
219
235
  </script>
236
+
237
+ <style scoped>
238
+ .request-search {
239
+ min-width: 220px;
240
+ max-width: 340px;
241
+ }
242
+ </style>
@@ -17,6 +17,7 @@
17
17
  <v-list density="compact">
18
18
  <v-list-item prepend-icon="mdi-truck-delivery-outline" title="Record a delivery" @click="open('receive')" />
19
19
  <v-list-item prepend-icon="mdi-file-excel-outline" title="Import deliveries from Excel" @click="showReceiveImport = true" />
20
+ <v-list-item prepend-icon="mdi-clipboard-search-outline" title="Check a purchase order (PO)" @click="showPo = true" />
20
21
  </v-list>
21
22
  </v-menu>
22
23
  <v-btn v-if="ctl.issue && !storeEmpty" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-hand-extended-outline" @click="open('issue')">
@@ -117,6 +118,7 @@
117
118
  <InventoryItemDialog v-model="itemDialog.show" :store="store" :item="itemDialog.item" @done="done" />
118
119
  <InventoryImportDialog v-model="showImport" :store="store" @done="done" />
119
120
  <InventoryImportDialog v-model="showReceiveImport" :store="store" kind="receipts" @done="done" />
121
+ <InventoryPoDialog v-model="showPo" :store="store" />
120
122
  <InventoryLegacyImportDialog v-model="showLegacy" :store="store" @done="done" />
121
123
  <v-dialog v-model="bulk.show" max-width="480">
122
124
  <v-card class="screen-modal">
@@ -256,6 +258,7 @@ function openItem(item: TInventoryItem) {
256
258
 
257
259
  const showImport = ref(false);
258
260
  const showReceiveImport = ref(false);
261
+ const showPo = ref(false);
259
262
  const showLegacy = ref(false);
260
263
  // Offer the old Equipment copy only where this store's sites have old records not yet copied.
261
264
  const legacyToCopy = ref(false);
@@ -53,7 +53,7 @@ export default function useInventory() {
53
53
  discardUsageDraft: (id: string) => api(`/usage-drafts/${id}/discard`, { method: "POST" }),
54
54
  submitUsageDraft: (id: string, body: { photosAfter?: string[]; date?: string }) => api<TInventoryTransaction>(`/usage-drafts/${id}/submit`, { method: "POST", body }),
55
55
 
56
- getRequests: (store: string, query: { page?: number; limit?: number; status?: string; mine?: boolean }) =>
56
+ getRequests: (store: string, query: { page?: number; limit?: number; status?: string; mine?: boolean; search?: string }) =>
57
57
  api<{ items: TInventoryRequest[]; total: number; pendingCount: number; page: number; pages: number }>(`/stores/${store}/requests`, { query }),
58
58
  createRequest: (store: string, body: Record<string, any>) => api(`/stores/${store}/requests`, { method: "POST", body }),
59
59
  approveRequest: (id: string, body: { lines?: TInventoryLine[]; remarks?: string }) =>
@@ -84,6 +84,9 @@ export default function useInventory() {
84
84
 
85
85
  getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers", query: TInventoryPeriod & Record<string, any>) =>
86
86
  api<{ rows: any[]; totalQty?: number; totalCost?: number; scope?: "all" | "own_company"; staleAfterDays?: number }>(`/stores/${store}/reports/${report}`, { query }),
87
+ /** One PO across all its deliveries: ordered vs received per item (report viewers and store keepers). */
88
+ getPurchaseOrder: (store: string, po: string) =>
89
+ api<{ po: string; lines: TInventoryPoLine[] }>(`/stores/${store}/reports/purchase-order`, { query: { po } }),
87
90
  exportReport: (store: string, report: string, query: TInventoryPeriod & Record<string, any>) =>
88
91
  api<Blob>(`/stores/${store}/reports/${report}/export`, { query, responseType: "blob" }),
89
92
  checkUsage: (store: string, ids: string[], checked: boolean) =>
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.93.0",
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.",
@@ -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
+ }