@7365admin1/layer-common 4.90.2-staging.494 → 4.90.2-staging.496
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.
- package/components/InventoryHistoryDialog.vue +3 -3
- package/components/InventoryItemDetail.vue +2 -2
- package/components/InventoryItemDialog.vue +1 -1
- package/components/InventoryLinesDialog.vue +11 -7
- package/components/InventoryMain.vue +6 -2
- package/components/InventoryMyStockTab.vue +18 -15
- package/components/InventoryPoDialog.vue +2 -1
- package/components/InventoryReceiptActions.vue +2 -1
- package/components/InventoryReportsTab.vue +3 -3
- package/components/InventoryRequestsTab.vue +3 -3
- package/components/InventorySettingsTab.vue +1 -1
- package/components/InventoryStockTab.vue +5 -5
- package/components/InventoryTechniciansTab.vue +4 -3
- package/components/InventoryUsageDialog.vue +3 -3
- package/components/InventoryWriteOffDialog.vue +10 -8
- package/package.json +1 -1
- package/utils/inventory-ack.ts +27 -3
- package/utils/inventory-request-text.ts +5 -3
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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-
|
|
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: "
|
|
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
|
|
283
|
-
if (props.mode === "receive") return `In store: ${item.onHand
|
|
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
|
|
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="
|
|
81
|
+
:items="setupSiteOptions"
|
|
82
82
|
item-title="name"
|
|
83
83
|
item-value="_id"
|
|
84
84
|
multiple
|
|
@@ -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";
|
|
@@ -336,6 +336,10 @@ async function refreshCounts() {
|
|
|
336
336
|
/** The properties this person works at in this organisation (the same list as the site switcher). */
|
|
337
337
|
const { getAll: getCustomerSites } = useCustomerSite();
|
|
338
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
|
+
);
|
|
339
343
|
const currentSiteName = computed(() => mySites.value.find((s) => s._id === props.siteId)?.name || storeSites.value.find((s) => s._id === props.siteId)?.name || "this property");
|
|
340
344
|
async function loadMySites() {
|
|
341
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
|
|
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
|
|
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
|
|
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>
|
|
@@ -60,12 +60,13 @@
|
|
|
60
60
|
</template>
|
|
61
61
|
|
|
62
62
|
<!-- Colleagues' open drafts (same company): any technician can take one over and finish it from their own stock. -->
|
|
63
|
-
<template v-if="
|
|
63
|
+
<template v-if="colleagueDraftsOffered">
|
|
64
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>
|
|
65
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))">
|
|
66
67
|
<InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
|
|
67
68
|
<div class="flex-grow-1" style="min-width: 200px">
|
|
68
|
-
<div class="font-weight-medium">{{ d.itemName }} × {{ d.qty
|
|
69
|
+
<div class="font-weight-medium">{{ d.itemName }} × {{ qtyUnit(d.qty, d.unit) }}</div>
|
|
69
70
|
<div class="text-body-2">{{ colleagueDraftText(d, standardFormatDateTime) }}</div>
|
|
70
71
|
</div>
|
|
71
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>
|
|
@@ -87,13 +88,13 @@
|
|
|
87
88
|
</v-avatar>
|
|
88
89
|
<div class="flex-grow-1">
|
|
89
90
|
<div class="font-weight-medium">{{ h.itemName }}</div>
|
|
90
|
-
<div class="text-body-2">{{ h.qty
|
|
91
|
+
<div class="text-body-2">{{ qtyUnit(h.qty, h.unit) }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
|
|
91
92
|
<div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
|
|
92
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>
|
|
93
94
|
</div>
|
|
94
95
|
<div class="d-flex flex-column ga-1">
|
|
95
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>
|
|
96
|
-
<v-btn v-if="h
|
|
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>
|
|
97
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>
|
|
98
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>
|
|
99
100
|
<v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
|
|
@@ -146,9 +147,9 @@
|
|
|
146
147
|
</v-card-title>
|
|
147
148
|
<v-card-text class="screen-modal__body">
|
|
148
149
|
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
149
|
-
{{ give.holding?.itemName }}:
|
|
150
|
+
{{ give.holding?.itemName }}: {{ holdText(give.holding) }}. It moves to your colleague once they confirm they received it.
|
|
150
151
|
</p>
|
|
151
|
-
<v-form ref="giveForm">
|
|
152
|
+
<v-form ref="giveForm" validate-on="blur invalid-input">
|
|
152
153
|
<InputLabel title="Colleague" required class="mb-1" />
|
|
153
154
|
<v-autocomplete
|
|
154
155
|
v-model="give.to"
|
|
@@ -159,6 +160,7 @@
|
|
|
159
160
|
placeholder="Search a name"
|
|
160
161
|
density="comfortable"
|
|
161
162
|
no-filter
|
|
163
|
+
no-data-text="No colleague from your company works at this property."
|
|
162
164
|
:rules="[(v: string) => !!v || 'Choose who you are giving it to']"
|
|
163
165
|
@update:search="searchColleagues"
|
|
164
166
|
/>
|
|
@@ -190,9 +192,9 @@
|
|
|
190
192
|
<v-card-title>Return to store</v-card-title>
|
|
191
193
|
<v-card-text class="screen-modal__body">
|
|
192
194
|
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
193
|
-
{{ ret.holding.itemName }}: you can return up to {{ freeOf(ret.holding)
|
|
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.
|
|
194
196
|
</p>
|
|
195
|
-
<v-form ref="retForm">
|
|
197
|
+
<v-form ref="retForm" validate-on="blur invalid-input">
|
|
196
198
|
<InputLabel title="Quantity" required class="mb-1" />
|
|
197
199
|
<v-text-field
|
|
198
200
|
v-model.number="ret.qty"
|
|
@@ -235,7 +237,7 @@ import useUtils from "../composables/useUtils";
|
|
|
235
237
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
236
238
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
237
239
|
import { colleagueDraftText, draftCardText } from "../utils/inventory-usage";
|
|
238
|
-
import { ackText, inventoryStepsLive } from "../utils/inventory-ack";
|
|
240
|
+
import { ackText, holdText, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
239
241
|
|
|
240
242
|
const props = defineProps({
|
|
241
243
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -267,7 +269,7 @@ const freeOf = (h: TInventoryHolding) => Math.max(0, (h.free ?? h.qty) - pending
|
|
|
267
269
|
// Issued to me and not yet confirmed (or reported not received).
|
|
268
270
|
const toReceive = ref<TInventoryRequest[]>([]);
|
|
269
271
|
const receiveTitle = (r: TInventoryRequest) =>
|
|
270
|
-
`${r.decidedByName || "The store"} issued you ${r.lines.filter((l) => l.issuedQty > 0).map((l) => `${l.issuedQty
|
|
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)}` : ""}`;
|
|
271
273
|
async function loadToReceive() {
|
|
272
274
|
if (!stepsLive.value) return (toReceive.value = []);
|
|
273
275
|
try {
|
|
@@ -334,6 +336,7 @@ async function loadDrafts() {
|
|
|
334
336
|
}
|
|
335
337
|
}
|
|
336
338
|
const colleagueDrafts = ref<TInventoryUsageDraft[]>([]);
|
|
339
|
+
const colleagueDraftsOffered = computed(() => ctl.value.recordUsage && stepsLive.value);
|
|
337
340
|
async function loadColleagueDrafts() {
|
|
338
341
|
// stepsLive = the API knows ?colleagues (an older API ignores it and returns my own, hence the mine/holder filter too).
|
|
339
342
|
if (!ctl.value.recordUsage || !stepsLive.value) return (colleagueDrafts.value = []);
|
|
@@ -346,7 +349,7 @@ async function loadColleagueDrafts() {
|
|
|
346
349
|
}
|
|
347
350
|
async function takeOver(d: TInventoryUsageDraft) {
|
|
348
351
|
const who = d.holderName || d.startedByName || "your colleague";
|
|
349
|
-
if (!confirm(`Take over ${who}'s job (${d.qty
|
|
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;
|
|
350
353
|
acting.value = d._id + "t";
|
|
351
354
|
try {
|
|
352
355
|
await api.takeOverDraft(String(d._id));
|
|
@@ -386,7 +389,7 @@ async function decide(h: TInventoryHandover, accept: boolean) {
|
|
|
386
389
|
try {
|
|
387
390
|
if (accept) await api.acceptHandover(h._id);
|
|
388
391
|
else await api.declineHandover(h._id, "");
|
|
389
|
-
notify(accept ? `You received ${h.qty
|
|
392
|
+
notify(accept ? `You received ${qtyUnit(h.qty, h.unit)} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
|
|
390
393
|
changed();
|
|
391
394
|
} catch (error: any) {
|
|
392
395
|
notify(error?.data?.message || "Could not update the hand-over.", "error");
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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));
|
|
@@ -73,11 +73,11 @@
|
|
|
73
73
|
</v-card-title>
|
|
74
74
|
<v-card-text class="screen-modal__body">
|
|
75
75
|
<p class="text-body-2 text-medium-emphasis mb-3">Hand over the items, then approve. You can give less than asked.</p>
|
|
76
|
-
<v-form ref="decisionForm">
|
|
76
|
+
<v-form ref="decisionForm" validate-on="blur invalid-input">
|
|
77
77
|
<div v-for="l in decision.lines" :key="l.item" class="d-flex align-center ga-3 mb-1">
|
|
78
78
|
<div class="flex-grow-1">
|
|
79
79
|
<div class="font-weight-medium">{{ l.itemName }}</div>
|
|
80
|
-
<div class="text-caption text-medium-emphasis">Asked {{ l.qty }} · You can give up to {{ l.onHand ?? "…"
|
|
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>
|
|
81
81
|
</div>
|
|
82
82
|
<v-text-field
|
|
83
83
|
v-model.number="l.give"
|
|
@@ -111,7 +111,7 @@ import useUtils from "../composables/useUtils";
|
|
|
111
111
|
import { REQUEST_STATUS, requestLineText, requestOutcomeText } from "../utils/inventory-request-text";
|
|
112
112
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
113
113
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
114
|
-
import { ACK_CHIP, ackText, approveCap, inventoryStepsLive } from "../utils/inventory-ack";
|
|
114
|
+
import { ACK_CHIP, ackText, approveCap, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
115
115
|
|
|
116
116
|
const props = defineProps({
|
|
117
117
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -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>
|
|
@@ -97,13 +97,13 @@
|
|
|
97
97
|
</template>
|
|
98
98
|
<template #item.onHand="{ item }">
|
|
99
99
|
<!-- Owner 2026-10-03: what is requested comes off this number at once (set aside), so everyone sees what is really left. -->
|
|
100
|
-
<span :class="item.lowStock ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ availableQty(item) }}</span>
|
|
101
|
-
<span class="text-medium-emphasis ml-1">{{ item.unit }}</span>
|
|
100
|
+
<span :class="item.lowStock ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ qtyUnit(availableQty(item), item.unit) }}</span>
|
|
102
101
|
<v-chip v-if="item.lowStock" size="x-small" color="error" variant="tonal" class="ml-2">Low</v-chip>
|
|
103
102
|
<v-chip v-if="item.status === 'inactive'" size="x-small" variant="tonal" class="ml-2">Not in use</v-chip>
|
|
104
103
|
<div v-if="item.reserved" class="text-caption text-medium-emphasis">+ {{ item.reserved }} set aside for requests (still on the shelf)</div>
|
|
105
104
|
<div v-if="item.awaitingReceipt" class="text-caption text-medium-emphasis">{{ item.awaitingReceipt }} handed out, waiting for Received</div>
|
|
106
|
-
|
|
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
107
|
{{ lowStockAlertText(item.lowStockAlert, standardFormatDate) }}
|
|
108
108
|
<a v-if="canAckLow && !item.lowStockAlert.poNumber" href="#" class="text-primary ml-1" @click.prevent.stop="openAck(item)">Acknowledge</a>
|
|
109
109
|
</div>
|
|
@@ -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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
107
|
+
reason.value = writeOffDefaultReason(props.from);
|
|
106
108
|
remarks.value = "";
|
|
107
109
|
photos.value = [];
|
|
108
110
|
tried.value = false;
|
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.
|
|
5
|
+
"version": "4.90.2-staging.496",
|
|
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.",
|
package/utils/inventory-ack.ts
CHANGED
|
@@ -12,6 +12,27 @@ 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
|
+
|
|
30
|
+
/** "you hold 2 pcs" or, while some still wait for Received, "you hold 2 pcs (2 waiting for you to tap Received)". */
|
|
31
|
+
export function holdText(h: { qty?: number; unit?: string; unacknowledged?: number } | null | undefined) {
|
|
32
|
+
const locked = Math.min(Number(h?.unacknowledged ?? 0), Number(h?.qty ?? 0));
|
|
33
|
+
return `you hold ${qtyUnit(h?.qty ?? 0, h?.unit)}${locked > 0 ? ` (${locked} waiting for you to tap Received)` : ""}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
/** What can still be requested: `available` from the API, else what is on the shelf. */
|
|
16
37
|
export function availableQty(item: { onHand: number; available?: number }) {
|
|
17
38
|
return item.available ?? item.onHand;
|
|
@@ -20,7 +41,7 @@ export function availableQty(item: { onHand: number; available?: number }) {
|
|
|
20
41
|
/** "Available: 8 pcs · 2 set aside for requests". */
|
|
21
42
|
export function availableText(item: { onHand: number; available?: number; reserved?: number; unit: string }) {
|
|
22
43
|
const reserved = item.reserved ?? 0;
|
|
23
|
-
return `Available: ${availableQty(item)
|
|
44
|
+
return `Available: ${qtyUnit(availableQty(item), item.unit)}${reserved > 0 ? ` · ${reserved} set aside for requests` : ""}`;
|
|
24
45
|
}
|
|
25
46
|
|
|
26
47
|
/**
|
|
@@ -67,6 +88,9 @@ export const WRITE_OFF_REASONS = [
|
|
|
67
88
|
] as const;
|
|
68
89
|
export type TWriteOffReason = (typeof WRITE_OFF_REASONS)[number]["value"];
|
|
69
90
|
|
|
91
|
+
/** A store keeper writing off from the shelf picks the reason (nothing chosen first); a holder starts on "Used up". */
|
|
92
|
+
export const writeOffDefaultReason = (from: "holding" | "store") => (from === "store" ? "" : "expended");
|
|
93
|
+
|
|
70
94
|
export const writeOffLabel = (reason?: string) => WRITE_OFF_REASONS.find((r) => r.value === reason)?.title ?? (reason || "");
|
|
71
95
|
|
|
72
96
|
/** The same sentence the API uses, shown before saving. */
|
|
@@ -80,7 +104,7 @@ export function writeOffProblem(input: { reason: string; remarks: string }) {
|
|
|
80
104
|
export function writeOffReasonText(byReason?: Record<string, number> | null, unit = "") {
|
|
81
105
|
return Object.entries(byReason ?? {})
|
|
82
106
|
.filter(([, n]) => n > 0)
|
|
83
|
-
.map(([reason, n]) => `${n
|
|
107
|
+
.map(([reason, n]) => `${qtyUnit(n, unit)} ${writeOffLabel(reason).toLowerCase()}`)
|
|
84
108
|
.join(", ");
|
|
85
109
|
}
|
|
86
110
|
|
|
@@ -134,7 +158,7 @@ export function reconciliationPersonText(p: {
|
|
|
134
158
|
if (p.writtenOff) parts.push(writeOffReasonText(p.writtenOffByReason) || `${p.writtenOff} written off`);
|
|
135
159
|
if (p.toColleagues) parts.push(`gave ${p.toColleagues} to colleagues`);
|
|
136
160
|
if (p.returned) parts.push(`returned ${p.returned} to the store`);
|
|
137
|
-
parts.push(`holds ${p.closing
|
|
161
|
+
parts.push(`holds ${qtyUnit(p.closing, unit)}`);
|
|
138
162
|
if (p.awaitingReceipt) parts.push(`${p.awaitingReceipt} not yet confirmed as received`);
|
|
139
163
|
return parts.join(" · ");
|
|
140
164
|
}
|
|
@@ -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
|
-
|
|
18
|
-
|
|
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. */
|