@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
|
@@ -6,6 +6,41 @@
|
|
|
6
6
|
<v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">Request Stock</v-btn>
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
|
+
<!-- Give to colleague: what waits for me to confirm, and what I gave that waits for them. -->
|
|
10
|
+
<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))">
|
|
11
|
+
<v-icon icon="mdi-hand-coin-outline" color="warning" />
|
|
12
|
+
<div class="flex-grow-1">
|
|
13
|
+
<div class="font-weight-medium">{{ h.fromLabel || h.fromName }} is giving you {{ h.qty }} {{ h.unit }} {{ h.itemName }}</div>
|
|
14
|
+
<div class="text-caption text-medium-emphasis">{{ standardFormatDate(h.createdAt) }}{{ h.note ? ` · ${h.note}` : "" }} · Confirm once you have it in hand.</div>
|
|
15
|
+
</div>
|
|
16
|
+
<v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" :loading="acting === h._id + 'd'" @click="decide(h, false)">Decline</v-btn>
|
|
17
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === h._id + 'a'" @click="decide(h, true)">I received it</v-btn>
|
|
18
|
+
</AppCard>
|
|
19
|
+
<AppCard v-for="h in waiting" :key="h._id" class="pa-3 mb-3 d-flex flex-wrap align-center ga-3">
|
|
20
|
+
<v-icon icon="mdi-timer-sand" color="medium-emphasis" />
|
|
21
|
+
<div class="flex-grow-1 text-body-2">Waiting for {{ h.toName }} to confirm {{ h.qty }} {{ h.unit }} {{ h.itemName }}.</div>
|
|
22
|
+
<v-btn class="text-none" variant="text" size="small" :loading="acting === h._id + 'c'" @click="cancelHandover(h)">Cancel</v-btn>
|
|
23
|
+
</AppCard>
|
|
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
|
+
|
|
9
44
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
10
45
|
<AppCard v-else-if="!holdings.length" class="pa-6 text-center">
|
|
11
46
|
<div class="text-body-1 mb-1">You are not holding any stock.</div>
|
|
@@ -22,13 +57,22 @@
|
|
|
22
57
|
<div class="flex-grow-1">
|
|
23
58
|
<div class="font-weight-medium">{{ h.itemName }}</div>
|
|
24
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>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="d-flex flex-column ga-1">
|
|
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>
|
|
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>
|
|
25
66
|
</div>
|
|
26
|
-
<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>
|
|
27
67
|
</AppCard>
|
|
28
68
|
</v-col>
|
|
29
69
|
</v-row>
|
|
30
70
|
|
|
31
|
-
<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>
|
|
32
76
|
<v-table density="compact">
|
|
33
77
|
<thead>
|
|
34
78
|
<tr>
|
|
@@ -44,10 +88,10 @@
|
|
|
44
88
|
<tr v-for="t in activity" :key="t._id">
|
|
45
89
|
<td class="text-no-wrap">{{ standardFormatDate(t.date) }}</td>
|
|
46
90
|
<td>{{ t.itemName }}</td>
|
|
47
|
-
<td>{{ t
|
|
91
|
+
<td>{{ activityText(t) }}</td>
|
|
48
92
|
<td class="text-right">{{ t.qty }}</td>
|
|
49
93
|
<td class="text-caption">
|
|
50
|
-
{{ [t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area, t.reference].filter(Boolean).join(" · ") }}
|
|
94
|
+
{{ [t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area, t.type === "handover" ? t.remarks : t.reference].filter(Boolean).join(" · ") }}
|
|
51
95
|
</td>
|
|
52
96
|
<td class="text-right">{{ t.holderBalance }}</td>
|
|
53
97
|
</tr>
|
|
@@ -57,7 +101,56 @@
|
|
|
57
101
|
</tbody>
|
|
58
102
|
</v-table>
|
|
59
103
|
|
|
60
|
-
<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" />
|
|
106
|
+
<v-dialog v-model="give.show" max-width="520" persistent>
|
|
107
|
+
<v-card class="screen-modal">
|
|
108
|
+
<v-card-title class="d-flex align-center">
|
|
109
|
+
Give to colleague
|
|
110
|
+
<v-spacer />
|
|
111
|
+
<v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="give.show = false" />
|
|
112
|
+
</v-card-title>
|
|
113
|
+
<v-card-text class="screen-modal__body">
|
|
114
|
+
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
115
|
+
{{ give.holding?.itemName }}: you hold {{ give.holding?.qty }} {{ give.holding?.unit }}. It moves to your colleague once they confirm they received it.
|
|
116
|
+
</p>
|
|
117
|
+
<v-form ref="giveForm">
|
|
118
|
+
<InputLabel title="Colleague" required class="mb-1" />
|
|
119
|
+
<v-autocomplete
|
|
120
|
+
v-model="give.to"
|
|
121
|
+
:items="colleagues"
|
|
122
|
+
:item-title="(p: any) => p.label || p.name"
|
|
123
|
+
item-value="_id"
|
|
124
|
+
:loading="colleaguesLoading"
|
|
125
|
+
placeholder="Search a name"
|
|
126
|
+
density="comfortable"
|
|
127
|
+
no-filter
|
|
128
|
+
:rules="[(v: string) => !!v || 'Choose who you are giving it to']"
|
|
129
|
+
@update:search="searchColleagues"
|
|
130
|
+
/>
|
|
131
|
+
<InputLabel title="Quantity" required class="mb-1" />
|
|
132
|
+
<v-text-field
|
|
133
|
+
v-model.number="give.qty"
|
|
134
|
+
type="number"
|
|
135
|
+
min="1"
|
|
136
|
+
density="comfortable"
|
|
137
|
+
:suffix="give.holding?.unit"
|
|
138
|
+
:rules="[giveQtyRule]"
|
|
139
|
+
hint="Counted in the unit the store issues it in."
|
|
140
|
+
persistent-hint
|
|
141
|
+
/>
|
|
142
|
+
<InputLabel title="Note (optional)" class="mb-1 mt-2" />
|
|
143
|
+
<v-text-field v-model="give.note" density="comfortable" maxlength="500" placeholder="e.g. for the lift lobby job" />
|
|
144
|
+
<InputLabel title="Photo (optional)" class="mb-1" />
|
|
145
|
+
<InventoryPhotoInput v-model="give.photos" :max="1" />
|
|
146
|
+
</v-form>
|
|
147
|
+
</v-card-text>
|
|
148
|
+
<div class="screen-modal__footer">
|
|
149
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="give.saving" @click="give.show = false">Cancel</v-btn>
|
|
150
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="give.saving" @click="submitGive">Give</v-btn>
|
|
151
|
+
</div>
|
|
152
|
+
</v-card>
|
|
153
|
+
</v-dialog>
|
|
61
154
|
<InventoryLinesDialog v-model="showRequest" mode="request" :store="store" :site-id="siteId" @done="changed" />
|
|
62
155
|
</div>
|
|
63
156
|
</template>
|
|
@@ -68,6 +161,7 @@ import useFile from "../composables/useFile";
|
|
|
68
161
|
import useUtils from "../composables/useUtils";
|
|
69
162
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
70
163
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
164
|
+
import { draftCardText } from "../utils/inventory-usage";
|
|
71
165
|
|
|
72
166
|
const props = defineProps({
|
|
73
167
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -80,14 +174,135 @@ const emit = defineEmits(["changed"]);
|
|
|
80
174
|
const api = useInventory();
|
|
81
175
|
const { typeLabels } = api;
|
|
82
176
|
const { getFileUrl } = useFile();
|
|
83
|
-
const { standardFormatDate } = useUtils();
|
|
177
|
+
const { standardFormatDate, standardFormatDateTime } = useUtils();
|
|
84
178
|
const { currentUser } = useLocalAuth();
|
|
85
179
|
|
|
86
180
|
const holdings = ref<TInventoryHolding[]>([]);
|
|
87
181
|
const activity = ref<TInventoryTransaction[]>([]);
|
|
88
182
|
const loading = ref(false);
|
|
89
183
|
const showRequest = ref(false);
|
|
90
|
-
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
|
+
}
|
|
212
|
+
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
213
|
+
|
|
214
|
+
const handovers = ref<TInventoryHandover[]>([]);
|
|
215
|
+
const me = computed(() => String(currentUser.value?._id ?? ""));
|
|
216
|
+
const toConfirm = computed(() => handovers.value.filter((h) => h.status === "pending" && String(h.to) === me.value));
|
|
217
|
+
const waiting = computed(() => handovers.value.filter((h) => h.status === "pending" && String(h.from) === me.value));
|
|
218
|
+
const acting = ref("");
|
|
219
|
+
|
|
220
|
+
function activityText(t: TInventoryTransaction) {
|
|
221
|
+
if (t.type === "issue") return "Taken from store";
|
|
222
|
+
if (t.type === "return") return "Given back to store";
|
|
223
|
+
if (t.type === "handover") return (t.holderChange ?? 0) < 0 ? "Given to colleague" : "Received from colleague";
|
|
224
|
+
return typeLabels[t.type];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function loadHandovers() {
|
|
228
|
+
try {
|
|
229
|
+
handovers.value = (await api.getHandovers(props.store._id, { mine: true, status: "pending", limit: 50 })).items;
|
|
230
|
+
} catch {
|
|
231
|
+
handovers.value = [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function decide(h: TInventoryHandover, accept: boolean) {
|
|
236
|
+
acting.value = h._id + (accept ? "a" : "d");
|
|
237
|
+
try {
|
|
238
|
+
if (accept) await api.acceptHandover(h._id);
|
|
239
|
+
else await api.declineHandover(h._id, "");
|
|
240
|
+
notify(accept ? `You received ${h.qty} ${h.unit} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
|
|
241
|
+
changed();
|
|
242
|
+
} catch (error: any) {
|
|
243
|
+
notify(error?.data?.message || "Could not update the hand-over.", "error");
|
|
244
|
+
} finally {
|
|
245
|
+
acting.value = "";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function cancelHandover(h: TInventoryHandover) {
|
|
250
|
+
acting.value = h._id + "c";
|
|
251
|
+
try {
|
|
252
|
+
await api.cancelHandover(h._id);
|
|
253
|
+
notify("Hand-over cancelled.");
|
|
254
|
+
changed();
|
|
255
|
+
} catch (error: any) {
|
|
256
|
+
notify(error?.data?.message || "Could not cancel.", "error");
|
|
257
|
+
} finally {
|
|
258
|
+
acting.value = "";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const giveForm = ref();
|
|
263
|
+
const give = reactive({ show: false, holding: null as TInventoryHolding | null, to: "", qty: 1 as number | null, note: "", photos: [] as string[], saving: false });
|
|
264
|
+
const pendingOf = (item: string) => waiting.value.filter((h) => String(h.item) === item).reduce((s, h) => s + h.qty, 0);
|
|
265
|
+
const giveQtyRule = (v: number | null) => {
|
|
266
|
+
if (!v || v < 1 || !Number.isInteger(Number(v))) return "Enter a whole number";
|
|
267
|
+
const free = (give.holding?.qty ?? 0) - pendingOf(String(give.holding?.item ?? ""));
|
|
268
|
+
return Number(v) <= free || `You can give up to ${Math.max(0, free)}`;
|
|
269
|
+
};
|
|
270
|
+
const colleagues = ref<{ _id: string; name: string; label?: string }[]>([]);
|
|
271
|
+
const colleaguesLoading = ref(false);
|
|
272
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
273
|
+
function searchColleagues(q: string) {
|
|
274
|
+
clearTimeout(searchTimer);
|
|
275
|
+
searchTimer = setTimeout(async () => {
|
|
276
|
+
colleaguesLoading.value = true;
|
|
277
|
+
try {
|
|
278
|
+
colleagues.value = await api.getColleagues(props.store._id, q || "");
|
|
279
|
+
} catch {
|
|
280
|
+
colleagues.value = [];
|
|
281
|
+
} finally {
|
|
282
|
+
colleaguesLoading.value = false;
|
|
283
|
+
}
|
|
284
|
+
}, 300);
|
|
285
|
+
}
|
|
286
|
+
function openGive(h: TInventoryHolding) {
|
|
287
|
+
Object.assign(give, { show: true, holding: h, to: "", qty: 1, note: "", photos: [], saving: false });
|
|
288
|
+
searchColleagues("");
|
|
289
|
+
}
|
|
290
|
+
async function submitGive() {
|
|
291
|
+
const { valid } = await giveForm.value.validate();
|
|
292
|
+
if (!valid || !give.holding) return;
|
|
293
|
+
give.saving = true;
|
|
294
|
+
try {
|
|
295
|
+
await api.createHandover(props.store._id, { site: props.siteId, item: String(give.holding.item), to: give.to, qty: Number(give.qty), note: give.note, photos: give.photos });
|
|
296
|
+
const name = colleagues.value.find((c) => c._id === give.to)?.name ?? "your colleague";
|
|
297
|
+
notify(`Waiting for ${name} to confirm.`);
|
|
298
|
+
give.show = false;
|
|
299
|
+
changed();
|
|
300
|
+
} catch (error: any) {
|
|
301
|
+
notify(error?.data?.message || "Could not give it.", "error");
|
|
302
|
+
} finally {
|
|
303
|
+
give.saving = false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
91
306
|
|
|
92
307
|
async function load() {
|
|
93
308
|
loading.value = true;
|
|
@@ -103,14 +318,16 @@ async function load() {
|
|
|
103
318
|
}
|
|
104
319
|
|
|
105
320
|
function use(h: TInventoryHolding) {
|
|
106
|
-
Object.assign(usage, { show: true, holding: h });
|
|
321
|
+
Object.assign(usage, { show: true, holding: h, draft: null });
|
|
107
322
|
}
|
|
108
323
|
|
|
109
324
|
function changed() {
|
|
110
325
|
load();
|
|
326
|
+
loadHandovers();
|
|
327
|
+
loadDrafts();
|
|
111
328
|
emit("changed");
|
|
112
329
|
}
|
|
113
330
|
|
|
114
|
-
watch(() => props.store._id, load);
|
|
115
|
-
onMounted(load);
|
|
331
|
+
watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts()));
|
|
332
|
+
onMounted(() => (load(), loadHandovers(), loadDrafts()));
|
|
116
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>
|