@7365admin1/layer-common 4.90.2-staging.492 → 4.90.2-staging.494

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.
@@ -7,6 +7,7 @@
7
7
  <v-btn v-if="ctl.fullReports" value="technicians" class="text-none">{{ worker.many }}</v-btn>
8
8
  <v-btn v-if="ctl.fullReports" value="purchases" class="text-none">Purchases</v-btn>
9
9
  <v-btn v-if="ctl.fullReports || ctl.review" value="handovers" class="text-none">Hand-overs</v-btn>
10
+ <v-btn v-if="stepsLive && (ctl.fullReports || ctl.review)" value="reconciliation" class="text-none">Reconciliation</v-btn>
10
11
  </v-btn-toggle>
11
12
  <v-select
12
13
  v-if="report === 'purchases'"
@@ -21,7 +22,7 @@
21
22
  />
22
23
  <v-text-field v-model="month" type="month" :label="report === 'purchases' && span === 'year' ? 'Up to month' : 'Month'" density="compact" hide-details style="max-width: 180px" />
23
24
  <v-select
24
- v-if="(report === 'usage' || report === 'movement') && sites.length > 1"
25
+ v-if="(report === 'usage' || report === 'movement' || report === 'reconciliation') && sites.length > 1"
25
26
  v-model="site"
26
27
  :items="[{ _id: '', name: 'All properties' }, ...sites]"
27
28
  item-title="name"
@@ -60,6 +61,7 @@
60
61
  hide-details
61
62
  style="max-width: 260px"
62
63
  />
64
+ <v-text-field v-if="report === 'reconciliation'" v-model="itemText" label="Item" prepend-inner-icon="mdi-magnify" density="compact" hide-details clearable style="max-width: 220px" />
63
65
  <v-checkbox v-if="report === 'usage' && canCheck" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
64
66
  <v-spacer />
65
67
  <v-btn v-if="report === 'purchases'" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-clipboard-search-outline" @click="openPo('')">
@@ -146,7 +148,35 @@
146
148
  </v-row>
147
149
  </template>
148
150
 
151
+ <!-- Reconciliation (client 2026-10-02): per item, where every unit went, and whether it tallies. -->
152
+ <template v-if="report === 'reconciliation'">
153
+ <v-progress-linear v-if="loading" indeterminate color="primary" />
154
+ <div v-else-if="!reconRows.length" class="text-center text-medium-emphasis py-10">Nothing moved in this period.</div>
155
+ <v-alert v-if="!loading && reconRows.length && !reconFull" type="info" variant="tonal" density="compact" class="mb-2">
156
+ You see your own company's people only. The store's owner sees the store's side too.
157
+ </v-alert>
158
+ <v-alert v-if="!loading && notTallying" type="error" variant="tonal" density="compact" class="mb-2">
159
+ {{ notTallying === 1 ? "1 item does not" : `${notTallying} items do not` }} tally. They are marked in red below.
160
+ </v-alert>
161
+ <AppCard v-for="r in reconRows" :key="String(r.item)" class="pa-4 mb-3" :style="r.check && !r.check.ok ? 'border-left: 4px solid rgb(var(--v-theme-error))' : ''">
162
+ <div class="d-flex flex-wrap align-center ga-2 mb-1">
163
+ <div class="font-weight-medium">{{ r.name }}</div>
164
+ <div class="text-caption text-medium-emphasis">{{ [r.code, r.unit].filter(Boolean).join(" · ") }}</div>
165
+ <v-spacer />
166
+ <v-chip v-if="r.check" size="small" variant="tonal" :color="r.check.ok ? 'success' : 'error'">{{ r.check.ok ? "Tallies" : "Does not tally" }}</v-chip>
167
+ </div>
168
+ <div v-if="r.store" class="text-body-2 mb-1">
169
+ <strong>Store:</strong> {{ reconStoreText(r) }}
170
+ </div>
171
+ <div v-for="p in r.people" :key="String(p.holder)" class="text-body-2">
172
+ <strong>{{ p.holderLabel || p.name }}:</strong> {{ reconciliationPersonText(p, r.unit) }}
173
+ </div>
174
+ <div v-if="r.check" class="text-caption mt-1" :class="r.check.ok ? 'text-medium-emphasis' : 'text-error'">{{ reconciliationCheckText(r.check) }}</div>
175
+ </AppCard>
176
+ </template>
177
+
149
178
  <v-data-table
179
+ v-else
150
180
  v-model="selected"
151
181
  :headers="tableHeaders"
152
182
  :items="shownRows"
@@ -190,6 +220,9 @@
190
220
  </template>
191
221
  <template #item.state="{ item }">
192
222
  <v-chip size="x-small" variant="tonal" :color="item.status === 'draft' ? 'info' : 'success'">{{ item.status === "draft" ? "In progress" : "Submitted" }}</v-chip>
223
+ <div v-if="item.status === 'draft' && stepsLive && ctl.recordUsage && String(item.holder) !== me" class="mt-1">
224
+ <v-btn size="x-small" variant="outlined" class="text-none" :loading="takingOver === item._id" @click="takeOver(item)">Take over</v-btn>
225
+ </div>
193
226
  <div v-for="f in item.flags ?? []" :key="f" class="mt-1">
