@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
@@ -6,7 +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 value="handovers" class="text-none">Hand-overs</v-btn>
9
+ <v-btn v-if="ctl.fullReports || ctl.review" value="handovers" class="text-none">Hand-overs</v-btn>
10
10
  </v-btn-toggle>
11
11
  <v-select
12
12
  v-if="report === 'purchases'"
@@ -60,21 +60,36 @@
60
60
  hide-details
61
61
  style="max-width: 260px"
62
62
  />
63
- <v-checkbox v-if="report === 'usage'" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
63
+ <v-checkbox v-if="report === 'usage' && canCheck" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
64
64
  <v-spacer />
65
- <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">
66
66
  Export Excel
67
67
  </v-btn>
68
68
  </div>
69
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
+
70
84
  <p class="text-caption text-medium-emphasis mb-2">{{ help[report] }}</p>
71
85
  <p v-if="report === 'movement' && siteName" class="text-caption text-medium-emphasis mb-2">
72
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.
73
87
  </p>
74
88
 
75
- <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">
76
90
  <div class="text-body-2 font-weight-medium">
77
- {{ 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>
78
93
  </div>
79
94
  <v-spacer />
80
95
  <template v-if="canCheck">
@@ -88,6 +103,19 @@
88
103
  </template>
89
104
  </div>
90
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
+
91
119
  <template v-if="report === 'purchases' && rows.length">
92
120
  <div class="text-body-2 font-weight-medium mb-2">Total purchases {{ money(totals.cost) }}</div>
93
121
  <v-row dense class="mb-2">
@@ -121,11 +149,12 @@
121
149
  :items="shownRows"
122
150
  :loading="loading"
123
151
  :show-select="report === 'usage' && canCheck"
152
+ :item-selectable="(r: any) => r.status !== 'draft'"
124
153
  item-value="_id"
125
154
  density="compact"
126
155
  :items-per-page="50"
127
156
  class="border rounded"
128
- no-data-text="Nothing recorded in this period."
157
+ :no-data-text="noDataText"
129
158
  >
130
159
  <template #item.date="{ value }">{{ standardFormatDate(value) }}</template>
131
160
  <template #item.createdAt="{ value }">{{ standardFormatDate(value) }}</template>
@@ -142,14 +171,31 @@
142
171
  <template #item.receivedCost="{ value }">{{ money(value) }}</template>
143
172
  <template #item.usedCost="{ value }">{{ money(value) }}</template>
144
173
  <template #item.siteUsedCost="{ value }">{{ money(value) }}</template>
145
- <!-- 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). -->
146
175
  <template #item.photos="{ item }">
147
- <div v-if="usagePhotos(item).length" class="d-flex ga-1 py-1">
148
- <a v-for="ph in usagePhotos(item)" :key="ph.id" :href="getFileUrl(ph.id)" target="_blank" rel="noopener" :title="ph.label">
149
- <v-img :src="getFileUrl(ph.id)" width="36" height="36" cover class="rounded" :alt="ph.label" />
150
- </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>
151
182
  </div>
152
- <span v-else class="text-medium-emphasis">—</span>
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>
195
+ </div>
196
+ </template>
197
+ <template #item.path="{ item }">
198
+ <span class="text-caption">{{ usagePathText(item.path, standardFormatDate) || "—" }}</span>
153
199
  </template>
154
200
  <template #item.checkedByName="{ item }">
155
201
  <v-chip v-if="item.checkedByName" size="x-small" color="success" variant="tonal">{{ item.checkedByName }}</v-chip>
@@ -159,6 +205,7 @@
159
205
  <span :class="item.reorderLevel && item.closing <= item.reorderLevel ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ item.closing }}</span>
160
206
  </template>
161
207
  </v-data-table>
208
+ <InventoryHistoryDialog v-model="drill.show" :store="store" :holder="drill.holder" :item="drill.item" :title="drill.title" />
162
209
  </div>
163
210
  </template>
164
211
 
@@ -166,9 +213,9 @@
166
213
  import useInventory, { saveBlob } from "../composables/useInventory";
167
214
  import useWorkerTerm, { workerAppKey } from "../composables/useWorkerTerm";
168
215
  import useUtils from "../composables/useUtils";
169
- import useFile from "../composables/useFile";
170
216
  import { inventoryControls } from "../utils/inventory-presets";
171
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";
172
219
 
173
220
  const props = defineProps({
174
221
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -177,12 +224,7 @@ const props = defineProps({
177
224
  const ctl = computed(() => inventoryControls(props.store.access));
178
225
 
179
226
  const api = useInventory();
180
- const { standardFormatDate } = useUtils();
181
- const { getFileUrl } = useFile();
182
- const usagePhotos = (row: any) => [
183
- ...(row.photos?.before ?? []).map((id: string) => ({ id, label: "Before" })),
184
- ...(row.photos?.after ?? []).map((id: string) => ({ id, label: "After" })),
185
- ];
227
+ const { standardFormatDate, standardFormatDateTime } = useUtils();
186
228
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
187
229
 
188
230
  const worker = useWorkerTerm({ providers: true });
@@ -197,6 +239,26 @@ const site = ref("");
197
239
  const locationType = ref("");
198
240
  const uncheckedOnly = ref(false);
199
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
+ }
200
262
  const rows = ref<any[]>([]);
201
263
  const selected = ref<string[]>([]);
202
264
  const purchases = reactive({ byMonth: [] as { month: string; deliveries: number; totalCost: number }[], bySupplier: [] as { supplier: string; deliveries: number; totalCost: number }[] });
@@ -214,7 +276,7 @@ const barWidth = (v: number) => {
214
276
 
215
277
  const help: Record<string, string> = {
216
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.",
217
- 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.",
218
280
  technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
219
281
  purchases: "Every delivery received, and opening stock entered with new items (Type: Opening stock), with totals by month and by supplier.",
220
282
  handovers: "Stock passed from one person to a colleague: who gave it, who received it, and whether they confirmed.",
@@ -228,7 +290,43 @@ const serviceFilter = ref("");
228
290
  const serviceChoices = computed(() =>
229
291
  [...new Set(rows.value.map(companyOf).filter(Boolean))].sort().map((v) => ({ value: v, title: v })),
230
292
  );
231
- const shownRows = computed(() => (serviceFilter.value ? rows.value.filter((r) => companyOf(r) === serviceFilter.value || [r.toCompany, r.toServiceLabel].filter(Boolean).join(" · ") === serviceFilter.value) : rows.value));
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
+ );
232
330
 
233
331
  const headers: Record<string, any[]> = {
234
332
  movement: [
@@ -247,6 +345,7 @@ const headers: Record<string, any[]> = {
247
345
  ],
248
346
  usage: [
249
347
  { title: "Date", key: "date" },
348
+ { title: "Status", key: "state", sortable: false },
250
349
  { title: "Property", key: "siteName" },
251
350
  { title: "Where", key: "locationType" },
252
351
  { title: "Unit / Area", key: "location" },
@@ -255,12 +354,13 @@ const headers: Record<string, any[]> = {
255
354
  { title: "Qty", key: "qty" },
256
355
  { title: "Unit price", key: "unitCost" },
257
356
  { title: "Total", key: "totalCost" },
258
- { title: worker.one, key: "technician" },
259
- { title: "Company", key: "holderCompany" },
260
- { title: "Service", key: "holderServiceLabel" },
261
- { title: "Photos", key: "photos", sortable: false },
262
- { 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" },
263
362
  { title: "Reported by", key: "reportedBy" },
363
+ { title: "Remarks", key: "remarks" },
264
364
  { title: "Checked by", key: "checkedByName" },
265
365
  ],
266
366
  technicians: [
@@ -326,9 +426,27 @@ function period(): TInventoryPeriod & { unchecked?: boolean } {
326
426
  ...(report.value === "usage" && locationType.value ? { locationType: locationType.value } : {}),
327
427
  ...(report.value === "usage" && uncheckedOnly.value ? { unchecked: true } : {}),
328
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
+ : {}),
329
432
  };
330
433
  }
331
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
+
332
450
  async function load() {
333
451
  if (!month.value) return;
334
452
  loading.value = true;
@@ -338,6 +456,8 @@ async function load() {
338
456
  rows.value = res.rows;
339
457
  totals.qty = res.totalQty ?? 0;
340
458
  totals.cost = res.totalCost ?? 0;
459
+ scope.value = res.scope ?? "all";
460
+ staleDays.value = res.staleAfterDays ?? 3;
341
461
  purchases.byMonth = res.byMonth ?? [];
342
462
  purchases.bySupplier = res.bySupplier ?? [];
343
463
  } catch (error: any) {
@@ -365,7 +485,7 @@ async function setChecked(checked: boolean) {
365
485
  async function exportExcel() {
366
486
  exporting.value = true;
367
487
  try {
368
- 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 });
369
489
  saveBlob(blob, `${props.store.name} - ${report.value === "technicians" ? worker.many : report.value} - ${month.value}.xlsx`);
370
490
  } catch {
371
491
  notify("Could not export the report.", "error");
@@ -374,7 +494,13 @@ async function exportExcel() {
374
494
  }
375
495
  }
376
496
 
377
- watch([report, month, span, site, locationType, uncheckedOnly, usedBy, () => 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
+ });
378
504
  watch(report, () => (serviceFilter.value = ""));
379
505
  onMounted(load);
380
506
  </script>
@@ -2,24 +2,30 @@
2
2
  <v-dialog v-model="show" max-width="620" persistent scrollable>
3
3
  <v-card v-if="holding" class="screen-modal">
4
4
  <v-card-title class="d-flex align-center">
5
- Record Usage
5
+ {{ draft ? "Finish usage (draft)" : "Record Usage" }}
6
6
  <v-spacer />
7
7
  <v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="show = false" />
8
8
  </v-card-title>
9
9
  <v-card-text class="screen-modal__body">
10
10
  <div class="font-weight-medium">{{ holding.itemName }}</div>
11
- <div class="text-body-2 text-medium-emphasis mb-4">You have {{ holding.qty }} {{ holding.unit }} in hand.</div>
11
+ <div class="text-body-2 text-medium-emphasis mb-4">
12
+ You have {{ holding.qty }} {{ holding.unit }} in hand<template v-if="otherDrafts"> · {{ otherDrafts }} already in your other drafts</template>.
13
+ <template v-if="!draft"> Took the before photo but the job is not done yet? Save a draft now and finish it later.</template>
14
+ </div>
15
+ <v-alert v-if="draft" type="info" variant="tonal" density="compact" class="mb-4">
16
+ Saved as a draft {{ draft.draftedAt ? standardFormatDateTime(draft.draftedAt) : "" }}. Nothing has been taken off your stock yet — add the after photo and submit when the job is done.
17
+ </v-alert>
12
18
  <v-form ref="form">
13
19
  <InputLabel title="How many did you use?" required class="mb-1" />
14
20
  <v-text-field
15
21
  v-model.number="qty"
16
22
  type="number"
17
23
  min="1"
18
- :max="holding.qty"
24
+ :max="maxQty"
19
25
  :suffix="holding.unit"
20
26
  density="comfortable"
21
27
  style="max-width: 200px"
22
- :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= holding!.qty) || `1 to ${holding!.qty}`]"
28
+ :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= maxQty) || `1 to ${maxQty}`]"
23
29
  />
24
30
 
25
31
  <InputLabel title="Where?" required class="mb-1" />
@@ -74,15 +80,17 @@
74
80
  <v-col cols="12" sm="6">
75
81
  <InputLabel title="Photo before" required class="mb-1" />
76
82
  <InventoryPhotoInput v-model="photosBefore" :max="3" />
83
+ <div v-if="draft?.photosBeforeAt" class="text-caption text-medium-emphasis mt-1">Taken {{ standardFormatDateTime(draft.photosBeforeAt) }}</div>
77
84
  </v-col>
78
85
  <v-col cols="12" sm="6">
79
86
  <InputLabel title="Photo after" required class="mb-1" />
80
87
  <InventoryPhotoInput v-model="photosAfter" :max="3" />
88
+ <div v-if="draft?.photosAfterAt" class="text-caption text-medium-emphasis mt-1">Taken {{ standardFormatDateTime(draft.photosAfterAt) }}</div>
81
89
  </v-col>
82
90
  </v-row>
83
91
  <div v-if="photoError" class="text-error text-body-2 mt-1" role="alert">{{ photoError }}</div>
84
92
 
85
- <InputLabel title="Date used" class="mb-1 mt-3" />
93
+ <InputLabel :title="draft ? 'Date of the job (used when you submit)' : 'Date used'" class="mb-1 mt-3" />
86
94
  <v-text-field v-model="date" type="date" :max="today" density="comfortable" style="max-width: 200px" />
87
95
  <InputLabel title="Fault reported by (optional)" class="mb-1" />
88
96
  <v-text-field v-model="reportedBy" density="comfortable" placeholder="e.g. tenant, security guard" maxlength="120" />
@@ -91,8 +99,21 @@
91
99
  </v-form>
92
100
  </v-card-text>
93
101
  <div class="screen-modal__footer">
94
- <v-btn class="screen-btn-ghost" variant="outlined" :disabled="saving" @click="show = false">Cancel</v-btn>
95
- <v-btn class="screen-btn-primary" variant="flat" :loading="saving" @click="save">Save usage</v-btn>
102
+ <v-btn v-if="draft" class="text-none" variant="text" color="error" :disabled="!!saving" @click="confirmDiscard = true">Discard draft</v-btn>
103
+ <v-spacer v-if="draft" />
104
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="!!saving" @click="show = false">Cancel</v-btn>
105
+ <v-btn class="screen-btn-ghost text-none" variant="outlined" prepend-icon="mdi-content-save-outline" :loading="saving === 'draft'" :disabled="!!saving" @click="saveDraft">Save draft</v-btn>
106
+ <v-btn class="screen-btn-primary" variant="flat" :loading="saving === 'submit'" :disabled="!!saving" @click="save">{{ draft ? "Submit usage" : "Save usage" }}</v-btn>
107
+ </div>
108
+ </v-card>
109
+ </v-dialog>
110
+ <v-dialog v-model="confirmDiscard" max-width="420">
111
+ <v-card class="screen-modal">
112
+ <v-card-title>Discard this draft?</v-card-title>
113
+ <v-card-text>The draft and its photos are removed. Nothing was taken off your stock, so your stock stays as it is.</v-card-text>
114
+ <div class="screen-modal__footer">
115
+ <v-btn class="screen-btn-ghost" variant="outlined" @click="confirmDiscard = false">Keep it</v-btn>
116
+ <v-btn color="error" variant="flat" :loading="saving === 'discard'" @click="discard">Discard</v-btn>
96
117
  </div>
97
118
  </v-card>
98
119
  </v-dialog>
@@ -107,16 +128,24 @@ const props = defineProps({
107
128
  store: { type: Object as PropType<TInventoryStore>, required: true },
108
129
  siteId: { type: String, required: true },
109
130
  holding: { type: Object as PropType<TInventoryHolding | null>, default: null },
131
+ /** An existing draft to finish (edit, add the after photo, submit or discard). */
132
+ draft: { type: Object as PropType<TInventoryUsageDraft | null>, default: null },
110
133
  sites: { type: Array as PropType<{ _id: string; name: string }[]>, default: () => [] },
111
134
  });
112
135
  const emit = defineEmits(["done"]);
113
136
 
114
137
  const api = useInventory();
115
- const { requiredRule, debounce } = useUtils();
138
+ const { requiredRule, debounce, standardFormatDateTime } = useUtils();
116
139
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
117
140
 
118
141
  const form = ref();
119
- const saving = ref(false);
142
+ const saving = ref<"" | "draft" | "submit" | "discard">("");
143
+ const confirmDiscard = ref(false);
144
+ // Stock already promised to the person's OTHER drafts; this draft's own qty is free for it.
145
+ const otherDrafts = computed(() => Math.max(0, (props.holding?.inDrafts ?? 0) - (props.draft?.qty ?? 0)));
146
+ const maxQty = computed(() => Math.max(1, (props.holding?.qty ?? 0) - otherDrafts.value));
147
+ const USAGE_DRAFT_NEEDS_BEFORE = "Take the before photo to save a draft.";
148
+ let prefilling = false;
120
149
  const today = new Date().toLocaleDateString("en-CA");
121
150
  const qty = ref<number | null>(1);
122
151
  const where = ref<"tenant_unit" | "common_area">("tenant_unit");
@@ -159,38 +188,81 @@ async function loadAreas() {
159
188
  }
160
189
  }
161
190
 
191
+ function body() {
192
+ return {
193
+ item: props.holding!.item,
194
+ qty: Number(qty.value),
195
+ locationType: where.value,
196
+ ...(where.value === "tenant_unit" ? { unit: unit.value } : { area: area.value }),
197
+ reference: reference.value,
198
+ photosBefore: photosBefore.value,
199
+ photosAfter: photosAfter.value,
200
+ remarks: remarks.value,
201
+ reportedBy: reportedBy.value,
202
+ };
203
+ }
204
+
205
+ function finished(text: string) {
206
+ notify(text);
207
+ show.value = false;
208
+ emit("done");
209
+ }
210
+
211
+ /** Save draft: before photo required, after photo optional; nothing is deducted. */
212
+ async function saveDraft() {
213
+ const { valid } = await form.value.validate();
214
+ photoError.value = photosBefore.value.length ? "" : USAGE_DRAFT_NEEDS_BEFORE;
215
+ if (!valid || photoError.value || !props.holding) return;
216
+ saving.value = "draft";
217
+ try {
218
+ if (props.draft) await api.updateUsageDraft(props.draft._id, body());
219
+ else await api.createUsageDraft(props.store._id, { site: site.value, ...body() });
220
+ finished("Draft saved. Add the after photo and submit it when the job is done — it is under My drafts.");
221
+ } catch (error: any) {
222
+ notify(error?.data?.message || "Could not save the draft.", "error");
223
+ } finally {
224
+ saving.value = "";
225
+ }
226
+ }
227
+
162
228
  async function save() {
163
229
  const { valid } = await form.value.validate();
164
230
  // Owner decision 2026-09-27: a before AND an after photo on every usage record (the API refuses without).
165
231
  photoError.value = photosBefore.value.length && photosAfter.value.length ? "" : USAGE_PHOTOS_REQUIRED;
166
232
  if (!valid || photoError.value || !props.holding) return;
167
- saving.value = true;
233
+ saving.value = "submit";
168
234
  try {
169
- await api.recordUsage(props.store._id, {
170
- site: site.value,
171
- item: props.holding.item,
172
- qty: Number(qty.value),
173
- locationType: where.value,
174
- ...(where.value === "tenant_unit" ? { unit: unit.value } : { area: area.value }),
175
- reference: reference.value,
176
- date: pickedDate(date.value),
177
- photosBefore: photosBefore.value,
178
- photosAfter: photosAfter.value,
179
- remarks: remarks.value,
180
- reportedBy: reportedBy.value,
181
- });
182
- notify("Usage saved.");
183
- show.value = false;
184
- emit("done");
235
+ if (props.draft) {
236
+ // Keep any edits, then submit: the stock comes off now.
237
+ await api.updateUsageDraft(props.draft._id, body());
238
+ await api.submitUsageDraft(props.draft._id, { photosAfter: photosAfter.value, date: pickedDate(date.value) });
239
+ } else {
240
+ await api.recordUsage(props.store._id, { site: site.value, ...body(), date: pickedDate(date.value) });
241
+ }
242
+ finished("Usage saved.");
185
243
  } catch (error: any) {
186
244
  notify(error?.data?.message || "Could not save the usage.", "error");
187
245
  } finally {
188
- saving.value = false;
246
+ saving.value = "";
247
+ }
248
+ }
249
+
250
+ async function discard() {
251
+ if (!props.draft) return;
252
+ saving.value = "discard";
253
+ try {
254
+ await api.discardUsageDraft(props.draft._id);
255
+ confirmDiscard.value = false;
256
+ finished("Draft discarded.");
257
+ } catch (error: any) {
258
+ notify(error?.data?.message || "Could not discard the draft.", "error");
259
+ } finally {
260
+ saving.value = "";
189
261
  }
190
262
  }
191
263
 
192
264
  watch(site, () => {
193
- unit.value = "";
265
+ if (!prefilling) unit.value = "";
194
266
  searchUnits("");
195
267
  loadAreas();
196
268
  });
@@ -212,6 +284,25 @@ watch(show, (open) => {
212
284
  reportedBy.value = "";
213
285
  date.value = today;
214
286
  site.value = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
287
+ const d = props.draft;
288
+ if (d) {
289
+ // Finish a draft: everything as saved; the site cannot change.
290
+ prefilling = true;
291
+ site.value = String(d.site ?? site.value);
292
+ qty.value = d.qty;
293
+ where.value = d.location?.type === "common_area" ? "common_area" : "tenant_unit";
294
+ area.value = d.location?.area ?? "";
295
+ unit.value = String(d.location?.unit ?? "");
296
+ if (unit.value) units.value = [{ _id: unit.value, name: d.location?.unitName ?? "", tenantName: d.location?.tenantName ?? "" }];
297
+ reference.value = d.reference ?? "";
298
+ remarks.value = d.remarks ?? "";
299
+ reportedBy.value = d.reportedBy ?? "";
300
+ photosBefore.value = [...(d.photos?.before ?? [])];
301
+ photosAfter.value = [...(d.photos?.after ?? [])];
302
+ nextTick(() => (prefilling = false));
303
+ loadAreas();
304
+ return;
305
+ }
215
306
  searchUnits("");
216
307
  loadAreas();
217
308
  });
@@ -388,9 +388,27 @@ watch(
388
388
  const roles = ref<any[]>([])
389
389
  const { getRoles } = useRole()
390
390
 
391
+ /*
392
+ * `type` is the ROLE type, not the service the client subscribes to, and the
393
+ * only caller (`ClientMain.vue` "Invite Client") passes no `app` prop -- so
394
+ * this sent `type: ""` and the endpoint applied no type filter at all. The
395
+ * dropdown therefore listed every org-less role of every type, which includes
396
+ * the platform's OWN staff roles (`type: "admin"` -- the two rows the console's
397
+ * `/super-admin/role-permissions` screen edits). Offering a Seven365 console
398
+ * staff role on a CLIENT invitation is the defect.
399
+ *
400
+ * A client admin's role is `type: "organization"`, which is what the client
401
+ * roles screen (`org/[organization]/roles-permissions.vue`) already lists. This
402
+ * narrows to exactly that. It is a narrowing of this one picker only: no role
403
+ * changes, no permission changes, and the org-less `organization` roles that
404
+ * were already being picked are all still offered.
405
+ *
406
+ * Keying off the selected service instead would empty the dropdown -- no
407
+ * org-less role carries a service type -- so `props.app` is not the filter.
408
+ */
391
409
  const { data: roleData, refresh: refreshRoles } = await useLazyAsyncData(
392
410
  "roles",
393
- () => getRoles({ org: org.value?._id, type: props.app, limit: 50 }),
411
+ () => getRoles({ org: org.value?._id, type: "organization", limit: 50 }),
394
412
  { watch: [() => props.app] }
395
413
  )
396
414
 
@@ -910,8 +910,20 @@ const hidQrReaderId = computed(() => (
910
910
  hidQrConfiguredReaderId.value || String(hidQrReader.value?._id || "")
911
911
  ));
912
912
 
913
+ /*
914
+ * Visitor QR passes are hidden at the manager's request (2026-09-23).
915
+ *
916
+ * A flag rather than a deletion: issuing, the preview dialog, printing and the
917
+ * regenerate handler below are all intact and untouched, so bringing the
918
+ * feature back is this one line. Every surface that offers a Visitor QR reads
919
+ * `hidQrCodePassAllowed`, which is why the switch belongs here and not in each
920
+ * of them.
921
+ */
922
+ const HID_VISITOR_QR_ENABLED = false;
923
+
913
924
  const hidQrCodePassAllowed = computed(() => {
914
925
  return (
926
+ HID_VISITOR_QR_ENABLED &&
915
927
  hidQrCodePassEnabled.value &&
916
928
  ["guest", "contractor", "walk-in"].includes(prop.type)
917
929
  );