@7365admin1/layer-common 4.90.2-staging.491 → 4.90.2-staging.493

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.
@@ -64,7 +64,12 @@
64
64
  <template #item.photos="{ item }">
65
65
  <InventoryPhotoThumbs :photos="item.photos" :before-at="item.photosBeforeAt" :after-at="item.photosAfterAt" />
66
66
  </template>
67
- <template #item.by="{ item }">{{ item.createdByLabel || item.createdByName }}</template>
67
+ <template #item.by="{ item }">
68
+ {{ item.createdByLabel || item.createdByName }}
69
+ <div v-if="item.attendedWith" class="text-caption text-medium-emphasis">Also attended: {{ item.attendedWith }}</div>
70
+ <div v-if="item.startedByName" class="text-caption text-medium-emphasis">Started by {{ item.startedByName }}</div>
71
+ <div v-if="item.type === 'write_off' && item.remarks" class="text-caption text-medium-emphasis">"{{ item.remarks }}"</div>
72
+ </template>
68
73
  <template #item.holderBalance="{ value }">{{ value ?? "—" }}</template>
69
74
  </v-data-table-server>
70
75
  </v-card-text>
@@ -77,6 +82,7 @@ import useInventory from "../composables/useInventory";
77
82
  import useUtils from "../composables/useUtils";
78
83
  import useLocalAuth from "../composables/useLocalAuth";
79
84
  import { historyText, whereText } from "../utils/inventory-usage";
85
+ import { inventoryStepsLive } from "../utils/inventory-ack";
80
86
 
