@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.
@@ -6,6 +6,7 @@
6
6
  <v-btn value="usage" class="text-none">Usage</v-btn>
7
7
  <v-btn v-if="ctl.fullReports" value="technicians" class="text-none">{{ worker.many }}</v-btn>
8
8
  <v-btn v-if="ctl.fullReports" value="purchases" class="text-none">Purchases</v-btn>
9
+ <v-btn v-if="ctl.fullReports || ctl.review" value="handovers" class="text-none">Hand-overs</v-btn>
9
10
  </v-btn-toggle>
10
11
  <v-select
11
12
  v-if="report === 'purchases'"
@@ -41,21 +42,54 @@
41
42
  hide-details
42
43
  style="max-width: 260px"
43
44
  />
44
- <v-checkbox v-if="report === 'usage'" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
45
+ <v-select
46
+ v-if="store.ownerType === 'property'"
47
+ v-model="usedBy"
48
+ :items="USED_BY_FILTERS"
49
+ label="Used by"
50
+ density="compact"
51
+ hide-details
52
+ style="max-width: 220px"
53
+ />
54
+ <v-select
55
+ v-if="serviceChoices.length > 1"
56
+ v-model="serviceFilter"
57
+ :items="[{ value: '', title: 'All companies' }, ...serviceChoices]"
58
+ label="Company / service"
59
+ density="compact"
60
+ hide-details
61
+ style="max-width: 260px"
62
+ />
63
+ <v-checkbox v-if="report === 'usage' && canCheck" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
45
64
  <v-spacer />
46
- <v-btn v-if="ctl.fullReports" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-file-excel-outline" :loading="exporting" @click="exportExcel">
65
+ <v-btn v-if="ctl.fullReports || report === 'usage'" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-file-excel-outline" :loading="exporting" @click="exportExcel">
47
66
  Export Excel
48
67
  </v-btn>
49
68
  </div>
50
69
 
70
+ <!-- Usage tracking (owner 2026-09-29): who used what from this store, where, for which work order, with photos. -->
71
+ <div v-if="report === 'usage'" class="d-flex flex-wrap ga-2 align-center mb-3">
72
+ <v-select v-model="status" :items="USAGE_STATUS_CHOICES" label="Status" density="compact" hide-details style="max-width: 200px" />
73
+ <v-select v-model="person" :items="[{ value: '', title: 'Everyone' }, ...personChoices]" label="Person" density="compact" hide-details style="max-width: 280px" />
74
+ <v-select v-model="itemFilter" :items="[{ value: '', title: 'All items' }, ...itemChoices]" label="Item" density="compact" hide-details style="max-width: 220px" />
75
+ <v-text-field v-model="locationText" label="Where (unit, tenant or area)" density="compact" hide-details clearable style="max-width: 220px" />
76
+ <v-text-field v-model="referenceText" label="Work order / ref" density="compact" hide-details clearable style="max-width: 180px" />
77
+ <v-select v-model="flag" :items="flagChoices" label="Needs a look" density="compact" hide-details style="max-width: 220px" />
78
+ <v-text-field v-model="search" label="Search" prepend-inner-icon="mdi-magnify" density="compact" hide-details clearable style="max-width: 220px" />
79
+ </div>
80
+ <v-alert v-if="report === 'usage' && scope === 'own_company'" type="info" variant="tonal" density="compact" class="mb-2">
81
+ You see your own company's people only. The store's owner sees everyone's.
82
+ </v-alert>
83
+
51
84
  <p class="text-caption text-medium-emphasis mb-2">{{ help[report] }}</p>
52
85
  <p v-if="report === 'movement' && siteName" class="text-caption text-medium-emphasis mb-2">
53
86
  Start, received and end are the whole store's (shared by {{ sites.map((s) => s.name).join(" and ") }}). The last columns show what was issued for {{ siteName }} and used there.
54
87
  </p>
55
88
 
56
- <div v-if="report === 'usage' && rows.length" class="d-flex flex-wrap align-center ga-3 mb-2">
89
+ <div v-if="report === 'usage' && shownRows.length" class="d-flex flex-wrap align-center ga-3 mb-2">
57
90
  <div class="text-body-2 font-weight-medium">
58
- {{ rows.length }} record(s) · {{ totals.qty }} item(s) used · Total cost {{ money(totals.cost) }}
91
+ <template v-if="usageSummary.records">{{ usageSummary.records }} record(s) · {{ usageSummary.qty }} item(s) used · Total cost {{ money(usageSummary.cost) }}</template>
92
+ <template v-if="usageSummary.drafts">{{ usageSummary.records ? " · " : "" }}{{ usageSummary.drafts }} in progress (drafts — not counted as usage until submitted)</template>
59
93
  </div>
