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

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.
@@ -24,7 +24,7 @@
24
24
  <tbody>
25
25
  <tr v-for="h in holdings" :key="h._id">
26
26
  <td>{{ h.itemName }}</td>
27
- <td class="text-right">{{ h.qty }} {{ h.unit }}</td>
27
+ <td class="text-right">{{ qtyUnit(h.qty, h.unit) }}</td>
28
28
  <td class="text-right">{{ h.inDrafts || "—" }}</td>
29
29
  <td class="text-right">{{ h.usedThisMonth ?? 0 }}</td>
30
30
  </tr>
@@ -58,7 +58,7 @@
58
58
  <template #item.date="{ value }">{{ standardFormatDateTime(value) }}</template>
59
59
  <template #item.what="{ item }">{{ historyText(item) }}</template>
60
60
  <template #item.person="{ item }">{{ item.holderLabel || item.holderName || "—" }}</template>
61
- <template #item.qty="{ item }">{{ item.qty }} {{ item.unit }}</template>
61
+ <template #item.qty="{ item }">{{ qtyUnit(item.qty, item.unit) }}</template>
62
62
  <template #item.where="{ item }">{{ whereText(item.location) || (item.type === "handover" ? item.remarks : "") || "—" }}</template>
63
63
  <template #item.reference="{ item }">{{ item.type === "handover" ? "—" : item.reference || "—" }}</template>
64
64
  <template #item.photos="{ item }">
@@ -82,7 +82,7 @@ import useInventory from "../composables/useInventory";
82
82
  import useUtils from "../composables/useUtils";
83
83
  import useLocalAuth from "../composables/useLocalAuth";
84
84
  import { historyText, whereText } from "../utils/inventory-usage";
