@7365admin1/layer-common 4.64.4 → 4.65.1

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 (35) hide show
  1. package/CHANGELOG.md +50 -4
  2. package/assets/css/primitives.css +13 -0
  3. package/components/AccessCardAddForm.vue +5 -4
  4. package/components/AccessCardAssignToUnitForm.vue +3 -2
  5. package/components/AccessCardPreviewDialog.vue +4 -3
  6. package/components/AreaMain.vue +33 -30
  7. package/components/BuildingManagement/units.vue +30 -21
  8. package/components/CameraBulkImportDialog.vue +41 -12
  9. package/components/DashboardMain.vue +14 -7
  10. package/components/EntryPass/QrTemplatePreview.vue +21 -25
  11. package/components/EntryPassInformation.vue +119 -24
  12. package/components/ImportExcelDialog.vue +136 -0
  13. package/components/InventoryImportDialog.vue +33 -104
  14. package/components/InventoryItemDetail.vue +3 -2
  15. package/components/InventoryItemDialog.vue +62 -7
  16. package/components/InventoryReportsTab.vue +2 -1
  17. package/components/InventorySettingsTab.vue +2 -5
  18. package/components/InventoryStockTab.vue +13 -4
  19. package/components/NotificationBell.vue +1 -1
  20. package/components/NotificationSettings.vue +1 -1
  21. package/components/Robot/MiniMap.vue +1 -1
  22. package/components/UnitMain.vue +30 -28
  23. package/components/VehicleBulkAddForm.vue +30 -16
  24. package/composables/useAccessManagement.ts +5 -0
  25. package/composables/useImportTemplates.ts +24 -0
  26. package/composables/useInventory.ts +12 -5
  27. package/composables/useSiteEntryPassSettings.ts +26 -0
  28. package/composables/useWebUsb.ts +14 -11
  29. package/package.json +1 -1
  30. package/types/inventory.d.ts +2 -0
  31. package/utils/camera-csv.ts +2 -0
  32. package/utils/dashboard.ts +11 -0
  33. package/utils/import-result.ts +62 -0
  34. package/utils/inventory-store-label.ts +27 -0
  35. package/utils/vehicle-bulk-sheet.ts +59 -3
@@ -99,10 +99,18 @@
99
99
  <template v-if="passType === 'NFC'">
100
100
  <div class="mt-3">
101
101
  <InputLabel title="Select Cards" />
102
- <v-select
102
+ <!-- Was a `v-select` over the first 100 cards, which only lets the
103
+ operator scroll. Typing now goes to the endpoint as `search`
104
+ (debounced, page 1), and reaching the end of the list fetches
105
+ the next page. Vuetify's own client-side match on `cardNo` is
106
+ left on as well: API-core does not read `search` yet, so until
107
+ it does that is what makes typing narrow the list. -->
108
+ <v-autocomplete
103
109
  v-model="selectedCards"
110
+ v-model:search="cardSearch"
104
111
  :items="filteredCardItems"
105
112
  :loading="cardsLoading"
113
+ :hide-no-data="false"
106
114
  item-title="cardNo"
107
115
  item-value="_id"
108
116
  density="comfortable"
@@ -110,10 +118,30 @@
110
118
  chips
111
119
  closable-chips
112
120
  hide-details
113
- placeholder="Select cards..."
121
+ placeholder="Search or select cards..."
114
122
  return-object
123
+ @update:model-value="cardSearch = ''"
115
124
  >
116
- </v-select>
125
+ <template #append-item>
126
+ <!-- Sentinel for infinite scroll. Keyed on the page so it
127
+ re-mounts after each append: an IntersectionObserver only
128
+ reports changes, so a sentinel that stays in view after a
129
+ short page would otherwise never fire again. -->
130
+ <div
131
+ v-if="cardHasMore"
132
+ :key="cardPage"
133
+ v-intersect="onCardListEndReached"
134
+ class="d-flex justify-center py-2"
135
+ >
136
+ <v-progress-circular
137
+ v-if="cardsLoadingMore"
138
+ indeterminate
139
+ size="20"
140
+ width="2"
141
+ />
142
+ </div>
143
+ </template>
144
+ </v-autocomplete>
117
145
  </div>
118
146
 
119
147
  <div class="mt-3">