60
94
  <v-spacer />
61
95
  <template v-if="canCheck">
@@ -69,6 +103,19 @@
69
103
  </template>
70
104
  </div>
71
105
 
106
+ <v-row v-if="report === 'usage' && usageSummary.records" dense class="mb-2">
107
+ <v-col v-for="box in totalBoxes" :key="box.title" cols="12" md="4">
108
+ <AppCard class="pa-3">
109
+ <div class="text-subtitle-2 mb-2">{{ box.title }}</div>
110
+ <div v-for="t in box.rows.slice(0, 6)" :key="t.label" class="d-flex justify-space-between text-body-2 mb-1 ga-2">
111
+ <span class="text-truncate">{{ t.label }}</span>
112
+ <span class="text-no-wrap">{{ t.records }} × · {{ t.qty }}{{ t.unit ? ` ${t.unit}` : "" }} · {{ money(t.totalCost) }}</span>
113
+ </div>
114
+ <div v-if="box.rows.length > 6" class="text-caption text-medium-emphasis">and {{ box.rows.length - 6 }} more (all in the Excel file)</div>
115
+ </AppCard>
116
+ </v-col>
117
+ </v-row>
118
+
72
119
  <template v-if="report === 'purchases' && rows.length">
73
120
  <div class="text-body-2 font-weight-medium mb-2">Total purchases {{ money(totals.cost) }}</div>
74
121
  <v-row dense class="mb-2">
@@ -99,16 +146,22 @@
99
146
  <v-data-table
100
147
  v-model="selected"
101
148
  :headers="tableHeaders"
102
- :items="rows"
149
+ :items="shownRows"
103
150
  :loading="loading"
104
151
  :show-select="report === 'usage' && canCheck"
152
+ :item-selectable="(r: any) => r.status !== 'draft'"
105
153
  item-value="_id"
106
154
  density="compact"
107
155
  :items-per-page="50"
108
156
  class="border rounded"
109
- no-data-text="Nothing recorded in this period."
157
+ :no-data-text="noDataText"
110
158
  >
111
159
  <template #item.date="{ value }">{{ standardFormatDate(value) }}</template>
160
+ <template #item.createdAt="{ value }">{{ standardFormatDate(value) }}</template>
161
+ <template #item.decidedAt="{ value }">{{ value ? standardFormatDate(value) : "—" }}</template>
162
+ <template #item.status="{ value }">
163
+ <v-chip size="x-small" variant="tonal" :color="value === 'accepted' ? 'success' : value === 'pending' ? 'warning' : undefined">{{ handoverStatus[value] ?? value }}</v-chip>
164
+ </template>
112
165
  <template #item.locationType="{ value }">{{ value === "tenant_unit" ? "Tenant unit" : "Common area" }}</template>
113
166
  <template #item.unitCost="{ value }">{{ money(value) }}</template>
114
167
  <template #item.totalCost="{ value }">{{ money(value) }}</template>
@@ -118,14 +171,31 @@
118
171
  <template #item.receivedCost="{ value }">{{ money(value) }}</template>
119
172
  <template #item.usedCost="{ value }">{{ money(value) }}</template>
120
173
  <template #item.siteUsedCost="{ value }">{{ money(value) }}</template>
121
- <!-- The supervisor checks usage against the technician's before/after photos. -->
174
+ <!-- The supervisor checks usage against the technician's before/after photos (click to enlarge, with the time taken). -->
122
175
  <template #item.photos="{ item }">
