@7365admin1/layer-common 4.94.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.
@@ -6,11 +6,26 @@
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" />
12
27
  <div class="flex-grow-1">
13
- <div class="font-weight-medium">{{ h.fromLabel || h.fromName }} is giving you {{ h.qty }} {{ h.unit }} {{ h.itemName }}</div>
28
+ <div class="font-weight-medium">{{ h.fromLabel || h.fromName }} is giving you {{ qtyUnit(h.qty, h.unit) }} {{ h.itemName }}</div>
14
29
  <div class="text-caption text-medium-emphasis">{{ standardFormatDate(h.createdAt) }}{{ h.note ? ` · ${h.note}` : "" }} · Confirm once you have it in hand.</div>
15
30
  </div>
16
31
  <v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" :loading="acting === h._id + 'd'" @click="decide(h, false)">Decline</v-btn>
@@ -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 ${qtyUnit(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 ${qtyUnit(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
 
@@ -33,14 +50,29 @@
33
50
  >
34
51
  <InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
35
52
  <div class="flex-grow-1" style="min-width: 200px">
36
- <div class="font-weight-medium">{{ d.itemName }} × {{ d.qty }} {{ d.unit }}<span v-if="d.reference" class="text-medium-emphasis"> · {{ d.reference }}</span></div>
53
+ <div class="font-weight-medium">{{ d.itemName }} × {{ qtyUnit(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>
41
59
  </AppCard>
42
60
  </template>
43
61
 
62
+ <!-- Colleagues' open drafts (same company): any technician can take one over and finish it from their own stock. -->
63
+ <template v-if="colleagueDraftsOffered">
64
+ <div class="text-subtitle-2 mb-2">Colleagues' jobs in progress ({{ colleagueDrafts.length }})</div>
65
+ <div v-if="!colleagueDrafts.length" class="text-body-2 text-medium-emphasis mb-3">No unfinished jobs from your colleagues right now.</div>
66
+ <AppCard v-for="d in colleagueDrafts" :key="d._id" class="pa-3 mb-2 d-flex flex-wrap align-center ga-3" style="border-left: 4px solid rgb(var(--v-theme-secondary))">
67
+ <InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
68
+ <div class="flex-grow-1" style="min-width: 200px">
69
+ <div class="font-weight-medium">{{ d.itemName }} × {{ qtyUnit(d.qty, d.unit) }}</div>
70
+ <div class="text-body-2">{{ colleagueDraftText(d, standardFormatDateTime) }}</div>
71
+ </div>
72
+ <v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === d._id + 't'" @click="takeOver(d)">Take over</v-btn>
73
+ </AppCard>
74
+ </template>
75
+
44
76
  <v-progress-linear v-if="loading" indeterminate color="primary" />
45
77
  <AppCard v-else-if="!holdings.length" class="pa-6 text-center">
46
78
  <div class="text-body-1 mb-1">You are not holding any stock.</div>
@@ -56,12 +88,15 @@
56
88
  </v-avatar>
57
89
  <div class="flex-grow-1">
58
90
  <div class="font-weight-medium">{{ h.itemName }}</div>
59
- <div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
91
+ <div class="text-body-2">{{ qtyUnit(h.qty, h.unit) }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
60
92
  <div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
93
+ <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
94
  </div>
62
95
  <div class="d-flex flex-column ga-1">
63
96
  <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
- <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>
97
+ <v-btn v-if="freeOf(h) > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openGive(h)">Give to colleague</v-btn>
98
+ <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>
99
+ <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
100
  <v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
66
101
  </div>
67
102
  </AppCard>
@@ -112,9 +147,9 @@
112
147
  </v-card-title>
113
148
  <v-card-text class="screen-modal__body">
114
149
  <p class="text-body-2 text-medium-emphasis mb-3">
115
- {{ give.holding?.itemName }}: you hold {{ give.holding?.qty }} {{ give.holding?.unit }}. It moves to your colleague once they confirm they received it.
150
+ {{ give.holding?.itemName }}: {{ holdText(give.holding) }}. It moves to your colleague once they confirm they received it.
116
151
  </p>
117
- <v-form ref="giveForm">
152
+ <v-form ref="giveForm" validate-on="blur invalid-input">
118
153
  <InputLabel title="Colleague" required class="mb-1" />
119
154
  <v-autocomplete
120
155
  v-model="give.to"
@@ -125,6 +160,7 @@
125
160
  placeholder="Search a name"
126
161
  density="comfortable"
127
162
  no-filter
163
+ no-data-text="No colleague from your company works at this property."
128
164
  :rules="[(v: string) => !!v || 'Choose who you are giving it to']"
129
165
  @update:search="searchColleagues"
130
166
  />
@@ -151,6 +187,45 @@
151
187
  </div>
152
188
  </v-card>
153
189
  </v-dialog>
190
+ <v-dialog v-model="ret.show" max-width="480" persistent>
191
+ <v-card v-if="ret.holding" class="screen-modal">
192
+ <v-card-title>Return to store</v-card-title>
193
+ <v-card-text class="screen-modal__body">
194
+ <p class="text-body-2 text-medium-emphasis mb-3">
195
+ {{ ret.holding.itemName }}: you can return up to {{ qtyUnit(freeOf(ret.holding), ret.holding.unit) }}. The store keeper is told; it goes back into the store once they accept it.
196
+ </p>
197
+ <v-form ref="retForm" validate-on="blur invalid-input">
198
+ <InputLabel title="Quantity" required class="mb-1" />
199
+ <v-text-field
200
+ v-model.number="ret.qty"
201
+ type="number"
202
+ min="1"
203
+ density="comfortable"
204
+ :suffix="ret.holding.unit"
205
+ style="max-width: 200px"
206
+ :rules="[retQtyRule]"
207
+ />
208
+ <InputLabel title="Note (optional)" class="mb-1" />
209
+ <v-text-field v-model="ret.note" density="comfortable" maxlength="500" placeholder="e.g. job cancelled, not needed" />
210
+ </v-form>
211
+ </v-card-text>
212
+ <div class="screen-modal__footer">
213
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="ret.saving" @click="ret.show = false">Cancel</v-btn>
214
+ <v-btn class="screen-btn-primary" variant="flat" :loading="ret.saving" @click="submitReturn">Ask to return</v-btn>
215
+ </div>
216
+ </v-card>
217
+ </v-dialog>
218
+ <InventoryWriteOffDialog
219
+ v-model="wo.show"
220
+ :store="store"
221
+ from="holding"
222
+ :item="String(wo.holding?.item ?? '')"
223
+ :item-name="wo.holding?.itemName ?? ''"
224
+ :unit="wo.holding?.unit ?? ''"
225
+ :max="wo.holding ? freeOf(wo.holding) : 0"
226
+ :site-id="siteId"
227
+ @done="changed"
228
+ />
154
229
  <InventoryLinesDialog v-model="showRequest" mode="request" :store="store" :site-id="siteId" @done="changed" />
155
230
  </div>
156
231
  </template>
@@ -161,7 +236,8 @@ import useFile from "../composables/useFile";
161
236
  import useUtils from "../composables/useUtils";
162
237
  import useLocalAuth from "../composables/useLocalAuth";
163
238
  import { inventoryControls } from "../utils/inventory-presets";
164
- import { draftCardText } from "../utils/inventory-usage";
239
+ import { colleagueDraftText, draftCardText } from "../utils/inventory-usage";
240
+ import { ackText, holdText, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
165
241
 
166
242
  const props = defineProps({
167
243
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -185,6 +261,56 @@ const usage = reactive({ show: false, holding: null as TInventoryHolding | null,
185
261
  const drafts = ref<TInventoryUsageDraft[]>([]);
186
262
  const staleDays = ref(3);
187
263
  const history = reactive({ show: false, item: "", title: "", usageOnly: false });
264
+ const stepsLive = computed(() => inventoryStepsLive(props.store));
265
+
266
+ /** Free to use, give, return or write off: not in drafts, not waiting for Received (the API's `free`), not already being given. */
267
+ const freeOf = (h: TInventoryHolding) => Math.max(0, (h.free ?? h.qty) - pendingOf(String(h.item)));
268
+
269
+ // Issued to me and not yet confirmed (or reported not received).
270
+ const toReceive = ref<TInventoryRequest[]>([]);
271
+ const receiveTitle = (r: TInventoryRequest) =>
272
+ `${r.decidedByName || "The store"} issued you ${r.lines.filter((l) => l.issuedQty > 0).map((l) => `${qtyUnit(l.issuedQty, l.unit)} ${l.itemName}`).join(", ")}${r.decidedAt ? ` on ${standardFormatDate(r.decidedAt)}` : ""}`;
273
+ async function loadToReceive() {
274
+ if (!stepsLive.value) return (toReceive.value = []);
275
+ try {
276
+ const res = await api.getRequests(props.store._id, { status: "issued", limit: 100 });
277
+ toReceive.value = res.items.filter((r) => String(r.requestedBy) === me.value && (r.ackStatus === "awaiting" || r.ackStatus === "not_received"));
278
+ } catch {
279
+ toReceive.value = [];
280
+ }
281
+ }
282
+
283
+ const retForm = ref();
284
+ const ret = reactive({ show: false, holding: null as TInventoryHolding | null, qty: 1 as number | null, note: "", saving: false });
285
+ const retQtyRule = (v: any) => {
286
+ if (!Number.isInteger(Number(v)) || Number(v) < 1) return "Enter a whole number";
287
+ const max = ret.holding ? freeOf(ret.holding) : 0;
288
+ return Number(v) <= max || `You can return up to ${max}`;
289
+ };
290
+ function openReturn(h: TInventoryHolding) {
291
+ Object.assign(ret, { show: true, holding: h, qty: freeOf(h), note: "", saving: false });
292
+ }
293
+ async function submitReturn() {
294
+ const { valid } = await retForm.value.validate();
295
+ if (!valid || !ret.holding) return;
296
+ ret.saving = true;
297
+ try {
298
+ const site = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
299
+ await api.requestReturn(props.store._id, { site, item: String(ret.holding.item), qty: Number(ret.qty), note: ret.note });
300
+ notify("Return requested. Bring the items to the store keeper; they leave your stock once the keeper accepts them.");
301
+ ret.show = false;
302
+ changed();
303
+ } catch (error: any) {
304
+ notify(error?.data?.message || "Could not request the return.", "error");
305
+ } finally {
306
+ ret.saving = false;
307
+ }
308
+ }
309
+
310
+ const wo = reactive({ show: false, holding: null as TInventoryHolding | null });
311
+ function openWriteOff(h: TInventoryHolding) {
312
+ Object.assign(wo, { show: true, holding: h });
313
+ }
188
314
 
189
315
  function openHistory(h: TInventoryHolding) {
190
316
  Object.assign(history, { show: true, item: String(h.item), title: `History — ${h.itemName}`, usageOnly: false });
@@ -209,6 +335,32 @@ async function loadDrafts() {
209
335
  drafts.value = [];
210
336
  }
211
337
  }
338
+ const colleagueDrafts = ref<TInventoryUsageDraft[]>([]);
339
+ const colleagueDraftsOffered = computed(() => ctl.value.recordUsage && stepsLive.value);
340
+ async function loadColleagueDrafts() {
341
+ // stepsLive = the API knows ?colleagues (an older API ignores it and returns my own, hence the mine/holder filter too).
342
+ if (!ctl.value.recordUsage || !stepsLive.value) return (colleagueDrafts.value = []);
343
+ try {
344
+ const res = await api.getUsageDrafts(props.store._id, "colleagues");
345
+ colleagueDrafts.value = res.items.filter((d) => !d.mine && String(d.holder) !== me.value);
346
+ } catch {
347
+ colleagueDrafts.value = []; // 403/404: not offered, nothing to show
348
+ }
349
+ }
350
+ async function takeOver(d: TInventoryUsageDraft) {
351
+ const who = d.holderName || d.startedByName || "your colleague";
352
+ if (!confirm(`Take over ${who}'s job (${qtyUnit(d.qty, d.unit)} ${d.itemName})? You finish it (after photo, submit), and the stock comes from YOUR own stock, not theirs.`)) return;
353
+ acting.value = d._id + "t";
354
+ try {
355
+ await api.takeOverDraft(String(d._id));
356
+ notify("The job is yours now. Finish it under My drafts.");
357
+ changed();
358
+ } catch (error: any) {
359
+ notify(error?.data?.message || "Could not take over the job.", "error");
360
+ } finally {
361
+ acting.value = "";
362
+ }
363
+ }
212
364
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
213
365
 
214
366
  const handovers = ref<TInventoryHandover[]>([]);
@@ -237,7 +389,7 @@ async function decide(h: TInventoryHandover, accept: boolean) {
237
389
  try {
238
390
  if (accept) await api.acceptHandover(h._id);
239
391
  else await api.declineHandover(h._id, "");
240
- notify(accept ? `You received ${h.qty} ${h.unit} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
392
+ notify(accept ? `You received ${qtyUnit(h.qty, h.unit)} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
241
393
  changed();
242
394
  } catch (error: any) {
243
395
  notify(error?.data?.message || "Could not update the hand-over.", "error");
@@ -264,7 +416,7 @@ const give = reactive({ show: false, holding: null as TInventoryHolding | null,
264
416
  const pendingOf = (item: string) => waiting.value.filter((h) => String(h.item) === item).reduce((s, h) => s + h.qty, 0);
265
417
  const giveQtyRule = (v: number | null) => {
266
418
  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 ?? ""));
419
+ const free = give.holding ? freeOf(give.holding) : 0;
268
420
  return Number(v) <= free || `You can give up to ${Math.max(0, free)}`;
269
421
  };
270
422
  const colleagues = ref<{ _id: string; name: string; label?: string }[]>([]);
@@ -325,9 +477,12 @@ function changed() {
325
477
  load();
326
478
  loadHandovers();
327
479
  loadDrafts();
480
+ loadColleagueDrafts();
481
+ loadToReceive();
328
482
  emit("changed");
329
483
  }
330
484
 
331
- watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts()));
332
- onMounted(() => (load(), loadHandovers(), loadDrafts()));
485
+ watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts(), loadColleagueDrafts(), loadToReceive()));
486
+ watch(stepsLive, () => (loadToReceive(), loadColleagueDrafts()));
487
+ onMounted(() => (load(), loadHandovers(), loadDrafts(), loadColleagueDrafts(), loadToReceive()));
333
488
  </script>
@@ -40,7 +40,7 @@
40
40
  :aria-label="`${l.received} of ${l.ordered} received`"
41
41
  />
42
42
  <div v-for="d in l.deliveries" :key="d._id" class="text-caption text-medium-emphasis">
43
- {{ standardFormatDate(d.date) }} · {{ d.qty }} {{ l.unit }} · {{ deliveryText(d) }}
43
+ {{ standardFormatDate(d.date) }} · {{ qtyUnit(d.qty, l.unit) }} · {{ deliveryText(d) }}
44
44
  </div>
45
45
  </AppCard>
46
46
  </template>
@@ -50,6 +50,7 @@
50
50
  </template>
51
51
 
52
52
  <script setup lang="ts">
53
+ import { qtyUnit } from "../utils/inventory-ack";
53
54
  import useInventory from "../composables/useInventory";
54
55
  import useUtils from "../composables/useUtils";
55
56
  import { poLineText, receivingLabel } from "../utils/inventory-receiving";
@@ -0,0 +1,67 @@
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 { qtyUnit } from "../utils/inventory-ack";
25
+ import useInventory from "../composables/useInventory";
26
+
27
+ const props = defineProps({ request: { type: Object as PropType<TInventoryRequest>, required: true } });
28
+ const emit = defineEmits(["done"]);
29
+
30
+ const api = useInventory();
31
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
32
+ const busy = ref<"" | "received" | "not">("");
33
+ const nr = reactive({ show: false, reason: "" });
34
+
35
+ const items = () => props.request.lines.filter((l) => l.issuedQty > 0).map((l) => `${qtyUnit(l.issuedQty, l.unit)} ${l.itemName}`).join(", ");
36
+
37
+ function openNotReceived() {
38
+ Object.assign(nr, { show: true, reason: "" });
39
+ }
40
+
41
+ async function received() {
42
+ busy.value = "received";
43
+ try {
44
+ await api.acknowledgeRequest(props.request._id);
45
+ notify(`Thank you. ${items()} ${props.request.lines.length === 1 ? "is" : "are"} now in My Stock, ready to use.`);
46
+ emit("done");
47
+ } catch (error: any) {
48
+ notify(error?.data?.message || "Could not save. Please try again.", "error");
49
+ } finally {
50
+ busy.value = "";
51
+ }
52
+ }
53
+
54
+ async function notReceived() {
55
+ busy.value = "not";
56
+ try {
57
+ await api.notReceived(props.request._id, nr.reason.trim());
58
+ notify("Sent. The store keeper will check and follow up with you.");
59
+ nr.show = false;
60
+ emit("done");
61
+ } catch (error: any) {
62
+ notify(error?.data?.message || "Could not send. Please try again.", "error");
63
+ } finally {
64
+ busy.value = "";
65
+ }
66
+ }
67
+ </script>
@@ -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, qtyUnit, 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 ${qtyUnit(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 ${qtyUnit(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";