@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 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
- GET /api/members/user/:user/app/admin the Seven365 staff membership
2048
- 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
2049
2065
 
2050
- owner = member.type === "admin" && role.type === "admin" && role.default === true
2051
- 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"
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.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);
@@ -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="false"
78
+ :close-on-content-click="true"
67
79
  offset-y
68
- width="150"
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 @click="openConfirmDialog(item._id ?? '')" v-if="item.status === 'pending'">
76
- Cancel Invite
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
- Are you sure you want to cancel this invitation? This action cannot be
99
- undone.
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: "metadata.siteName",
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
- _getVerifications({
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 selectedInviteId = ref<string | null>(null);
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(id: string) {
316
- selectedInviteId.value = id;
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 (!selectedInviteId.value) return;
390
+ if (!selectedInviteIds.value.length) return;
322
391
 
323
392
  cancelLoading.value = true;
324
393
  try {
325
- const res = await cancelUserInvitation(selectedInviteId.value);
326
-
327
- showMessage(res.message || "Invitation cancelled successfully", "success");
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
- selectedInviteId.value = null;
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
  }
@@ -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
- const serviceProviderEmail = (route.query.email as string) ?? "";
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.92.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
+ }
@@ -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
+ }