123
- <div v-if="usagePhotos(item).length" class="d-flex ga-1 py-1">
124
- <a v-for="ph in usagePhotos(item)" :key="ph.id" :href="getFileUrl(ph.id)" target="_blank" rel="noopener" :title="ph.label">
125
- <v-img :src="getFileUrl(ph.id)" width="36" height="36" cover class="rounded" :alt="ph.label" />
126
- </a>
176
+ <InventoryPhotoThumbs :photos="item.photos" :before-at="item.photosBeforeAt" :after-at="item.photosAfterAt" />
177
+ </template>
178
+ <template #item.state="{ item }">
179
+ <v-chip size="x-small" variant="tonal" :color="item.status === 'draft' ? 'info' : 'success'">{{ item.status === "draft" ? "In progress" : "Submitted" }}</v-chip>
180
+ <div v-for="f in item.flags ?? []" :key="f" class="mt-1">
181
+ <v-chip size="x-small" variant="tonal" color="warning">{{ flagWords[f] ?? f }}</v-chip>
182
+ </div>
183
+ </template>
184
+ <template #item.technician="{ item }">
185
+ <a href="#" class="text-primary" @click.prevent="openPerson(item)">{{ item.holderLabel || item.technician }}</a>
186
+ </template>
187
+ <template #item.itemName="{ item }">
188
+ <a v-if="report === 'usage' && scope === 'all'" href="#" class="text-primary" @click.prevent="openItem(item)">{{ item.itemName }}</a>
189
+ <span v-else>{{ item.itemName }}</span>
190
+ </template>
191
+ <template #item.times="{ item }">
192
+ <div class="text-caption text-no-wrap">
193
+ <div v-if="item.draftedAt">Draft {{ standardFormatDateTime(item.draftedAt) }}</div>
194
+ <div v-if="item.submittedAt">Submitted {{ standardFormatDateTime(item.submittedAt) }}</div>
127
195
  </div>
128
- <span v-else class="text-medium-emphasis">—</span>
196
+ </template>
197
+ <template #item.path="{ item }">
198
+ <span class="text-caption">{{ usagePathText(item.path, standardFormatDate) || "—" }}</span>
129
199
  </template>
130
200
  <template #item.checkedByName="{ item }">
131
201
  <v-chip v-if="item.checkedByName" size="x-small" color="success" variant="tonal">{{ item.checkedByName }}</v-chip>
@@ -135,6 +205,7 @@
135
205
  <span :class="item.reorderLevel && item.closing <= item.reorderLevel ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ item.closing }}</span>
136
206
  </template>
137
207
  </v-data-table>
208
+ <InventoryHistoryDialog v-model="drill.show" :store="store" :holder="drill.holder" :item="drill.item" :title="drill.title" />
138
209
  </div>
139
210
  </template>
140
211
 
@@ -142,8 +213,9 @@
142
213
  import useInventory, { saveBlob } from "../composables/useInventory";
143
214
  import useWorkerTerm, { workerAppKey } from "../composables/useWorkerTerm";
144
215
  import useUtils from "../composables/useUtils";
145
- import useFile from "../composables/useFile";
146
216
  import { inventoryControls } from "../utils/inventory-presets";
217
+ import { USED_BY_FILTERS } from "../utils/inventory-used-by";
218
+ import { USAGE_FLAG_WORDS, USAGE_STATUS_CHOICES, usagePathText, usageQuery, usageTotals } from "../utils/inventory-usage";
147
219
 
