@7365admin1/layer-common 4.95.0 → 4.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -4
- package/components/InventoryHistoryDialog.vue +10 -3
- package/components/InventoryItemDetail.vue +18 -1
- package/components/InventoryItemDialog.vue +1 -1
- package/components/InventoryLinesDialog.vue +35 -9
- package/components/InventoryMain.vue +107 -5
- package/components/InventoryMyStockTab.vue +167 -12
- package/components/InventoryPoDialog.vue +2 -1
- package/components/InventoryReceiptActions.vue +67 -0
- package/components/InventoryReportsTab.vue +97 -6
- package/components/InventoryRequestsTab.vue +36 -14
- package/components/InventorySettingsTab.vue +27 -1
- package/components/InventoryStockTab.vue +52 -4
- package/components/InventoryTechniciansTab.vue +77 -4
- package/components/InventoryUsageDialog.vue +21 -2
- package/components/InventoryWriteOffDialog.vue +112 -0
- package/composables/useInventory.ts +24 -6
- package/package.json +1 -1
- package/types/inventory.d.ts +39 -2
- package/utils/inventory-ack.ts +172 -0
- package/utils/inventory-request-text.ts +5 -3
- package/utils/inventory-usage.ts +16 -1
- package/utils/notification-target.ts +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.96.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8fae702: Inventory: requested stock is set aside at once (Available / set aside for requests), Received / Not received after approval, write-offs (used up, spoilt, damaged, lost, other), holder-started returns, draft take-over and "Also attended", low-stock acknowledge with PO number and reminder setting, Reconciliation report with Excel export, Written off columns, and a "Switch property" hint for people who work at several sites. Screens hide the new actions when the API does not have them yet. Technicians see colleagues' open jobs in My Stock with Take over, and approved / confirm-received notifications open that exact request.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 7b70ce1: Inventory wording and setup fixes from the store keeper walk: store setup names the property instead of its id, quantities read "1 pc" / "2 pcs", "(asked N)" only when less was handed over than asked, a shelf write-off starts with no reason chosen, form errors only after a field is left or the button is pressed, no empty box under "Give to", Receive dialog titled by type (PO / DO / cash purchase), and the reminder setting says "0 = no reminders".
|
|
12
|
+
|
|
3
13
|
## 4.95.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
|
@@ -2066,11 +2076,11 @@
|
|
|
2066
2076
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
2067
2077
|
own rule from two endpoints the console already calls, unprojected:
|
|
2068
2078
|
|
|
2069
|
-
|
|
2070
|
-
|
|
2079
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
2080
|
+
GET /api/roles/id/:role that membership's role document
|
|
2071
2081
|
|
|
2072
|
-
|
|
2073
|
-
|
|
2082
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
2083
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
2074
2084
|
|
|
2075
2085
|
`role.default` is the marker because it is the only property of a platform
|
|
2076
2086
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -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,13 +58,18 @@
|
|
|
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 }">
|
|
65
65
|
<InventoryPhotoThumbs :photos="item.photos" :before-at="item.photosBeforeAt" :after-at="item.photosAfterAt" />
|
|
66
66
|
</template>
|
|
67
|
-
<template #item.by="{ item }">
|
|
67
|
+
<template #item.by="{ item }">
|
|
68
|
+
{{ item.createdByLabel || item.createdByName }}
|
|
69
|
+
<div v-if="item.attendedWith" class="text-caption text-medium-emphasis">Also attended: {{ item.attendedWith }}</div>
|
|
70
|
+
<div v-if="item.startedByName" class="text-caption text-medium-emphasis">Started by {{ item.startedByName }}</div>
|
|
71
|
+
<div v-if="item.type === 'write_off' && item.remarks" class="text-caption text-medium-emphasis">"{{ item.remarks }}"</div>
|
|
72
|
+
</template>
|
|
68
73
|
<template #item.holderBalance="{ value }">{{ value ?? "—" }}</template>
|
|
69
74
|
</v-data-table-server>
|
|
70
75
|
</v-card-text>
|
|
@@ -77,6 +82,7 @@ import useInventory from "../composables/useInventory";
|
|
|
77
82
|
import useUtils from "../composables/useUtils";
|
|
78
83
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
79
84
|
import { historyText, whereText } from "../utils/inventory-usage";
|
|
85
|
+
import { inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
80
86
|
|
|
81
87
|
/**
|
|
82
88
|
* History of stock (owner 2026-09-29, "where they used their stock"): one person's (`holder`, "me" =
|
|
@@ -116,6 +122,7 @@ const typeChoices = [
|
|
|
116
122
|
{ value: "issue", title: "Taken from store" },
|
|
117
123
|
{ value: "handover", title: "Hand-overs" },
|
|
118
124
|
{ value: "return", title: "Given back" },
|
|
125
|
+
...(inventoryStepsLive(props.store) ? [{ value: "write_off", title: "Written off" }] : []),
|
|
119
126
|
];
|
|
120
127
|
|
|
121
128
|
const intro = computed(() =>
|
|
@@ -10,7 +10,9 @@
|
|
|
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
|
+
<div v-if="item.reserved" class="text-body-2 mb-1">{{ availableText(item) }} (the rest is set aside for requests waiting for approval)</div>
|
|
15
|
+
<div v-if="item.lowStockAlert" class="text-body-2 text-error mb-1">{{ lowStockAlertText(item.lowStockAlert, standardFormatDate) }}</div>
|
|
14
16
|
<div class="text-body-2 text-medium-emphasis">
|
|
15
17
|
{{ [inventoryItemMeta(item), item.reorderLevel ? `Alert at ${item.reorderLevel}` : "", item.unitCost !== undefined ? `$${Number(item.unitCost).toFixed(2)} each` : ""].filter(Boolean).join(" · ") }}
|
|
16
18
|
</div>
|
|
@@ -21,7 +23,18 @@
|
|
|
21
23
|
<div class="d-flex flex-wrap ga-2 mb-4" v-if="ctl.editItem || ctl.count">
|
|
22
24
|
<v-btn v-if="ctl.editItem" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-pencil-outline" @click="emit('edit', item)">Edit item</v-btn>
|
|
23
25
|
<v-btn v-if="ctl.count" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-counter" @click="counting = !counting">Stock count</v-btn>
|
|
26
|
+
<v-btn v-if="ctl.count && stepsLive && item.onHand > 0" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-delete-alert-outline" @click="writingOff = true">Write off</v-btn>
|
|
24
27
|
</div>
|
|
28
|
+
<InventoryWriteOffDialog
|
|
29
|
+
v-model="writingOff"
|
|
30
|
+
:store="store"
|
|
31
|
+
from="store"
|
|
32
|
+
:item="item._id"
|
|
33
|
+
:item-name="item.name"
|
|
34
|
+
:unit="item.unit"
|
|
35
|
+
:max="item.onHand"
|
|
36
|
+
@done="(show = false), emit('done')"
|
|
37
|
+
/>
|
|
25
38
|
|
|
26
39
|
<v-form v-if="counting" ref="countForm" class="mb-4">
|
|
27
40
|
<AppCard class="pa-4">
|
|
@@ -80,6 +93,7 @@ import useUtils from "../composables/useUtils";
|
|
|
80
93
|
import { inventoryItemMeta } from "../utils/inventory-store-label";
|
|
81
94
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
82
95
|
import { receiptRefText } from "../utils/inventory-receiving";
|
|
96
|
+
import { availableText, inventoryStepsLive, lowStockAlertText, qtyUnit, writeOffLabel } from "../utils/inventory-ack";
|
|
83
97
|
|
|
84
98
|
const show = defineModel<boolean>({ default: false });
|
|
85
99
|
const props = defineProps({
|
|
@@ -103,6 +117,8 @@ const counted = ref<number | null>(null);
|
|
|
103
117
|
const reason = ref("");
|
|
104
118
|
const saving = ref(false);
|
|
105
119
|
const countForm = ref();
|
|
120
|
+
const writingOff = ref(false);
|
|
121
|
+
const stepsLive = computed(() => inventoryStepsLive(props.store));
|
|
106
122
|
|
|
107
123
|
const formatDate = (d: string) => standardFormatDate(d);
|
|
108
124
|
|
|
@@ -121,6 +137,7 @@ function describe(t: TInventoryTransaction) {
|
|
|
121
137
|
t.reference || "",
|
|
122
138
|
);
|
|
123
139
|
if (t.type === "adjust") parts.push(t.remarks || "");
|
|
140
|
+
if (t.type === "write_off") parts.push(`${t.qty} ${writeOffLabel(t.writeOffReason).toLowerCase()}`, (t.holderChange ?? 0) < 0 ? `by ${who}` : "from the shelf", t.remarks || "");
|
|
124
141
|
if (t.type === "opening") parts.push(t.supplier || "", t.reference ? `PO ${t.reference}` : "", t.receivedBy ? `received by ${t.receivedBy}` : "", t.remarks || "");
|
|
125
142
|
return parts.filter(Boolean).join(" · ");
|
|
126
143
|
}
|
|
@@ -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,6 +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";
|
|
174
|
+
import { availableQty, availableText as availableTextOf, qtyUnit } from "../utils/inventory-ack";
|
|
173
175
|
import { RECEIVING_TYPES, receivingFields, receivingProblem, type TReceivingType } from "../utils/inventory-receiving";
|
|
174
176
|
|
|
175
177
|
type TMode = "receive" | "issue" | "request" | "return";
|
|
@@ -194,7 +196,7 @@ const notify = inject<(text: string, color?: string) => void>("inventoryNotify",
|
|
|
194
196
|
const COPY: Record<TMode, { title: string; help: string; button: string; success: string }> = {
|
|
195
197
|
receive: {
|
|
196
198
|
title: "Receive Stock",
|
|
197
|
-
help: "
|
|
199
|
+
help: "The quantities are added to the store straight away.",
|
|
198
200
|
button: "Add to store",
|
|
199
201
|
success: "Stock received.",
|
|
200
202
|
},
|
|
@@ -217,7 +219,27 @@ const COPY: Record<TMode, { title: string; help: string; button: string; success
|
|
|
217
219
|
success: "Stock returned to the store.",
|
|
218
220
|
},
|
|
219
221
|
};
|
|
220
|
-
|
|
222
|
+
// The API sets requested stock aside at once (owner 2026-10-03) when its items carry `available`.
|
|
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" };
|
|
225
|
+
const copy = computed(() => {
|
|
226
|
+
const c = COPY[props.mode];
|
|
227
|
+
if (props.mode === "receive") return { ...c, title: RECEIVE_TITLES[receivingType.value] };
|
|
228
|
+
if (!reserving.value) return c;
|
|
229
|
+
if (props.mode === "request")
|
|
230
|
+
return {
|
|
231
|
+
...c,
|
|
232
|
+
help: "Ask the store for what you need, up to what is available (shown under each item). The quantity is set aside for you as soon as you send it, so nobody else can take it. If the request is rejected or cancelled, it goes back to the store.",
|
|
233
|
+
success: "Request sent. The items are set aside for you until the store keeper approves. Then open My Requests and tap Received once you have them.",
|
|
234
|
+
};
|
|
235
|
+
if (props.mode === "issue")
|
|
236
|
+
return {
|
|
237
|
+
...c,
|
|
238
|
+
help: "Hand stock to someone at the store counter. It leaves the store at once. They tap Received in their app to confirm they got it, and can use it after that.",
|
|
239
|
+
success: "Stock issued. The person is asked to tap Received to confirm they got it.",
|
|
240
|
+
};
|
|
241
|
+
return c;
|
|
242
|
+
});
|
|
221
243
|
|
|
222
244
|
const form = ref();
|
|
223
245
|
const saving = ref(false);
|
|
@@ -254,12 +276,15 @@ const choices = computed<any[]>(() =>
|
|
|
254
276
|
const siteChoices = computed(() => (props.sites.length ? props.sites : props.store.sites.map((_id) => ({ _id, name: _id === props.siteId ? "This property" : "Other property" }))));
|
|
255
277
|
|
|
256
278
|
const unitOf = (id: string) => choices.value.find((c) => c._id === id)?.unit ?? "";
|
|
257
|
-
const availableOf = (id: string) =>
|
|
279
|
+
const availableOf = (id: string) => {
|
|
280
|
+
const c = choices.value.find((x) => x._id === id);
|
|
281
|
+
return c && (props.mode === "request" || props.mode === "issue") ? availableQty(c) : c?.onHand;
|
|
282
|
+
};
|
|
258
283
|
|
|
259
284
|
function availableText(item: any) {
|
|
260
|
-
if (props.mode === "return") return `In hand: ${item.onHand
|
|
261
|
-
if (props.mode === "receive") return `In store: ${item.onHand
|
|
262
|
-
return
|
|
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)}`;
|
|
287
|
+
return availableTextOf(item);
|
|
263
288
|
}
|
|
264
289
|
|
|
265
290
|
function qtyRule(line: TRow) {
|
|
@@ -270,7 +295,7 @@ function qtyRule(line: TRow) {
|
|
|
270
295
|
const total = rows.value.filter((r) => r.item === line.item).reduce((s, r) => s + Number(r.qty || 0), 0);
|
|
271
296
|
// Requests too (client 2026-10-02): nobody can ask for more than the store has; the server refuses it as well.
|
|
272
297
|
if (available !== undefined && total > available)
|
|
273
|
-
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";
|
|
274
299
|
return true;
|
|
275
300
|
};
|
|
276
301
|
}
|
|
@@ -364,6 +389,7 @@ async function submit() {
|
|
|
364
389
|
|
|
365
390
|
watch(show, (open) => {
|
|
366
391
|
if (!open) return;
|
|
392
|
+
form.value?.resetValidation();
|
|
367
393
|
rows.value = [blank()];
|
|
368
394
|
supplier.value = "";
|
|
369
395
|
reference.value = "";
|
|
@@ -34,6 +34,22 @@
|
|
|
34
34
|
Equipment is now in Inventory.
|
|
35
35
|
</v-alert>
|
|
36
36
|
|
|
37
|
+
<!-- Client 2026-10-02 (Winsland): each property keeps its own stock, so say plainly whose stock this is. -->
|
|
38
|
+
<v-alert v-if="mySites.length > 1" type="info" variant="tonal" density="compact" class="mb-4" icon="mdi-office-building-marker-outline">
|
|
39
|
+
<div class="d-flex flex-wrap align-center ga-2">
|
|
40
|
+
<span>You are viewing <strong>{{ currentSiteName }}</strong>'s inventory.</span>
|
|
41
|
+
<v-spacer />
|
|
42
|
+
<v-menu>
|
|
43
|
+
<template #activator="{ props: menu }">
|
|
44
|
+
<v-btn v-bind="menu" size="small" variant="outlined" class="text-none" append-icon="mdi-chevron-down">Switch property</v-btn>
|
|
45
|
+
</template>
|
|
46
|
+
<v-list density="compact" max-height="320">
|
|
47
|
+
<v-list-item v-for="s in mySites" :key="s._id" :title="s.name" :active="s._id === siteId" @click="switchSite(s._id)" />
|
|
48
|
+
</v-list>
|
|
49
|
+
</v-menu>
|
|
50
|
+
</div>
|
|
51
|
+
</v-alert>
|
|
52
|
+
|
|
37
53
|
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
|
|
38
54
|
|
|
39
55
|
<AppCard v-else-if="loadError" class="pa-6">
|
|
@@ -62,7 +78,7 @@
|
|
|
62
78
|
<InputLabel title="Properties this store supplies" required class="mb-1" />
|
|
63
79
|
<v-select
|
|
64
80
|
v-model="setup.sites"
|
|
65
|
-
:items="
|
|
81
|
+
:items="setupSiteOptions"
|
|
66
82
|
item-title="name"
|
|
67
83
|
item-value="_id"
|
|
68
84
|
multiple
|
|
@@ -99,6 +115,12 @@
|
|
|
99
115
|
<v-btn size="small" variant="flat" color="warning" class="text-none" @click="tab = 'requests'">Review requests</v-btn>
|
|
100
116
|
</div>
|
|
101
117
|
</v-alert>
|
|
118
|
+
<v-alert v-if="toConfirm > 0 && tab !== 'mine'" type="warning" variant="tonal" density="compact" class="mb-4" icon="mdi-package-variant-closed-check">
|
|
119
|
+
<div class="d-flex align-center flex-wrap ga-2">
|
|
120
|
+
<span>{{ toConfirm === 1 ? "1 delivery is" : `${toConfirm} deliveries are` }} waiting for you to tap Received. You can use the items after that.</span>
|
|
121
|
+
<v-btn size="small" variant="flat" color="warning" class="text-none" @click="tab = 'mine'">Open My Stock</v-btn>
|
|
122
|
+
</div>
|
|
123
|
+
</v-alert>
|
|
102
124
|
<v-tabs v-model="tab" color="primary" class="mb-4" show-arrows>
|
|
103
125
|
<v-tab v-for="t in tabs" :key="t.value" :value="t.value" class="text-none">
|
|
104
126
|
{{ t.label }}
|
|
@@ -114,7 +136,7 @@
|
|
|
114
136
|
<InventoryMyStockTab :store="store" :site-id="siteId" :sites="storeSites" @changed="refreshCounts" />
|
|
115
137
|
</v-window-item>
|
|
116
138
|
<v-window-item v-if="ctl.tabs.requests" value="requests">
|
|
117
|
-
<InventoryRequestsTab :store="store" :site-id="siteId" @changed="refreshCounts" />
|
|
139
|
+
<InventoryRequestsTab :store="store" :site-id="siteId" :focus-request="focusRequest" @changed="refreshCounts" />
|
|
118
140
|
</v-window-item>
|
|
119
141
|
<v-window-item v-if="ctl.tabs.people" value="technicians">
|
|
120
142
|
<InventoryTechniciansTab :store="store" @changed="refreshCounts" />
|
|
@@ -139,9 +161,12 @@
|
|
|
139
161
|
import useInventory from "../composables/useInventory";
|
|
140
162
|
import useWorkerTerm from "../composables/useWorkerTerm";
|
|
141
163
|
import useSite from "../composables/useSite";
|
|
164
|
+
import useCustomerSite from "../composables/useCustomerSite";
|
|
142
165
|
import useUtils from "../composables/useUtils";
|
|
143
|
-
import { inventoryManagedBy, inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
|
|
166
|
+
import { inventoryManagedBy, inventorySiteOptions, inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
|
|
144
167
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
168
|
+
import { inventoryStepsLive } from "../utils/inventory-ack";
|
|
169
|
+
import useLocalAuth from "../composables/useLocalAuth";
|
|
145
170
|
|
|
146
171
|
const props = defineProps({
|
|
147
172
|
orgId: { type: String, required: true },
|
|
@@ -152,8 +177,10 @@ const movedFromEquipment = useRoute().query.moved === "equipment";
|
|
|
152
177
|
// A notification link (`?storeId=&tab=`, NotificationBell): open that store on that tab, once.
|
|
153
178
|
const linkQuery = useRoute().query;
|
|
154
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 ?? "");
|
|
155
182
|
|
|
156
|
-
const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
183
|
+
const { getHandovers, getStores, getStore, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
157
184
|
const { getSites } = useSite();
|
|
158
185
|
const { requiredRule } = useUtils();
|
|
159
186
|
|
|
@@ -217,7 +244,7 @@ const tabs = computed(() => {
|
|
|
217
244
|
// Hide, never grey: each tab only for the job that uses it (API-core decides `access`).
|
|
218
245
|
...(ctl.value.tabs.mine ? [{ value: "mine", label: "My Stock" }] : []),
|
|
219
246
|
...(ctl.value.tabs.requests ? [{ value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 }] : []),
|
|
220
|
-
...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many }] : []),
|
|
247
|
+
...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many, badge: returnsWaiting.value }] : []),
|
|
221
248
|
...(ctl.value.tabs.reports ? [{ value: "reports", label: a.canReports ? "Reports" : "Usage check" }] : []),
|
|
222
249
|
...(!ctl.value.tabs.reports && companyUsage.value ? [{ value: "reports", label: "Company usage" }] : []),
|
|
223
250
|
...(ctl.value.tabs.suppliers ? [{ value: "suppliers", label: "Suppliers" }] : []),
|
|
@@ -244,6 +271,7 @@ async function loadStores() {
|
|
|
244
271
|
loading.value = false;
|
|
245
272
|
}
|
|
246
273
|
refreshCounts();
|
|
274
|
+
loadStoreDetail();
|
|
247
275
|
}
|
|
248
276
|
|
|
249
277
|
/**
|
|
@@ -265,7 +293,38 @@ async function probeCompanyUsage() {
|
|
|
265
293
|
}
|
|
266
294
|
}
|
|
267
295
|
|
|
296
|
+
/** Stock issued to me that I have not confirmed yet (only where the API has the Received step). */
|
|
297
|
+
const toConfirm = ref(0);
|
|
298
|
+
const { currentUser } = useLocalAuth();
|
|
299
|
+
async function refreshToConfirm() {
|
|
300
|
+
const s = store.value;
|
|
301
|
+
toConfirm.value = 0;
|
|
302
|
+
if (!s || !ctl.value.tabs.mine || !inventoryStepsLive(s)) return;
|
|
303
|
+
try {
|
|
304
|
+
const me = String(currentUser.value?._id ?? "");
|
|
305
|
+
const res = await getRequests(s._id, { status: "issued", limit: 100 });
|
|
306
|
+
toConfirm.value = res.items.filter((r) => String(r.requestedBy) === me && (r.ackStatus === "awaiting" || r.ackStatus === "not_received")).length;
|
|
307
|
+
} catch {
|
|
308
|
+
toConfirm.value = 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Returns started by holders, waiting for a store keeper (badge on the staff tab). */
|
|
313
|
+
const returnsWaiting = ref(0);
|
|
314
|
+
async function refreshReturns() {
|
|
315
|
+
const s = store.value;
|
|
316
|
+
returnsWaiting.value = 0;
|
|
317
|
+
if (!s || !ctl.value.takeReturn || !inventoryStepsLive(s)) return;
|
|
318
|
+
try {
|
|
319
|
+
returnsWaiting.value = (await getHandovers(s._id, { status: "pending", limit: 1 }))?.returnsWaiting ?? 0;
|
|
320
|
+
} catch {
|
|
321
|
+
returnsWaiting.value = 0;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
268
325
|
async function refreshCounts() {
|
|
326
|
+
refreshToConfirm();
|
|
327
|
+
refreshReturns();
|
|
269
328
|
if (!store.value?.access.canApprove) return (pendingCount.value = 0);
|
|
270
329
|
try {
|
|
271
330
|
pendingCount.value = (await getRequests(store.value._id, { status: "pending", limit: 1 }))?.pendingCount ?? 0;
|
|
@@ -274,6 +333,47 @@ async function refreshCounts() {
|
|
|
274
333
|
}
|
|
275
334
|
}
|
|
276
335
|
|
|
336
|
+
/** The properties this person works at in this organisation (the same list as the site switcher). */
|
|
337
|
+
const { getAll: getCustomerSites } = useCustomerSite();
|
|
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
|
+
);
|
|
343
|
+
const currentSiteName = computed(() => mySites.value.find((s) => s._id === props.siteId)?.name || storeSites.value.find((s) => s._id === props.siteId)?.name || "this property");
|
|
344
|
+
async function loadMySites() {
|
|
345
|
+
try {
|
|
346
|
+
const res = await getCustomerSites({ org: props.orgId, limit: 100 });
|
|
347
|
+
const seen = new Set<string>();
|
|
348
|
+
mySites.value = (res?.items ?? [])
|
|
349
|
+
.map((i: any) => ({ _id: String(i.site ?? ""), name: String(i.name ?? "") }))
|
|
350
|
+
.filter((s: { _id: string }) => s._id && !seen.has(s._id) && seen.add(s._id));
|
|
351
|
+
} catch {
|
|
352
|
+
mySites.value = [];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const route = useRoute();
|
|
356
|
+
function switchSite(site: string) {
|
|
357
|
+
if (site === props.siteId) return;
|
|
358
|
+
navigateTo({ name: route.name as string, params: { ...route.params, site } });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The store's own settings (the reminder interval also tells the screens the new steps exist on the server). */
|
|
362
|
+
async function loadStoreDetail() {
|
|
363
|
+
const s = store.value;
|
|
364
|
+
if (!s) return;
|
|
365
|
+
try {
|
|
366
|
+
const full = await getStore(s._id);
|
|
367
|
+
if (typeof full?.lowStockReminderDays === "number") {
|
|
368
|
+
s.lowStockReminderDays = full.lowStockReminderDays;
|
|
369
|
+
refreshToConfirm();
|
|
370
|
+
refreshReturns();
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
/* the list's copy is enough to work with */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
277
377
|
async function loadOrgSites() {
|
|
278
378
|
try {
|
|
279
379
|
const res = await getSites({ orgId: props.orgId, limit: 100 });
|
|
@@ -307,11 +407,13 @@ watch(storeId, () => {
|
|
|
307
407
|
tab.value = wanted && tabs.value.some((t) => t.value === wanted) ? wanted : "stock";
|
|
308
408
|
refreshCounts();
|
|
309
409
|
probeCompanyUsage();
|
|
410
|
+
loadStoreDetail();
|
|
310
411
|
});
|
|
311
412
|
watch(() => props.siteId, loadStores);
|
|
312
413
|
|
|
313
414
|
onMounted(() => {
|
|
314
415
|
loadStores();
|
|
315
416
|
loadOrgSites();
|
|
417
|
+
loadMySites();
|
|
316
418
|
});
|
|
317
419
|
</script>
|