@@ -322,6 +350,7 @@ const props = defineProps({
322
350
  const emit = defineEmits(["update:modelValue", "update:quantity", "update:cards", "update:availableQrCount"]);
323
351
 
324
352
  const { getAvailableContractorCards, generateQrVms } = useAccessManagement();
353
+ const { debounce } = useUtils();
325
354
 
326
355
  const nfcEnabled = computed(() => props.settings?.data?.settings?.nfcPass ?? false);
327
356
  const hidQrCodeEnabled = computed(() => {
@@ -367,21 +396,53 @@ const filteredCardItems = computed(() =>
367
396
  : cardItems.value
368
397
  );
369
398
  const cardsLoading = ref(false);
399
+ const cardsLoadingMore = ref(false);
400
+ // Typed text in the card picker. Reset after each pick so the next search
401
+ // starts from the full list instead of staying narrowed to the last term.
402
+ const cardSearch = ref("");
403
+ const CARD_PAGE_SIZE = 100;
404
+ const cardPage = ref(1);
405
+ const cardHasMore = ref(false);
406
+ // Bumped on every request. A response whose number is no longer current is
407
+ // dropped, so a slow page-1 search cannot overwrite a newer one and a
408
+ // load-more cannot append onto a list that has since been replaced.
409
+ let cardRequestSeq = 0;
370
410
  const generating = ref(false);
371
411
  const availableCount = ref<number | null>(null);
372
412
 
373
- async function fetchCards(type: "NFC" | "QRCODE" = "NFC") {
413
+ // The picker is NFC-only (see the template's `v-if`), so only NFC fetches
414
+ // carry the search term and only NFC reacts to typing. The QR path keeps
415
+ // its page-1, no-search fetch and its top-up generation untouched.
416
+ const cardFetchType = computed<"NFC" | "QRCODE" | null>(() =>
417
+ passType.value === "NFC" ? "NFC" : passType.value === "QR" ? "QRCODE" : null,
418
+ );
419
+
420
+ async function fetchCards(
421
+ type: "NFC" | "QRCODE" = "NFC",
422
+ opts: { page?: number; append?: boolean } = {},
423
+ ) {
374
424
  if (!props.siteId || !props.unitId) return;
375
- cardsLoading.value = true;
425
+ const page = opts.page ?? 1;
426
+ const append = opts.append === true;
427
+ const seq = ++cardRequestSeq;
428
+ const query = {
429
+ type,
430
+ siteId: props.siteId,
431
+ unitId: props.unitId,
432
+ page,
433
+ limit: CARD_PAGE_SIZE,
434
+ // Vuetify writes null into the search model when it clears the field.
435
+ search: type === "NFC" ? cardSearch.value ?? "" : undefined,
436
+ };
437
+
438
+ if (append) cardsLoadingMore.value = true;
439
+ else cardsLoading.value = true;
376
440
  if (type === "QRCODE") emit("update:availableQrCount", null);
377
441
  try {
378
- const res = await getAvailableContractorCards({
379
- type,
380
- siteId: props.siteId,
381
- unitId: props.unitId,
382
- });
383
- cardItems.value = res?.data?.[0]?.items ?? [];
384
- availableCount.value = (res?.data?.[2] as any)?.count ?? cardItems.value.length;
442
+ const res = await getAvailableContractorCards(query);
443
+ if (seq !== cardRequestSeq) return;
444
+ let items: any[] = res?.data?.[0]?.items ?? [];
445
+ availableCount.value = (res?.data?.[2] as any)?.count ?? items.length;
385
446
 
386
447
  if (type === "QRCODE" && props.settings?.data?._id && availableCount.value !== null && availableCount.value > 0 && availableCount.value < 50) {
387
448
  cardsLoading.value = false;
@@ -392,30 +453,64 @@ async function fetchCards(type: "NFC" | "QRCODE" = "NFC") {
392
453
  quantity: 50,
393
454
  });
394
455
  // Re-fetch to get the updated count after generation
395
- const refreshed = await getAvailableContractorCards({
396
- type,
397
- siteId: props.siteId,
398
- unitId: props.unitId,
399
- });
400
- cardItems.value = refreshed?.data?.[0]?.items ?? [];
401
- availableCount.value = (refreshed?.data?.[2] as any)?.count ?? cardItems.value.length;
456
+ const refreshed = await getAvailableContractorCards(query);
457
+ if (seq !== cardRequestSeq) return;
458
+ items = refreshed?.data?.[0]?.items ?? [];
459
+ availableCount.value = (refreshed?.data?.[2] as any)?.count ?? items.length;
402
460
  generating.value = false;
403
461
  }
404
462
 
463
+ cardItems.value = append ? [...cardItems.value, ...items] : items;
464
+ cardPage.value = page;
465
+ // A short page is the last one. An exact multiple of the page size costs
466
+ // one extra, empty fetch, which then turns this off.
467
+ cardHasMore.value = items.length === CARD_PAGE_SIZE;
468
+
405
469
  if (type === "QRCODE") emit("update:availableQrCount", availableCount.value);
406
470
  } catch (err: any) {
471
+ if (seq !== cardRequestSeq) return;
407
472
  const msg = err?.data?.message ?? err?.message ?? String(err);
408
473
  showSnackbar(`Entry pass service error: ${msg}`);
409
- availableCount.value = null;
410
- cardItems.value = [];
411
474
  generating.value = false;
412
- if (type === "QRCODE") emit("update:availableQrCount", null);
475
+ // A failed load-more keeps what is already listed; a failed first page
476
+ // clears the list as before.
477
+ if (!append) {
478
+ availableCount.value = null;
479
+ cardItems.value = [];
480
+ cardHasMore.value = false;
481
+ if (type === "QRCODE") emit("update:availableQrCount", null);
482
+ }
413
483
  } finally {
414
- cardsLoading.value = false;
415
- generating.value = false;
484
+ // Only the request that is still current may clear the flags; a stale
485
+ // one finishing late must not switch off a newer request's spinner.
486
+ if (seq === cardRequestSeq) {
487
+ cardsLoading.value = false;
488
+ cardsLoadingMore.value = false;
489
+ generating.value = false;
490
+ }
416
491
  }
417
492
  }
418
493
 
494
+ function onCardListEndReached(isIntersecting: boolean) {
495
+ if (!isIntersecting || !cardHasMore.value) return;
496
+ if (cardsLoading.value || cardsLoadingMore.value) return;
497
+ const type = cardFetchType.value;
498
+ if (!type) return;
499
+ fetchCards(type, { page: cardPage.value + 1, append: true });
500
+ }
501
+
502
+ const searchCards = debounce(() => {
503
+ if (cardFetchType.value !== "NFC") return;
504
+ fetchCards("NFC");
505
+ }, 500);
506
+
507
+ // Vuetify hands back null when it clears the field, and re-sets "" on blur;
508
+ // only a real change of the trimmed term should cost a request.
509
+ watch(cardSearch, (val, old) => {
510
+ if ((val ?? "").trim() === (old ?? "").trim()) return;
511
+ searchCards();
512
+ });
513
+
419
514
  function addCard(card: any) {
420
515
  const already = selectedCards.value.some((c) => c._id === card._id);
421
516
  if (!already) selectedCards.value = [...selectedCards.value, card];
@@ -0,0 +1,136 @@
1
+ <template>
2
+ <v-dialog v-model="show" max-width="820" persistent 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" :disabled="busy" @click="show = false" />
8
+ </v-card-title>
9
+ <v-card-text class="screen-modal__body">
10
+ <ol class="text-body-2 mb-4 pl-4">
11
+ <li class="mb-1">
12
+ Download the template. It already has this property's lists as dropdowns and an Instructions sheet.
13
+ <v-btn variant="text" size="small" class="text-none" prepend-icon="mdi-download" :loading="downloading" @click="download">Download template</v-btn>
14
+ </li>
15
+ <li class="mb-1">{{ fillHint }}</li>
16
+ <li class="mb-1">Upload it and press <strong>Check file</strong>. Nothing is saved yet — you see every row first.</li>
17
+ <li>If any row needs fixing, fix it in the file and upload it again. {{ duplicateNote }}</li>
18
+ </ol>
19
+
20
+ <v-file-input
21
+ v-model="file"
22
+ accept=".xlsx"
23
+ label="Excel file made from the template (.xlsx)"
24
+ prepend-icon="mdi-file-excel-outline"
25
+ density="comfortable"
26
+ show-size
27
+ :disabled="busy"
28
+ @update:model-value="result = null"
29
+ />
30
+
31
+ <template v-if="result">
32
+ <v-alert :type="result.committed ? 'success' : result.errors ? 'error' : 'info'" variant="tonal" class="mb-3">
33
+ {{ importSummary(result, noun) }}
34
+ <div v-if="result.errors && !result.committed" class="mt-2">
35
+ Fix the rows marked "Needs fixing" in your file, save it, then choose it again above.
36
+ <br />
37
+ <v-btn size="small" variant="outlined" class="text-none mt-2" prepend-icon="mdi-file-download-outline" @click="downloadErrors">Download the list of rows to fix</v-btn>
38
+ </div>
39
+ </v-alert>
40
+ <v-table density="compact" height="300" fixed-header>
41
+ <thead>
42
+ <tr>
43
+ <th style="width: 72px">Row</th>
44
+ <th>{{ nameLabel }}</th>
45
+ <th>Result</th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ <tr v-for="r in result.rows" :key="r.row">
50
+ <td>{{ r.row }}</td>
51
+ <td>{{ r.name || "—" }}</td>
52
+ <td :class="r.status === 'error' ? 'text-error' : r.status === 'skipped' ? 'text-medium-emphasis' : 'text-success'">
53
+ <strong>{{ IMPORT_STATUS_LABEL[r.status] }}</strong>{{ r.message ? ` — ${r.message}` : "" }}
54
+ </td>
55
+ </tr>
56
+ </tbody>
57
+ </v-table>
58
+ </template>
59
+ </v-card-text>
60
+ <div class="screen-modal__footer">
61
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="busy" @click="show = false">{{ result?.committed ? "Close" : "Cancel" }}</v-btn>
62
+ <v-btn v-if="!result" class="screen-btn-primary" variant="flat" :disabled="!selected" :loading="busy" @click="run(false)">Check file</v-btn>
63
+ <v-btn v-else-if="!result.committed && !result.errors" class="screen-btn-primary" variant="flat" :disabled="!result.ready" :loading="busy" @click="run(true)">
64
+ Save {{ result.ready }} {{ noun }}
65
+ </v-btn>
66
+ </div>
67
+ </v-card>
68
+ </v-dialog>
69
+ </template>
70
+
71
+ <script setup lang="ts">
72
+ import { saveBlob } from "../composables/useInventory";
73
+ import { IMPORT_STATUS_LABEL, importErrorReportCsv, importSummary, type TImportResult } from "../utils/import-result";
74
+
75
+ /**
76
+ * One "Import from Excel" dialog for every list: download the live template,
77
+ * check the file (nothing saved), see every row with its row number and
78
+ * reason, download the rows to fix, and save only an error-free file.
79
+ */
80
+ const show = defineModel<boolean>({ default: false });
81
+ const props = defineProps({
82
+ title: { type: String, required: true },
83
+ /** Plural noun for the rows, e.g. "items", "units". */
84
+ noun: { type: String, default: "rows" },
85
+ nameLabel: { type: String, default: "Name" },
86
+ fillHint: { type: String, default: "Fill in one row per line, starting under the grey example row. Columns marked * are required." },
87
+ duplicateNote: { type: String, default: "Rows already in the system are skipped and left unchanged." },
88
+ templateFileName: { type: String, required: true },
89
+ downloadTemplate: { type: Function as PropType<() => Promise<Blob>>, required: true },
90
+ check: { type: Function as PropType<(file: File) => Promise<TImportResult>>, required: true },
91
+ commit: { type: Function as PropType<(file: File) => Promise<TImportResult>>, required: true },
92
+ });
93
+ const emit = defineEmits<{ done: [result: TImportResult]; error: [message: string] }>();
94
+
95
+ const file = ref<File | File[] | null>(null);
96
+ const selected = computed<File | null>(() => (Array.isArray(file.value) ? (file.value[0] ?? null) : file.value));
97
+ const result = ref<TImportResult | null>(null);
98
+ const busy = ref(false);
99
+ const downloading = ref(false);
100
+
101
+ async function download() {
102
+ downloading.value = true;
103
+ try {
104
+ saveBlob(await props.downloadTemplate(), props.templateFileName);
105
+ } catch (error: any) {
106
+ emit("error", error?.data?.message || "Could not download the template.");
107
+ } finally {
108
+ downloading.value = false;
109
+ }
110
+ }
111
+
112
+ function downloadErrors() {
113
+ if (!result.value) return;
114
+ saveBlob(new Blob([importErrorReportCsv(result.value)], { type: "text/csv;charset=utf-8" }), props.templateFileName.replace(/\.xlsx$/i, "") + "-rows-to-fix.csv");
115
+ }
116
+
117
+ async function run(commit: boolean) {
118
+ if (!selected.value) return;
119
+ busy.value = true;
120
+ try {
121
+ result.value = await (commit ? props.commit : props.check)(selected.value);
122
+ if (commit) emit("done", result.value);
123
+ } catch (error: any) {
124
+ emit("error", error?.data?.message || "Could not read the file.");
125
+ } finally {
126
+ busy.value = false;
127
+ }
128
+ }
129
+
130
+ watch(show, (open) => {
131
+ if (open) {
132
+ file.value = null;
133
+ result.value = null;
134
+ }
135
+ });
136
+ </script>
@@ -1,117 +1,46 @@
1
1
  <template>
2
- <v-dialog v-model="show" max-width="760" persistent scrollable>
3
- <v-card class="screen-modal">
4
- <v-card-title class="d-flex align-center">
5
- Import Items from Excel
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
- <ol class="text-body-2 mb-4 pl-4">
11
- <li class="mb-1">
12
- Download the template and fill in one row per item, with the quantity in the store today.
13
- <v-btn variant="text" size="small" class="text-none" prepend-icon="mdi-download" :loading="downloading" @click="download">Download template</v-btn>
14
- </li>
15
- <li class="mb-1">Upload the file. We check it first; nothing is saved yet.</li>
16
- <li>Review the result and confirm. Items already in the store are skipped, so uploading twice never doubles stock.</li>
17
- </ol>
18
-
19
- <v-file-input
20
- v-model="file"
21
- accept=".xlsx"
22
- label="Excel file (.xlsx)"
23
- prepend-icon="mdi-file-excel-outline"
24
- density="comfortable"
25
- show-size
26
- @update:model-value="result = null"
27
- />
28
-
29
- <template v-if="result">
30
- <v-alert :type="result.errors ? 'warning' : 'success'" variant="tonal" class="mb-3">
31
- <template v-if="result.committed">{{ result.ready }} item(s) added.</template>
32
- <template v-else>{{ result.ready }} item(s) ready to add.</template>
33
- <span v-if="result.skipped"> {{ result.skipped }} skipped.</span>
34
- <span v-if="result.errors"> {{ result.errors }} row(s) need fixing (they will not be added).</span>
35
- </v-alert>
36
- <v-table density="compact" height="280" fixed-header>
37
- <thead>
38
- <tr>
39
- <th>Row</th>
40
- <th>Item</th>
41
- <th>Result</th>
42
- </tr>
43
- </thead>
44
- <tbody>
45
- <tr v-for="r in result.rows" :key="r.row">
46
- <td>{{ r.row }}</td>
47
- <td>{{ r.name || "—" }}</td>
48
- <td :class="r.status === 'error' ? 'text-error' : r.status === 'skipped' ? 'text-medium-emphasis' : 'text-success'">
49
- {{ label[r.status] }}{{ r.message ? `: ${r.message}` : "" }}
50
- </td>
51
- </tr>
52
- </tbody>
53
- </v-table>
54
- </template>
55
- </v-card-text>
56
- <div class="screen-modal__footer">
57
- <v-btn class="screen-btn-ghost" variant="outlined" :disabled="busy" @click="show = false">{{ result?.committed ? "Close" : "Cancel" }}</v-btn>
58
- <v-btn v-if="!result" class="screen-btn-primary" variant="flat" :disabled="!selected" :loading="busy" @click="run(false)">Check file</v-btn>
59
- <v-btn v-else-if="!result.committed" class="screen-btn-primary" variant="flat" :disabled="!result.ready" :loading="busy" @click="run(true)">
60
- Add {{ result.ready }} item(s)
61
- </v-btn>
62
- </div>
63
- </v-card>
64
- </v-dialog>
2
+ <ImportExcelDialog
3
+ v-model="show"
4
+ :title="kind === 'receipts' ? 'Import Deliveries from Excel' : 'Import Items from Excel'"
5
+ :noun="kind === 'receipts' ? 'delivery lines' : 'items'"
6
+ name-label="Item"
7
+ :fill-hint="
8
+ kind === 'receipts'
9
+ ? 'One row per item delivered: date received, supplier, PO no., the item (pick from the dropdown), quantity and price. Rows with the same date, supplier and PO no. are saved as one delivery.'
10
+ : 'One row per item. Unit, Packing and Category have dropdowns with this store\'s values. Current Stock and the columns after it are the opening stock (date, supplier, PO no.), recorded like a delivery.'
11
+ "
12
+ :duplicate-note="kind === 'receipts' ? 'Every row is a delivery — uploading the same file twice records it twice.' : 'Items already in the store are skipped and never changed, so uploading twice never doubles stock.'"
13
+ :template-file-name="kind === 'receipts' ? `receive-stock-${slug}.xlsx` : `inventory-items-${slug}.xlsx`"
14
+ :download-template="downloadTemplate"
15
+ :check="(file: File) => run(file, false)"
16
+ :commit="(file: File) => run(file, true)"
17
+ @done="finished"
18
+ @error="(message: string) => notify(message, 'error')"
19
+ />
65
20
  </template>
66
21
 
67
22
  <script setup lang="ts">
68
- import useInventory, { saveBlob } from "../composables/useInventory";
23
+ import useInventory from "../composables/useInventory";
24
+ import type { TImportResult } from "../utils/import-result";
69
25
 
70
26
  const show = defineModel<boolean>({ default: false });
71
- const props = defineProps({ store: { type: Object as PropType<TInventoryStore>, required: true } });
27
+ const props = defineProps({
28
+ store: { type: Object as PropType<TInventoryStore>, required: true },
29
+ /** items: the item list with opening stock. receipts: bulk deliveries of existing items. */
30
+ kind: { type: String as PropType<"items" | "receipts">, default: "items" },
31
+ });
72
32
  const emit = defineEmits(["done"]);
73
33
 
74
- const { importItems, importTemplate } = useInventory();
34
+ const { importItems, importTemplate, receiveTemplate, importReceipts } = useInventory();
75
35
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
36
+ const slug = computed(() => String(props.store.name ?? "store").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "store");
76
37
 
77
- const file = ref<File | File[] | null>(null);
78
- const selected = computed<File | null>(() => (Array.isArray(file.value) ? file.value[0] ?? null : file.value));
79
- const result = ref<Awaited<ReturnType<typeof importItems>> | null>(null);
80
- const busy = ref(false);
81
- const downloading = ref(false);
82
- const label: Record<string, string> = { ready: "Ready", added: "Added", skipped: "Skipped", error: "Needs fixing" };
38
+ const downloadTemplate = () => (props.kind === "receipts" ? receiveTemplate(props.store._id) : importTemplate(props.store._id));
39
+ const run = (file: File, commit: boolean) =>
40
+ (props.kind === "receipts" ? importReceipts(props.store._id, file, commit) : importItems(props.store._id, file, commit)) as Promise<TImportResult>;
83
41
 
84
- async function download() {
85
- downloading.value = true;
86
- try {
87
- saveBlob(await importTemplate(), "inventory-import-template.xlsx");
88
- } catch {
89
- notify("Could not download the template.", "error");
90
- } finally {
91
- downloading.value = false;
92
- }
42
+ function finished(result: TImportResult) {
43
+ notify(props.kind === "receipts" ? `${result.ready} delivery line(s) received.` : `${result.ready} item(s) added.`);
44
+ emit("done");
93
45
  }
94
-
95
- async function run(commit: boolean) {
96
- if (!selected.value) return;
97
- busy.value = true;
98
- try {
99
- result.value = await importItems(props.store._id, selected.value, commit);
100
- if (commit) {
101
- notify(`${result.value.ready} item(s) added.`);
102
- emit("done");
103
- }
104
- } catch (error: any) {
105
- notify(error?.data?.message || "Could not read the file.", "error");
106
- } finally {
107
- busy.value = false;
108
- }
109
- }
110
-
111
- watch(show, (open) => {
112
- if (open) {
113
- file.value = null;
114
- result.value = null;
115
- }
116
- });
117
46
  </script>
@@ -12,7 +12,7 @@
12
12
  <div class="flex-grow-1">
13
13
  <div class="text-h5 mb-1" :class="item.lowStock ? 'text-error' : ''">{{ item.onHand }} {{ item.unit }} in store</div>
14
14
  <div class="text-body-2 text-medium-emphasis">
15
- {{ [item.code && `Code ${item.code}`, item.category, item.packing, item.reorderLevel ? `Alert at ${item.reorderLevel}` : "", item.unitCost !== undefined ? `$${Number(item.unitCost).toFixed(2)} each` : ""].filter(Boolean).join(" · ") }}
15
+ {{ [inventoryItemMeta(item), item.reorderLevel ? `Alert at ${item.reorderLevel}` : "", item.unitCost !== undefined ? `$${Number(item.unitCost).toFixed(2)} each` : ""].filter(Boolean).join(" · ") }}
16
16
  </div>
17
17
  <div v-if="item.description" class="text-body-2 mt-2">{{ item.description }}</div>
18
18
  </div>
@@ -77,6 +77,7 @@
77
77
  import useInventory from "../composables/useInventory";
78
78
  import useFile from "../composables/useFile";
79
79
  import useUtils from "../composables/useUtils";
80
+ import { inventoryItemMeta } from "../utils/inventory-store-label";
80
81
 
81
82
  const show = defineModel<boolean>({ default: false });
82
83
  const props = defineProps({
@@ -114,7 +115,7 @@ function describe(t: TInventoryTransaction) {
114
115
  t.reference || "",
115
116
  );
116
117
  if (t.type === "adjust") parts.push(t.remarks || "");
117
- if (t.type === "opening") parts.push(t.remarks || "");
118
+ if (t.type === "opening") parts.push(t.supplier || "", t.reference ? `PO ${t.reference}` : "", t.receivedBy ? `received by ${t.receivedBy}` : "", t.remarks || "");
118
119
  return parts.filter(Boolean).join(" · ");
119
120
  }
120
121
 
@@ -35,11 +35,39 @@
35
35
  <InputLabel title="Unit price" class="mb-1" />
36
36
  <v-text-field v-model.number="f.unitCost" type="number" min="0" step="0.01" prefix="$" density="comfortable" :rules="[moneyRule]" hint="Price of one unit" persistent-hint />
37
37
  </v-col>
38
- <v-col v-if="!item" cols="12" sm="4">
39
- <InputLabel title="Current stock" class="mb-1" />
40
- <v-text-field v-model.number="f.openingQty" type="number" min="0" density="comfortable" :rules="[wholeRule]" hint="Quantity in the store today" persistent-hint />
41
- </v-col>
42
38
  </v-row>
39
+ <!-- Stock already on the shelf, recorded like a delivery (one "Opening stock" entry in the item's history). -->
40
+ <template v-if="!item">
41
+ <div class="text-subtitle-2 mt-4">Opening stock</div>
42
+ <div class="text-caption text-medium-emphasis mb-2">
43
+ Stock you already have. It is recorded like a delivery and shows in the item's history and the Purchases report as "Opening stock".
44
+ Supplier and PO no. are optional — old stock may have none. Leave the quantity at 0 if you have none yet.
45
+ </div>
46
+ <v-row dense>
47
+ <v-col cols="12" sm="4">
48
+ <InputLabel title="Quantity" class="mb-1" />
49
+ <v-text-field v-model.number="f.openingQty" type="number" min="0" density="comfortable" :rules="[wholeRule]" :suffix="f.unit || ''" hint="How many are in the store now" persistent-hint />
50
+ </v-col>
51
+ <v-col cols="12" sm="4">
52
+ <InputLabel title="Date received (as at)" class="mb-1" />
53
+ <v-text-field v-model="f.openingDate" type="date" :max="today" density="comfortable" :disabled="!f.openingQty" hint="Blank = today" persistent-hint />
54
+ </v-col>
55
+ <v-col cols="12" sm="4">
56
+ <InputLabel title="Received by" class="mb-1" />
57
+ <v-text-field v-model="f.openingReceivedBy" density="comfortable" maxlength="120" :disabled="!f.openingQty" placeholder="You" hint="Blank = you" persistent-hint />
58
+ </v-col>
59
+ <v-col cols="12" sm="6">
60
+ <InputLabel title="Supplier" class="mb-1" />
61
+ <v-text-field v-model="f.openingSupplier" density="comfortable" maxlength="200" :disabled="!f.openingQty" hint="Optional" persistent-hint />
62
+ </v-col>
63
+ <v-col cols="12" sm="6">
64
+ <InputLabel title="PO no." class="mb-1" />
65
+ <v-text-field v-model="f.openingReference" density="comfortable" maxlength="100" :disabled="!f.openingQty" hint="Optional" persistent-hint />
66
+ </v-col>
67
+ </v-row>
68
+ <InputLabel title="Remarks" class="mb-1 mt-2" />
69
+ <v-text-field v-model="f.openingRemarks" density="comfortable" maxlength="1000" :disabled="!f.openingQty" placeholder="e.g. counted during stock-take" />
70
+ </template>
43
71
  <InputLabel title="Description" class="mb-1 mt-2" />
44
72
  <v-textarea v-model="f.description" rows="2" auto-grow density="comfortable" placeholder="Size, model, where it is used" />
45
73
  <InputLabel title="Photo" class="mb-1" />
@@ -63,7 +91,7 @@
63
91
  </template>
64
92
 
65
93
  <script setup lang="ts">
66
- import useInventory from "../composables/useInventory";
94
+ import useInventory, { pickedDate } from "../composables/useInventory";
67
95
  import useUtils from "../composables/useUtils";
68
96
 
69
97
  const show = defineModel<boolean>({ default: false });
@@ -83,7 +111,23 @@ const moneyRule = (v: any) => v === "" || v === null || Number(v) >= 0 || "Enter
83
111
 
84
112
  const form = ref();
85
113
  const saving = ref(false);
86
- const blank = () => ({ name: "", unit: "pcs", packing: "", code: "", category: "", reorderLevel: 0, unitCost: 0, openingQty: 0, description: "" });
114
+ const blank = () => ({
115
+ name: "",
116
+ unit: "pcs",
117
+ packing: "",
118
+ code: "",
119
+ category: "",
120
+ reorderLevel: 0,
121
+ unitCost: 0,
122
+ openingQty: 0,
123
+ openingDate: "",
124
+ openingSupplier: "",
125
+ openingReference: "",
126
+ openingReceivedBy: "",
127
+ openingRemarks: "",
128
+ description: "",
129
+ });
130
+ const today = new Date().toLocaleDateString("en-CA");
87
131
  const f = reactive(blank());
88
132
  const photo = ref<string[]>([]);
89
133
  const inUse = ref(true);
@@ -116,7 +160,18 @@ async function save() {
116
160
  await updateItem(props.item._id, { ...body, status: inUse.value ? "active" : "inactive" });
117
161
  notify("Item saved.");
118
162
  } else {
119
- await createItem(props.store._id, { ...body, openingQty: Number(f.openingQty || 0) });
163
+ const qty = Number(f.openingQty || 0);
164
+ const opening = qty
165
+ ? {
166
+ openingQty: qty,
167
+ openingDate: pickedDate(f.openingDate),
168
+ openingSupplier: f.openingSupplier,
169
+ openingReference: f.openingReference,
170
+ openingReceivedBy: f.openingReceivedBy,
171
+ openingRemarks: f.openingRemarks,
172
+ }
173
+ : { openingQty: 0 };
174
+ await createItem(props.store._id, { ...body, ...opening });
120
175
  notify("Item added.");
121
176
  }
122
177
  show.value = false;
@@ -184,7 +184,7 @@ const help: Record<string, string> = {
184
184
  movement: "For each item: stock at the start of the month, what came in, what was issued and returned, count differences, and stock at the end.",
185
185
  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.",
186
186
  technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
187
- purchases: "Every delivery received, with totals by month and by supplier, to see how purchasing changes over time.",
187
+ purchases: "Every delivery received, and opening stock entered with new items (Type: Opening stock), with totals by month and by supplier.",
188
188
  };
189
189
 
190
190
  const headers: Record<string, any[]> = {
@@ -231,6 +231,7 @@ const headers: Record<string, any[]> = {
231
231
  ],
232
232
  purchases: [
233
233
  { title: "Date", key: "date" },
234
+ { title: "Type", key: "kind" },
234
235
  { title: "Supplier", key: "supplier" },
235
236
  { title: "PO No.", key: "reference" },
236
237
  { title: "Item", key: "itemName" },
@@ -39,6 +39,7 @@
39
39
  <script setup lang="ts">
40
40
  import useInventory from "../composables/useInventory";
41
41
  import useUtils from "../composables/useUtils";
42
+ import { inventorySiteOptions } from "../utils/inventory-store-label";
42
43
 
43
44
  const props = defineProps({
44
45
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -57,11 +58,7 @@ const sites = ref<string[]>([]);
57
58
  const showInResidentApp = ref(false);
58
59
  const showCostInResidentApp = ref(false);
59
60
 
60
- const siteItems = computed(() => {
61
- const known = new Map(props.orgSites.map((s) => [s._id, s.name]));
62
- for (const id of props.store.sites) if (!known.has(id)) known.set(id, "Current property");
63
- return [...known].map(([_id, name]) => ({ _id, name }));
64
- });
61
+ const siteItems = computed(() => inventorySiteOptions(props.orgSites, props.store));
65
62
 
66
63
  function reset() {
67
64
  name.value = props.store.name;