148
220
  const props = defineProps({
149
221
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -152,12 +224,7 @@ const props = defineProps({
152
224
  const ctl = computed(() => inventoryControls(props.store.access));
153
225
 
154
226
  const api = useInventory();
155
- const { standardFormatDate } = useUtils();
156
- const { getFileUrl } = useFile();
157
- const usagePhotos = (row: any) => [
158
- ...(row.photos?.before ?? []).map((id: string) => ({ id, label: "Before" })),
159
- ...(row.photos?.after ?? []).map((id: string) => ({ id, label: "After" })),
160
- ];
227
+ const { standardFormatDate, standardFormatDateTime } = useUtils();
161
228
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
162
229
 
163
230
  const worker = useWorkerTerm({ providers: true });
@@ -166,11 +233,32 @@ const appSlug = workerAppKey();
166
233
  const now = new Date();
167
234
  const month = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
168
235
  // An approver without `view-reports` gets only the usage check screen.
169
- const report = ref<"movement" | "usage" | "technicians" | "purchases">(ctl.value.fullReports ? "movement" : "usage");
236
+ const report = ref<"movement" | "usage" | "technicians" | "purchases" | "handovers">(ctl.value.fullReports ? "movement" : "usage");
170
237
  const span = ref<"month" | "year">("year");
171
238
  const site = ref("");
172
239
  const locationType = ref("");
173
240
  const uncheckedOnly = ref(false);
241
+ const usedBy = ref("");
242
+ // Usage tracking filters. Status / place / work order / flag / search go to the server; person and item
243
+ // narrow the loaded rows (their choices come from the rows).
244
+ const status = ref("submitted");
245
+ const person = ref("");
246
+ const itemFilter = ref("");
247
+ const locationText = ref("");
248
+ const referenceText = ref("");
249
+ const flag = ref("");
250
+ const search = ref("");
251
+ const scope = ref<"all" | "own_company">("all");
252
+ const staleDays = ref(3);
253
+ const flagWords = computed(() => ({ ...USAGE_FLAG_WORDS, old_draft: `Draft older than ${staleDays.value} days` }) as Record<string, string>);
254
+ const flagChoices = computed(() => [{ value: "", title: "Everything" }, ...Object.entries(flagWords.value).map(([value, title]) => ({ value, title }))]);
255
+ const drill = reactive({ show: false, holder: "", item: "", title: "" });
256
+ function openPerson(r: any) {
257
+ Object.assign(drill, { show: true, holder: String(r.holder ?? ""), item: "", title: r.holderLabel || r.technician });
258
+ }
259
+ function openItem(r: any) {
260
+ Object.assign(drill, { show: true, holder: "", item: String(r.item ?? ""), title: `${r.itemName} — where it went` });
261
+ }
174
262
  const rows = ref<any[]>([]);
175
263
  const selected = ref<string[]>([]);
176
264
  const purchases = reactive({ byMonth: [] as { month: string; deliveries: number; totalCost: number }[], bySupplier: [] as { supplier: string; deliveries: number; totalCost: number }[] });
@@ -188,11 +276,58 @@ const barWidth = (v: number) => {
188
276
 
189
277
  const help: Record<string, string> = {
190
278
  movement: "For each item: stock at the start of the month, what came in (deliveries and opening stock), what was issued and returned, count differences, and stock at the end.",
191
- usage: "Every recorded usage: where it was used, for which tenant, by whom, and the cost. Use this for tenant billing. Tick records and mark them checked once verified.",
279
+ usage: "Every usage of this store's items, by anyone: who (company and service), where, which work order, before and after photos with their times, and how the stock reached them. Click a person or an item to see its full history. Use it for tenant billing; tick records and mark them checked once verified.",
192
280
  technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
193
281
  purchases: "Every delivery received, and opening stock entered with new items (Type: Opening stock), with totals by month and by supplier.",
282
+ handovers: "Stock passed from one person to a colleague: who gave it, who received it, and whether they confirmed.",
194
283
  };
195
284
 
285
+ const handoverStatus: Record<string, string> = { pending: "Waiting to confirm", accepted: "Confirmed", declined: "Declined", cancelled: "Cancelled" };
286
+
287
+ // "Company · Service" of the person on each row, for the filter (usage, staff and hand-over reports).
288
+ const companyOf = (r: any) => [r.holderCompany ?? r.fromCompany, r.holderServiceLabel ?? r.fromServiceLabel].filter(Boolean).join(" · ");
289
+ const serviceFilter = ref("");
290
+ const serviceChoices = computed(() =>
291
+ [...new Set(rows.value.map(companyOf).filter(Boolean))].sort().map((v) => ({ value: v, title: v })),
292
+ );
293
+ const shownRows = computed(() =>
294
+ rows.value.filter(
295
+ (r) =>
296
+ (!serviceFilter.value || companyOf(r) === serviceFilter.value || [r.toCompany, r.toServiceLabel].filter(Boolean).join(" · ") === serviceFilter.value) &&
297
+ (report.value !== "usage" || ((!person.value || String(r.holder) === person.value) && (!itemFilter.value || String(r.item) === itemFilter.value))),
298
+ ),
299
+ );
300
+ const choices = (key: (r: any) => string, label: (r: any) => string) =>
301
+ report.value !== "usage"
302
+ ? []
303
+ : [...new Map(rows.value.map((r) => [key(r), String(label(r) ?? "")])).entries()].map(([value, title]) => ({ value, title })).sort((a, b) => a.title.localeCompare(b.title));
304
+ const personChoices = computed(() => choices((r) => String(r.holder), (r) => r.holderLabel || r.technician));
305
+ const itemChoices = computed(() => choices((r) => String(r.item), (r) => r.itemName));
306
+ const usageSummary = computed(() => {
307
+ const done = shownRows.value.filter((r) => r.status !== "draft");
308
+ return {
309
+ records: done.length,
310
+ qty: done.reduce((sum, r) => sum + r.qty, 0),
311
+ cost: Math.round(done.reduce((sum, r) => sum + (r.totalCost ?? 0), 0) * 100) / 100,
312
+ drafts: shownRows.value.length - done.length,
313
+ };
314
+ });
315
+ const totalBoxes = computed(() => {
316
+ const t = usageTotals(shownRows.value);
317
+ return [
318
+ { title: "By item", rows: t.byItem },
319
+ { title: "By person", rows: t.byPerson },
320
+ { title: "By company", rows: t.byCompany },
321
+ ];
322
+ });
323
+ const noDataText = computed(() =>
324
+ report.value !== "usage"
325
+ ? "Nothing recorded in this period."
326
+ : status.value === "draft"
327
+ ? "No usage in progress. Drafts appear here once someone saves a before photo."
328
+ : "No usage recorded for these filters in this month.",
329
+ );
330
+
196
331
  const headers: Record<string, any[]> = {
197
332
  movement: [
198
333
  { title: "Item", key: "name" },
@@ -210,6 +345,7 @@ const headers: Record<string, any[]> = {
210
345
  ],
211
346
  usage: [
212
347
  { title: "Date", key: "date" },
348
+ { title: "Status", key: "state", sortable: false },
213
349
  { title: "Property", key: "siteName" },
214
350
  { title: "Where", key: "locationType" },
215
351
  { title: "Unit / Area", key: "location" },
@@ -218,23 +354,41 @@ const headers: Record<string, any[]> = {
218
354
  { title: "Qty", key: "qty" },
219
355
  { title: "Unit price", key: "unitCost" },
220
356
  { title: "Total", key: "totalCost" },
221
- { title: worker.one, key: "technician" },
222
- { title: "Photos", key: "photos", sortable: false },
223
- { title: "Reference", key: "reference" },
357
+ { title: worker.one, key: "technician", minWidth: "200px" },
358
+ { title: "Photos", key: "photos", sortable: false, minWidth: "124px" },
359
+ { title: "Work order / ref", key: "reference", minWidth: "130px" },
360
+ { title: "Saved / submitted", key: "times", sortable: false },
361
+ { title: "Came from", key: "path", sortable: false, minWidth: "220px" },
224
362
  { title: "Reported by", key: "reportedBy" },
363
+ { title: "Remarks", key: "remarks" },
225
364
  { title: "Checked by", key: "checkedByName" },
226
365
  ],
227
366
  technicians: [
228
367
  { title: "Name", key: "technician" },
368
+ { title: "Company", key: "holderCompany" },
369
+ { title: "Service", key: "holderServiceLabel" },
229
370
  { title: "Item", key: "itemName" },
230
371
  { title: "Unit", key: "unit" },
231
372
  { title: "Holding at start", key: "opening" },
232
373
  { title: "Taken", key: "taken" },
374
+ { title: "From colleagues", key: "gotFromColleagues" },
233
375
  { title: "Used", key: "used" },
234
376
  { title: "Returned", key: "returned" },
377
+ { title: "To colleagues", key: "gaveToColleagues" },
235
378
  { title: "Holding at end", key: "closing" },
236
379
  { title: "In hand now", key: "inHandNow" },
237
380
  ],
381
+ handovers: [
382
+ { title: "Date", key: "createdAt" },
383
+ { title: "Item", key: "itemName" },
384
+ { title: "Qty", key: "qty" },
385
+ { title: "Unit", key: "unit" },
386
+ { title: "Given by", key: "fromLabel" },
387
+ { title: "Given to", key: "toLabel" },
388
+ { title: "Status", key: "status" },
389
+ { title: "Confirmed / declined", key: "decidedAt" },
390
+ { title: "Note", key: "note" },
391
+ ],
238
392
  purchases: [
239
393
  { title: "Date", key: "date" },
240
394
  { title: "Type", key: "kind" },
@@ -271,9 +425,28 @@ function period(): TInventoryPeriod & { unchecked?: boolean } {
271
425
  ...((report.value === "usage" || report.value === "movement") && site.value ? { site: site.value } : {}),
272
426
  ...(report.value === "usage" && locationType.value ? { locationType: locationType.value } : {}),
273
427
  ...(report.value === "usage" && uncheckedOnly.value ? { unchecked: true } : {}),
428
+ ...(usedBy.value ? { usedBy: usedBy.value } : {}),
429
+ ...(report.value === "usage"
430
+ ? usageQuery({ status: status.value === "submitted" ? "" : status.value, location: locationText.value, reference: referenceText.value, flag: flag.value, search: search.value })
431
+ : {}),
274
432
  };
275
433
  }
276
434
 
435
+ /** The export carries the person / item / company picked on screen too. */
436
+ function exportFilters() {
437
+ if (report.value !== "usage") return {};
438
+ const pick = rows.value.find((r) => companyOf(r) === serviceFilter.value);
439
+ const base = String(useRuntimeConfig().public.API_DO_STORAGE_ENDPOINT ?? "");
440
+ return usageQuery({
441
+ holder: person.value,
442
+ item: itemFilter.value,
443
+ company: serviceFilter.value ? pick?.holderCompany : "",
444
+ service: serviceFilter.value ? pick?.holderService : "",
445
+ // Photo links in the file open the photo itself.
446
+ photoBase: base.startsWith("https://") ? base : "",
447
+ });
448
+ }
449
+
277
450
  async function load() {
278
451
  if (!month.value) return;
279
452
  loading.value = true;
@@ -283,6 +456,8 @@ async function load() {
283
456
  rows.value = res.rows;
284
457
  totals.qty = res.totalQty ?? 0;
285
458
  totals.cost = res.totalCost ?? 0;
459
+ scope.value = res.scope ?? "all";
460
+ staleDays.value = res.staleAfterDays ?? 3;
286
461
  purchases.byMonth = res.byMonth ?? [];
287
462
  purchases.bySupplier = res.bySupplier ?? [];
288
463
  } catch (error: any) {
@@ -310,7 +485,7 @@ async function setChecked(checked: boolean) {
310
485
  async function exportExcel() {
311
486
  exporting.value = true;
312
487
  try {
313
- const blob = await api.exportReport(props.store._id, report.value, { ...period(), appSlug });
488
+ const blob = await api.exportReport(props.store._id, report.value, { ...period(), ...exportFilters(), appSlug });
314
489
  saveBlob(blob, `${props.store.name} - ${report.value === "technicians" ? worker.many : report.value} - ${month.value}.xlsx`);
315
490
  } catch {
316
491
  notify("Could not export the report.", "error");
@@ -319,6 +494,13 @@ async function exportExcel() {
319
494
  }
320
495
  }
321
496
 
322
- watch([report, month, span, site, locationType, uncheckedOnly, () => props.store._id], load);
497
+ watch([report, month, span, site, locationType, uncheckedOnly, usedBy, status, flag, () => props.store._id], load);
498
+ // Typed filters wait until the typing stops.
499
+ let typing: ReturnType<typeof setTimeout> | undefined;
500
+ watch([locationText, referenceText, search], () => {
501
+ clearTimeout(typing);
502
+ typing = setTimeout(load, 400);
503
+ });
504
+ watch(report, () => (serviceFilter.value = ""));
323
505
  onMounted(load);
324
506
  </script>
@@ -20,7 +20,7 @@
20
20
 
21
21
  <AppCard v-for="r in requests" :key="r._id" class="pa-4 mb-3">
22
22
  <div class="d-flex flex-wrap align-center ga-2 mb-2">
23
- <div class="font-weight-medium">{{ r.requestedByName || "Someone" }}</div>
23
+ <div class="font-weight-medium">{{ r.requestedByLabel || r.requestedByName || "Someone" }}</div>
24
24
  <div class="text-caption text-medium-emphasis">{{ standardFormatDateTime(r.createdAt) }}</div>
25
25
  <v-chip size="small" :color="chip[r.status].color" variant="tonal">{{ chip[r.status].label }}</v-chip>
26
26
  <!-- One store can supply several properties: say which one this request is for. -->
@@ -47,7 +47,7 @@
47
47
  <v-dialog v-model="decision.show" max-width="620" persistent scrollable>
48
48
  <v-card v-if="decision.request" class="screen-modal">
49
49
  <v-card-title class="d-flex align-center">
50
- Request from {{ decision.request.requestedByName }}
50
+ Request from {{ decision.request.requestedByLabel || decision.request.requestedByName }}
51
51
  <v-spacer />
52
52
  <v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="decision.show = false" />
53
53
  </v-card-title>
@@ -35,7 +35,25 @@
35
35
  <v-list-item v-if="legacyToCopy" prepend-icon="mdi-content-copy" title="Copy from old Equipment list" @click="showLegacy = true" />
36
36
  </v-list>
37
37
  </v-menu>
38
+ <v-btn
39
+ v-if="usedByOwner && selected.length"
40
+ class="text-none screen-btn-ghost"
41
+ variant="outlined"
42
+ prepend-icon="mdi-account-group-outline"
43
+ @click="openBulk"
44
+ >
45
+ Set "Used by" for {{ selected.length }} item{{ selected.length === 1 ? "" : "s" }}
46
+ </v-btn>
38
47
  <v-spacer />
48
+ <v-select
49
+ v-if="usedByOwner"
50
+ v-model="usedByFilter"
51
+ :items="USED_BY_FILTERS"
52
+ label="Used by"
53
+ density="compact"
54
+ hide-details
55
+ style="min-width: 200px; max-width: 240px"
56
+ />
39
57
  <v-checkbox v-model="lowOnly" label="Low stock only" density="compact" hide-details class="flex-grow-0" />
40
58
  <v-text-field
41
59
  v-model="search"
@@ -56,6 +74,8 @@
56
74
  v-model:page="page"
57
75
  :pages="pages"
58
76
  :page-range="pageRange"
77
+ :show-select="usedByOwner"
78
+ v-model:selected="selected"
59
79
  @refresh="load"
60
80
  @row-click="(row: any) => openItem(row.item)"
61
81
  >
@@ -68,6 +88,9 @@
68
88
  <div>
69
89
  <div class="font-weight-medium">{{ item.name }}</div>
70
90
  <div class="text-caption text-medium-emphasis">{{ inventoryItemMeta(item) }}</div>
91
+ <div v-if="isMain && usedByChips(item.usedBy).length" class="d-flex flex-wrap ga-1 mt-1">
92
+ <v-chip v-for="c in usedByChips(item.usedBy)" :key="c" size="x-small" variant="tonal" color="primary">{{ c }}</v-chip>
93
+ </div>
71
94
  </div>
72
95
  </div>
73
96
  </template>
@@ -78,6 +101,7 @@
78
101
  <v-chip v-if="item.status === 'inactive'" size="x-small" variant="tonal" class="ml-2">Not in use</v-chip>
79
102
  </template>
80
103
  <template #item.reorderLevel="{ value }">{{ value || "—" }}</template>
104
+ <template #item.usedBy="{ item }">{{ usedByText(item.usedBy) }}</template>
81
105
  <template #item.unitCost="{ value }">{{ value !== undefined ? money(value) : "—" }}</template>
82
106
  <template #no-data>
83
107
  <div class="py-8 text-center">
@@ -94,6 +118,29 @@
94
118
  <InventoryImportDialog v-model="showImport" :store="store" @done="done" />
95
119
  <InventoryImportDialog v-model="showReceiveImport" :store="store" kind="receipts" @done="done" />
96
120
  <InventoryLegacyImportDialog v-model="showLegacy" :store="store" @done="done" />
121
+ <v-dialog v-model="bulk.show" max-width="480">
122
+ <v-card class="screen-modal">
123
+ <v-card-title>Set "Used by"</v-card-title>
124
+ <v-card-text class="screen-modal__body">
125
+ <p class="text-body-2 mb-3">For the {{ selected.length }} selected item{{ selected.length === 1 ? "" : "s" }}.</p>
126
+ <v-select
127
+ :model-value="bulk.usedBy"
128
+ :items="usedByItems"
129
+ multiple
130
+ chips
131
+ closable-chips
132
+ density="comfortable"
133
+ :hint="USED_BY_HINT"
134
+ persistent-hint
135
+ @update:model-value="(v: string[]) => (bulk.usedBy = nextUsedBy(bulk.usedBy, v))"
136
+ />
137
+ </v-card-text>
138
+ <div class="screen-modal__footer">
139
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="bulk.saving" @click="bulk.show = false">Cancel</v-btn>
140
+ <v-btn class="screen-btn-primary" variant="flat" :loading="bulk.saving" @click="saveBulk">Save</v-btn>
141
+ </div>
142
+ </v-card>
143
+ </v-dialog>
97
144
  <InventoryItemDetail v-model="detail.show" :store="store" :item="detail.item" @edit="editItem" @done="done" />
98
145
  </div>
99
146
  </template>
@@ -105,6 +152,7 @@ import useUtils from "../composables/useUtils";
105
152
  import { inventoryItemMeta } from "../utils/inventory-store-label";
106
153
  import { inventoryControls } from "../utils/inventory-presets";
107
154
  import { hasLegacyEquipmentToCopy } from "../utils/inventory-legacy";
155
+ import { nextUsedBy, USED_BY_ALL, USED_BY_ALL_LABEL, USED_BY_FILTERS, USED_BY_HINT, USED_BY_OPTIONS, usedByChips, usedByText } from "../utils/inventory-used-by";
108
156
 
109
157
  const props = defineProps({
110
158
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -114,7 +162,34 @@ const props = defineProps({
114
162
  const ctl = computed(() => inventoryControls(props.store.access));
115
163
  const emit = defineEmits(["changed"]);
116
164
 
117
- const { getItems, getLegacyEquipment } = useInventory();
165
+ const { getItems, getLegacyEquipment, setUsedBy } = useInventory();
166
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
167
+
168
+ // Used by belongs to the main (property) store; its owner sees the column, the filter and bulk edit.
169
+ const isMain = computed(() => props.store.ownerType === "property");
170
+ const usedByOwner = computed(() => isMain.value && props.store.access.canManage);
171
+ const usedByFilter = ref("");
172
+ const selected = ref<string[]>([]);
173
+ const usedByItems = [{ value: USED_BY_ALL, title: USED_BY_ALL_LABEL }, ...USED_BY_OPTIONS];
174
+ const bulk = reactive({ show: false, usedBy: [USED_BY_ALL] as string[], saving: false });
175
+ function openBulk() {
176
+ Object.assign(bulk, { show: true, usedBy: [USED_BY_ALL], saving: false });
177
+ }
178
+ async function saveBulk() {
179
+ bulk.saving = true;
180
+ try {
181
+ const ids = selected.value.map((s: any) => (typeof s === "string" ? s : s._id));
182
+ const res = await setUsedBy(props.store._id, ids, bulk.usedBy);
183
+ notify(`"Used by" set to ${usedByText(bulk.usedBy)} for ${res?.updated ?? ids.length} item(s).`);
184
+ bulk.show = false;
185
+ selected.value = [];
186
+ load();
187
+ } catch (error: any) {
188
+ notify(error?.data?.message || "Could not save.", "error");
189
+ } finally {
190
+ bulk.saving = false;
191
+ }
192
+ }
118
193
  const { getFileUrl } = useFile();
119
194
  const { debounce } = useUtils();
120
195
 
@@ -135,6 +210,7 @@ const headers = computed(() => [
135
210
  { title: "Item", value: "name" },
136
211
  { title: "In store", value: "onHand" },
137
212
  { title: "Low stock alert at", value: "reorderLevel" },
213
+ ...(usedByOwner.value ? [{ title: "Used by", value: "usedBy" }] : []),
138
214
  ...(ctl.value.unitPrice ? [{ title: "Unit price", value: "unitCost" }] : []),
139
215
  ]);
140
216
 
@@ -149,6 +225,7 @@ async function load() {
149
225
  search: search.value || "",
150
226
  lowStock: lowOnly.value || undefined,
151
227
  includeInactive: props.store.access.canManage || undefined,
228
+ usedBy: (usedByOwner.value && usedByFilter.value) || undefined,
152
229
  });
153
230
  items.value = res.items;
154
231
  pages.value = res.pages;
@@ -199,6 +276,7 @@ function done() {
199
276
 
200
277
  watch(page, load);
201
278
  watch(lowOnly, () => (page.value === 1 ? load() : (page.value = 1)));
279
+ watch(usedByFilter, () => (page.value === 1 ? load() : (page.value = 1)));
202
280
  watch(search, debounce(() => (page.value === 1 ? load() : (page.value = 1)), 400));
203
281
  watch(() => props.store._id, () => (checkLegacy(), page.value === 1 ? load() : (page.value = 1)));
204
282
  onMounted(() => (load(), checkLegacy()));
@@ -12,6 +12,7 @@
12
12
  <AppCard v-for="g in groups" :key="g.holder" class="pa-4 mb-3">
13
13
  <div class="d-flex align-center mb-2">
14
14
  <div class="font-weight-medium">{{ g.holderName || "Unnamed" }}</div>
15
+ <div v-if="g.company || g.serviceLabel" class="text-caption text-medium-emphasis">{{ [g.company, g.serviceLabel].filter(Boolean).join(" · ") }}</div>
15
16
  <v-spacer />
16
17
  <v-btn v-if="ctl.takeReturn" class="text-none screen-btn-ghost" variant="outlined" size="small" prepend-icon="mdi-keyboard-return" @click="openReturn(g)">Receive return</v-btn>
17
18
  </div>
@@ -47,11 +48,11 @@ const search = ref("");
47
48
 
48
49
  const groups = computed(() => {
49
50
  const q = (search.value || "").toLowerCase();
50
- const map = new Map<string, { holder: string; holderName: string; items: TInventoryHolding[] }>();
51
+ const map = new Map<string, { holder: string; holderName: string; company?: string; serviceLabel?: string; items: TInventoryHolding[] }>();
51
52
  for (const h of holdings.value) {
52
53
  if (q && !h.holderName.toLowerCase().includes(q) && !h.itemName.toLowerCase().includes(q)) continue;
53
54
  const key = String(h.holder);
54
- if (!map.has(key)) map.set(key, { holder: key, holderName: h.holderName, items: [] });
55
+ if (!map.has(key)) map.set(key, { holder: key, holderName: h.holderName, company: (h as any).holderCompany, serviceLabel: (h as any).holderServiceLabel, items: [] });
55
56
  map.get(key)!.items.push(h);
56
57
  }
57
58
  return [...map.values()];