81
87
  /**
82
88
  * History of stock (owner 2026-09-29, "where they used their stock"): one person's (`holder`, "me" =
@@ -116,6 +122,7 @@ const typeChoices = [
116
122
  { value: "issue", title: "Taken from store" },
117
123
  { value: "handover", title: "Hand-overs" },
118
124
  { value: "return", title: "Given back" },
125
+ ...(inventoryStepsLive(props.store) ? [{ value: "write_off", title: "Written off" }] : []),
119
126
  ];
120
127
 
121
128
  const intro = computed(() =>
@@ -11,6 +11,8 @@
11
11
  <v-img v-if="item.photo" :src="getFileUrl(item.photo)" width="120" height="120" cover class="rounded flex-grow-0" />
12
12
  <div class="flex-grow-1">
13
13
  <div class="text-h5 mb-1" :class="item.lowStock ? 'text-error' : ''">{{ item.onHand }} {{ item.unit }} in store</div>
14
+ <div v-if="item.reserved" class="text-body-2 mb-1">{{ availableText(item) }} (the rest is set aside for requests waiting for approval)</div>
15
+ <div v-if="item.lowStockAlert" class="text-body-2 text-error mb-1">{{ lowStockAlertText(item.lowStockAlert, standardFormatDate) }}</div>
14
16
  <div class="text-body-2 text-medium-emphasis">
15
17
  {{ [inventoryItemMeta(item), item.reorderLevel ? `Alert at ${item.reorderLevel}` : "", item.unitCost !== undefined ? `$${Number(item.unitCost).toFixed(2)} each` : ""].filter(Boolean).join(" · ") }}
16
18
  </div>
@@ -21,7 +23,18 @@
21
23
  <div class="d-flex flex-wrap ga-2 mb-4" v-if="ctl.editItem || ctl.count">
22
24
  <v-btn v-if="ctl.editItem" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-pencil-outline" @click="emit('edit', item)">Edit item</v-btn>
23
25
  <v-btn v-if="ctl.count" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-counter" @click="counting = !counting">Stock count</v-btn>
26
+ <v-btn v-if="ctl.count && stepsLive && item.onHand > 0" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-delete-alert-outline" @click="writingOff = true">Write off</v-btn>
24
27
  </div>
28
+ <InventoryWriteOffDialog
29
+ v-model="writingOff"
30
+ :store="store"
31
+ from="store"
32
+ :item="item._id"
33
+ :item-name="item.name"
34
+ :unit="item.unit"
35
+ :max="item.onHand"
36
+ @done="(show = false), emit('done')"
37
+ />
25
38
 
26
39
  <v-form v-if="counting" ref="countForm" class="mb-4">
27
40
  <AppCard class="pa-4">
@@ -80,6 +93,7 @@ import useUtils from "../composables/useUtils";
80
93
  import { inventoryItemMeta } from "../utils/inventory-store-label";
81
94
  import { inventoryControls } from "../utils/inventory-presets";
82
95
  import { receiptRefText } from "../utils/inventory-receiving";
96
+ import { availableText, inventoryStepsLive, lowStockAlertText, writeOffLabel } from "../utils/inventory-ack";
83
97
 
84
98
  const show = defineModel<boolean>({ default: false });
85
99
  const props = defineProps({
@@ -103,6 +117,8 @@ const counted = ref<number | null>(null);
103
117
  const reason = ref("");
104
118
  const saving = ref(false);
105
119
  const countForm = ref();
120
+ const writingOff = ref(false);
121
+ const stepsLive = computed(() => inventoryStepsLive(props.store));
106
122
 
107
123
  const formatDate = (d: string) => standardFormatDate(d);
108
124
 
@@ -121,6 +137,7 @@ function describe(t: TInventoryTransaction) {
121
137
  t.reference || "",
122
138
  );
123
139
  if (t.type === "adjust") parts.push(t.remarks || "");
140
+ if (t.type === "write_off") parts.push(`${t.qty} ${writeOffLabel(t.writeOffReason).toLowerCase()}`, (t.holderChange ?? 0) < 0 ? `by ${who}` : "from the shelf", t.remarks || "");
124
141
  if (t.type === "opening") parts.push(t.supplier || "", t.reference ? `PO ${t.reference}` : "", t.receivedBy ? `received by ${t.receivedBy}` : "", t.remarks || "");
125
142
  return parts.filter(Boolean).join(" · ");
126
143
  }
@@ -170,6 +170,7 @@
170
170
  import useInventory, { pickedDate } from "../composables/useInventory";
171
171
  import useUtils from "../composables/useUtils";
172
172
  import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
173
+ import { availableQty, availableText as availableTextOf } from "../utils/inventory-ack";
173
174
  import { RECEIVING_TYPES, receivingFields, receivingProblem, type TReceivingType } from "../utils/inventory-receiving";
174
175
 
175
176
  type TMode = "receive" | "issue" | "request" | "return";
@@ -217,7 +218,25 @@ const COPY: Record<TMode, { title: string; help: string; button: string; success
217
218
  success: "Stock returned to the store.",
218
219
  },
219
220
  };
220
- const copy = computed(() => COPY[props.mode]);
221
+ // The API sets requested stock aside at once (owner 2026-10-03) when its items carry `available`.
222
+ const reserving = computed(() => itemChoices.value.some((i) => i.available !== undefined));
223
+ const copy = computed(() => {
224
+ const c = COPY[props.mode];
225
+ if (!reserving.value) return c;
226
+ if (props.mode === "request")
227
+ return {
228
+ ...c,
229
+ help: "Ask the store for what you need, up to what is available (shown under each item). The quantity is set aside for you as soon as you send it, so nobody else can take it. If the request is rejected or cancelled, it goes back to the store.",
230
+ success: "Request sent. The items are set aside for you until the store keeper approves. Then open My Requests and tap Received once you have them.",
231
+ };
232
+ if (props.mode === "issue")
233
+ return {
234
+ ...c,
235
+ help: "Hand stock to someone at the store counter. It leaves the store at once. They tap Received in their app to confirm they got it, and can use it after that.",
236
+ success: "Stock issued. The person is asked to tap Received to confirm they got it.",
237
+ };
238
+ return c;
239
+ });
221
240
 
222
241
  const form = ref();
223
242
  const saving = ref(false);
@@ -254,12 +273,15 @@ const choices = computed<any[]>(() =>
254
273
  const siteChoices = computed(() => (props.sites.length ? props.sites : props.store.sites.map((_id) => ({ _id, name: _id === props.siteId ? "This property" : "Other property" }))));
255
274
 
256
275
  const unitOf = (id: string) => choices.value.find((c) => c._id === id)?.unit ?? "";
257
- const availableOf = (id: string) => choices.value.find((c) => c._id === id)?.onHand;
276
+ const availableOf = (id: string) => {
277
+ const c = choices.value.find((x) => x._id === id);
278
+ return c && (props.mode === "request" || props.mode === "issue") ? availableQty(c) : c?.onHand;
279
+ };
258
280
 
259
281
  function availableText(item: any) {
260
282
  if (props.mode === "return") return `In hand: ${item.onHand} ${item.unit}`;
261
283
  if (props.mode === "receive") return `In store: ${item.onHand} ${item.unit}`;
262
- return `Available: ${item.onHand} ${item.unit}`;
284
+ return availableTextOf(item);
263
285
  }
264
286
 
265
287
  function qtyRule(line: TRow) {
@@ -270,7 +292,7 @@ function qtyRule(line: TRow) {
270
292
  const total = rows.value.filter((r) => r.item === line.item).reduce((s, r) => s + Number(r.qty || 0), 0);
271
293
  // Requests too (client 2026-10-02): nobody can ask for more than the store has; the server refuses it as well.
272
294
  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";
295
+ return available > 0 ? `Only ${available} ${unitOf(line.item)} available — ${props.mode === "request" ? "ask for" : "give"} ${available} or fewer` : "None available right now";
274
296
  return true;
275
297
  };
276
298
  }
@@ -34,6 +34,22 @@
34
34
  Equipment is now in Inventory.
35
35
  </v-alert>
36
36
 
37
+ <!-- Client 2026-10-02 (Winsland): each property keeps its own stock, so say plainly whose stock this is. -->
38
+ <v-alert v-if="mySites.length > 1" type="info" variant="tonal" density="compact" class="mb-4" icon="mdi-office-building-marker-outline">
39
+ <div class="d-flex flex-wrap align-center ga-2">
40
+ <span>You are viewing <strong>{{ currentSiteName }}</strong>'s inventory.</span>
41
+ <v-spacer />
42
+ <v-menu>
43
+ <template #activator="{ props: menu }">
44
+ <v-btn v-bind="menu" size="small" variant="outlined" class="text-none" append-icon="mdi-chevron-down">Switch property</v-btn>
45
+ </template>
46
+ <v-list density="compact" max-height="320">
47
+ <v-list-item v-for="s in mySites" :key="s._id" :title="s.name" :active="s._id === siteId" @click="switchSite(s._id)" />
48
+ </v-list>
49
+ </v-menu>
50
+ </div>
51
+ </v-alert>
52
+
37
53
  <v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
38
54
 
39
55
  <AppCard v-else-if="loadError" class="pa-6">
@@ -99,6 +115,12 @@
99
115
  <v-btn size="small" variant="flat" color="warning" class="text-none" @click="tab = 'requests'">Review requests</v-btn>
100
116
  </div>
101
117
  </v-alert>
118
+ <v-alert v-if="toConfirm > 0 && tab !== 'mine'" type="warning" variant="tonal" density="compact" class="mb-4" icon="mdi-package-variant-closed-check">
119
+ <div class="d-flex align-center flex-wrap ga-2">
120
+ <span>{{ toConfirm === 1 ? "1 delivery is" : `${toConfirm} deliveries are` }} waiting for you to tap Received. You can use the items after that.</span>
121
+ <v-btn size="small" variant="flat" color="warning" class="text-none" @click="tab = 'mine'">Open My Stock</v-btn>
122
+ </div>
123
+ </v-alert>
102
124
  <v-tabs v-model="tab" color="primary" class="mb-4" show-arrows>
103
125
  <v-tab v-for="t in tabs" :key="t.value" :value="t.value" class="text-none">
104
126
  {{ t.label }}
@@ -139,9 +161,12 @@
139
161
  import useInventory from "../composables/useInventory";
140
162
  import useWorkerTerm from "../composables/useWorkerTerm";
141
163
  import useSite from "../composables/useSite";
164
+ import useCustomerSite from "../composables/useCustomerSite";
142
165
  import useUtils from "../composables/useUtils";
143
166
  import { inventoryManagedBy, inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
144
167
  import { inventoryControls } from "../utils/inventory-presets";
168
+ import { inventoryStepsLive } from "../utils/inventory-ack";
169
+ import useLocalAuth from "../composables/useLocalAuth";
145
170
 
146
171
  const props = defineProps({
147
172
  orgId: { type: String, required: true },
@@ -153,7 +178,7 @@ const movedFromEquipment = useRoute().query.moved === "equipment";
153
178
  const linkQuery = useRoute().query;
154
179
  const linked = { storeId: String(linkQuery.storeId ?? ""), tab: String(linkQuery.tab ?? "") };
155
180
 
156
- const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
181
+ const { getHandovers, getStores, getStore, createStore: apiCreateStore, getRequests, getReport } = useInventory();
157
182
  const { getSites } = useSite();
158
183
  const { requiredRule } = useUtils();
159
184
 
@@ -217,7 +242,7 @@ const tabs = computed(() => {
217
242
  // Hide, never grey: each tab only for the job that uses it (API-core decides `access`).
218
243
  ...(ctl.value.tabs.mine ? [{ value: "mine", label: "My Stock" }] : []),
219
244
  ...(ctl.value.tabs.requests ? [{ value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 }] : []),
220
- ...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many }] : []),
245
+ ...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many, badge: returnsWaiting.value }] : []),
221
246
  ...(ctl.value.tabs.reports ? [{ value: "reports", label: a.canReports ? "Reports" : "Usage check" }] : []),
222
247
  ...(!ctl.value.tabs.reports && companyUsage.value ? [{ value: "reports", label: "Company usage" }] : []),
223
248
  ...(ctl.value.tabs.suppliers ? [{ value: "suppliers", label: "Suppliers" }] : []),
@@ -244,6 +269,7 @@ async function loadStores() {
244
269
  loading.value = false;
245
270
  }
246
271
  refreshCounts();
272
+ loadStoreDetail();
247
273
  }
248
274
 
249
275
  /**
@@ -265,7 +291,38 @@ async function probeCompanyUsage() {
265
291
  }
266
292
  }
267
293
 
294
+ /** Stock issued to me that I have not confirmed yet (only where the API has the Received step). */
295
+ const toConfirm = ref(0);
296
+ const { currentUser } = useLocalAuth();
297
+ async function refreshToConfirm() {
298
+ const s = store.value;
299
+ toConfirm.value = 0;
300
+ if (!s || !ctl.value.tabs.mine || !inventoryStepsLive(s)) return;
301
+ try {
302
+ const me = String(currentUser.value?._id ?? "");
303
+ const res = await getRequests(s._id, { status: "issued", limit: 100 });
304
+ toConfirm.value = res.items.filter((r) => String(r.requestedBy) === me && (r.ackStatus === "awaiting" || r.ackStatus === "not_received")).length;
305
+ } catch {
306
+ toConfirm.value = 0;
307
+ }
308
+ }
309
+
310
+ /** Returns started by holders, waiting for a store keeper (badge on the staff tab). */
311
+ const returnsWaiting = ref(0);
312
+ async function refreshReturns() {
313
+ const s = store.value;
314
+ returnsWaiting.value = 0;
315
+ if (!s || !ctl.value.takeReturn || !inventoryStepsLive(s)) return;
316
+ try {
317
+ returnsWaiting.value = (await getHandovers(s._id, { status: "pending", limit: 1 }))?.returnsWaiting ?? 0;
318
+ } catch {
319
+ returnsWaiting.value = 0;
320
+ }
321
+ }
322
+
268
323
  async function refreshCounts() {
324
+ refreshToConfirm();
325
+ refreshReturns();
269
326
  if (!store.value?.access.canApprove) return (pendingCount.value = 0);
270
327
  try {
271
328
  pendingCount.value = (await getRequests(store.value._id, { status: "pending", limit: 1 }))?.pendingCount ?? 0;
@@ -274,6 +331,43 @@ async function refreshCounts() {
274
331
  }
275
332
  }
276
333
 
334
+ /** The properties this person works at in this organisation (the same list as the site switcher). */
335
+ const { getAll: getCustomerSites } = useCustomerSite();
336
+ const mySites = ref<{ _id: string; name: string }[]>([]);
337
+ const currentSiteName = computed(() => mySites.value.find((s) => s._id === props.siteId)?.name || storeSites.value.find((s) => s._id === props.siteId)?.name || "this property");
338
+ async function loadMySites() {
339
+ try {
340
+ const res = await getCustomerSites({ org: props.orgId, limit: 100 });
341
+ const seen = new Set<string>();
342
+ mySites.value = (res?.items ?? [])
343
+ .map((i: any) => ({ _id: String(i.site ?? ""), name: String(i.name ?? "") }))
344
+ .filter((s: { _id: string }) => s._id && !seen.has(s._id) && seen.add(s._id));
345
+ } catch {
346
+ mySites.value = [];
347
+ }
348
+ }
349
+ const route = useRoute();
350
+ function switchSite(site: string) {
351
+ if (site === props.siteId) return;
352
+ navigateTo({ name: route.name as string, params: { ...route.params, site } });
353
+ }
354
+
355
+ /** The store's own settings (the reminder interval also tells the screens the new steps exist on the server). */
356
+ async function loadStoreDetail() {
357
+ const s = store.value;
358
+ if (!s) return;
359
+ try {
360
+ const full = await getStore(s._id);
361
+ if (typeof full?.lowStockReminderDays === "number") {
362
+ s.lowStockReminderDays = full.lowStockReminderDays;
363
+ refreshToConfirm();
364
+ refreshReturns();
365
+ }
366
+ } catch {
367
+ /* the list's copy is enough to work with */
368
+ }
369
+ }
370
+
277
371
  async function loadOrgSites() {
278
372
  try {
279
373
  const res = await getSites({ orgId: props.orgId, limit: 100 });
@@ -307,11 +401,13 @@ watch(storeId, () => {
307
401
  tab.value = wanted && tabs.value.some((t) => t.value === wanted) ? wanted : "stock";
308
402
  refreshCounts();
309
403
  probeCompanyUsage();
404
+ loadStoreDetail();
310
405
  });
311
406
  watch(() => props.siteId, loadStores);
312
407
 
313
408
  onMounted(() => {
314
409
  loadStores();
315
410
  loadOrgSites();
411
+ loadMySites();
316
412
  });
317
413
  </script>
@@ -6,6 +6,21 @@
6
6
  <v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">Request Stock</v-btn>
7
7
  </div>
8
8
 
9
+ <!-- Issued to me, waiting for me to tap Received (owner 2026-10-03). Reminded daily until I do. -->
10
+ <AppCard
11
+ v-for="r in toReceive"
12
+ :key="r._id"
13
+ class="pa-4 mb-3 d-flex flex-wrap align-center ga-3"
14
+ :style="{ borderLeft: `4px solid rgb(var(--v-theme-${r.ackStatus === 'not_received' ? 'error' : 'warning'}))` }"
15
+ >
16
+ <v-icon :icon="r.ackStatus === 'not_received' ? 'mdi-package-variant-remove' : 'mdi-package-variant-closed-check'" :color="r.ackStatus === 'not_received' ? 'error' : 'warning'" />
17
+ <div class="flex-grow-1" style="min-width: 220px">
18
+ <div class="font-weight-medium">{{ receiveTitle(r) }}</div>
19
+ <div class="text-body-2">{{ ackText(r, true) }}</div>
20
+ </div>
21
+ <InventoryReceiptActions :request="r" @done="changed" />
22
+ </AppCard>
23
+
9
24
  <!-- Give to colleague: what waits for me to confirm, and what I gave that waits for them. -->
10
25
  <AppCard v-for="h in toConfirm" :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
26
  <v-icon icon="mdi-hand-coin-outline" color="warning" />
@@ -18,7 +33,9 @@
18
33
  </AppCard>
19
34
  <AppCard v-for="h in waiting" :key="h._id" class="pa-3 mb-3 d-flex flex-wrap align-center ga-3">
20
35
  <v-icon icon="mdi-timer-sand" color="medium-emphasis" />
21
- <div class="flex-grow-1 text-body-2">Waiting for {{ h.toName }} to confirm {{ h.qty }} {{ h.unit }} {{ h.itemName }}.</div>
36
+ <div class="flex-grow-1 text-body-2">
37
+ {{ h.kind === "return" ? `Returning ${h.qty} ${h.unit} ${h.itemName} to ${h.toName || "the store"}. Hand it to the store keeper; it leaves your stock once they accept it.` : `Waiting for ${h.toName} to confirm ${h.qty} ${h.unit} ${h.itemName}.` }}
38
+ </div>
22
39
  <v-btn class="text-none" variant="text" size="small" :loading="acting === h._id + 'c'" @click="cancelHandover(h)">Cancel</v-btn>
23
40
  </AppCard>
24
41
 
@@ -35,6 +52,7 @@
35
52
  <div class="flex-grow-1" style="min-width: 200px">
36
53
  <div class="font-weight-medium">{{ d.itemName }} × {{ d.qty }} {{ d.unit }}<span v-if="d.reference" class="text-medium-emphasis"> · {{ d.reference }}</span></div>
37
54
  <div class="text-body-2">{{ draftCardText(d, standardFormatDateTime, staleDays).line }}</div>
55
+ <div v-if="d.startedByName" class="text-caption text-medium-emphasis">Started by {{ d.startedByName }}{{ d.takenOverAt ? `, taken over by you ${standardFormatDateTime(d.takenOverAt)}` : "" }}. The stock comes from your own stock when you submit.</div>
38
56
  <div v-if="d.stale" class="text-caption text-error">{{ draftCardText(d, standardFormatDateTime, staleDays).warning }}</div>
39
57
  </div>
40
58
  <v-btn class="text-none screen-btn-primary" variant="flat" size="small" @click="continueDraft(d)">{{ d.photos?.after?.length ? "Review and submit" : "Add after photo" }}</v-btn>
@@ -58,10 +76,13 @@
58
76
  <div class="font-weight-medium">{{ h.itemName }}</div>
59
77
  <div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
60
78
  <div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
79
+ <div v-if="h.unacknowledged" class="text-caption text-warning">{{ h.unacknowledged }} waiting for you to tap Received (above) before you can use them</div>
61
80
  </div>
62
81
  <div class="d-flex flex-column ga-1">
63
82
  <v-btn v-if="(h.free ?? h.qty) > 0 && ctl.recordUsage" class="text-none screen-btn-primary" variant="flat" size="small" @click="use(h)">Record usage</v-btn>
64
83
  <v-btn v-if="h.qty > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openGive(h)">Give to colleague</v-btn>
84
+ <v-btn v-if="stepsLive && freeOf(h) > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openReturn(h)">Return to store</v-btn>
85
+ <v-btn v-if="stepsLive && ctl.recordUsage && freeOf(h) > 0" class="text-none" variant="text" size="small" prepend-icon="mdi-delete-alert-outline" @click="openWriteOff(h)">Write off</v-btn>
65
86
  <v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
66
87
  </div>
67
88
  </AppCard>
@@ -151,6 +172,45 @@
151
172
  </div>
152
173
  </v-card>
153
174
  </v-dialog>
175
+ <v-dialog v-model="ret.show" max-width="480" persistent>
176
+ <v-card v-if="ret.holding" class="screen-modal">
177
+ <v-card-title>Return to store</v-card-title>
178
+ <v-card-text class="screen-modal__body">
179
+ <p class="text-body-2 text-medium-emphasis mb-3">
180
+ {{ ret.holding.itemName }}: you can return up to {{ freeOf(ret.holding) }} {{ ret.holding.unit }}. The store keeper is told; it goes back into the store once they accept it.
181
+ </p>
182
+ <v-form ref="retForm">
183
+ <InputLabel title="Quantity" required class="mb-1" />
184
+ <v-text-field
185
+ v-model.number="ret.qty"
186
+ type="number"
187
+ min="1"
188
+ density="comfortable"
189
+ :suffix="ret.holding.unit"
190
+ style="max-width: 200px"
191
+ :rules="[retQtyRule]"
192
+ />
193
+ <InputLabel title="Note (optional)" class="mb-1" />
194
+ <v-text-field v-model="ret.note" density="comfortable" maxlength="500" placeholder="e.g. job cancelled, not needed" />
195
+ </v-form>
196
+ </v-card-text>
197
+ <div class="screen-modal__footer">
198
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="ret.saving" @click="ret.show = false">Cancel</v-btn>
199
+ <v-btn class="screen-btn-primary" variant="flat" :loading="ret.saving" @click="submitReturn">Ask to return</v-btn>
200
+ </div>
201
+ </v-card>
202
+ </v-dialog>
203
+ <InventoryWriteOffDialog
204
+ v-model="wo.show"
205
+ :store="store"
206
+ from="holding"
207
+ :item="String(wo.holding?.item ?? '')"
208
+ :item-name="wo.holding?.itemName ?? ''"
209
+ :unit="wo.holding?.unit ?? ''"
210
+ :max="wo.holding ? freeOf(wo.holding) : 0"
211
+ :site-id="siteId"
212
+ @done="changed"
213
+ />
154
214
  <InventoryLinesDialog v-model="showRequest" mode="request" :store="store" :site-id="siteId" @done="changed" />
155
215
  </div>
156
216
  </template>
@@ -162,6 +222,7 @@ import useUtils from "../composables/useUtils";
162
222
  import useLocalAuth from "../composables/useLocalAuth";
163
223
  import { inventoryControls } from "../utils/inventory-presets";
164
224
  import { draftCardText } from "../utils/inventory-usage";
225
+ import { ackText, inventoryStepsLive } from "../utils/inventory-ack";
165
226
 
166
227
  const props = defineProps({
167
228
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -185,6 +246,56 @@ const usage = reactive({ show: false, holding: null as TInventoryHolding | null,
185
246
  const drafts = ref<TInventoryUsageDraft[]>([]);
186
247
  const staleDays = ref(3);
187
248
  const history = reactive({ show: false, item: "", title: "", usageOnly: false });
249
+ const stepsLive = computed(() => inventoryStepsLive(props.store));
250
+
251
+ /** Free to use, give, return or write off: not in drafts, not waiting for Received (the API's `free`), not already being given. */
252
+ const freeOf = (h: TInventoryHolding) => Math.max(0, (h.free ?? h.qty) - pendingOf(String(h.item)));
253
+
254
+ // Issued to me and not yet confirmed (or reported not received).
255
+ const toReceive = ref<TInventoryRequest[]>([]);
256
+ const receiveTitle = (r: TInventoryRequest) =>
257
+ `${r.decidedByName || "The store"} issued you ${r.lines.filter((l) => l.issuedQty > 0).map((l) => `${l.issuedQty} ${l.unit} ${l.itemName}`).join(", ")}${r.decidedAt ? ` on ${standardFormatDate(r.decidedAt)}` : ""}`;
258
+ async function loadToReceive() {
259
+ if (!stepsLive.value) return (toReceive.value = []);
260
+ try {
261
+ const res = await api.getRequests(props.store._id, { status: "issued", limit: 100 });
262
+ toReceive.value = res.items.filter((r) => String(r.requestedBy) === me.value && (r.ackStatus === "awaiting" || r.ackStatus === "not_received"));
263
+ } catch {
264
+ toReceive.value = [];
265
+ }
266
+ }
267
+
268
+ const retForm = ref();
269
+ const ret = reactive({ show: false, holding: null as TInventoryHolding | null, qty: 1 as number | null, note: "", saving: false });
270
+ const retQtyRule = (v: any) => {
271
+ if (!Number.isInteger(Number(v)) || Number(v) < 1) return "Enter a whole number";
272
+ const max = ret.holding ? freeOf(ret.holding) : 0;
273
+ return Number(v) <= max || `You can return up to ${max}`;
274
+ };
275
+ function openReturn(h: TInventoryHolding) {
276
+ Object.assign(ret, { show: true, holding: h, qty: freeOf(h), note: "", saving: false });
277
+ }
278
+ async function submitReturn() {
279
+ const { valid } = await retForm.value.validate();
280
+ if (!valid || !ret.holding) return;
281
+ ret.saving = true;
282
+ try {
283
+ const site = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
284
+ await api.requestReturn(props.store._id, { site, item: String(ret.holding.item), qty: Number(ret.qty), note: ret.note });
285
+ notify("Return requested. Bring the items to the store keeper; they leave your stock once the keeper accepts them.");
286
+ ret.show = false;
287
+ changed();
288
+ } catch (error: any) {
289
+ notify(error?.data?.message || "Could not request the return.", "error");
290
+ } finally {
291
+ ret.saving = false;
292
+ }
293
+ }
294
+
295
+ const wo = reactive({ show: false, holding: null as TInventoryHolding | null });
296
+ function openWriteOff(h: TInventoryHolding) {
297
+ Object.assign(wo, { show: true, holding: h });
298
+ }
188
299
 
189
300
  function openHistory(h: TInventoryHolding) {
190
301
  Object.assign(history, { show: true, item: String(h.item), title: `History — ${h.itemName}`, usageOnly: false });
@@ -264,7 +375,7 @@ const give = reactive({ show: false, holding: null as TInventoryHolding | null,
264
375
  const pendingOf = (item: string) => waiting.value.filter((h) => String(h.item) === item).reduce((s, h) => s + h.qty, 0);
265
376
  const giveQtyRule = (v: number | null) => {
266
377
  if (!v || v < 1 || !Number.isInteger(Number(v))) return "Enter a whole number";
267
- const free = (give.holding?.qty ?? 0) - pendingOf(String(give.holding?.item ?? ""));
378
+ const free = give.holding ? freeOf(give.holding) : 0;
268
379
  return Number(v) <= free || `You can give up to ${Math.max(0, free)}`;
269
380
  };
270
381
  const colleagues = ref<{ _id: string; name: string; label?: string }[]>([]);
@@ -325,9 +436,11 @@ function changed() {
325
436
  load();
326
437
  loadHandovers();
327
438
  loadDrafts();
439
+ loadToReceive();
328
440
  emit("changed");
329
441
  }
330
442
 
331
- watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts()));
332
- onMounted(() => (load(), loadHandovers(), loadDrafts()));
443
+ watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts(), loadToReceive()));
444
+ watch(stepsLive, loadToReceive);
445
+ onMounted(() => (load(), loadHandovers(), loadDrafts(), loadToReceive()));
333
446
  </script>
@@ -0,0 +1,66 @@
1
+ <template>
2
+ <!-- The person the stock was issued to confirms it reached them (owner 2026-10-03). -->
3
+ <div class="d-flex flex-wrap ga-2">
4
+ <v-btn v-if="request.ackStatus === 'awaiting'" class="text-none screen-btn-ghost" variant="outlined" size="small" :disabled="!!busy" @click="openNotReceived">Not received</v-btn>
5
+ <v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="busy === 'received'" :disabled="!!busy" @click="received">Received</v-btn>
6
+
7
+ <v-dialog v-model="nr.show" max-width="480">
8
+ <v-card class="screen-modal">
9
+ <v-card-title>Not received</v-card-title>
10
+ <v-card-text class="screen-modal__body">
11
+ <p class="text-body-2 mb-3">Tell the store keeper what happened. They are told at once and will follow up with you.</p>
12
+ <v-textarea v-model="nr.reason" rows="2" auto-grow density="comfortable" maxlength="500" placeholder="e.g. nobody handed it to me, only 2 of the 5 were in the bag" />
13
+ </v-card-text>
14
+ <div class="screen-modal__footer">
15
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="busy === 'not'" @click="nr.show = false">Cancel</v-btn>
16
+ <v-btn class="screen-btn-primary" variant="flat" :loading="busy === 'not'" :disabled="!nr.reason.trim()" @click="notReceived">Send to store keeper</v-btn>
17
+ </div>
18
+ </v-card>
19
+ </v-dialog>
20
+ </div>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import useInventory from "../composables/useInventory";
25
+
26
+ const props = defineProps({ request: { type: Object as PropType<TInventoryRequest>, required: true } });
27
+ const emit = defineEmits(["done"]);
28
+
29
+ const api = useInventory();
30
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
31
+ const busy = ref<"" | "received" | "not">("");
32
+ const nr = reactive({ show: false, reason: "" });
33
+
34
+ const items = () => props.request.lines.filter((l) => l.issuedQty > 0).map((l) => `${l.issuedQty} ${l.unit} ${l.itemName}`).join(", ");
35
+
36
+ function openNotReceived() {
37
+ Object.assign(nr, { show: true, reason: "" });
38
+ }
39
+
40
+ async function received() {
41
+ busy.value = "received";
42
+ try {
43
+ await api.acknowledgeRequest(props.request._id);
44
+ notify(`Thank you. ${items()} ${props.request.lines.length === 1 ? "is" : "are"} now in My Stock, ready to use.`);
45
+ emit("done");
46
+ } catch (error: any) {
47
+ notify(error?.data?.message || "Could not save. Please try again.", "error");
48
+ } finally {
49
+ busy.value = "";
50
+ }
51
+ }
52
+
53
+ async function notReceived() {
54
+ busy.value = "not";
55
+ try {
56
+ await api.notReceived(props.request._id, nr.reason.trim());
57
+ notify("Sent. The store keeper will check and follow up with you.");
58
+ nr.show = false;
59
+ emit("done");
60
+ } catch (error: any) {
61
+ notify(error?.data?.message || "Could not send. Please try again.", "error");
62
+ } finally {
63
+ busy.value = "";
64
+ }
65
+ }
66
+ </script>