@7365admin1/layer-common 4.95.0 → 4.96.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.
@@ -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">
@@ -62,11 +73,11 @@
62
73
  </v-card-title>
63
74
  <v-card-text class="screen-modal__body">
64
75
  <p class="text-body-2 text-medium-emphasis mb-3">Hand over the items, then approve. You can give less than asked.</p>
65
- <v-form ref="decisionForm">
76
+ <v-form ref="decisionForm" validate-on="blur invalid-input">
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 {{ qtyUnit(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, qtyUnit } 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="`How often to remind (days). 0 = no reminders. ${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>
100
- <span class="text-medium-emphasis ml-1">{{ item.unit }}</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'">{{ qtyUnit(availableQty(item), item.unit) }}</span>
101
101
  <v-chip v-if="item.lowStock" size="x-small" color="error" variant="tonal" class="ml-2">Low</v-chip>
102
102
  <v-chip v-if="item.status === 'inactive'" size="x-small" variant="tonal" class="ml-2">Not in use</v-chip>
103
+ <div v-if="item.reserved" class="text-caption text-medium-emphasis">+ {{ item.reserved }} set aside for requests (still on the shelf)</div>
104
+ <div v-if="item.awaitingReceipt" class="text-caption text-medium-emphasis">{{ item.awaitingReceipt }} handed out, waiting for Received</div>
105
+ <!-- Acknowledge / PO details are for the people who handle the store; a technician sees only the Low chip. -->
106
+ <div v-if="item.lowStockAlert && canAckLow" 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
+ {{ qtyUnit(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, qtyUnit } 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 {{ qtyUnit(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 }}: {{ qtyUnit(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,77 @@
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">
63
+ import { qtyUnit } from "../utils/inventory-ack";
37
64
  import useInventory from "../composables/useInventory";
38
65
  import { inventoryControls } from "../utils/inventory-presets";
66
+ import useUtils from "../composables/useUtils";
39
67
 
40
68
  const props = defineProps({ store: { type: Object as PropType<TInventoryStore>, required: true } });
41
69
  const ctl = computed(() => inventoryControls(props.store.access));
42
70
  const emit = defineEmits(["changed"]);
43
71
 
44
- const { getHoldings } = useInventory();
72
+ const { getHoldings, getHandovers, acceptHandover, declineHandover } = useInventory();
73
+ const { standardFormatDate } = useUtils();
74
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
75
+
76
+ const returns = ref<TInventoryHandover[]>([]);
77
+ const acting = ref("");
78
+ const decline = reactive({ show: false, handover: null as TInventoryHandover | null, reason: "" });
79
+ async function loadReturns() {
80
+ if (!ctl.value.takeReturn) return (returns.value = []);
81
+ try {
82
+ returns.value = (await getHandovers(props.store._id, { status: "pending", limit: 50 })).items.filter((h) => h.kind === "return");
83
+ } catch {
84
+ returns.value = [];
85
+ }
86
+ }
87
+ async function acceptReturn(h: TInventoryHandover) {
88
+ acting.value = h._id;
89
+ try {
90
+ await acceptHandover(h._id);
91
+ notify(`${qtyUnit(h.qty, h.unit)} ${h.itemName} is back in the store.`);
92
+ changed();
93
+ } catch (error: any) {
94
+ notify(error?.data?.message || "Could not accept the return.", "error");
95
+ } finally {
96
+ acting.value = "";
97
+ }
98
+ }
99
+ function openDecline(h: TInventoryHandover) {
100
+ Object.assign(decline, { show: true, handover: h, reason: "" });
101
+ }
102
+ async function declineReturn() {
103
+ const h = decline.handover;
104
+ if (!h) return;
105
+ acting.value = h._id;
106
+ try {
107
+ await declineHandover(h._id, decline.reason.trim());
108
+ notify(`Return not accepted. The ${h.itemName} stays with ${h.fromName}, who has been told.`);
109
+ decline.show = false;
110
+ changed();
111
+ } catch (error: any) {
112
+ notify(error?.data?.message || "Could not update the return.", "error");
113
+ } finally {
114
+ acting.value = "";
115
+ }
116
+ }
45
117
  const holdings = ref<TInventoryHolding[]>([]);
46
118
  const loading = ref(false);
47
119
  const search = ref("");
@@ -76,9 +148,10 @@ function openReturn(g: { holder: string; holderName: string; items: TInventoryHo
76
148
 
77
149
  function changed() {
78
150
  load();
151
+ loadReturns();
79
152
  emit("changed");
80
153
  }
81
154
 
82
- watch(() => props.store._id, load);
83
- onMounted(load);
155
+ watch(() => props.store._id, () => (load(), loadReturns()));
156
+ onMounted(() => (load(), loadReturns()));
84
157
  </script>
@@ -9,13 +9,13 @@
9
9
  <v-card-text class="screen-modal__body">
10
10
  <div class="font-weight-medium">{{ holding.itemName }}</div>
11
11
  <div class="text-body-2 text-medium-emphasis mb-4">
12
- You have {{ holding.qty }} {{ holding.unit }} in hand<template v-if="otherDrafts"> · {{ otherDrafts }} already in your other drafts</template>.
12
+ You have {{ qtyUnit(holding.qty, holding.unit) }} in hand<template v-if="otherDrafts"> · {{ otherDrafts }} already in your other drafts</template>.
13
13
  <template v-if="!draft"> Took the before photo but the job is not done yet? Save a draft now and finish it later.</template>
14
14
  </div>
15
15
  <v-alert v-if="draft" type="info" variant="tonal" density="compact" class="mb-4">
16
16
  Saved as a draft {{ draft.draftedAt ? standardFormatDateTime(draft.draftedAt) : "" }}. Nothing has been taken off your stock yet — add the after photo and submit when the job is done.
17
17
  </v-alert>
18
- <v-form ref="form">
18
+ <v-form ref="form" validate-on="blur invalid-input">
19
19
  <InputLabel title="How many did you use?" required class="mb-1" />
20
20
  <v-text-field
21
21
  v-model.number="qty"
@@ -92,6 +92,18 @@
92
92
 
93
93
  <InputLabel :title="draft ? 'Date of the job (used when you submit)' : 'Date used'" class="mb-1 mt-3" />
94
94
  <v-text-field v-model="date" type="date" :max="today" density="comfortable" style="max-width: 200px" />
95
+ <template v-if="stepsLive">
96
+ <InputLabel title="Also attended (optional)" class="mb-1" />
97
+ <v-text-field
98
+ v-model="attendedWith"
99
+ density="comfortable"
100
+ maxlength="200"
101
+ placeholder="e.g. Ahmad, Kumar"
102
+ hint="Colleagues who did this job with you. The stock still comes from your own stock."
103
+ persistent-hint
104
+ class="mb-2"
105
+ />
106
+ </template>
95
107
  <InputLabel title="Fault reported by (optional)" class="mb-1" />
96
108
  <v-text-field v-model="reportedBy" density="comfortable" placeholder="e.g. tenant, security guard" maxlength="120" />
97
109
  <InputLabel title="Remarks (optional)" class="mb-1" />
@@ -122,6 +134,7 @@
122
134
  <script setup lang="ts">
123
135
  import useInventory, { pickedDate } from "../composables/useInventory";
124
136
  import useUtils from "../composables/useUtils";
137
+ import { inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
125
138
 
126
139
  const show = defineModel<boolean>({ default: false });
127
140
  const props = defineProps({
@@ -155,6 +168,8 @@ const area = ref("");
155
168
  const reference = ref("");
156
169
  const remarks = ref("");
157
170
  const reportedBy = ref("");
171
+ const attendedWith = ref("");
172
+ const stepsLive = computed(() => inventoryStepsLive(props.store));
158
173
  const date = ref(today);
159
174
  const photosBefore = ref<string[]>([]);
160
175
  const photosAfter = ref<string[]>([]);
@@ -199,6 +214,8 @@ function body() {
199
214
  photosAfter: photosAfter.value,
200
215
  remarks: remarks.value,
201
216
  reportedBy: reportedBy.value,
217
+ // Only to an API that knows it (an older one refuses an unknown field).
218
+ ...(stepsLive.value && attendedWith.value.trim() ? { attendedWith: attendedWith.value.trim() } : {}),
202
219
  };
203
220
  }
204
221
 
@@ -282,6 +299,7 @@ watch(show, (open) => {
282
299
  reference.value = "";
283
300
  remarks.value = "";
284
301
  reportedBy.value = "";
302
+ attendedWith.value = "";
285
303
  date.value = today;
286
304
  site.value = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
287
305
  const d = props.draft;
@@ -297,6 +315,7 @@ watch(show, (open) => {
297
315
  reference.value = d.reference ?? "";
298
316
  remarks.value = d.remarks ?? "";
299
317
  reportedBy.value = d.reportedBy ?? "";
318
+ attendedWith.value = d.attendedWith ?? "";
300
319
  photosBefore.value = [...(d.photos?.before ?? [])];
301
320
  photosAfter.value = [...(d.photos?.after ?? [])];
302
321
  nextTick(() => (prefilling = false));
@@ -0,0 +1,112 @@
1
+ <template>
2
+ <v-dialog v-model="show" max-width="560" persistent scrollable>
3
+ <v-card class="screen-modal">
4
+ <v-card-title class="d-flex align-center">
5
+ Write off {{ itemName }}
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
+ <v-card-text class="screen-modal__body">
10
+ <p class="text-body-2 text-medium-emphasis mb-3">
11
+ {{
12
+ from === "store"
13
+ ? `For stock on the store shelf that can no longer be used. ${qtyUnit(max, unit)} on the shelf.`
14
+ : `For stock in your hands that is used up, spoilt, damaged or lost. It does not go back to the store and is not counted as usage. You can write off up to ${qtyUnit(max, unit)}.`
15
+ }}
16
+ </p>
17
+ <!-- Errors once a field is left or Write off is pressed, never from opening the form. -->
18
+ <v-form ref="form" validate-on="blur invalid-input">
19
+ <InputLabel title="How many?" required class="mb-1" />
20
+ <v-text-field
21
+ v-model.number="qty"
22
+ type="number"
23
+ min="1"
24
+ :max="max"
25
+ density="comfortable"
26
+ :suffix="unit"
27
+ style="max-width: 200px"
28
+ :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1) || 'Enter a whole number', (v: any) => Number(v) <= max || `You can write off up to ${max}`]"
29
+ />
30
+ <InputLabel title="Why?" required class="mb-1" />
31
+ <v-chip-group v-model="reason" :mandatory="from !== 'store'" selected-class="text-primary" class="mb-2">
32
+ <v-chip v-for="r in WRITE_OFF_REASONS" :key="r.value" :value="r.value" variant="outlined" filter>{{ r.title }}</v-chip>
33
+ </v-chip-group>
34
+ <InputLabel :title="reason === 'other' ? 'What happened?' : 'Remarks (optional)'" :required="reason === 'other'" class="mb-1" />
35
+ <v-textarea v-model="remarks" rows="2" auto-grow density="comfortable" maxlength="1000" placeholder="e.g. fell into the drain at block 3" />
36
+ <InputLabel title="Photos (optional)" class="mb-1" />
37
+ <InventoryPhotoInput v-model="photos" :max="5" />
38
+ <v-alert v-if="problem" type="warning" variant="tonal" density="compact" class="mt-2">{{ problem }}</v-alert>
39
+ </v-form>
40
+ </v-card-text>
41
+ <div class="screen-modal__footer">
42
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="saving" @click="show = false">Cancel</v-btn>
43
+ <v-btn class="screen-btn-primary" variant="flat" :loading="saving" @click="save">Write off</v-btn>
44
+ </div>
45
+ </v-card>
46
+ </v-dialog>
47
+ </template>
48
+
49
+ <script setup lang="ts">
50
+ import useInventory from "../composables/useInventory";
51
+ import { qtyUnit, WRITE_OFF_REASONS, writeOffDefaultReason, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
52
+
53
+ /** Write-off (owner 2026-10-03): from the person's own stock (`holding`) or, for a store keeper, from the shelf (`store`). */
54
+ const show = defineModel<boolean>({ default: false });
55
+ const props = defineProps({
56
+ store: { type: Object as PropType<TInventoryStore>, required: true },
57
+ from: { type: String as PropType<"holding" | "store">, default: "holding" },
58
+ item: { type: String, default: "" },
59
+ itemName: { type: String, default: "" },
60
+ unit: { type: String, default: "" },
61
+ max: { type: Number, default: 0 },
62
+ siteId: { type: String, default: "" },
63
+ });
64
+ const emit = defineEmits(["done"]);
65
+
66
+ const api = useInventory();
67
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
68
+
69
+ const form = ref();
70
+ const qty = ref<number | null>(1);
71
+ const reason = ref<string>(writeOffDefaultReason(props.from));
72
+ const remarks = ref("");
73
+ const photos = ref<string[]>([]);
74
+ const saving = ref(false);
75
+ const tried = ref(false);
76
+ const problem = computed(() => (tried.value ? writeOffProblem({ reason: reason.value ?? "", remarks: remarks.value }) : ""));
77
+
78
+ async function save() {
79
+ tried.value = true;
80
+ const { valid } = await form.value.validate();
81
+ if (!valid || writeOffProblem({ reason: reason.value ?? "", remarks: remarks.value })) return;
82
+ saving.value = true;
83
+ try {
84
+ await api.writeOff(props.store._id, {
85
+ from: props.from,
86
+ ...(props.siteId && props.store.sites.includes(props.siteId) ? { site: props.siteId } : {}),
87
+ item: props.item,
88
+ qty: Number(qty.value),
89
+ reason: reason.value,
90
+ remarks: remarks.value.trim(),
91
+ photos: photos.value,
92
+ });
93
+ notify(`${qtyUnit(qty.value, props.unit)} of ${props.itemName} written off as ${writeOffLabel(reason.value).toLowerCase()}.${props.from === "holding" ? " The store keeper has been told." : ""}`);
94
+ show.value = false;
95
+ emit("done");
96
+ } catch (error: any) {
97
+ notify(error?.data?.message || "Could not write it off. Please try again.", "error");
98
+ } finally {
99
+ saving.value = false;
100
+ }
101
+ }
102
+
103
+ watch(show, (open) => {
104
+ if (!open) return;
105
+ form.value?.resetValidation();
106
+ qty.value = 1;
107
+ reason.value = writeOffDefaultReason(props.from);
108
+ remarks.value = "";
109
+ photos.value = [];
110
+ tried.value = false;
111
+ });
112
+ </script>