@7365admin1/layer-common 4.71.2 → 4.73.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 +16 -4
- package/components/InventoryHistoryDialog.vue +205 -0
- package/components/InventoryItemDetail.vue +7 -4
- package/components/InventoryItemDialog.vue +21 -1
- package/components/InventoryLinesDialog.vue +25 -3
- package/components/InventoryMain.vue +49 -3
- package/components/InventoryMyStockTab.vue +227 -10
- package/components/InventoryPhotoThumbs.vue +67 -0
- package/components/InventoryReportsTab.vue +208 -26
- package/components/InventoryRequestsTab.vue +2 -2
- package/components/InventoryStockTab.vue +79 -1
- package/components/InventoryTechniciansTab.vue +3 -2
- package/components/InventoryUsageDialog.vue +119 -28
- package/composables/useInventory.ts +28 -5
- package/package.json +1 -1
- package/types/inventory.d.ts +51 -3
- package/utils/inventory-store-label.ts +14 -1
- package/utils/inventory-usage.ts +94 -0
- package/utils/inventory-used-by.ts +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.73.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cd5501e: Inventory: usage drafts (save the before photo first, add the after photo and submit later; edit or discard with a confirm; My drafts cards with an old-draft warning; "in drafts / free" on holdings); History per item and "My usage" with date, item and place filters; Usage report as the store owner's usage tracking (status incl. in-progress drafts, person, item, place, work order, flags, search; totals by item / person / company; photo thumbnails with times; draft and submit times; how the stock reached the person; Excel with the same filters and photo links; click a person or an item for its full history); a provider's supervisor gets "Company usage" for its own people at the main store.
|
|
8
|
+
|
|
9
|
+
## 4.72.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- eb2e98f: Inventory: "Main store · <company>" with who manages it, a request-first view for other companies, pending-approval banner for the owner; Used by on items (form, chips, column, filter, bulk edit, report filter); Give to colleague (My Stock) with confirm/decline; Hand-overs report; name · company · service on requests, holdings, history, reports; Issue Stock "Give to" grouped by company with a Used-by warning.
|
|
14
|
+
|
|
3
15
|
## 4.71.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1873,11 +1885,11 @@
|
|
|
1873
1885
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1874
1886
|
own rule from two endpoints the console already calls, unprojected:
|
|
1875
1887
|
|
|
1876
|
-
|
|
1877
|
-
|
|
1888
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1889
|
+
GET /api/roles/id/:role that membership's role document
|
|
1878
1890
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1891
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1892
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1881
1893
|
|
|
1882
1894
|
`role.default` is the marker because it is the only property of a platform
|
|
1883
1895
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog v-model="show" max-width="1100" scrollable>
|
|
3
|
+
<v-card class="screen-modal">
|
|
4
|
+
<v-card-title class="d-flex align-center">
|
|
5
|
+
{{ title }}
|
|
6
|
+
<v-spacer />
|
|
7
|
+
<v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="show = false" />
|
|
8
|
+
</v-card-title>
|
|
9
|
+
<v-card-text class="screen-modal__body">
|
|
10
|
+
<p class="text-body-2 text-medium-emphasis mb-3">{{ intro }}</p>
|
|
11
|
+
|
|
12
|
+
<!-- A supervisor opening a person: what they hold now, then everything that happened to their stock. -->
|
|
13
|
+
<template v-if="holder && holder !== 'me' && holder !== selfId && (store.access.canApprove || store.access.canHandle)">
|
|
14
|
+
<div class="text-subtitle-2 mb-1">Holding now</div>
|
|
15
|
+
<v-table density="compact" class="mb-4 border rounded">
|
|
16
|
+
<thead>
|
|
17
|
+
<tr>
|
|
18
|
+
<th>Item</th>
|
|
19
|
+
<th class="text-right">In hand</th>
|
|
20
|
+
<th class="text-right">In drafts</th>
|
|
21
|
+
<th class="text-right">Used this month</th>
|
|
22
|
+
</tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<tr v-for="h in holdings" :key="h._id">
|
|
26
|
+
<td>{{ h.itemName }}</td>
|
|
27
|
+
<td class="text-right">{{ h.qty }} {{ h.unit }}</td>
|
|
28
|
+
<td class="text-right">{{ h.inDrafts || "—" }}</td>
|
|
29
|
+
<td class="text-right">{{ h.usedThisMonth ?? 0 }}</td>
|
|
30
|
+
</tr>
|
|
31
|
+
<tr v-if="!holdings.length">
|
|
32
|
+
<td colspan="4" class="text-center text-medium-emphasis py-3">Not holding any stock now.</td>
|
|
33
|
+
</tr>
|
|
34
|
+
</tbody>
|
|
35
|
+
</v-table>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<div class="d-flex flex-wrap ga-2 mb-2">
|
|
39
|
+
<v-text-field v-model="from" type="date" label="From" density="compact" hide-details style="max-width: 170px" />
|
|
40
|
+
<v-text-field v-model="to" type="date" label="To" density="compact" hide-details style="max-width: 170px" />
|
|
41
|
+
<v-select v-model="type" :items="typeChoices" label="What" density="compact" hide-details style="max-width: 200px" />
|
|
42
|
+
<v-select v-if="!item" v-model="itemPick" :items="[{ value: '', title: 'All items' }, ...itemChoices]" label="Item" density="compact" hide-details style="max-width: 240px" />
|
|
43
|
+
<v-text-field v-model="location" label="Where (unit, tenant or area)" density="compact" hide-details clearable style="max-width: 240px" />
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<v-data-table-server
|
|
47
|
+
:headers="headers"
|
|
48
|
+
:items="rows"
|
|
49
|
+
:items-length="total"
|
|
50
|
+
:loading="loading"
|
|
51
|
+
:items-per-page="limit"
|
|
52
|
+
:page="page"
|
|
53
|
+
density="compact"
|
|
54
|
+
class="border rounded"
|
|
55
|
+
:no-data-text="emptyText"
|
|
56
|
+
@update:page="(p: number) => ((page = p), load())"
|
|
57
|
+
>
|
|
58
|
+
<template #item.date="{ value }">{{ standardFormatDateTime(value) }}</template>
|
|
59
|
+
<template #item.what="{ item }">{{ historyText(item) }}</template>
|
|
60
|
+
<template #item.person="{ item }">{{ item.holderLabel || item.holderName || "—" }}</template>
|
|
61
|
+
<template #item.qty="{ item }">{{ item.qty }} {{ item.unit }}</template>
|
|
62
|
+
<template #item.where="{ item }">{{ whereText(item.location) || (item.type === "handover" ? item.remarks : "") || "—" }}</template>
|
|
63
|
+
<template #item.reference="{ item }">{{ item.type === "handover" ? "—" : item.reference || "—" }}</template>
|
|
64
|
+
<template #item.photos="{ item }">
|
|
65
|
+
<InventoryPhotoThumbs :photos="item.photos" :before-at="item.photosBeforeAt" :after-at="item.photosAfterAt" />
|
|
66
|
+
</template>
|
|
67
|
+
<template #item.by="{ item }">{{ item.createdByLabel || item.createdByName }}</template>
|
|
68
|
+
<template #item.holderBalance="{ value }">{{ value ?? "—" }}</template>
|
|
69
|
+
</v-data-table-server>
|
|
70
|
+
</v-card-text>
|
|
71
|
+
</v-card>
|
|
72
|
+
</v-dialog>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<script setup lang="ts">
|
|
76
|
+
import useInventory from "../composables/useInventory";
|
|
77
|
+
import useUtils from "../composables/useUtils";
|
|
78
|
+
import useLocalAuth from "../composables/useLocalAuth";
|
|
79
|
+
import { historyText, whereText } from "../utils/inventory-usage";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* History of stock (owner 2026-09-29, "where they used their stock"): one person's (`holder`, "me" =
|
|
83
|
+
* the signed-in person), one item's (`item`: its whole chain across people), or both. Received from the
|
|
84
|
+
* store, hand-overs in and out, usage with where + work order + photos, and returns. Drafts are not history.
|
|
85
|
+
*/
|
|
86
|
+
const show = defineModel<boolean>({ default: false });
|
|
87
|
+
const props = defineProps({
|
|
88
|
+
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
89
|
+
holder: { type: String, default: "" },
|
|
90
|
+
item: { type: String, default: "" },
|
|
91
|
+
title: { type: String, default: "History" },
|
|
92
|
+
/** Open on "Used" only (My usage). */
|
|
93
|
+
usageOnly: { type: Boolean, default: false },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const api = useInventory();
|
|
97
|
+
const { standardFormatDateTime } = useUtils();
|
|
98
|
+
const { currentUser } = useLocalAuth();
|
|
99
|
+
const selfId = computed(() => String(currentUser.value?._id ?? ""));
|
|
100
|
+
const rows = ref<TInventoryTransaction[]>([]);
|
|
101
|
+
const holdings = ref<TInventoryHolding[]>([]);
|
|
102
|
+
const total = ref(0);
|
|
103
|
+
const page = ref(1);
|
|
104
|
+
const limit = 50;
|
|
105
|
+
const loading = ref(false);
|
|
106
|
+
const from = ref("");
|
|
107
|
+
const to = ref("");
|
|
108
|
+
const type = ref("");
|
|
109
|
+
const location = ref("");
|
|
110
|
+
const itemPick = ref("");
|
|
111
|
+
const itemChoices = ref<{ value: string; title: string }[]>([]);
|
|
112
|
+
|
|
113
|
+
const typeChoices = [
|
|
114
|
+
{ value: "", title: "Everything" },
|
|
115
|
+
{ value: "use", title: "Used" },
|
|
116
|
+
{ value: "issue", title: "Taken from store" },
|
|
117
|
+
{ value: "handover", title: "Hand-overs" },
|
|
118
|
+
{ value: "return", title: "Given back" },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const intro = computed(() =>
|
|
122
|
+
props.usageOnly
|
|
123
|
+
? "Where the stock was used: date, place, work order and the before / after photos. Filter by date, item or place."
|
|
124
|
+
: props.item && !props.holder
|
|
125
|
+
? "Every movement of this item: into the store, to each person, between colleagues, where it was used and what came back."
|
|
126
|
+
: "Everything that happened to this stock: taken from the store, hand-overs, where it was used (with the work order and photos) and returns.",
|
|
127
|
+
);
|
|
128
|
+
const emptyText = computed(() => (from.value || to.value || type.value || location.value || itemPick.value ? "Nothing matches these filters." : "Nothing recorded yet."));
|
|
129
|
+
const headers = computed(() => [
|
|
130
|
+
{ title: "Date", key: "date", sortable: false },
|
|
131
|
+
{ title: "What happened", key: "what", sortable: false },
|
|
132
|
+
...(props.item ? [] : [{ title: "Item", key: "itemName", sortable: false }]),
|
|
133
|
+
...(props.holder ? [] : [{ title: "Person", key: "person", sortable: false }]),
|
|
134
|
+
{ title: "Qty", key: "qty", sortable: false },
|
|
135
|
+
{ title: "Where", key: "where", sortable: false },
|
|
136
|
+
{ title: "Work order / ref", key: "reference", sortable: false },
|
|
137
|
+
{ title: "Photos", key: "photos", sortable: false },
|
|
138
|
+
{ title: "Recorded by", key: "by", sortable: false },
|
|
139
|
+
...(props.holder ? [{ title: "In hand after", key: "holderBalance", sortable: false }] : []),
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
async function load() {
|
|
143
|
+
loading.value = true;
|
|
144
|
+
try {
|
|
145
|
+
const res = await api.getTransactions(props.store._id, {
|
|
146
|
+
page: page.value,
|
|
147
|
+
limit,
|
|
148
|
+
...(props.holder ? { holder: props.holder } : {}),
|
|
149
|
+
...(props.item || itemPick.value ? { item: props.item || itemPick.value } : {}),
|
|
150
|
+
...(type.value ? { type: type.value } : {}),
|
|
151
|
+
...(location.value ? { location: location.value } : {}),
|
|
152
|
+
...(from.value ? { from: new Date(`${from.value}T00:00:00`).toISOString() } : {}),
|
|
153
|
+
...(to.value ? { to: new Date(`${to.value}T23:59:59.999`).toISOString() } : {}),
|
|
154
|
+
});
|
|
155
|
+
rows.value = res.items;
|
|
156
|
+
total.value = res.total;
|
|
157
|
+
} catch (error: any) {
|
|
158
|
+
rows.value = [];
|
|
159
|
+
total.value = 0;
|
|
160
|
+
notify(error?.data?.message || "Could not load the history.", "error");
|
|
161
|
+
} finally {
|
|
162
|
+
loading.value = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
166
|
+
|
|
167
|
+
async function loadHoldings() {
|
|
168
|
+
holdings.value = [];
|
|
169
|
+
// The API gives another person's holdings to the store's approvers / storekeepers only.
|
|
170
|
+
if (!props.holder || props.holder === "me" || props.holder === selfId.value || !(props.store.access.canApprove || props.store.access.canHandle)) return;
|
|
171
|
+
try {
|
|
172
|
+
holdings.value = await api.getHoldings(props.store._id, props.holder);
|
|
173
|
+
} catch {
|
|
174
|
+
holdings.value = [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let resetting = false;
|
|
179
|
+
watch([from, to, type, location, itemPick], () => {
|
|
180
|
+
if (resetting) return;
|
|
181
|
+
page.value = 1;
|
|
182
|
+
load();
|
|
183
|
+
});
|
|
184
|
+
watch(show, async (open) => {
|
|
185
|
+
if (!open) return;
|
|
186
|
+
resetting = true;
|
|
187
|
+
page.value = 1;
|
|
188
|
+
from.value = "";
|
|
189
|
+
to.value = "";
|
|
190
|
+
location.value = "";
|
|
191
|
+
itemPick.value = "";
|
|
192
|
+
type.value = props.usageOnly ? "use" : "";
|
|
193
|
+
await nextTick();
|
|
194
|
+
resetting = false;
|
|
195
|
+
load();
|
|
196
|
+
loadHoldings();
|
|
197
|
+
if (!props.item) {
|
|
198
|
+
try {
|
|
199
|
+
itemChoices.value = (await api.getItems(props.store._id, { limit: 200 })).items.map((i) => ({ value: i._id, title: i.name }));
|
|
200
|
+
} catch {
|
|
201
|
+
itemChoices.value = [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
</script>
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
</td>
|
|
59
59
|
<td class="text-right">{{ t.type === "use" ? "" : t.storeBalance }}</td>
|
|
60
60
|
<td class="text-caption">{{ describe(t) }}</td>
|
|
61
|
-
<td class="text-caption">{{ t.createdByName }}</td>
|
|
61
|
+
<td class="text-caption">{{ (t as any).createdByLabel || t.createdByName }}</td>
|
|
62
62
|
</tr>
|
|
63
63
|
<tr v-if="!history.length && !loading">
|
|
64
64
|
<td colspan="6" class="text-center text-medium-emphasis py-4">Nothing recorded yet.</td>
|
|
@@ -108,11 +108,14 @@ const formatDate = (d: string) => standardFormatDate(d);
|
|
|
108
108
|
function describe(t: TInventoryTransaction) {
|
|
109
109
|
const parts: string[] = [];
|
|
110
110
|
if (t.type === "receive") parts.push(t.supplier || "", t.reference ? `PO ${t.reference}` : "");
|
|
111
|
-
|
|
112
|
-
if (t.type === "
|
|
111
|
+
const who = (t as any).holderLabel || t.holderName;
|
|
112
|
+
if (t.type === "issue") parts.push(`${t.qty} to ${who}`, t.reference || "");
|
|
113
|
+
if (t.type === "return") parts.push(`${t.qty} from ${who}`);
|
|
114
|
+
// Give to colleague: one row per holder.
|
|
115
|
+
if (t.type === "handover") parts.push(who || "", t.remarks || "");
|
|
113
116
|
if (t.type === "use")
|
|
114
117
|
parts.push(
|
|
115
|
-
`${
|
|
118
|
+
`${who} used ${t.qty}`,
|
|
116
119
|
t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area || "",
|
|
117
120
|
t.reference || "",
|
|
118
121
|
);
|
|
@@ -36,6 +36,21 @@
|
|
|
36
36
|
<v-text-field v-model.number="f.unitCost" type="number" min="0" step="0.01" prefix="$" density="comfortable" :rules="[moneyRule]" hint="Price of one unit" persistent-hint />
|
|
37
37
|
</v-col>
|
|
38
38
|
</v-row>
|
|
39
|
+
<!-- Main store only: which service providers see and may request it (owner 2026-09-29). -->
|
|
40
|
+
<template v-if="store.ownerType === 'property'">
|
|
41
|
+
<InputLabel title="Used by" class="mb-1 mt-2" />
|
|
42
|
+
<v-select
|
|
43
|
+
:model-value="f.usedBy"
|
|
44
|
+
:items="usedByItems"
|
|
45
|
+
multiple
|
|
46
|
+
chips
|
|
47
|
+
closable-chips
|
|
48
|
+
density="comfortable"
|
|
49
|
+
:hint="USED_BY_HINT"
|
|
50
|
+
persistent-hint
|
|
51
|
+
@update:model-value="(v: string[]) => (f.usedBy = nextUsedBy(f.usedBy, v))"
|
|
52
|
+
/>
|
|
53
|
+
</template>
|
|
39
54
|
<!-- Stock already on the shelf, recorded like a delivery (one "Opening stock" entry in the item's history). -->
|
|
40
55
|
<template v-if="!item">
|
|
41
56
|
<div class="text-subtitle-2 mt-4">Opening stock</div>
|
|
@@ -93,6 +108,9 @@
|
|
|
93
108
|
<script setup lang="ts">
|
|
94
109
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
95
110
|
import useUtils from "../composables/useUtils";
|
|
111
|
+
import { normaliseUsedBy, nextUsedBy, USED_BY_ALL, USED_BY_ALL_LABEL, USED_BY_HINT, USED_BY_OPTIONS } from "../utils/inventory-used-by";
|
|
112
|
+
|
|
113
|
+
const usedByItems = [{ value: USED_BY_ALL, title: USED_BY_ALL_LABEL }, ...USED_BY_OPTIONS];
|
|
96
114
|
|
|
97
115
|
const show = defineModel<boolean>({ default: false });
|
|
98
116
|
const props = defineProps({
|
|
@@ -126,6 +144,7 @@ const blank = () => ({
|
|
|
126
144
|
openingReceivedBy: "",
|
|
127
145
|
openingRemarks: "",
|
|
128
146
|
description: "",
|
|
147
|
+
usedBy: [USED_BY_ALL] as string[],
|
|
129
148
|
});
|
|
130
149
|
const today = new Date().toLocaleDateString("en-CA");
|
|
131
150
|
const f = reactive(blank());
|
|
@@ -135,7 +154,7 @@ const inUse = ref(true);
|
|
|
135
154
|
watch(show, (open) => {
|
|
136
155
|
if (!open) return;
|
|
137
156
|
const i = props.item;
|
|
138
|
-
Object.assign(f, blank(), i ? { name: i.name, unit: i.unit, packing: i.packing ?? "", code: i.code, category: i.category, reorderLevel: i.reorderLevel, unitCost: i.unitCost ?? 0, description: i.description } : {});
|
|
157
|
+
Object.assign(f, blank(), i ? { name: i.name, unit: i.unit, packing: i.packing ?? "", code: i.code, category: i.category, reorderLevel: i.reorderLevel, unitCost: i.unitCost ?? 0, description: i.description, usedBy: normaliseUsedBy(i.usedBy) } : {});
|
|
139
158
|
photo.value = i?.photo ? [i.photo] : [];
|
|
140
159
|
inUse.value = i ? i.status === "active" : true;
|
|
141
160
|
});
|
|
@@ -154,6 +173,7 @@ async function save() {
|
|
|
154
173
|
unitCost: Number(f.unitCost || 0),
|
|
155
174
|
description: f.description,
|
|
156
175
|
photo: photo.value[0] ?? "",
|
|
176
|
+
...(props.store.ownerType === "property" ? { usedBy: normaliseUsedBy(f.usedBy) } : {}),
|
|
157
177
|
};
|
|
158
178
|
try {
|
|
159
179
|
if (props.item) {
|
|
@@ -16,19 +16,23 @@
|
|
|
16
16
|
v-if="mode === 'issue'"
|
|
17
17
|
v-model="holder"
|
|
18
18
|
:items="people"
|
|
19
|
-
item-title="name"
|
|
19
|
+
:item-title="(p: any) => p.label || p.name"
|
|
20
20
|
item-value="_id"
|
|
21
21
|
:loading="peopleLoading"
|
|
22
22
|
placeholder="Search a name"
|
|
23
23
|
density="comfortable"
|
|
24
24
|
:rules="[requiredRule]"
|
|
25
25
|
no-filter
|
|
26
|
+
hint="Everyone at this property who can take stock: your staff and the service providers' people."
|
|
27
|
+
persistent-hint
|
|
26
28
|
@update:search="searchPeople"
|
|
27
29
|
>
|
|
28
30
|
<template #item="{ props: p, item }">
|
|
29
|
-
<v-list-
|
|
31
|
+
<v-list-subheader v-if="groupStarts(item.raw)">{{ item.raw.company || "Other" }}</v-list-subheader>
|
|
32
|
+
<v-list-item v-bind="p" :title="item.raw.name" :subtitle="[item.raw.serviceLabel, item.raw.email].filter(Boolean).join(' · ')" />
|
|
30
33
|
</template>
|
|
31
34
|
</v-autocomplete>
|
|
35
|
+
<v-alert v-if="usedByWarning" type="warning" variant="tonal" density="compact" class="mt-2">{{ usedByWarning }}</v-alert>
|
|
32
36
|
<v-text-field v-else :model-value="holderName" density="comfortable" readonly />
|
|
33
37
|
</template>
|
|
34
38
|
|
|
@@ -129,6 +133,7 @@
|
|
|
129
133
|
<script setup lang="ts">
|
|
130
134
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
131
135
|
import useUtils from "../composables/useUtils";
|
|
136
|
+
import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
|
|
132
137
|
|
|
133
138
|
type TMode = "receive" | "issue" | "request" | "return";
|
|
134
139
|
type TRow = { item: string; qty: number | null; unitCost?: number | null };
|
|
@@ -230,7 +235,24 @@ async function loadItems(search = "") {
|
|
|
230
235
|
}
|
|
231
236
|
const searchItems = debounce((q: string) => loadItems(q || ""), 300);
|
|
232
237
|
|
|
233
|
-
|
|
238
|
+
type TPerson = { _id: string; name: string; orgName: string; email?: string; company?: string; service?: string; serviceLabel?: string; label?: string };
|
|
239
|
+
const people = ref<TPerson[]>([]);
|
|
240
|
+
/** A company heading above the first person of each company (the server sorts by company). */
|
|
241
|
+
const groupStarts = (p: TPerson) => {
|
|
242
|
+
const i = people.value.indexOf(p);
|
|
243
|
+
return i === 0 || (i > 0 && people.value[i - 1].company !== p.company);
|
|
244
|
+
};
|
|
245
|
+
/** Used by: warn before the server refuses an item meant for another service. */
|
|
246
|
+
const usedByWarning = computed(() => {
|
|
247
|
+
if (props.mode !== "issue" || !holder.value) return "";
|
|
248
|
+
const person = people.value.find((p) => p._id === holder.value);
|
|
249
|
+
if (!person?.service || person.service === "property") return "";
|
|
250
|
+
const blocked = rows.value
|
|
251
|
+
.map((r) => itemChoices.value.find((c) => c._id === r.item))
|
|
252
|
+
.filter((i): i is TInventoryItem => Boolean(i) && usedByChips(i!.usedBy).length > 0 && !normaliseUsedBy(i!.usedBy).includes(person.service!));
|
|
253
|
+
if (!blocked.length) return "";
|
|
254
|
+
return `${blocked.map((i) => i.name).join(", ")} ${blocked.length === 1 ? "is" : "are"} for ${usedByText(blocked[0].usedBy)} only — ${person.name} works for ${person.serviceLabel || "another service"}.`;
|
|
255
|
+
});
|
|
234
256
|
const peopleLoading = ref(false);
|
|
235
257
|
const searchPeople = debounce(async (q: string) => {
|
|
236
258
|
peopleLoading.value = true;
|
|
@@ -82,6 +82,23 @@
|
|
|
82
82
|
</AppCard>
|
|
83
83
|
|
|
84
84
|
<template v-else>
|
|
85
|
+
<!-- Owner rule 2026-09-29: another organisation's store is for requesting only. -->
|
|
86
|
+
<v-alert v-if="requestOnly" type="info" variant="tonal" density="compact" class="mb-4" icon="mdi-cart-arrow-down">
|
|
87
|
+
<div class="font-weight-medium">Request items from the main store</div>
|
|
88
|
+
<div class="text-body-2">Pick the items you need and press Request Stock. You are told when the store keeper approves it.</div>
|
|
89
|
+
</v-alert>
|
|
90
|
+
<v-alert
|
|
91
|
+
v-else-if="store.access.canApprove && pendingCount > 0 && tab !== 'requests'"
|
|
92
|
+
type="warning"
|
|
93
|
+
variant="tonal"
|
|
94
|
+
density="compact"
|
|
95
|
+
class="mb-4"
|
|
96
|
+
>
|
|
97
|
+
<div class="d-flex align-center flex-wrap ga-2">
|
|
98
|
+
<span>{{ pendingCount === 1 ? "1 request is" : `${pendingCount} requests are` }} waiting for your approval.</span>
|
|
99
|
+
<v-btn size="small" variant="flat" color="warning" class="text-none" @click="tab = 'requests'">Review requests</v-btn>
|
|
100
|
+
</div>
|
|
101
|
+
</v-alert>
|
|
85
102
|
<v-tabs v-model="tab" color="primary" class="mb-4" show-arrows>
|
|
86
103
|
<v-tab v-for="t in tabs" :key="t.value" :value="t.value" class="text-none">
|
|
87
104
|
{{ t.label }}
|
|
@@ -102,7 +119,7 @@
|
|
|
102
119
|
<v-window-item v-if="ctl.tabs.people" value="technicians">
|
|
103
120
|
<InventoryTechniciansTab :store="store" @changed="refreshCounts" />
|
|
104
121
|
</v-window-item>
|
|
105
|
-
<v-window-item v-if="ctl.tabs.reports" value="reports">
|
|
122
|
+
<v-window-item v-if="ctl.tabs.reports || companyUsage" value="reports">
|
|
106
123
|
<InventoryReportsTab :store="store" :sites="storeSites" />
|
|
107
124
|
</v-window-item>
|
|
108
125
|
<v-window-item v-if="ctl.tabs.suppliers" value="suppliers">
|
|
@@ -123,7 +140,7 @@ import useInventory from "../composables/useInventory";
|
|
|
123
140
|
import useWorkerTerm from "../composables/useWorkerTerm";
|
|
124
141
|
import useSite from "../composables/useSite";
|
|
125
142
|
import useUtils from "../composables/useUtils";
|
|
126
|
-
import { inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
|
|
143
|
+
import { inventoryManagedBy, inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
|
|
127
144
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
128
145
|
|
|
129
146
|
const props = defineProps({
|
|
@@ -133,7 +150,7 @@ const props = defineProps({
|
|
|
133
150
|
|
|
134
151
|
const movedFromEquipment = useRoute().query.moved === "equipment";
|
|
135
152
|
|
|
136
|
-
const { getStores, createStore: apiCreateStore, getRequests } = useInventory();
|
|
153
|
+
const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
137
154
|
const { getSites } = useSite();
|
|
138
155
|
const { requiredRule } = useUtils();
|
|
139
156
|
|
|
@@ -167,8 +184,16 @@ const storeSites = computed(() =>
|
|
|
167
184
|
})),
|
|
168
185
|
);
|
|
169
186
|
|
|
187
|
+
// Another organisation's (main) store: the person requests from it and manages nothing there.
|
|
188
|
+
const requestOnly = computed(() => Boolean(store.value && store.value.owner === false));
|
|
189
|
+
const managedBy = computed(() => (store.value ? inventoryManagedBy(store.value) : ""));
|
|
190
|
+
|
|
170
191
|
const subtitle = computed(() => {
|
|
171
192
|
if (!store.value) return "";
|
|
193
|
+
if (store.value.main && store.value.owner === false) {
|
|
194
|
+
// "Main store · managed by Management Pte Ltd (Property management)"
|
|
195
|
+
return managedBy.value ? `Main store · ${managedBy.value.replace(/^Managed/, "managed")}` : "Main store";
|
|
196
|
+
}
|
|
172
197
|
if (storeSites.value.length > 1) {
|
|
173
198
|
const supplies = inventorySuppliesText(storeSites.value.map((s) => s.name));
|
|
174
199
|
return stores.value.length > 1 ? `${inventoryStoreLabel(store.value, stores.value)} · ${supplies}` : supplies;
|
|
@@ -191,6 +216,7 @@ const tabs = computed(() => {
|
|
|
191
216
|
...(ctl.value.tabs.requests ? [{ value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 }] : []),
|
|
192
217
|
...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many }] : []),
|
|
193
218
|
...(ctl.value.tabs.reports ? [{ value: "reports", label: a.canReports ? "Reports" : "Usage check" }] : []),
|
|
219
|
+
...(!ctl.value.tabs.reports && companyUsage.value ? [{ value: "reports", label: "Company usage" }] : []),
|
|
194
220
|
...(ctl.value.tabs.suppliers ? [{ value: "suppliers", label: "Suppliers" }] : []),
|
|
195
221
|
...(ctl.value.tabs.settings ? [{ value: "settings", label: "Settings" }] : []),
|
|
196
222
|
];
|
|
@@ -214,6 +240,25 @@ async function loadStores() {
|
|
|
214
240
|
refreshCounts();
|
|
215
241
|
}
|
|
216
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Another company's supervisor at the main store may track its OWN people's usage (the API answers the usage
|
|
245
|
+
* report for them, scoped). The store's access flags do not say so, so ask once per store.
|
|
246
|
+
* ponytail: one probe request per store switch; add a flag to the store's access if the API grows one.
|
|
247
|
+
*/
|
|
248
|
+
const companyUsage = ref(false);
|
|
249
|
+
async function probeCompanyUsage() {
|
|
250
|
+
companyUsage.value = false;
|
|
251
|
+
const s = store.value;
|
|
252
|
+
if (!s || ctl.value.tabs.reports || !s.main || s.owner !== false) return;
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
try {
|
|
255
|
+
await getReport(s._id, "usage", { from: now, to: now });
|
|
256
|
+
companyUsage.value = true;
|
|
257
|
+
} catch {
|
|
258
|
+
companyUsage.value = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
217
262
|
async function refreshCounts() {
|
|
218
263
|
if (!store.value?.access.canApprove) return (pendingCount.value = 0);
|
|
219
264
|
try {
|
|
@@ -252,6 +297,7 @@ async function createStore() {
|
|
|
252
297
|
watch(storeId, () => {
|
|
253
298
|
tab.value = "stock";
|
|
254
299
|
refreshCounts();
|
|
300
|
+
probeCompanyUsage();
|
|
255
301
|
});
|
|
256
302
|
watch(() => props.siteId, loadStores);
|
|
257
303
|
|