85
- import { inventoryStepsLive } from "../utils/inventory-ack";
85
+ import { inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
86
86
 
87
87
  /**
88
88
  * History of stock (owner 2026-09-29, "where they used their stock"): one person's (`holder`, "me" =
@@ -10,7 +10,7 @@
10
10
  <div class="d-flex flex-wrap ga-4 mb-4">
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
- <div class="text-h5 mb-1" :class="item.lowStock ? 'text-error' : ''">{{ item.onHand }} {{ item.unit }} in store</div>
13
+ <div class="text-h5 mb-1" :class="item.lowStock ? 'text-error' : ''">{{ qtyUnit(item.onHand, item.unit) }} in store</div>
14
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
15
  <div v-if="item.lowStockAlert" class="text-body-2 text-error mb-1">{{ lowStockAlertText(item.lowStockAlert, standardFormatDate) }}</div>
16
16
  <div class="text-body-2 text-medium-emphasis">
@@ -93,7 +93,7 @@ import useUtils from "../composables/useUtils";
93
93
  import { inventoryItemMeta } from "../utils/inventory-store-label";
94
94
  import { inventoryControls } from "../utils/inventory-presets";
95
95
  import { receiptRefText } from "../utils/inventory-receiving";
96
- import { availableText, inventoryStepsLive, lowStockAlertText, writeOffLabel } from "../utils/inventory-ack";
96
+ import { availableText, inventoryStepsLive, lowStockAlertText, qtyUnit, writeOffLabel } from "../utils/inventory-ack";
97
97
 
98
98
  const show = defineModel<boolean>({ default: false });
99
99
  const props = defineProps({
@@ -7,7 +7,7 @@
7
7
  <v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="show = false" />
8
8
  </v-card-title>
9
9
  <v-card-text class="screen-modal__body">
10
- <v-form ref="form">
10
+ <v-form ref="form" validate-on="blur invalid-input">
11
11
  <InputLabel title="Item name" required class="mb-1" />
12
12
  <v-text-field v-model="f.name" :rules="[requiredRule]" density="comfortable" placeholder="e.g. Philips T8 2ft 9W LED Tube" />
13
13
  <v-row dense>
@@ -9,7 +9,8 @@
9
9
 
10
10
  <v-card-text class="screen-modal__body">
11
11
  <p class="text-body-2 text-medium-emphasis mb-4">{{ copy.help }}</p>
12
- <v-form ref="form">
12
+ <!-- Errors once a field is left or the button is pressed, never from opening or resetting the form. -->
13
+ <v-form ref="form" validate-on="blur invalid-input">
13
14
  <template v-if="mode === 'receive'">
14
15
  <InputLabel title="How did this stock come in?" required class="mb-1" />
15
16
  <v-btn-toggle v-model="receivingType" mandatory divided variant="outlined" density="comfortable" class="mb-1 receiving-type">
@@ -40,7 +41,7 @@
40
41
  </template>
41
42
  </v-autocomplete>
42
43
  <v-alert v-if="usedByWarning" type="warning" variant="tonal" density="compact" class="mt-2">{{ usedByWarning }}</v-alert>
43
- <v-text-field v-else :model-value="holderName" density="comfortable" readonly />
44
+ <v-text-field v-if="mode !== 'issue'" :model-value="holderName" density="comfortable" readonly />
44
45
  </template>
45
46
 
46
47
  <div class="text-subtitle-2 mb-2">Items</div>
@@ -170,7 +171,7 @@
170
171
  import useInventory, { pickedDate } from "../composables/useInventory";
171
172
  import useUtils from "../composables/useUtils";
172
173
  import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
173
- import { availableQty, availableText as availableTextOf } from "../utils/inventory-ack";
174
+ import { availableQty, availableText as availableTextOf, qtyUnit } from "../utils/inventory-ack";
174
175
  import { RECEIVING_TYPES, receivingFields, receivingProblem, type TReceivingType } from "../utils/inventory-receiving";
175
176
 
176
177
  type TMode = "receive" | "issue" | "request" | "return";
@@ -195,7 +196,7 @@ const notify = inject<(text: string, color?: string) => void>("inventoryNotify",
195
196
  const COPY: Record<TMode, { title: string; help: string; button: string; success: string }> = {
196
197
  receive: {
197
198
  title: "Receive Stock",
198
- help: "Record a delivery from a supplier. The quantities are added to the store straight away.",
199
+ help: "The quantities are added to the store straight away.",
199
200
  button: "Add to store",
200
201
  success: "Stock received.",
201
202
  },
@@ -220,8 +221,10 @@ const COPY: Record<TMode, { title: string; help: string; button: string; success
220
221
  };
221
222
  // The API sets requested stock aside at once (owner 2026-10-03) when its items carry `available`.
222
223
  const reserving = computed(() => itemChoices.value.some((i) => i.available !== undefined));
224
+ const RECEIVE_TITLES: Record<TReceivingType, string> = { PO: "Record a delivery for a PO", DO: "Record a delivery order (DO)", CASH: "Record a cash purchase" };
223
225
  const copy = computed(() => {
224
226
  const c = COPY[props.mode];
227
+ if (props.mode === "receive") return { ...c, title: RECEIVE_TITLES[receivingType.value] };
225
228
  if (!reserving.value) return c;
226
229
  if (props.mode === "request")
227
230
  return {
@@ -279,8 +282,8 @@ const availableOf = (id: string) => {
279
282
  };
280
283
 
281
284
  function availableText(item: any) {
282
- if (props.mode === "return") return `In hand: ${item.onHand} ${item.unit}`;
283
- if (props.mode === "receive") return `In store: ${item.onHand} ${item.unit}`;
285
+ if (props.mode === "return") return `In hand: ${qtyUnit(item.onHand, item.unit)}`;
286
+ if (props.mode === "receive") return `In store: ${qtyUnit(item.onHand, item.unit)}`;
284
287
  return availableTextOf(item);
285
288
  }
286
289
 
@@ -292,7 +295,7 @@ function qtyRule(line: TRow) {
292
295
  const total = rows.value.filter((r) => r.item === line.item).reduce((s, r) => s + Number(r.qty || 0), 0);
293
296
  // Requests too (client 2026-10-02): nobody can ask for more than the store has; the server refuses it as well.
294
297
  if (available !== undefined && total > available)
295
- return available > 0 ? `Only ${available} ${unitOf(line.item)} available — ${props.mode === "request" ? "ask for" : "give"} ${available} or fewer` : "None available right now";
298
+ return available > 0 ? `Only ${qtyUnit(available, unitOf(line.item))} available — ${props.mode === "request" ? "ask for" : "give"} ${available} or fewer` : "None available right now";
296
299
  return true;
297
300
  };
298
301
  }
@@ -386,6 +389,7 @@ async function submit() {
386
389
 
387
390
  watch(show, (open) => {
388
391
  if (!open) return;
392
+ form.value?.resetValidation();
389
393
  rows.value = [blank()];
390
394
  supplier.value = "";
391
395
  reference.value = "";
@@ -78,7 +78,7 @@
78
78
  <InputLabel title="Properties this store supplies" required class="mb-1" />
79
79
  <v-select
80
80
  v-model="setup.sites"
81
- :items="orgSites"
81
+ :items="setupSiteOptions"
82
82
  item-title="name"
83
83
  item-value="_id"
84
84
  multiple
@@ -136,7 +136,7 @@
136
136
  <InventoryMyStockTab :store="store" :site-id="siteId" :sites="storeSites" @changed="refreshCounts" />
137
137
  </v-window-item>
138
138
  <v-window-item v-if="ctl.tabs.requests" value="requests">
139
- <InventoryRequestsTab :store="store" :site-id="siteId" @changed="refreshCounts" />
139
+ <InventoryRequestsTab :store="store" :site-id="siteId" :focus-request="focusRequest" @changed="refreshCounts" />
140
140
  </v-window-item>
141
141
  <v-window-item v-if="ctl.tabs.people" value="technicians">
142
142
  <InventoryTechniciansTab :store="store" @changed="refreshCounts" />
@@ -163,7 +163,7 @@ import useWorkerTerm from "../composables/useWorkerTerm";
163
163
  import useSite from "../composables/useSite";
164
164
  import useCustomerSite from "../composables/useCustomerSite";
165
165
  import useUtils from "../composables/useUtils";
166
- import { inventoryManagedBy, inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
166
+ import { inventoryManagedBy, inventorySiteOptions, inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
167
167
  import { inventoryControls } from "../utils/inventory-presets";
168
168
  import { inventoryStepsLive } from "../utils/inventory-ack";
169
169
  import useLocalAuth from "../composables/useLocalAuth";
@@ -177,6 +177,8 @@ const movedFromEquipment = useRoute().query.moved === "equipment";
177
177
  // A notification link (`?storeId=&tab=`, NotificationBell): open that store on that tab, once.
178
178
  const linkQuery = useRoute().query;
179
179
  const linked = { storeId: String(linkQuery.storeId ?? ""), tab: String(linkQuery.tab ?? "") };
180
+ // `?requestId=` (approved / confirm-received alerts): the Requests tab opens on that request.
181
+ const focusRequest = String(linkQuery.requestId ?? "");
180
182
 
181
183
  const { getHandovers, getStores, getStore, createStore: apiCreateStore, getRequests, getReport } = useInventory();
182
184
  const { getSites } = useSite();
@@ -334,6 +336,10 @@ async function refreshCounts() {
334
336
  /** The properties this person works at in this organisation (the same list as the site switcher). */
335
337
  const { getAll: getCustomerSites } = useCustomerSite();
336
338
  const mySites = ref<{ _id: string; name: string }[]>([]);
339
+ // The property being viewed may be one the organisation serves, not owns: name it, never show its id.
340
+ const setupSiteOptions = computed(() =>
341
+ inventorySiteOptions(orgSites.value, { sites: setup.sites, siteNames: [...mySites.value, { _id: props.siteId, name: currentSiteName.value }] }),
342
+ );
337
343
  const currentSiteName = computed(() => mySites.value.find((s) => s._id === props.siteId)?.name || storeSites.value.find((s) => s._id === props.siteId)?.name || "this property");
338
344
  async function loadMySites() {
339
345
  try {
@@ -25,7 +25,7 @@
25
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))">
26
26
  <v-icon icon="mdi-hand-coin-outline" color="warning" />
27
27
  <div class="flex-grow-1">
28
- <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>
29
29
  <div class="text-caption text-medium-emphasis">{{ standardFormatDate(h.createdAt) }}{{ h.note ? ` · ${h.note}` : "" }} · Confirm once you have it in hand.</div>
30
30
  </div>
31
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>
@@ -34,7 +34,7 @@
34
34
  <AppCard v-for="h in waiting" :key="h._id" class="pa-3 mb-3 d-flex flex-wrap align-center ga-3">
35
35
  <v-icon icon="mdi-timer-sand" color="medium-emphasis" />
36
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}.` }}
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
38
  </div>
39
39
  <v-btn class="text-none" variant="text" size="small" :loading="acting === h._id + 'c'" @click="cancelHandover(h)">Cancel</v-btn>
40
40
  </AppCard>
@@ -50,7 +50,7 @@
50
50
  >
51
51
  <InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
52
52
  <div class="flex-grow-1" style="min-width: 200px">
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>
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>
54
54
  <div class="text-body-2">{{ draftCardText(d, standardFormatDateTime, staleDays).line }}</div>
55
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>
56
56
  <div v-if="d.stale" class="text-caption text-error">{{ draftCardText(d, standardFormatDateTime, staleDays).warning }}</div>
@@ -59,6 +59,19 @@
59
59
  </AppCard>
60
60
  </template>
61
61
 
62
+ <!-- Colleagues' open drafts (same company): any technician can take one over and finish it from their own stock. -->
63
+ <template v-if="colleagueDrafts.length">
64
+ <div class="text-subtitle-2 mb-2">Colleagues' jobs in progress ({{ colleagueDrafts.length }})</div>
65
+ <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))">
66
+ <InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
67
+ <div class="flex-grow-1" style="min-width: 200px">
68
+ <div class="font-weight-medium">{{ d.itemName }} × {{ qtyUnit(d.qty, d.unit) }}</div>
69
+ <div class="text-body-2">{{ colleagueDraftText(d, standardFormatDateTime) }}</div>
70
+ </div>
71
+ <v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === d._id + 't'" @click="takeOver(d)">Take over</v-btn>
72
+ </AppCard>
73
+ </template>
74
+
62
75
  <v-progress-linear v-if="loading" indeterminate color="primary" />
63
76
  <AppCard v-else-if="!holdings.length" class="pa-6 text-center">
64
77
  <div class="text-body-1 mb-1">You are not holding any stock.</div>
@@ -74,7 +87,7 @@
74
87
  </v-avatar>
75
88
  <div class="flex-grow-1">
76
89
  <div class="font-weight-medium">{{ h.itemName }}</div>
77
- <div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
90
+ <div class="text-body-2">{{ qtyUnit(h.qty, h.unit) }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
78
91
  <div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
79
92
  <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>
80
93
  </div>
@@ -133,9 +146,9 @@
133
146
  </v-card-title>
134
147
  <v-card-text class="screen-modal__body">
135
148
  <p class="text-body-2 text-medium-emphasis mb-3">
136
- {{ give.holding?.itemName }}: you hold {{ give.holding?.qty }} {{ give.holding?.unit }}. It moves to your colleague once they confirm they received it.
149
+ {{ give.holding?.itemName }}: you hold {{ qtyUnit(give.holding?.qty, give.holding?.unit) }}. It moves to your colleague once they confirm they received it.
137
150
  </p>
138
- <v-form ref="giveForm">
151
+ <v-form ref="giveForm" validate-on="blur invalid-input">
139
152
  <InputLabel title="Colleague" required class="mb-1" />
140
153
  <v-autocomplete
141
154
  v-model="give.to"
@@ -177,9 +190,9 @@
177
190
  <v-card-title>Return to store</v-card-title>
178
191
  <v-card-text class="screen-modal__body">
179
192
  <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.
193
+ {{ 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.
181
194
  </p>
182
- <v-form ref="retForm">
195
+ <v-form ref="retForm" validate-on="blur invalid-input">
183
196
  <InputLabel title="Quantity" required class="mb-1" />
184
197
  <v-text-field
185
198
  v-model.number="ret.qty"
@@ -221,8 +234,8 @@ import useFile from "../composables/useFile";
221
234
  import useUtils from "../composables/useUtils";
222
235
  import useLocalAuth from "../composables/useLocalAuth";
223
236
  import { inventoryControls } from "../utils/inventory-presets";
224
- import { draftCardText } from "../utils/inventory-usage";
225
- import { ackText, inventoryStepsLive } from "../utils/inventory-ack";
237
+ import { colleagueDraftText, draftCardText } from "../utils/inventory-usage";
238
+ import { ackText, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
226
239
 
227
240
  const props = defineProps({
228
241
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -254,7 +267,7 @@ const freeOf = (h: TInventoryHolding) => Math.max(0, (h.free ?? h.qty) - pending
254
267
  // Issued to me and not yet confirmed (or reported not received).
255
268
  const toReceive = ref<TInventoryRequest[]>([]);
256
269
  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)}` : ""}`;
270
+ `${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)}` : ""}`;
258
271
  async function loadToReceive() {
259
272
  if (!stepsLive.value) return (toReceive.value = []);
260
273
  try {
@@ -320,6 +333,31 @@ async function loadDrafts() {
320
333
  drafts.value = [];
321
334
  }
322
335
  }
336
+ const colleagueDrafts = ref<TInventoryUsageDraft[]>([]);
337
+ async function loadColleagueDrafts() {
338
+ // stepsLive = the API knows ?colleagues (an older API ignores it and returns my own, hence the mine/holder filter too).
339
+ if (!ctl.value.recordUsage || !stepsLive.value) return (colleagueDrafts.value = []);
340
+ try {
341
+ const res = await api.getUsageDrafts(props.store._id, "colleagues");
342
+ colleagueDrafts.value = res.items.filter((d) => !d.mine && String(d.holder) !== me.value);
343
+ } catch {
344
+ colleagueDrafts.value = []; // 403/404: not offered, nothing to show
345
+ }
346
+ }
347
+ async function takeOver(d: TInventoryUsageDraft) {
348
+ const who = d.holderName || d.startedByName || "your colleague";
349
+ 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;
350
+ acting.value = d._id + "t";
351
+ try {
352
+ await api.takeOverDraft(String(d._id));
353
+ notify("The job is yours now. Finish it under My drafts.");
354
+ changed();
355
+ } catch (error: any) {
356
+ notify(error?.data?.message || "Could not take over the job.", "error");
357
+ } finally {
358
+ acting.value = "";
359
+ }
360
+ }
323
361
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
324
362
 
325
363
  const handovers = ref<TInventoryHandover[]>([]);
@@ -348,7 +386,7 @@ async function decide(h: TInventoryHandover, accept: boolean) {
348
386
  try {
349
387
  if (accept) await api.acceptHandover(h._id);
350
388
  else await api.declineHandover(h._id, "");
351
- notify(accept ? `You received ${h.qty} ${h.unit} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
389
+ notify(accept ? `You received ${qtyUnit(h.qty, h.unit)} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
352
390
  changed();
353
391
  } catch (error: any) {
354
392
  notify(error?.data?.message || "Could not update the hand-over.", "error");
@@ -436,11 +474,12 @@ function changed() {
436
474
  load();
437
475
  loadHandovers();
438
476
  loadDrafts();
477
+ loadColleagueDrafts();
439
478
  loadToReceive();
440
479
  emit("changed");
441
480
  }
442
481
 
443
- watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts(), loadToReceive()));
444
- watch(stepsLive, loadToReceive);
445
- onMounted(() => (load(), loadHandovers(), loadDrafts(), loadToReceive()));
482
+ watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts(), loadColleagueDrafts(), loadToReceive()));
483
+ watch(stepsLive, () => (loadToReceive(), loadColleagueDrafts()));
484
+ onMounted(() => (load(), loadHandovers(), loadDrafts(), loadColleagueDrafts(), loadToReceive()));
446
485
  </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";
