@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.
- package/CHANGELOG.md +50 -4
- package/assets/css/primitives.css +13 -0
- package/components/AccessCardAddForm.vue +5 -4
- package/components/AccessCardAssignToUnitForm.vue +3 -2
- package/components/AccessCardPreviewDialog.vue +4 -3
- package/components/AreaMain.vue +33 -30
- package/components/BuildingManagement/units.vue +30 -21
- package/components/CameraBulkImportDialog.vue +41 -12
- package/components/DashboardMain.vue +14 -7
- package/components/EntryPass/QrTemplatePreview.vue +21 -25
- package/components/EntryPassInformation.vue +119 -24
- package/components/ImportExcelDialog.vue +136 -0
- package/components/InventoryImportDialog.vue +33 -104
- package/components/InventoryItemDetail.vue +3 -2
- package/components/InventoryItemDialog.vue +62 -7
- package/components/InventoryReportsTab.vue +2 -1
- package/components/InventorySettingsTab.vue +2 -5
- package/components/InventoryStockTab.vue +13 -4
- package/components/NotificationBell.vue +1 -1
- package/components/NotificationSettings.vue +1 -1
- package/components/Robot/MiniMap.vue +1 -1
- package/components/UnitMain.vue +30 -28
- package/components/VehicleBulkAddForm.vue +30 -16
- package/composables/useAccessManagement.ts +5 -0
- package/composables/useImportTemplates.ts +24 -0
- package/composables/useInventory.ts +12 -5
- package/composables/useSiteEntryPassSettings.ts +26 -0
- package/composables/useWebUsb.ts +14 -11
- package/package.json +1 -1
- package/types/inventory.d.ts +2 -0
- package/utils/camera-csv.ts +2 -0
- package/utils/dashboard.ts +11 -0
- package/utils/import-result.ts +62 -0
- package/utils/inventory-store-label.ts +27 -0
- 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
|
-
|
|
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="
|
|
121
|
+
placeholder="Search or select cards..."
|
|
114
122
|
return-object
|
|
123
|
+
@update:model-value="cardSearch = ''"
|
|
115
124
|
>
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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({
|
|
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
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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 = () => ({
|
|
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
|
-
|
|
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
|
|
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;
|