194
227
  <v-chip size="x-small" variant="tonal" color="warning">{{ flagWords[f] ?? f }}</v-chip>
195
228
  </div>
@@ -227,10 +260,12 @@
227
260
  import useInventory, { saveBlob } from "../composables/useInventory";
228
261
  import useWorkerTerm, { workerAppKey } from "../composables/useWorkerTerm";
229
262
  import useUtils from "../composables/useUtils";
263
+ import useLocalAuth from "../composables/useLocalAuth";
230
264
  import { inventoryControls } from "../utils/inventory-presets";
231
265
  import { USED_BY_FILTERS } from "../utils/inventory-used-by";
232
266
  import { USAGE_FLAG_WORDS, USAGE_STATUS_CHOICES, usagePathText, usageQuery, usageTotals } from "../utils/inventory-usage";
233
267
  import { receivingLabel } from "../utils/inventory-receiving";
268
+ import { inventoryStepsLive, reconciliationCheckText, reconciliationPersonText, writeOffReasonText } from "../utils/inventory-ack";
234
269
 
235
270
  const props = defineProps({
236
271
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -248,7 +283,8 @@ const appSlug = workerAppKey();
248
283
  const now = new Date();
249
284
  const month = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
250
285
  // An approver without `view-reports` gets only the usage check screen.
251
- const report = ref<"movement" | "usage" | "technicians" | "purchases" | "handovers">(ctl.value.fullReports ? "movement" : "usage");
286
+ const stepsLive = computed(() => inventoryStepsLive(props.store));
287
+ const report = ref<"movement" | "usage" | "technicians" | "purchases" | "handovers" | "reconciliation">(ctl.value.fullReports ? "movement" : "usage");
252
288
  const span = ref<"month" | "year">("year");
253
289
  const site = ref("");
254
290
  const locationType = ref("");
@@ -297,8 +333,35 @@ const help: Record<string, string> = {
297
333
  technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
298
334
  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.",
299
335
  handovers: "Stock passed from one person to a colleague: who gave it, who received it, and whether they confirmed.",
336
+ reconciliation:
337
+ "For each item: what came in (PO, DO, cash, opening stock), what the store issued, and for each person what they took, used (and where), wrote off, gave to colleagues, returned and still hold. Every item should tally; anything that does not is marked red.",
300
338
  };
301
339
 
340
+ // Reconciliation rows, narrowed by the Item box.
341
+ const itemText = ref("");
342
+ const reconFull = ref(true);
343
+ const reconRows = computed(() => {
344
+ if (report.value !== "reconciliation") return [];
345
+ const q = (itemText.value || "").trim().toLowerCase();
346
+ return rows.value.filter((r) => !q || String(r.name ?? "").toLowerCase().includes(q) || String(r.code ?? "").toLowerCase().includes(q));
347
+ });
348
+ const notTallying = computed(() => reconRows.value.filter((r) => r.check && !r.check.ok).length);
349
+ function reconStoreText(r: any) {
350
+ const s = r.store;
351
+ const got = [s.received.po && `${s.received.po} by PO`, s.received.do && `${s.received.do} by DO`, s.received.cash && `${s.received.cash} by cash`, s.received.opening && `${s.received.opening} opening stock`].filter(Boolean).join(", ");
352
+ return [
353
+ `start ${s.opening}`,
354
+ `received ${s.received.total}${got ? ` (${got})` : ""}`,
355
+ `issued ${s.issued}`,
356
+ s.returned ? `returned ${s.returned}` : "",
357
+ s.adjusted ? `stock count ${s.adjusted > 0 ? "+" : ""}${s.adjusted}` : "",
358
+ s.writtenOff ? `written off from the shelf ${s.writtenOff}` : "",
359
+ `end ${s.closing} ${r.unit}`,
360
+ ]
361
+ .filter(Boolean)
362
+ .join(" · ");
363
+ }
364
+
302
365
  const handoverStatus: Record<string, string> = { pending: "Waiting to confirm", accepted: "Confirmed", declined: "Declined", cancelled: "Cancelled" };
303
366
 
304
367
  // "Company · Service" of the person on each row, for the filter (usage, staff and hand-over reports).
@@ -359,6 +422,7 @@ const headers: Record<string, any[]> = {
359
422
  { title: "End", key: "closing" },
360
423
  { title: "Used", key: "usedByTechnicians" },
361
424
  { title: "Usage cost", key: "usedCost" },
425
+ { title: "Written off", key: "writtenOffAll" },
362
426
  ],
363
427
  usage: [
364
428
  { title: "Date", key: "date" },
@@ -392,6 +456,7 @@ const headers: Record<string, any[]> = {
392
456
  { title: "Used", key: "used" },
393
457
  { title: "Returned", key: "returned" },
394
458
  { title: "To colleagues", key: "gaveToColleagues" },
459
+ { title: "Written off", key: "writtenOffText" },
395
460
  { title: "Holding at end", key: "closing" },
396
461
  { title: "In hand now", key: "inHandNow" },
397
462
  ],
@@ -424,15 +489,17 @@ const headers: Record<string, any[]> = {
424
489
  };
425
490
 
426
491
  const siteName = computed(() => (site.value ? props.sites.find((s) => s._id === site.value)?.name ?? "" : ""));
492
+ // The "Written off" columns only from an API that records write-offs.
493
+ const baseHeaders = (key: string) => (stepsLive.value ? headers[key] : (headers[key] ?? []).filter((h) => h.key !== "writtenOffAll" && h.key !== "writtenOffText"));
427
494
  const tableHeaders = computed(() =>
428
495
  report.value === "movement" && siteName.value
429
496
  ? [
430
- ...headers.movement,
497
+ ...baseHeaders("movement"),
431
498
  { title: `Issued for ${siteName.value}`, key: "siteIssued" },
432
499
  { title: `Used at ${siteName.value}`, key: "siteUsed" },
433
500
  { title: `Usage cost at ${siteName.value}`, key: "siteUsedCost" },
434
501
  ]
435
- : headers[report.value],
502
+ : baseHeaders(report.value),
436
503
  );
437
504
 
438
505
  function period(): TInventoryPeriod & { unchecked?: boolean } {
@@ -442,7 +509,7 @@ function period(): TInventoryPeriod & { unchecked?: boolean } {
442
509
  return {
443
510
  from: from.toISOString(),
444
511
  to: to.toISOString(),
445
- ...((report.value === "usage" || report.value === "movement") && site.value ? { site: site.value } : {}),
512
+ ...((report.value === "usage" || report.value === "movement" || report.value === "reconciliation") && site.value ? { site: site.value } : {}),
446
513
  ...(report.value === "usage" && locationType.value ? { locationType: locationType.value } : {}),
447
514
  ...(report.value === "usage" && uncheckedOnly.value ? { unchecked: true } : {}),
448
515
  ...(usedBy.value ? { usedBy: usedBy.value } : {}),
@@ -473,7 +540,13 @@ async function load() {
473
540
  selected.value = [];
474
541
  try {
475
542
  const res: any = await api.getReport(props.store._id, report.value, period());
476
- rows.value = res.rows;
543
+ // Written off: the store shelf plus people's own stock (movement); "5 used up, 2 spoilt" (staff).
544
+ rows.value = (res.rows ?? []).map((r: any) => ({
545
+ ...r,
546
+ ...(r.writtenOff !== undefined || r.writtenOffByTechnicians !== undefined ? { writtenOffAll: (r.writtenOff ?? 0) + (r.writtenOffByTechnicians ?? 0) } : {}),
547
+ ...(report.value === "technicians" ? { writtenOffText: writeOffReasonText(r.writtenOffByReason) || (r.writtenOff ? String(r.writtenOff) : "") } : {}),
548
+ }));
549
+ reconFull.value = res.full !== false;
477
550
  totals.qty = res.totalQty ?? 0;
478
551
  totals.cost = res.totalCost ?? 0;
479
552
  scope.value = res.scope ?? "all";
@@ -488,6 +561,24 @@ async function load() {
488
561
  }
489
562
  }
490
563
 
564
+ // Shift hand-over (client 2026-10-02): a colleague finishes a draft someone else started, from their own stock.
565
+ const { currentUser } = useLocalAuth();
566
+ const me = computed(() => String(currentUser.value?._id ?? ""));
567
+ const takingOver = ref("");
568
+ async function takeOver(r: any) {
569
+ if (!confirm(`Take over ${r.technician || "your colleague"}'s draft for ${r.qty} ${r.unit ?? ""} ${r.itemName}? You finish it (after photo, submit), and the stock comes from YOUR own stock, not theirs.`)) return;
570
+ takingOver.value = r._id;
571
+ try {
572
+ await api.takeOverDraft(String(r._id));
573
+ notify("The draft is yours now. Finish it from My Stock > My drafts.");
574
+ await load();
575
+ } catch (error: any) {
576
+ notify(error?.data?.message || "Could not take over the draft.", "error");
577
+ } finally {
578
+ takingOver.value = "";
579
+ }
580
+ }
581
+
491
582
  async function setChecked(checked: boolean) {
492
583
  if (!selected.value.length) return;
493
584
  checking.value = checked ? "on" : "off";
@@ -4,6 +4,7 @@
4
4
  <v-btn-toggle v-model="status" mandatory density="comfortable" variant="outlined" divided>
5
5
  <v-btn value="pending" class="text-none">Waiting</v-btn>
6
6
  <v-btn value="issued" class="text-none">Approved</v-btn>
7
+ <v-btn v-if="stepsLive" value="confirm" class="text-none">Waiting for Received</v-btn>
7
8
  <v-btn value="rejected" class="text-none">Rejected</v-btn>
8
9
  <v-btn value="" class="text-none">All</v-btn>
9
10
  </v-btn-toggle>
@@ -24,14 +25,20 @@
24
25
 
25
26
  <v-progress-linear v-if="loading" indeterminate color="primary" />
26
27
  <div v-else-if="!requests.length" class="text-center text-medium-emphasis py-10">
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." }}
28
+ {{ search ? `No requests match "${search}". Try another word, or pick "All" to look through every status.` : status === "confirm" ? "Nothing is waiting for anyone to tap Received." : 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." }}
28
29
  </div>
29
30
 
30
- <AppCard v-for="r in requests" :key="r._id" class="pa-4 mb-3">
31
+ <AppCard
32
+ v-for="r in requests"
33
+ :key="r._id"
34
+ class="pa-4 mb-3"
35
+ :style="r._id === focusRequest ? { borderLeft: '4px solid rgb(var(--v-theme-primary))' } : undefined"
36
+ >
31
37
  <div class="d-flex flex-wrap align-center ga-2 mb-2">
32
38
  <div class="font-weight-medium">{{ r.requestedByLabel || r.requestedByName || "Someone" }}</div>
33
39
  <div class="text-caption text-medium-emphasis">{{ standardFormatDateTime(r.createdAt) }}</div>
34
40
  <v-chip size="small" :color="chip[r.status].color" variant="tonal">{{ chip[r.status].label }}</v-chip>
41
+ <v-chip v-if="r.status === 'issued' && r.ackStatus && r.ackStatus !== 'received'" size="small" :color="ACK_CHIP[r.ackStatus].color" variant="tonal">{{ ACK_CHIP[r.ackStatus].label }}</v-chip>
35
42
  <!-- One store can supply several properties: say which one this request is for. -->
36
43
  <v-chip v-if="store.sites.length > 1 && r.siteName" size="small" variant="outlined" prepend-icon="mdi-office-building-outline">For {{ r.siteName }}</v-chip>
37
44
  <v-spacer />
@@ -39,6 +46,7 @@
39
46
  <v-btn v-if="ctl.review" class="text-none screen-btn-primary" variant="flat" @click="openDecision(r)">Review</v-btn>
40
47
  <v-btn v-else-if="isMine(r)" class="text-none screen-btn-ghost" variant="outlined" :loading="busyId === r._id" @click="cancel(r)">Cancel</v-btn>
41
48
  </template>
49
+ <InventoryReceiptActions v-if="r.status === 'issued' && isMine(r) && (r.ackStatus === 'awaiting' || r.ackStatus === 'not_received')" :request="r" @done="changed" />
42
50
  </div>
43
51
  <div v-for="l in r.lines" :key="String(l.item)" class="text-body-2">
44
52
  {{ requestLineText(r.status, l) }}
@@ -47,6 +55,9 @@
47
55
  <div v-if="r.decidedByName" class="text-caption mt-1">
48
56
  {{ requestOutcomeText(r, standardFormatDateTime) }}
49
57
  </div>
58
+ <div v-if="ackText(r, isMine(r))" class="text-body-2 mt-1" :class="r.ackStatus === 'not_received' ? 'text-error' : r.ackStatus === 'awaiting' ? 'text-warning' : 'text-medium-emphasis'">
59
+ {{ ackText(r, isMine(r)) }}
60
+ </div>
50
61
  </AppCard>
51
62
 
52
63
  <div class="text-center" v-if="page < pages">
@@ -66,7 +77,7 @@
66
77
  <div v-for="l in decision.lines" :key="l.item" class="d-flex align-center ga-3 mb-1">
67
78
  <div class="flex-grow-1">
68
79
  <div class="font-weight-medium">{{ l.itemName }}</div>
69
- <div class="text-caption text-medium-emphasis">Asked {{ l.qty }} · In store {{ l.onHand ?? "…" }} {{ l.unit }}</div>
80
+ <div class="text-caption text-medium-emphasis">Asked {{ l.qty }} · You can give up to {{ l.onHand ?? "…" }} {{ l.unit }}{{ l.reservedQty ? ` (${l.reservedQty} set aside for this request)` : "" }}</div>
70
81
  </div>
71
82
  <v-text-field
72
83
  v-model.number="l.give"
@@ -76,7 +87,7 @@
76
87
  label="Give"
77
88
  density="comfortable"
78
89
  style="max-width: 120px"
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`]"
90
+ :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} available`]"
80
91
  />
81
92
  </div>
82
93
  <InputLabel title="Remarks" class="mb-1 mt-2" />
@@ -100,10 +111,13 @@ import useUtils from "../composables/useUtils";
100
111
  import { REQUEST_STATUS, requestLineText, requestOutcomeText } from "../utils/inventory-request-text";
101
112
  import useLocalAuth from "../composables/useLocalAuth";
102
113
  import { inventoryControls } from "../utils/inventory-presets";
114
+ import { ACK_CHIP, ackText, approveCap, inventoryStepsLive } from "../utils/inventory-ack";
103
115
 
104
116
  const props = defineProps({
105
117
  store: { type: Object as PropType<TInventoryStore>, required: true },
106
118
  siteId: { type: String, required: true },
119
+ /** A request to open first (notification link). Shown on top, marked, in the "All" list. */
120
+ focusRequest: { type: String, default: "" },
107
121
  });
108
122
  const ctl = computed(() => inventoryControls(props.store.access));
109
123
  const emit = defineEmits(["changed"]);
@@ -114,8 +128,9 @@ const { currentUser } = useLocalAuth();
114
128
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
115
129
 
116
130
  const chip = REQUEST_STATUS;
131
+ const stepsLive = computed(() => inventoryStepsLive(props.store));
117
132
 
118
- const status = ref("pending");
133
+ const status = ref(props.focusRequest ? "" : "pending");
119
134
  const search = ref("");
120
135
  const requests = ref<TInventoryRequest[]>([]);
121
136
  const page = ref(1);
@@ -129,8 +144,14 @@ const isMine = (r: TInventoryRequest) => String(r.requestedBy) === String(curren
129
144
  async function load(p = 1) {
130
145
  loading.value = true;
131
146
  try {
132
- const res = await api.getRequests(props.store._id, { page: p, limit: 20, status: status.value || undefined, search: search.value?.trim() || undefined });
133
- requests.value = p === 1 ? res.items : [...requests.value, ...res.items];
147
+ // "Waiting for Received" is approved requests whose person has not confirmed yet.
148
+ const confirm = status.value === "confirm";
149
+ const res = await api.getRequests(props.store._id, { page: p, limit: confirm ? 100 : 20, status: confirm ? "issued" : status.value || undefined, search: search.value?.trim() || undefined });
150
+ const got = confirm ? res.items.filter((r) => r.ackStatus === "awaiting" || r.ackStatus === "not_received") : res.items;
151
+ // ponytail: the linked request is found in the first page of "All" (the newest 20); an older one just shows the list.
152
+ const focused = p === 1 && props.focusRequest ? got.find((r) => r._id === props.focusRequest) : undefined;
153
+ const rows = focused ? [focused, ...got.filter((r) => r !== focused)] : got;
154
+ requests.value = p === 1 ? rows : [...requests.value, ...got];
134
155
  page.value = p;
135
156
  pages.value = res.pages;
136
157
  } catch {
@@ -146,7 +167,7 @@ function changed() {
146
167
  }
147
168
 
148
169
  async function cancel(r: TInventoryRequest) {
149
- if (!confirm("Cancel this request? The store keeper will no longer see it.")) return;
170
+ if (!confirm("Cancel this request? The store keeper will no longer see it, and anything set aside for it goes back to the store.")) return;
150
171
  busyId.value = r._id;
151
172
  try {
152
173
  await api.cancelRequest(r._id);
@@ -163,7 +184,7 @@ const decisionForm = ref();
163
184
  const decision = reactive({
164
185
  show: false,
165
186
  request: null as TInventoryRequest | null,
166
- lines: [] as { item: string; itemName: string; unit: string; qty: number; give: number; onHand?: number }[],
187
+ lines: [] as { item: string; itemName: string; unit: string; qty: number; give: number; onHand?: number; reservedQty?: number }[],
167
188
  remarks: "",
168
189
  busy: "" as "" | "approve" | "reject",
169
190
  });
@@ -174,13 +195,14 @@ async function openDecision(r: TInventoryRequest) {
174
195
  request: r,
175
196
  remarks: "",
176
197
  busy: "",
177
- lines: r.lines.map((l) => ({ item: String(l.item), itemName: l.itemName, unit: l.unit, qty: l.qty, give: l.qty, onHand: undefined })),
198
+ lines: r.lines.map((l) => ({ item: String(l.item), itemName: l.itemName, unit: l.unit, qty: l.qty, give: l.qty, onHand: undefined, reservedQty: l.reservedQty })),
178
199
  });
179
200
  try {
180
201
  const res = await api.getItems(props.store._id, { limit: 200, includeInactive: true });
181
202
  for (const line of decision.lines) {
182
203
  const found = res.items.find((i) => i._id === line.item);
183
- line.onHand = found?.onHand ?? 0;
204
+ // What is free, plus what this request already set aside for itself.
205
+ line.onHand = approveCap(found, line.reservedQty ?? 0) ?? 0;
184
206
  line.give = Math.min(line.qty, line.onHand);
185
207
  }
186
208
  } catch {
@@ -197,7 +219,7 @@ async function approve() {
197
219
  lines: decision.lines.map((l) => ({ item: l.item, qty: Number(l.give) })),
198
220
  remarks: decision.remarks,
199
221
  });
200
- notify("Approved. The stock is now with the requester.");
222
+ notify(stepsLive.value ? "Approved. The requester is asked to tap Received once they have the items." : "Approved. The stock is now with the requester.");
201
223
  decision.show = false;
202
224
  changed();
203
225
  } catch (error: any) {
@@ -213,7 +235,7 @@ async function reject() {
213
235
  decision.busy = "reject";
214
236
  try {
215
237
  await api.rejectRequest(decision.request._id, decision.remarks.trim());
216
- notify("Request rejected.");
238
+ notify(stepsLive.value ? "Request rejected. Anything set aside for it is back in the store." : "Request rejected.");
217
239
  decision.show = false;
218
240
  changed();
219
241
  } catch (error: any) {
@@ -21,6 +21,26 @@
21
21
  />
22
22
  </template>
23
23
 
24
+ <template v-if="stepsLive">
25
+ <v-divider class="my-4" />
26
+ <div class="text-subtitle-1 mb-1">Low stock reminders</div>
27
+ <p class="text-body-2 text-medium-emphasis mb-2">
28
+ When an item goes low, the people who manage this store get an alert. Choose how often to remind them until someone records a PO number for it.
29
+ </p>
30
+ <v-text-field
31
+ v-model.number="reminderDays"
32
+ type="number"
33
+ min="0"
34
+ max="30"
35
+ label="Remind every (days)"
36
+ density="comfortable"
37
+ style="max-width: 220px"
38
+ :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= 30) || 'A whole number from 0 to 30']"
39
+ :hint="reminderDaysText(reminderDays)"
40
+ persistent-hint
41
+ />
42
+ </template>
43
+
24
44
  <v-divider class="my-4" />
25
45
  <div class="text-subtitle-1 mb-1">Service365 app</div>
26
46
  <p class="text-body-2 text-medium-emphasis mb-2">
@@ -40,6 +60,7 @@
40
60
  import useInventory from "../composables/useInventory";
41
61
  import useUtils from "../composables/useUtils";
42
62
  import { inventorySiteOptions } from "../utils/inventory-store-label";
63
+ import { inventoryStepsLive, reminderDaysText } from "../utils/inventory-ack";
43
64
 
44
65
  const props = defineProps({
45
66
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -57,6 +78,8 @@ const name = ref("");
57
78
  const sites = ref<string[]>([]);
58
79
  const showInResidentApp = ref(false);
59
80
  const showCostInResidentApp = ref(false);
81
+ const reminderDays = ref<number>(1);
82
+ const stepsLive = computed(() => inventoryStepsLive(props.store));
60
83
 
61
84
  const siteItems = computed(() => inventorySiteOptions(props.orgSites, props.store));
62
85
 
@@ -65,6 +88,7 @@ function reset() {
65
88
  sites.value = [...props.store.sites];
66
89
  showInResidentApp.value = props.store.showInResidentApp;
67
90
  showCostInResidentApp.value = props.store.showCostInResidentApp;
91
+ reminderDays.value = props.store.lowStockReminderDays ?? 1;
68
92
  }
69
93
 
70
94
  async function save() {
@@ -77,6 +101,8 @@ async function save() {
77
101
  sites: sites.value,
78
102
  showInResidentApp: showInResidentApp.value,
79
103
  showCostInResidentApp: showInResidentApp.value && showCostInResidentApp.value,
104
+ // Only to an API that has reminders (an older one refuses the unknown field).
105
+ ...(stepsLive.value ? { lowStockReminderDays: Number(reminderDays.value) } : {}),
80
106
  });
81
107
  notify("Settings saved.");
82
108
  emit("saved");
@@ -87,5 +113,5 @@ async function save() {
87
113
  }
88
114
  }
89
115
 
90
- watch(() => props.store, reset, { immediate: true });
116
+ watch(() => [props.store, props.store.lowStockReminderDays], reset, { immediate: true });
91
117
  </script>
@@ -96,10 +96,17 @@
96
96
  </div>
97
97
  </template>
98
98
  <template #item.onHand="{ item }">
99
- <span :class="item.lowStock ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ item.onHand }}</span>
99
+ <!-- Owner 2026-10-03: what is requested comes off this number at once (set aside), so everyone sees what is really left. -->
100
+ <span :class="item.lowStock ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ availableQty(item) }}</span>
100
101
  <span class="text-medium-emphasis ml-1">{{ item.unit }}</span>
101
102
  <v-chip v-if="item.lowStock" size="x-small" color="error" variant="tonal" class="ml-2">Low</v-chip>
102
103
  <v-chip v-if="item.status === 'inactive'" size="x-small" variant="tonal" class="ml-2">Not in use</v-chip>
104
+ <div v-if="item.reserved" class="text-caption text-medium-emphasis">+ {{ item.reserved }} set aside for requests (still on the shelf)</div>
105
+ <div v-if="item.awaitingReceipt" class="text-caption text-medium-emphasis">{{ item.awaitingReceipt }} handed out, waiting for Received</div>
106
+ <div v-if="item.lowStockAlert" class="text-caption text-error" @click.stop>
107
+ {{ lowStockAlertText(item.lowStockAlert, standardFormatDate) }}
108
+ <a v-if="canAckLow && !item.lowStockAlert.poNumber" href="#" class="text-primary ml-1" @click.prevent.stop="openAck(item)">Acknowledge</a>
109
+ </div>
103
110
  </template>
104
111
  <template #item.reorderLevel="{ value }">{{ value || "—" }}</template>
105
112
  <template #item.usedBy="{ item }">{{ usedByText(item.usedBy) }}</template>
@@ -143,6 +150,23 @@
143
150
  </div>
144
151
  </v-card>
145
152
  </v-dialog>
153
+ <v-dialog v-model="ack.show" max-width="480">
154
+ <v-card v-if="ack.item" class="screen-modal">
155
+ <v-card-title>Low stock: {{ ack.item.name }}</v-card-title>
156
+ <v-card-text class="screen-modal__body">
157
+ <p class="text-body-2 mb-3">
158
+ {{ availableQty(ack.item) }} {{ ack.item.unit }} left (alert at {{ ack.item.reorderLevel }}). Record the PO number once you have ordered more:
159
+ the reminders then stop. Without a PO number, we note that you have seen it and the reminders carry on.
160
+ </p>
161
+ <InputLabel title="PO number (optional)" class="mb-1" />
162
+ <v-text-field v-model="ack.poNumber" density="comfortable" maxlength="100" placeholder="e.g. PO-0412" />
163
+ </v-card-text>
164
+ <div class="screen-modal__footer">
165
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="ack.saving" @click="ack.show = false">Cancel</v-btn>
166
+ <v-btn class="screen-btn-primary" variant="flat" :loading="ack.saving" @click="saveAck">{{ ack.poNumber.trim() ? "Save PO and stop reminders" : "Mark as seen" }}</v-btn>
167
+ </div>
168
+ </v-card>
169
+ </v-dialog>
146
170
  <InventoryItemDetail v-model="detail.show" :store="store" :item="detail.item" @edit="editItem" @done="done" />
147
171
  </div>
148
172
  </template>
@@ -154,6 +178,7 @@ import useUtils from "../composables/useUtils";
154
178
  import { inventoryItemMeta } from "../utils/inventory-store-label";
155
179
  import { inventoryControls } from "../utils/inventory-presets";
156
180
  import { hasLegacyEquipmentToCopy } from "../utils/inventory-legacy";
181
+ import { availableQty, lowStockAlertText } from "../utils/inventory-ack";
157
182
  import { nextUsedBy, USED_BY_ALL, USED_BY_ALL_LABEL, USED_BY_FILTERS, USED_BY_HINT, USED_BY_OPTIONS, usedByChips, usedByText } from "../utils/inventory-used-by";
158
183
 
159
184
  const props = defineProps({
@@ -164,7 +189,30 @@ const props = defineProps({
164
189
  const ctl = computed(() => inventoryControls(props.store.access));
165
190
  const emit = defineEmits(["changed"]);
166
191
 
167
- const { getItems, getLegacyEquipment, setUsedBy } = useInventory();
192
+ const { getItems, getLegacyEquipment, setUsedBy, acknowledgeLowStock } = useInventory();
193
+ const { standardFormatDate } = useUtils();
194
+
195
+ // Low stock acknowledge (client 2026-10-02): store managers, approvers and store keepers.
196
+ const canAckLow = computed(() => ctl.value.addItems || ctl.value.review || ctl.value.receive);
197
+ const ack = reactive({ show: false, item: null as TInventoryItem | null, poNumber: "", saving: false });
198
+ function openAck(item: TInventoryItem) {
199
+ Object.assign(ack, { show: true, item, poNumber: "", saving: false });
200
+ }
201
+ async function saveAck() {
202
+ if (!ack.item) return;
203
+ ack.saving = true;
204
+ try {
205
+ const po = ack.poNumber.trim();
206
+ await acknowledgeLowStock(ack.item._id, po);
207
+ notify(po ? `PO ${po} recorded for ${ack.item.name}. The low stock reminders have stopped.` : `Noted that you have seen ${ack.item.name} is low. Reminders continue until a PO number is recorded.`);
208
+ ack.show = false;
209
+ load();
210
+ } catch (error: any) {
211
+ notify(error?.data?.message || "Could not save. Please try again.", "error");
212
+ } finally {
213
+ ack.saving = false;
214
+ }
215
+ }
168
216
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
169
217
 
170
218
  // Used by belongs to the main (property) store; its owner sees the column, the filter and bulk edit.
@@ -210,7 +258,7 @@ const pageRange = computed(() => (total.value ? `${(page.value - 1) * limit + 1}
210
258
 
211
259
  const headers = computed(() => [
212
260
  { title: "Item", value: "name" },
213
- { title: "In store", value: "onHand" },
261
+ { title: "Available", value: "onHand" },
214
262
  { title: "Low stock alert at", value: "reorderLevel" },
215
263
  ...(usedByOwner.value ? [{ title: "Used by", value: "usedBy" }] : []),
216
264
  ...(ctl.value.unitPrice ? [{ title: "Unit price", value: "unitCost" }] : []),
@@ -6,6 +6,17 @@
6
6
  <v-text-field v-model="search" density="compact" placeholder="Search name or item" clearable hide-details append-inner-icon="mdi-magnify" style="max-width: 280px" />
7
7
  </div>
8
8
 
9
+ <!-- Returns the holder started (owner 2026-10-03): accept once the items are back on the shelf. -->
10
+ <AppCard v-for="h in returns" :key="h._id" class="pa-4 mb-3 d-flex flex-wrap align-center ga-3" style="border-left: 4px solid rgb(var(--v-theme-warning))">
11
+ <v-icon icon="mdi-keyboard-return" color="warning" />
12
+ <div class="flex-grow-1" style="min-width: 220px">
13
+ <div class="font-weight-medium">{{ h.fromLabel || h.fromName }} wants to return {{ h.qty }} {{ h.unit }} {{ h.itemName }}</div>
14
+ <div class="text-caption text-medium-emphasis">{{ standardFormatDate(h.createdAt) }}{{ h.note ? ` · "${h.note}"` : "" }} · Accept once you have the items back; they go back into the store.</div>
15
+ </div>
16
+ <v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" :disabled="!!acting" @click="openDecline(h)">Not received</v-btn>
17
+ <v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === h._id" :disabled="!!acting" @click="acceptReturn(h)">Accept return</v-btn>
18
+ </AppCard>
19
+
9
20
  <v-progress-linear v-if="loading" indeterminate color="primary" />
10
21
  <div v-else-if="!groups.length" class="text-center text-medium-emphasis py-10">Nobody is holding stock from this store.</div>
11
22
 
@@ -17,7 +28,9 @@
17
28
  <v-btn v-if="ctl.takeReturn" class="text-none screen-btn-ghost" variant="outlined" size="small" prepend-icon="mdi-keyboard-return" @click="openReturn(g)">Receive return</v-btn>
18
29
  </div>
19
30
  <div class="d-flex flex-wrap ga-2">
20
- <v-chip v-for="h in g.items" :key="h._id" variant="tonal">{{ h.itemName }}: {{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</v-chip>
31
+ <v-chip v-for="h in g.items" :key="h._id" variant="tonal">
32
+ {{ h.itemName }}: {{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month{{ h.unacknowledged ? ` · ${h.unacknowledged} not yet confirmed as received` : "" }}
33
+ </v-chip>
21
34
  </div>
22
35
  </AppCard>
23
36
 
@@ -30,18 +43,76 @@
30
43
  :holdings="ret.items"
31
44
  @done="changed"
32
45
  />
46
+ <v-dialog v-model="decline.show" max-width="480">
47
+ <v-card v-if="decline.handover" class="screen-modal">
48
+ <v-card-title>Return not received</v-card-title>
49
+ <v-card-text class="screen-modal__body">
50
+ <p class="text-body-2 mb-3">The {{ decline.handover.itemName }} stays with {{ decline.handover.fromName }}. Tell them why, so they know what to do.</p>
51
+ <v-textarea v-model="decline.reason" rows="2" auto-grow density="comfortable" maxlength="500" placeholder="e.g. nothing was brought to the store" />
52
+ </v-card-text>
53
+ <div class="screen-modal__footer">
54
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="!!acting" @click="decline.show = false">Cancel</v-btn>
55
+ <v-btn class="screen-btn-primary" variant="flat" :loading="!!acting" @click="declineReturn">Send</v-btn>
56
+ </div>
57
+ </v-card>
58
+ </v-dialog>
33
59
  </div>
34
60
  </template>
35
61
 
36
62
  <script setup lang="ts">
37
63
  import useInventory from "../composables/useInventory";
38
64
  import { inventoryControls } from "../utils/inventory-presets";
65
+ import useUtils from "../composables/useUtils";
39
66
 
40
67
  const props = defineProps({ store: { type: Object as PropType<TInventoryStore>, required: true } });
41
68
  const ctl = computed(() => inventoryControls(props.store.access));
42
69
  const emit = defineEmits(["changed"]);
43
70
 
44
- const { getHoldings } = useInventory();
71
+ const { getHoldings, getHandovers, acceptHandover, declineHandover } = useInventory();
72
+ const { standardFormatDate } = useUtils();
73
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
74
+
75
+ const returns = ref<TInventoryHandover[]>([]);
76
+ const acting = ref("");
77
+ const decline = reactive({ show: false, handover: null as TInventoryHandover | null, reason: "" });
78
+ async function loadReturns() {
79
+ if (!ctl.value.takeReturn) return (returns.value = []);
80
+ try {
81
+ returns.value = (await getHandovers(props.store._id, { status: "pending", limit: 50 })).items.filter((h) => h.kind === "return");
82
+ } catch {
83
+ returns.value = [];
84
+ }
85
+ }
86
+ async function acceptReturn(h: TInventoryHandover) {
87
+ acting.value = h._id;
88
+ try {
89
+ await acceptHandover(h._id);
90
+ notify(`${h.qty} ${h.unit} ${h.itemName} is back in the store.`);
91
+ changed();
92
+ } catch (error: any) {
93
+ notify(error?.data?.message || "Could not accept the return.", "error");
94
+ } finally {
95
+ acting.value = "";
96
+ }
97
+ }
98
+ function openDecline(h: TInventoryHandover) {
99
+ Object.assign(decline, { show: true, handover: h, reason: "" });
100
+ }
101
+ async function declineReturn() {
102
+ const h = decline.handover;
103
+ if (!h) return;
104
+ acting.value = h._id;
105
+ try {
106
+ await declineHandover(h._id, decline.reason.trim());
107
+ notify(`Return not accepted. The ${h.itemName} stays with ${h.fromName}, who has been told.`);
108
+ decline.show = false;
109
+ changed();
110
+ } catch (error: any) {
111
+ notify(error?.data?.message || "Could not update the return.", "error");
112
+ } finally {
113
+ acting.value = "";
114
+ }
115
+ }
45
116
  const holdings = ref<TInventoryHolding[]>([]);
46
117
  const loading = ref(false);
47
118
  const search = ref("");
@@ -76,9 +147,10 @@ function openReturn(g: { holder: string; holderName: string; items: TInventoryHo
76
147
 
77
148
  function changed() {
78
149
  load();
150
+ loadReturns();
79
151
  emit("changed");
80
152
  }
81
153
 
82
- watch(() => props.store._id, load);
83
- onMounted(load);
154
+ watch(() => props.store._id, () => (load(), loadReturns()));
155
+ onMounted(() => (load(), loadReturns()));
84
156
  </script>