@@ -21,6 +21,7 @@
21
21
  </template>
22
22
 
23
23
  <script setup lang="ts">
24
+ import { qtyUnit } from "../utils/inventory-ack";
24
25
  import useInventory from "../composables/useInventory";
25
26
 
26
27
  const props = defineProps({ request: { type: Object as PropType<TInventoryRequest>, required: true } });
@@ -31,7 +32,7 @@ const notify = inject<(text: string, color?: string) => void>("inventoryNotify",
31
32
  const busy = ref<"" | "received" | "not">("");
32
33
  const nr = reactive({ show: false, reason: "" });
33
34
 
34
- const items = () => props.request.lines.filter((l) => l.issuedQty > 0).map((l) => `${l.issuedQty} ${l.unit} ${l.itemName}`).join(", ");
35
+ const items = () => props.request.lines.filter((l) => l.issuedQty > 0).map((l) => `${qtyUnit(l.issuedQty, l.unit)} ${l.itemName}`).join(", ");
35
36
 
36
37
  function openNotReceived() {
37
38
  Object.assign(nr, { show: true, reason: "" });
@@ -265,7 +265,7 @@ import { inventoryControls } from "../utils/inventory-presets";
265
265
  import { USED_BY_FILTERS } from "../utils/inventory-used-by";
266
266
  import { USAGE_FLAG_WORDS, USAGE_STATUS_CHOICES, usagePathText, usageQuery, usageTotals } from "../utils/inventory-usage";
267
267
  import { receivingLabel } from "../utils/inventory-receiving";
268
- import { inventoryStepsLive, reconciliationCheckText, reconciliationPersonText, writeOffReasonText } from "../utils/inventory-ack";
268
+ import { inventoryStepsLive, qtyUnit, reconciliationCheckText, reconciliationPersonText, writeOffReasonText } from "../utils/inventory-ack";
269
269
 
270
270
  const props = defineProps({
271
271
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -356,7 +356,7 @@ function reconStoreText(r: any) {
356
356
  s.returned ? `returned ${s.returned}` : "",
357
357
  s.adjusted ? `stock count ${s.adjusted > 0 ? "+" : ""}${s.adjusted}` : "",
358
358
  s.writtenOff ? `written off from the shelf ${s.writtenOff}` : "",
359
- `end ${s.closing} ${r.unit}`,
359
+ `end ${qtyUnit(s.closing, r.unit)}`,
360
360
  ]
361
361
  .filter(Boolean)
362
362
  .join(" · ");
@@ -566,7 +566,7 @@ const { currentUser } = useLocalAuth();
566
566
  const me = computed(() => String(currentUser.value?._id ?? ""));
567
567
  const takingOver = ref("");
568
568
  async function takeOver(r: any) {
569
- if (!confirm(`Take over ${r.technician || "your colleague"}'s draft for ${r.qty} ${r.unit ?? ""} ${r.itemName}? You finish it (after photo, submit), and the stock comes from YOUR own stock, not theirs.`)) return;
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
570
  takingOver.value = r._id;
571
571
  try {
572
572
  await api.takeOverDraft(String(r._id));
@@ -28,7 +28,12 @@
28
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." }}
29
29
  </div>
30
30
 
31
- <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
+ >
32
37
  <div class="d-flex flex-wrap align-center ga-2 mb-2">
33
38
  <div class="font-weight-medium">{{ r.requestedByLabel || r.requestedByName || "Someone" }}</div>
34
39
  <div class="text-caption text-medium-emphasis">{{ standardFormatDateTime(r.createdAt) }}</div>
@@ -68,11 +73,11 @@
68
73
  </v-card-title>
69
74
  <v-card-text class="screen-modal__body">
70
75
  <p class="text-body-2 text-medium-emphasis mb-3">Hand over the items, then approve. You can give less than asked.</p>
71
- <v-form ref="decisionForm">
76
+ <v-form ref="decisionForm" validate-on="blur invalid-input">
72
77
  <div v-for="l in decision.lines" :key="l.item" class="d-flex align-center ga-3 mb-1">
73
78
  <div class="flex-grow-1">
74
79
  <div class="font-weight-medium">{{ l.itemName }}</div>
75
- <div class="text-caption text-medium-emphasis">Asked {{ l.qty }} · You can give up to {{ l.onHand ?? "…" }} {{ l.unit }}{{ l.reservedQty ? ` (${l.reservedQty} set aside for this request)` : "" }}</div>
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>
76
81
  </div>
77
82
  <v-text-field
78
83
  v-model.number="l.give"
@@ -106,11 +111,13 @@ import useUtils from "../composables/useUtils";
106
111
  import { REQUEST_STATUS, requestLineText, requestOutcomeText } from "../utils/inventory-request-text";
107
112
  import useLocalAuth from "../composables/useLocalAuth";
108
113
  import { inventoryControls } from "../utils/inventory-presets";
109
- import { ACK_CHIP, ackText, approveCap, inventoryStepsLive } from "../utils/inventory-ack";
114
+ import { ACK_CHIP, ackText, approveCap, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
110
115
 
111
116
  const props = defineProps({
112
117
  store: { type: Object as PropType<TInventoryStore>, required: true },
113
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: "" },
114
121
  });
115
122
  const ctl = computed(() => inventoryControls(props.store.access));
116
123
  const emit = defineEmits(["changed"]);
@@ -123,7 +130,7 @@ const notify = inject<(text: string, color?: string) => void>("inventoryNotify",
123
130
  const chip = REQUEST_STATUS;
124
131
  const stepsLive = computed(() => inventoryStepsLive(props.store));
125
132
 
126
- const status = ref("pending");
133
+ const status = ref(props.focusRequest ? "" : "pending");
127
134
  const search = ref("");
128
135
  const requests = ref<TInventoryRequest[]>([]);
129
136
  const page = ref(1);
@@ -141,7 +148,10 @@ async function load(p = 1) {
141
148
  const confirm = status.value === "confirm";
142
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 });
143
150
  const got = confirm ? res.items.filter((r) => r.ackStatus === "awaiting" || r.ackStatus === "not_received") : res.items;
144
- requests.value = p === 1 ? got : [...requests.value, ...got];
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];
145
155
  page.value = p;
146
156
  pages.value = res.pages;
147
157
  } catch {
@@ -36,7 +36,7 @@
36
36
  density="comfortable"
37
37
  style="max-width: 220px"
38
38
  :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= 30) || 'A whole number from 0 to 30']"
39
- :hint="reminderDaysText(reminderDays)"
39
+ :hint="`How often to remind (days). 0 = no reminders. ${reminderDaysText(reminderDays)}`"
40
40
  persistent-hint
41
41
  />
42
42
  </template>
@@ -155,7 +155,7 @@
155
155
  <v-card-title>Low stock: {{ ack.item.name }}</v-card-title>
156
156
  <v-card-text class="screen-modal__body">
157
157
  <p class="text-body-2 mb-3">
158
- {{ availableQty(ack.item) }} {{ ack.item.unit }} left (alert at {{ ack.item.reorderLevel }}). Record the PO number once you have ordered more:
158
+ {{ qtyUnit(availableQty(ack.item), ack.item.unit) }} left (alert at {{ ack.item.reorderLevel }}). Record the PO number once you have ordered more:
159
159
  the reminders then stop. Without a PO number, we note that you have seen it and the reminders carry on.
160
160
  </p>
161
161
  <InputLabel title="PO number (optional)" class="mb-1" />
@@ -178,7 +178,7 @@ import useUtils from "../composables/useUtils";
178
178
  import { inventoryItemMeta } from "../utils/inventory-store-label";
179
179
  import { inventoryControls } from "../utils/inventory-presets";
180
180
  import { hasLegacyEquipmentToCopy } from "../utils/inventory-legacy";
181
- import { availableQty, lowStockAlertText } from "../utils/inventory-ack";
181
+ import { availableQty, lowStockAlertText, qtyUnit } from "../utils/inventory-ack";
182
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";
183
183
 
184
184
  const props = defineProps({
@@ -10,7 +10,7 @@
10
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
11
  <v-icon icon="mdi-keyboard-return" color="warning" />
12
12
  <div class="flex-grow-1" style="min-width: 220px">
13
- <div class="font-weight-medium">{{ h.fromLabel || h.fromName }} wants to return {{ h.qty }} {{ h.unit }} {{ h.itemName }}</div>
13
+ <div class="font-weight-medium">{{ h.fromLabel || h.fromName }} wants to return {{ qtyUnit(h.qty, h.unit) }} {{ h.itemName }}</div>
14
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
15
  </div>
16
16
  <v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" :disabled="!!acting" @click="openDecline(h)">Not received</v-btn>
@@ -29,7 +29,7 @@
29
29
  </div>
30
30
  <div class="d-flex flex-wrap ga-2">
31
31
  <v-chip v-for="h in g.items" :key="h._id" variant="tonal">
32
- {{ h.itemName }}: {{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month{{ h.unacknowledged ? ` · ${h.unacknowledged} not yet confirmed as received` : "" }}
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
33
  </v-chip>
34
34
  </div>
35
35
  </AppCard>
@@ -60,6 +60,7 @@
60
60
  </template>
61
61
 
62
62
  <script setup lang="ts">
63
+ import { qtyUnit } from "../utils/inventory-ack";
63
64
  import useInventory from "../composables/useInventory";
64
65
  import { inventoryControls } from "../utils/inventory-presets";
65
66
  import useUtils from "../composables/useUtils";
@@ -87,7 +88,7 @@ async function acceptReturn(h: TInventoryHandover) {
87
88
  acting.value = h._id;
88
89
  try {
89
90
  await acceptHandover(h._id);
90
- notify(`${h.qty} ${h.unit} ${h.itemName} is back in the store.`);
91
+ notify(`${qtyUnit(h.qty, h.unit)} ${h.itemName} is back in the store.`);
91
92
  changed();
92
93
  } catch (error: any) {
93
94
  notify(error?.data?.message || "Could not accept the return.", "error");
@@ -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"
@@ -134,7 +134,7 @@
134
134
  <script setup lang="ts">
135
135
  import useInventory, { pickedDate } from "../composables/useInventory";
136
136
  import useUtils from "../composables/useUtils";
137
- import { inventoryStepsLive } from "../utils/inventory-ack";
137
+ import { inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
138
138
 
139
139
  const show = defineModel<boolean>({ default: false });
140
140
  const props = defineProps({
@@ -10,11 +10,12 @@
10
10
  <p class="text-body-2 text-medium-emphasis mb-3">
11
11
  {{
12
12
  from === "store"
13
- ? `For stock on the store shelf that can no longer be used. ${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 ${max} ${unit}.`
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
15
  }}
16
16
  </p>
17
- <v-form ref="form">
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">
18
19
  <InputLabel title="How many?" required class="mb-1" />
19
20
  <v-text-field
20
21
  v-model.number="qty"
@@ -27,7 +28,7 @@
27
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}`]"
28
29
  />
29
30
  <InputLabel title="Why?" required class="mb-1" />
30
- <v-chip-group v-model="reason" mandatory selected-class="text-primary" class="mb-2">
31
+ <v-chip-group v-model="reason" :mandatory="from !== 'store'" selected-class="text-primary" class="mb-2">
31
32
  <v-chip v-for="r in WRITE_OFF_REASONS" :key="r.value" :value="r.value" variant="outlined" filter>{{ r.title }}</v-chip>
32
33
  </v-chip-group>
33
34
  <InputLabel :title="reason === 'other' ? 'What happened?' : 'Remarks (optional)'" :required="reason === 'other'" class="mb-1" />
@@ -47,7 +48,7 @@
47
48
 
48
49
  <script setup lang="ts">
49
50
  import useInventory from "../composables/useInventory";
50
- import { WRITE_OFF_REASONS, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
51
+ import { qtyUnit, WRITE_OFF_REASONS, writeOffDefaultReason, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
51
52
 
52
53
  /** Write-off (owner 2026-10-03): from the person's own stock (`holding`) or, for a store keeper, from the shelf (`store`). */
53
54
  const show = defineModel<boolean>({ default: false });
@@ -67,7 +68,7 @@ const notify = inject<(text: string, color?: string) => void>("inventoryNotify",
67
68
 
68
69
  const form = ref();
69
70
  const qty = ref<number | null>(1);
70
- const reason = ref("expended");
71
+ const reason = ref<string>(writeOffDefaultReason(props.from));
71
72
  const remarks = ref("");
72
73
  const photos = ref<string[]>([]);
73
74
  const saving = ref(false);
@@ -89,7 +90,7 @@ async function save() {
89
90
  remarks: remarks.value.trim(),
90
91
  photos: photos.value,
91
92
  });
92
- notify(`${qty.value} ${props.unit} of ${props.itemName} written off as ${writeOffLabel(reason.value).toLowerCase()}.${props.from === "holding" ? " The store keeper has been told." : ""}`);
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." : ""}`);
93
94
  show.value = false;
94
95
  emit("done");
95
96
  } catch (error: any) {
@@ -101,8 +102,9 @@ async function save() {
101
102
 
102
103
  watch(show, (open) => {
103
104
  if (!open) return;
105
+ form.value?.resetValidation();
104
106
  qty.value = 1;
105
- reason.value = "expended";
107
+ reason.value = writeOffDefaultReason(props.from);
106
108
  remarks.value = "";
107
109
  photos.value = [];
108
110
  tried.value = false;
@@ -51,8 +51,9 @@ export default function useInventory() {
51
51
  receiveReturn: (store: string, body: Record<string, any>) => api(`/stores/${store}/returns`, { method: "POST", body }),
52
52
  recordUsage: (store: string, body: Record<string, any>) => api(`/stores/${store}/usage`, { method: "POST", body }),
53
53
  /** Usage drafts: before photo now, after photo and submit after the job (stock is deducted on submit). */
54
- getUsageDrafts: (store: string, everyone = false) =>
55
- api<{ items: TInventoryUsageDraft[]; staleAfterDays: number }>(`/stores/${store}/usage-drafts`, { query: everyone ? { everyone: true } : {} }),
54
+ /** Own drafts; "colleagues" = same-company colleagues' open drafts (anyone who records usage); "everyone" = supervisors. */
55
+ getUsageDrafts: (store: string, scope: "" | "colleagues" | "everyone" = "") =>
56
+ api<{ items: TInventoryUsageDraft[]; staleAfterDays: number }>(`/stores/${store}/usage-drafts`, { query: scope ? { [scope]: true } : {} }),
56
57
  createUsageDraft: (store: string, body: Record<string, any>) => api<TInventoryUsageDraft>(`/stores/${store}/usage-drafts`, { method: "POST", body }),
57
58
  updateUsageDraft: (id: string, body: Record<string, any>) => api<TInventoryUsageDraft>(`/usage-drafts/${id}`, { method: "PATCH", body }),
58
59
  discardUsageDraft: (id: string) => api(`/usage-drafts/${id}/discard`, { method: "POST" }),
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.90.2-staging.493",
5
+ "version": "4.90.2-staging.495",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -12,6 +12,21 @@ export function inventoryStepsLive(store?: { lowStockReminderDays?: number | nul
12
12
  return typeof store?.lowStockReminderDays === "number";
13
13
  }
14
14
 
15
+ /** Singular / plural of the item units the store offers; any other unit is shown as typed. */
16
+ const UNIT_FORMS: Record<string, [string, string]> = {
17
+ pc: ["pc", "pcs"], pcs: ["pc", "pcs"], box: ["box", "boxes"], boxes: ["box", "boxes"], set: ["set", "sets"], sets: ["set", "sets"],
18
+ pack: ["pack", "packs"], packs: ["pack", "packs"], roll: ["roll", "rolls"], rolls: ["roll", "rolls"], can: ["can", "cans"], cans: ["can", "cans"],
19
+ bottle: ["bottle", "bottles"], bottles: ["bottle", "bottles"], litre: ["litre", "litres"], litres: ["litre", "litres"],
20
+ metre: ["metre", "metres"], metres: ["metre", "metres"],
21
+ };
22
+
23
+ /** "1 pc", "2 pcs", "1 box", "3 boxes"; kg and unknown units unchanged. */
24
+ export function qtyUnit(qty: number | string | null | undefined, unit?: string | null) {
25
+ const forms = UNIT_FORMS[String(unit ?? "").trim().toLowerCase()];
26
+ const word = forms ? forms[Math.abs(Number(qty)) === 1 ? 0 : 1] : (unit ?? "");
27
+ return `${qty ?? ""} ${word}`.trim();
28
+ }
29
+
15
30
  /** What can still be requested: `available` from the API, else what is on the shelf. */
16
31
  export function availableQty(item: { onHand: number; available?: number }) {
17
32
  return item.available ?? item.onHand;
@@ -20,7 +35,7 @@ export function availableQty(item: { onHand: number; available?: number }) {
20
35
  /** "Available: 8 pcs · 2 set aside for requests". */
21
36
  export function availableText(item: { onHand: number; available?: number; reserved?: number; unit: string }) {
22
37
  const reserved = item.reserved ?? 0;
23
- return `Available: ${availableQty(item)} ${item.unit}${reserved > 0 ? ` · ${reserved} set aside for requests` : ""}`;
38
+ return `Available: ${qtyUnit(availableQty(item), item.unit)}${reserved > 0 ? ` · ${reserved} set aside for requests` : ""}`;
24
39
  }
25
40
 
26
41
  /**
@@ -67,6 +82,9 @@ export const WRITE_OFF_REASONS = [
67
82
  ] as const;
68
83
  export type TWriteOffReason = (typeof WRITE_OFF_REASONS)[number]["value"];
69
84
 
85
+ /** A store keeper writing off from the shelf picks the reason (nothing chosen first); a holder starts on "Used up". */
86
+ export const writeOffDefaultReason = (from: "holding" | "store") => (from === "store" ? "" : "expended");
87
+
70
88
  export const writeOffLabel = (reason?: string) => WRITE_OFF_REASONS.find((r) => r.value === reason)?.title ?? (reason || "");
71
89
 
72
90
  /** The same sentence the API uses, shown before saving. */
@@ -80,7 +98,7 @@ export function writeOffProblem(input: { reason: string; remarks: string }) {
80
98
  export function writeOffReasonText(byReason?: Record<string, number> | null, unit = "") {
81
99
  return Object.entries(byReason ?? {})
82
100
  .filter(([, n]) => n > 0)
83
- .map(([reason, n]) => `${n}${unit ? ` ${unit}` : ""} ${writeOffLabel(reason).toLowerCase()}`)
101
+ .map(([reason, n]) => `${qtyUnit(n, unit)} ${writeOffLabel(reason).toLowerCase()}`)
84
102
  .join(", ");
85
103
  }
86
104
 
@@ -134,7 +152,7 @@ export function reconciliationPersonText(p: {
134
152
  if (p.writtenOff) parts.push(writeOffReasonText(p.writtenOffByReason) || `${p.writtenOff} written off`);
135
153
  if (p.toColleagues) parts.push(`gave ${p.toColleagues} to colleagues`);
136
154
  if (p.returned) parts.push(`returned ${p.returned} to the store`);
137
- parts.push(`holds ${p.closing} ${unit}`.trim());
155
+ parts.push(`holds ${qtyUnit(p.closing, unit)}`);
138
156
  if (p.awaitingReceipt) parts.push(`${p.awaitingReceipt} not yet confirmed as received`);
139
157
  return parts.join(" · ");
140
158
  }
@@ -3,6 +3,8 @@
3
3
  * words, how many were handed over against how many were asked, and who
4
4
  * decided and when. Used by `InventoryRequestsTab`.
5
5
  */
6
+ import { qtyUnit } from "./inventory-ack.ts";
7
+
6
8
  type TLine = { itemName: string; unit: string; qty: number; issuedQty: number };
7
9
  type TRequest = { status: string; decidedByName?: string; decidedAt?: string; decisionRemarks?: string };
8
10
 
@@ -13,10 +15,10 @@ export const REQUEST_STATUS: Record<string, { label: string; color: string }> =
13
15
  cancelled: { label: "Cancelled", color: "grey" },
14
16
  };
15
17
 
18
+ /** "(asked N)" only when less was handed over than asked; a counter issue was never asked for. */
16
19
  export function requestLineText(status: string, l: TLine) {
17
- return status === "issued"
18
- ? `${l.itemName} — ${l.issuedQty} ${l.unit} handed over (asked ${l.qty})`
19
- : `${l.itemName} — ${l.qty} ${l.unit}`;
20
+ if (status !== "issued") return `${l.itemName} — ${qtyUnit(l.qty, l.unit)}`;
21
+ return `${l.itemName} — ${qtyUnit(l.issuedQty, l.unit)} handed over${l.issuedQty !== l.qty ? ` (asked ${l.qty})` : ""}`;
20
22
  }
21
23
 
22
24
  /** "Approved and handed over by X on <when> — remarks", or "" before a decision. */
@@ -36,6 +36,17 @@ export function draftCardText(d: TDraft, time: (iso: string) => string, staleDay
36
36
  };
37
37
  }
38
38
 
39
+ /** "Kevin started this job at Unit 05-12 at 9:40 - take it over to finish it with your own stock." (a colleague's open draft) */
40
+ export function colleagueDraftText(
41
+ d: { startedByName?: string; holderName?: string; location?: TLocation; reference?: string; draftedAt?: string; photosBeforeAt?: string | null },
42
+ time: (iso: string) => string,
43
+ ) {
44
+ const who = d.holderName || d.startedByName || "A colleague";
45
+ const where = whereText(d.location) || d.reference || "";
46
+ const at = d.draftedAt || d.photosBeforeAt;
47
+ return `${who} started this job${where ? ` at ${where}` : ""}${at ? ` at ${time(at)}` : ""} - take it over to finish it with your own stock.`;
48
+ }
49
+
39
50
  /** "Issued to Ali by Keeper (29/09) → Handed to Bala by Ali (29/09)". */
40
51
  export function usagePathText(steps: TStep[] | undefined, day: (iso: string) => string) {
41
52
  if (!steps?.length) return "";
@@ -45,7 +45,7 @@ export type TTargetRow = {
45
45
  * (core `withNotificationKind`); `site` stands in for `siteId` on rows kept
46
46
  * unscoped on purpose. Older rows have neither and are read from `module`.
47
47
  */
48
- params?: { id?: string; org?: string; kind?: string; site?: string; storeId?: string } | null;
48
+ params?: { id?: string; org?: string; kind?: string; site?: string; storeId?: string; requestId?: string } | null;
49
49
  };
50
50
 
51
51
  /** What the surrounding app knows that the stored row does not. */
@@ -144,7 +144,7 @@ export function notificationKind(item: TTargetRow | null | undefined): string {
144
144
  */
145
145
  export function inventoryTabForAlert(title: string): "requests" | "stock" | "mine" | "" {
146
146
  if (/stock handed to you|hand-over (confirmed|declined)/i.test(title)) return "mine";
147
- if (/request waiting|request (was )?(approved|rejected)/i.test(title)) return "requests";
147
+ if (/request waiting|request (was )?(approved|rejected)|confirm you received/i.test(title)) return "requests";
148
148
  if (/low (on )?stock/i.test(title)) return "stock";
149
149
  return "";
150
150
  }
@@ -237,7 +237,13 @@ function sitePageTarget(
237
237
  // Open the alert's own store on the tab it is about (InventoryMain reads ?storeId=&tab=).
238
238
  const storeId = String(item.params?.storeId ?? item.params?.id ?? "").trim();
239
239
  const tab = inventoryTabForAlert(String(item.title ?? ""));
240
- const query: Record<string, string> = { ...(storeId ? { storeId } : {}), ...(tab ? { tab } : {}) };
240
+ // Approved / please confirm / reminder alerts name their request (API 2026-10-03): open that request.
241
+ const requestId = String(item.params?.requestId ?? "").trim();
242
+ const query: Record<string, string> = {
243
+ ...(storeId ? { storeId } : {}),
244
+ ...(tab || requestId ? { tab: tab || "requests" } : {}),
245
+ ...(requestId ? { requestId } : {}),
246
+ };
241
247
  return Object.keys(query).length ? { candidates, query } : { candidates };
242
248
  }
243
249