@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -4
  2. package/assets/css/primitives.css +43 -0
  3. package/components/AccessCardPreviewDialog.vue +286 -50
  4. package/components/AppSelect.vue +77 -3
  5. package/components/BuildingUnitFormEdit.vue +6 -0
  6. package/components/BuildingUnitManagement.vue +298 -0
  7. package/components/Dialog/UpdateMoreAction.vue +11 -1
  8. package/components/DocumentForm.vue +407 -112
  9. package/components/DocumentManagement.vue +213 -12
  10. package/components/Facility/BookingSetup.vue +63 -0
  11. package/components/HidAccessLogDashboard.vue +74 -17
  12. package/components/HidAccessPermissions.vue +424 -0
  13. package/components/HidIntercomManagement.vue +183 -14
  14. package/components/HidProfileQrCode.vue +332 -0
  15. package/components/HidQrCodeConfiguration.vue +17 -195
  16. package/components/HidReaderManagement.vue +398 -0
  17. package/components/HidReaderUserRoster.vue +44 -3
  18. package/components/HidUserEnrollment.vue +845 -155
  19. package/components/InventoryHistoryDialog.vue +205 -0
  20. package/components/InventoryMain.vue +23 -2
  21. package/components/InventoryMyStockTab.vue +63 -8
  22. package/components/InventoryPhotoThumbs.vue +67 -0
  23. package/components/InventoryReportsTab.vue +154 -28
  24. package/components/InventoryUsageDialog.vue +119 -28
  25. package/components/InvitationClientForm.vue +19 -1
  26. package/components/VisitorForm.vue +12 -0
  27. package/components/VisitorManagement.vue +267 -5
  28. package/composables/useDocument.ts +7 -0
  29. package/composables/useFacility.ts +11 -0
  30. package/composables/useHidAmico.ts +165 -0
  31. package/composables/useHidNavigation.ts +25 -1
  32. package/composables/useHidReaderSelection.ts +50 -0
  33. package/composables/useInventory.ts +11 -3
  34. package/composables/useMember.ts +12 -0
  35. package/composables/usePeople.ts +19 -0
  36. package/composables/useSecurityPermission.ts +20 -0
  37. package/composables/useServiceProvider.ts +33 -2
  38. package/composables/useSiteCategory.ts +45 -0
  39. package/package.json +1 -1
  40. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  41. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  42. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  43. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  44. package/types/document.d.ts +14 -0
  45. package/types/facility.d.ts +4 -0
  46. package/types/inventory.d.ts +18 -2
  47. package/types/member.d.ts +4 -0
  48. package/types/people.d.ts +28 -1
  49. package/types/service-provider.d.ts +5 -0
  50. package/utils/hid-permission-assignments.ts +190 -0
  51. package/utils/hid-reader-selection.ts +52 -0
  52. package/utils/inventory-usage.ts +94 -0
  53. package/utils/occupancy-role.ts +109 -0
  54. 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="text-subtitle-2 mt-6 mb-2">My recent activity</div>
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>