@7365admin1/layer-common 4.72.0 → 4.73.1-staging.452
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 +10 -4
- package/assets/css/primitives.css +43 -0
- package/components/AccessCardPreviewDialog.vue +286 -50
- package/components/AppSelect.vue +77 -3
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +845 -155
- package/components/InventoryHistoryDialog.vue +205 -0
- package/components/InventoryMain.vue +23 -2
- package/components/InventoryMyStockTab.vue +63 -8
- package/components/InventoryPhotoThumbs.vue +67 -0
- package/components/InventoryReportsTab.vue +154 -28
- package/components/InventoryUsageDialog.vue +119 -28
- package/components/InvitationClientForm.vue +19 -1
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useInventory.ts +11 -3
- package/composables/useMember.ts +12 -0
- package/composables/usePeople.ts +19 -0
- package/composables/useSecurityPermission.ts +20 -0
- package/composables/useServiceProvider.ts +33 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/inventory.d.ts +18 -2
- package/types/member.d.ts +4 -0
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/utils/hid-permission-assignments.ts +190 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/inventory-usage.ts +94 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
|
@@ -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>
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
<v-window-item v-if="ctl.tabs.people" value="technicians">
|
|
120
120
|
<InventoryTechniciansTab :store="store" @changed="refreshCounts" />
|
|
121
121
|
</v-window-item>
|
|
122
|
-
<v-window-item v-if="ctl.tabs.reports" value="reports">
|
|
122
|
+
<v-window-item v-if="ctl.tabs.reports || companyUsage" value="reports">
|
|
123
123
|
<InventoryReportsTab :store="store" :sites="storeSites" />
|
|
124
124
|
</v-window-item>
|
|
125
125
|
<v-window-item v-if="ctl.tabs.suppliers" value="suppliers">
|
|
@@ -150,7 +150,7 @@ const props = defineProps({
|
|
|
150
150
|
|
|
151
151
|
const movedFromEquipment = useRoute().query.moved === "equipment";
|
|
152
152
|
|
|
153
|
-
const { getStores, createStore: apiCreateStore, getRequests } = useInventory();
|
|
153
|
+
const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
154
154
|
const { getSites } = useSite();
|
|
155
155
|
const { requiredRule } = useUtils();
|
|
156
156
|
|
|
@@ -216,6 +216,7 @@ const tabs = computed(() => {
|
|
|
216
216
|
...(ctl.value.tabs.requests ? [{ value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 }] : []),
|
|
217
217
|
...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many }] : []),
|
|
218
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" }] : []),
|
|
219
220
|
...(ctl.value.tabs.suppliers ? [{ value: "suppliers", label: "Suppliers" }] : []),
|
|
220
221
|
...(ctl.value.tabs.settings ? [{ value: "settings", label: "Settings" }] : []),
|
|
221
222
|
];
|
|
@@ -239,6 +240,25 @@ async function loadStores() {
|
|
|
239
240
|
refreshCounts();
|
|
240
241
|
}
|
|
241
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
|
+
|
|
242
262
|
async function refreshCounts() {
|
|
243
263
|
if (!store.value?.access.canApprove) return (pendingCount.value = 0);
|
|
244
264
|
try {
|
|
@@ -277,6 +297,7 @@ async function createStore() {
|
|
|
277
297
|
watch(storeId, () => {
|
|
278
298
|
tab.value = "stock";
|
|
279
299
|
refreshCounts();
|
|
300
|
+
probeCompanyUsage();
|
|
280
301
|
});
|
|
281
302
|
watch(() => props.siteId, loadStores);
|
|
282
303
|
|
|
@@ -22,6 +22,25 @@
|
|
|
22
22
|
<v-btn class="text-none" variant="text" size="small" :loading="acting === h._id + 'c'" @click="cancelHandover(h)">Cancel</v-btn>
|
|
23
23
|
</AppCard>
|
|
24
24
|
|
|
25
|
+
<!-- Usage drafts: before photo saved, job not finished yet. Nothing is taken off the stock until submitted. -->
|
|
26
|
+
<template v-if="drafts.length">
|
|
27
|
+
<div class="text-subtitle-2 mb-2">My drafts — in progress ({{ drafts.length }})</div>
|
|
28
|
+
<AppCard
|
|
29
|
+
v-for="d in drafts"
|
|
30
|
+
:key="d._id"
|
|
31
|
+
class="pa-3 mb-2 d-flex flex-wrap align-center ga-3"
|
|
32
|
+
:style="{ borderLeft: `4px solid rgb(var(--v-theme-${d.stale ? 'error' : 'info'}))` }"
|
|
33
|
+
>
|
|
34
|
+
<InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
|
|
35
|
+
<div class="flex-grow-1" style="min-width: 200px">
|
|
36
|
+
<div class="font-weight-medium">{{ d.itemName }} × {{ d.qty }} {{ d.unit }}<span v-if="d.reference" class="text-medium-emphasis"> · {{ d.reference }}</span></div>
|
|
37
|
+
<div class="text-body-2">{{ draftCardText(d, standardFormatDateTime, staleDays).line }}</div>
|
|
38
|
+
<div v-if="d.stale" class="text-caption text-error">{{ draftCardText(d, standardFormatDateTime, staleDays).warning }}</div>
|
|
39
|
+
</div>
|
|
40
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" size="small" @click="continueDraft(d)">{{ d.photos?.after?.length ? "Review and submit" : "Add after photo" }}</v-btn>
|
|
41
|
+
</AppCard>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
25
44
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
26
45
|
<AppCard v-else-if="!holdings.length" class="pa-6 text-center">
|
|
27
46
|
<div class="text-body-1 mb-1">You are not holding any stock.</div>
|
|
@@ -38,16 +57,22 @@
|
|
|
38
57
|
<div class="flex-grow-1">
|
|
39
58
|
<div class="font-weight-medium">{{ h.itemName }}</div>
|
|
40
59
|
<div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
|
|
60
|
+
<div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
|
|
41
61
|
</div>
|
|
42
62
|
<div class="d-flex flex-column ga-1">
|
|
43
|
-
<v-btn v-if="h.qty > 0 && ctl.recordUsage" class="text-none screen-btn-primary" variant="flat" size="small" @click="use(h)">Record usage</v-btn>
|
|
63
|
+
<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>
|
|
44
64
|
<v-btn v-if="h.qty > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openGive(h)">Give to colleague</v-btn>
|
|
65
|
+
<v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
|
|
45
66
|
</div>
|
|
46
67
|
</AppCard>
|
|
47
68
|
</v-col>
|
|
48
69
|
</v-row>
|
|
49
70
|
|
|
50
|
-
<div class="
|
|
71
|
+
<div class="d-flex align-center mt-6 mb-2">
|
|
72
|
+
<div class="text-subtitle-2">My recent activity</div>
|
|
73
|
+
<v-spacer />
|
|
74
|
+
<v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-map-marker-check-outline" @click="openMyUsage">My usage — where I used my stock</v-btn>
|
|
75
|
+
</div>
|
|
51
76
|
<v-table density="compact">
|
|
52
77
|
<thead>
|
|
53
78
|
<tr>
|
|
@@ -76,7 +101,8 @@
|
|
|
76
101
|
</tbody>
|
|
77
102
|
</v-table>
|
|
78
103
|
|
|
79
|
-
<InventoryUsageDialog v-model="usage.show" :store="store" :site-id="siteId" :holding="usage.holding" :sites="sites" @done="changed" />
|
|
104
|
+
<InventoryUsageDialog v-model="usage.show" :store="store" :site-id="siteId" :holding="usage.holding" :draft="usage.draft" :sites="sites" @done="changed" />
|
|
105
|
+
<InventoryHistoryDialog v-model="history.show" :store="store" :holder="me" :item="history.item" :title="history.title" :usage-only="history.usageOnly" />
|
|
80
106
|
<v-dialog v-model="give.show" max-width="520" persistent>
|
|
81
107
|
<v-card class="screen-modal">
|
|
82
108
|
<v-card-title class="d-flex align-center">
|
|
@@ -135,6 +161,7 @@ import useFile from "../composables/useFile";
|
|
|
135
161
|
import useUtils from "../composables/useUtils";
|
|
136
162
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
137
163
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
164
|
+
import { draftCardText } from "../utils/inventory-usage";
|
|
138
165
|
|
|
139
166
|
const props = defineProps({
|
|
140
167
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -147,14 +174,41 @@ const emit = defineEmits(["changed"]);
|
|
|
147
174
|
const api = useInventory();
|
|
148
175
|
const { typeLabels } = api;
|
|
149
176
|
const { getFileUrl } = useFile();
|
|
150
|
-
const { standardFormatDate } = useUtils();
|
|
177
|
+
const { standardFormatDate, standardFormatDateTime } = useUtils();
|
|
151
178
|
const { currentUser } = useLocalAuth();
|
|
152
179
|
|
|
153
180
|
const holdings = ref<TInventoryHolding[]>([]);
|
|
154
181
|
const activity = ref<TInventoryTransaction[]>([]);
|
|
155
182
|
const loading = ref(false);
|
|
156
183
|
const showRequest = ref(false);
|
|
157
|
-
const usage = reactive({ show: false, holding: null as TInventoryHolding | null });
|
|
184
|
+
const usage = reactive({ show: false, holding: null as TInventoryHolding | null, draft: null as TInventoryUsageDraft | null });
|
|
185
|
+
const drafts = ref<TInventoryUsageDraft[]>([]);
|
|
186
|
+
const staleDays = ref(3);
|
|
187
|
+
const history = reactive({ show: false, item: "", title: "", usageOnly: false });
|
|
188
|
+
|
|
189
|
+
function openHistory(h: TInventoryHolding) {
|
|
190
|
+
Object.assign(history, { show: true, item: String(h.item), title: `History — ${h.itemName}`, usageOnly: false });
|
|
191
|
+
}
|
|
192
|
+
function openMyUsage() {
|
|
193
|
+
Object.assign(history, { show: true, item: "", title: "My usage", usageOnly: true });
|
|
194
|
+
}
|
|
195
|
+
function continueDraft(d: TInventoryUsageDraft) {
|
|
196
|
+
// The draft's own item in hand (its qty is reserved there); a stand-in if the list has not loaded it.
|
|
197
|
+
const holding =
|
|
198
|
+
holdings.value.find((h) => String(h.item) === String(d.item)) ??
|
|
199
|
+
({ _id: d._id, item: d.item, itemName: d.itemName, unit: d.unit, photo: "", holder: "", holderName: "", qty: d.qty, inDrafts: d.qty } as TInventoryHolding);
|
|
200
|
+
Object.assign(usage, { show: true, holding, draft: d });
|
|
201
|
+
}
|
|
202
|
+
async function loadDrafts() {
|
|
203
|
+
if (!ctl.value.recordUsage) return;
|
|
204
|
+
try {
|
|
205
|
+
const res = await api.getUsageDrafts(props.store._id);
|
|
206
|
+
drafts.value = res.items;
|
|
207
|
+
staleDays.value = res.staleAfterDays ?? 3;
|
|
208
|
+
} catch {
|
|
209
|
+
drafts.value = [];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
158
212
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
159
213
|
|
|
160
214
|
const handovers = ref<TInventoryHandover[]>([]);
|
|
@@ -264,15 +318,16 @@ async function load() {
|
|
|
264
318
|
}
|
|
265
319
|
|
|
266
320
|
function use(h: TInventoryHolding) {
|
|
267
|
-
Object.assign(usage, { show: true, holding: h });
|
|
321
|
+
Object.assign(usage, { show: true, holding: h, draft: null });
|
|
268
322
|
}
|
|
269
323
|
|
|
270
324
|
function changed() {
|
|
271
325
|
load();
|
|
272
326
|
loadHandovers();
|
|
327
|
+
loadDrafts();
|
|
273
328
|
emit("changed");
|
|
274
329
|
}
|
|
275
330
|
|
|
276
|
-
watch(() => props.store._id, () => (load(), loadHandovers()));
|
|
277
|
-
onMounted(() => (load(), loadHandovers()));
|
|
331
|
+
watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts()));
|
|
332
|
+
onMounted(() => (load(), loadHandovers(), loadDrafts()));
|
|
278
333
|
</script>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="list.length" class="d-flex flex-wrap ga-1 py-1">
|
|
3
|
+
<button v-for="ph in list" :key="ph.id + ph.label" type="button" class="inventory-thumb" :title="ph.title" :aria-label="`Open ${ph.title}`" @click="open = ph">
|
|
4
|
+
<v-img :src="getFileUrl(ph.id)" :width="size" :height="size" cover class="rounded" :alt="ph.title" />
|
|
5
|
+
<span class="inventory-thumb__tag">{{ ph.label }}</span>
|
|
6
|
+
</button>
|
|
7
|
+
</div>
|
|
8
|
+
<span v-else class="text-medium-emphasis">—</span>
|
|
9
|
+
<v-dialog :model-value="!!open" max-width="820" @update:model-value="open = null">
|
|
10
|
+
<v-card v-if="open" class="screen-modal">
|
|
11
|
+
<v-card-title class="d-flex align-center">
|
|
12
|
+
{{ open.title }}
|
|
13
|
+
<v-spacer />
|
|
14
|
+
<v-btn icon="mdi-close" variant="text" aria-label="Close" @click="open = null" />
|
|
15
|
+
</v-card-title>
|
|
16
|
+
<v-card-text>
|
|
17
|
+
<v-img :src="getFileUrl(open.id)" max-height="70vh" contain />
|
|
18
|
+
<div class="d-flex mt-2">
|
|
19
|
+
<a :href="getFileUrl(open.id)" target="_blank" rel="noopener" class="text-body-2">Open full size</a>
|
|
20
|
+
</div>
|
|
21
|
+
</v-card-text>
|
|
22
|
+
</v-card>
|
|
23
|
+
</v-dialog>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
import useFile from "../composables/useFile";
|
|
28
|
+
import useUtils from "../composables/useUtils";
|
|
29
|
+
|
|
30
|
+
/** Before / after (and other) photos of a usage or hand-over: small thumbnails, click to enlarge with the time taken. */
|
|
31
|
+
const props = defineProps({
|
|
32
|
+
photos: { type: Object as PropType<{ before?: string[]; after?: string[]; other?: string[] } | undefined>, default: undefined },
|
|
33
|
+
beforeAt: { type: String as PropType<string | null | undefined>, default: null },
|
|
34
|
+
afterAt: { type: String as PropType<string | null | undefined>, default: null },
|
|
35
|
+
size: { type: Number, default: 36 },
|
|
36
|
+
});
|
|
37
|
+
const { getFileUrl } = useFile();
|
|
38
|
+
const { standardFormatDateTime } = useUtils();
|
|
39
|
+
const open = ref<{ id: string; label: string; title: string } | null>(null);
|
|
40
|
+
const at = (d?: string | null) => (d ? ` — taken ${standardFormatDateTime(d)}` : "");
|
|
41
|
+
const list = computed(() => [
|
|
42
|
+
...(props.photos?.before ?? []).map((id) => ({ id, label: "Before", title: `Before photo${at(props.beforeAt)}` })),
|
|
43
|
+
...(props.photos?.after ?? []).map((id) => ({ id, label: "After", title: `After photo${at(props.afterAt)}` })),
|
|
44
|
+
...(props.photos?.other ?? []).map((id) => ({ id, label: "Photo", title: "Photo" })),
|
|
45
|
+
]);
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<style scoped>
|
|
49
|
+
.inventory-thumb {
|
|
50
|
+
position: relative;
|
|
51
|
+
border: 0;
|
|
52
|
+
padding: 0;
|
|
53
|
+
background: none;
|
|
54
|
+
cursor: zoom-in;
|
|
55
|
+
}
|
|
56
|
+
.inventory-thumb__tag {
|
|
57
|
+
position: absolute;
|
|
58
|
+
left: 2px;
|
|
59
|
+
bottom: 2px;
|
|
60
|
+
font-size: 9px;
|
|
61
|
+
line-height: 1;
|
|
62
|
+
padding: 1px 3px;
|
|
63
|
+
border-radius: 3px;
|
|
64
|
+
background: rgba(0, 0, 0, 0.6);
|
|
65
|
+
color: #fff;
|
|
66
|
+
}
|
|
67
|
+
</style>
|