@7365admin1/layer-common 4.35.1-staging.425 → 4.35.1-staging.427
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/components/AreaMain.vue +33 -30
- package/components/BuildingManagement/units.vue +30 -21
- package/components/CameraBulkImportDialog.vue +41 -12
- package/components/ImportExcelDialog.vue +136 -0
- package/components/InventoryImportDialog.vue +33 -104
- package/components/InventoryItemDetail.vue +1 -1
- package/components/InventoryItemDialog.vue +62 -7
- package/components/InventoryReportsTab.vue +2 -1
- package/components/InventoryStockTab.vue +11 -3
- package/components/InvitationForm.vue +5 -3
- package/components/UnitMain.vue +30 -28
- package/components/VehicleBulkAddForm.vue +30 -16
- package/composables/useImportTemplates.ts +24 -0
- package/composables/useInventory.ts +12 -5
- package/package.json +1 -1
- package/types/inventory.d.ts +2 -0
- package/utils/camera-csv.ts +2 -0
- package/utils/import-result.ts +62 -0
- package/utils/vehicle-bulk-sheet.ts +59 -3
package/components/AreaMain.vue
CHANGED
|
@@ -64,9 +64,8 @@
|
|
|
64
64
|
v-bind="t"
|
|
65
65
|
class="ma-1 screen-icon-btn"
|
|
66
66
|
variant="flat"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
download
|
|
67
|
+
:loading="templateDownloading"
|
|
68
|
+
@click="downloadLiveTemplate"
|
|
70
69
|
>
|
|
71
70
|
<v-icon>mdi-download</v-icon>
|
|
72
71
|
</v-btn>
|
|
@@ -310,16 +309,25 @@
|
|
|
310
309
|
|
|
311
310
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
312
311
|
|
|
313
|
-
<
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
312
|
+
<ImportExcelDialog
|
|
313
|
+
v-model="showImport"
|
|
314
|
+
title="Import Areas from Excel"
|
|
315
|
+
noun="areas"
|
|
316
|
+
name-label="Area"
|
|
317
|
+
fill-hint="One area per row: its name, Type (Common or Toilet, from the dropdown), Set and the units to check, separated by commas."
|
|
318
|
+
duplicate-note="Areas already on the list (same name and type) are skipped and left unchanged."
|
|
319
|
+
template-file-name="areas-import-template.xlsx"
|
|
320
|
+
:download-template="() => downloadTemplate('areas', importQuery)"
|
|
321
|
+
:check="(file: File) => checkImport('areas', file, importQuery)"
|
|
322
|
+
:commit="(file: File) => commitImport('areas', file, importQuery)"
|
|
323
|
+
@done="getTaskRefresh()"
|
|
324
|
+
@error="(text: string) => showMessage(text, 'error')"
|
|
319
325
|
/>
|
|
320
326
|
</template>
|
|
321
327
|
|
|
322
328
|
<script setup lang="ts">
|
|
329
|
+
import useImportTemplates from "../composables/useImportTemplates";
|
|
330
|
+
import { saveBlob } from "../composables/useInventory";
|
|
323
331
|
import QrcodeVue from "qrcode.vue";
|
|
324
332
|
import useAreas from "../composables/useAreas";
|
|
325
333
|
import useUnits from "../composables/useUnits";
|
|
@@ -406,7 +414,6 @@ const {
|
|
|
406
414
|
createArea,
|
|
407
415
|
updateArea,
|
|
408
416
|
deleteArea,
|
|
409
|
-
uploadAreas,
|
|
410
417
|
getAreaById,
|
|
411
418
|
exportAreas,
|
|
412
419
|
} = useAreas();
|
|
@@ -461,7 +468,6 @@ const selectedItem = ref<Record<string, any> | null>(null);
|
|
|
461
468
|
const message = ref("");
|
|
462
469
|
const messageSnackbar = ref(false);
|
|
463
470
|
const messageColor = ref("");
|
|
464
|
-
const fileInput = ref<HTMLInputElement | null>(null);
|
|
465
471
|
|
|
466
472
|
const dialogShowMoreActions = ref(false);
|
|
467
473
|
const dialogShowForm = ref(false);
|
|
@@ -573,29 +579,26 @@ async function handleDownloadExcel() {
|
|
|
573
579
|
}
|
|
574
580
|
|
|
575
581
|
function onImportData() {
|
|
576
|
-
|
|
582
|
+
showImport.value = true;
|
|
577
583
|
}
|
|
578
584
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
} finally {
|
|
594
|
-
submitting.value = false;
|
|
595
|
-
if (target) target.value = "";
|
|
596
|
-
}
|
|
585
|
+
// The template is made live from this property's own data (dropdowns, instructions, the property named inside).
|
|
586
|
+
const { downloadTemplate, checkImport, commitImport } = useImportTemplates();
|
|
587
|
+
const importQuery = computed(() => ({ site: String(props.site ?? ""), serviceType: props.serviceType || undefined }));
|
|
588
|
+
const showImport = ref(false);
|
|
589
|
+
const templateDownloading = ref(false);
|
|
590
|
+
async function downloadLiveTemplate() {
|
|
591
|
+
templateDownloading.value = true;
|
|
592
|
+
try {
|
|
593
|
+
saveBlob(await downloadTemplate("areas", importQuery.value), "areas-import-template.xlsx");
|
|
594
|
+
} catch (error: any) {
|
|
595
|
+
showMessage(error?.data?.message || "Could not download the template.", "error");
|
|
596
|
+
} finally {
|
|
597
|
+
templateDownloading.value = false;
|
|
598
|
+
}
|
|
597
599
|
}
|
|
598
600
|
|
|
601
|
+
|
|
599
602
|
function onCreateItem() {
|
|
600
603
|
dialogMode.value = "add";
|
|
601
604
|
editingItem.value = {};
|
|
@@ -25,8 +25,21 @@
|
|
|
25
25
|
</AppButton>
|
|
26
26
|
</template>
|
|
27
27
|
</PageHeader>
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
<!-- Check-then-save import: every row shown with its row number and reason before anything is saved. -->
|
|
29
|
+
<ImportExcelDialog
|
|
30
|
+
v-model="showImport"
|
|
31
|
+
title="Import Blocks, Levels and Units from Excel"
|
|
32
|
+
noun="rows"
|
|
33
|
+
name-label="Building / Level / Unit"
|
|
34
|
+
fill-hint="One row per unit: block number, building name, level and unit. Building Name and Level have dropdowns with this property's own values."
|
|
35
|
+
duplicate-note="Units already on that level are skipped and left unchanged."
|
|
36
|
+
template-file-name="units-import-template.xlsx"
|
|
37
|
+
:download-template="() => downloadTemplate('building-units', { site })"
|
|
38
|
+
:check="(file: File) => checkImport('building-units', file, { site })"
|
|
39
|
+
:commit="(file: File) => commitImport('building-units', file, { site })"
|
|
40
|
+
@done="getBuildingUnit()"
|
|
41
|
+
@error="(text: string) => showMessage(text, 'error')"
|
|
42
|
+
/>
|
|
30
43
|
</v-col>
|
|
31
44
|
|
|
32
45
|
<!-- Was `v-card` + a `grey-lighten-4` `v-toolbar` + a bare `v-data-table`. -->
|
|
@@ -160,6 +173,8 @@
|
|
|
160
173
|
</template>
|
|
161
174
|
|
|
162
175
|
<script setup lang="ts">
|
|
176
|
+
import useImportTemplates from "../../composables/useImportTemplates";
|
|
177
|
+
import { saveBlob } from "../../composables/useInventory";
|
|
163
178
|
import { useDisplay } from 'vuetify/lib/composables/display.mjs';
|
|
164
179
|
|
|
165
180
|
const props = defineProps({
|
|
@@ -339,28 +354,20 @@ async function handleDeleteBuildingUnit() {
|
|
|
339
354
|
}
|
|
340
355
|
}
|
|
341
356
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
357
|
+
// The template is made live from this property's data; the import checks every row before saving.
|
|
358
|
+
const { downloadTemplate, checkImport, commitImport } = useImportTemplates();
|
|
359
|
+
const showImport = ref(false);
|
|
346
360
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
bulkUploadSkippedSummary.value = [];
|
|
350
|
-
bulkUploadSkippedDialog.value = false;
|
|
351
|
-
if (bulkUploadFileInput.value) {
|
|
352
|
-
bulkUploadFileInput.value.value = "";
|
|
353
|
-
bulkUploadFileInput.value.click();
|
|
354
|
-
}
|
|
361
|
+
function openBulkUploadFilePicker() {
|
|
362
|
+
showImport.value = true;
|
|
355
363
|
}
|
|
356
364
|
|
|
357
|
-
function downloadBulkUploadTemplate() {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
document.body.removeChild(link);
|
|
365
|
+
async function downloadBulkUploadTemplate() {
|
|
366
|
+
try {
|
|
367
|
+
saveBlob(await downloadTemplate("building-units", { site }), "units-import-template.xlsx");
|
|
368
|
+
} catch (error: any) {
|
|
369
|
+
showMessage(error?.data?.message || "Could not download the template.", "error");
|
|
370
|
+
}
|
|
364
371
|
}
|
|
365
372
|
|
|
366
373
|
function formatSkippedSummaryReason(item: Record<string, any>) {
|
|
@@ -413,6 +420,8 @@ function getBulkUploadSkippedSummary(result: Record<string, any> | null | undefi
|
|
|
413
420
|
return [];
|
|
414
421
|
}
|
|
415
422
|
|
|
423
|
+
// ponytail: the old direct upload (this handler and the skipped-rows dialog) is no longer reachable —
|
|
424
|
+
// Bulk Upload opens ImportExcelDialog. Delete it once the new import is confirmed in production.
|
|
416
425
|
async function handleBulkUploadFileChange(event: Event) {
|
|
417
426
|
const input = event.target as HTMLInputElement;
|
|
418
427
|
const file = input.files?.[0] ?? null;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<!-- Step 1 — the file. -->
|
|
7
7
|
<template v-if="!results.length">
|
|
8
8
|
<p class="text-body-2 mb-4">
|
|
9
|
-
Upload the camera survey sheet
|
|
9
|
+
Upload the camera survey sheet (the Excel template, or a CSV). Each row is one camera:
|
|
10
10
|
its name, the recorder it is on, and its channel number on that
|
|
11
11
|
recorder. The live-view address is worked out for you — do not type
|
|
12
12
|
one.
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
<v-file-input
|
|
16
16
|
v-model="file"
|
|
17
|
-
accept=".csv,text/csv"
|
|
17
|
+
accept=".xlsx,.csv,text/csv"
|
|
18
18
|
density="comfortable"
|
|
19
19
|
variant="outlined"
|
|
20
20
|
prepend-icon=""
|
|
21
21
|
prepend-inner-icon="mdi-file-delimited-outline"
|
|
22
|
-
label="Camera survey sheet (.csv)"
|
|
22
|
+
label="Camera survey sheet (.xlsx or .csv)"
|
|
23
23
|
:disabled="busy"
|
|
24
24
|
@update:model-value="readFile"
|
|
25
25
|
/>
|
|
@@ -147,6 +147,9 @@
|
|
|
147
147
|
<script setup lang="ts">
|
|
148
148
|
import useSiteSettings from "../composables/useSiteSettings";
|
|
149
149
|
import { cameraCsvTemplate, parseCameraCsv } from "../utils/camera-csv";
|
|
150
|
+
import { isExampleLine, isWorkbookName, templateRefusal, xlsxMeta, xlsxToGrid } from "../utils/vehicle-bulk-sheet";
|
|
151
|
+
import useImportTemplates from "../composables/useImportTemplates";
|
|
152
|
+
import { saveBlob } from "../composables/useInventory";
|
|
150
153
|
|
|
151
154
|
/**
|
|
152
155
|
* Adding a site's cameras from the technician's survey sheet.
|
|
@@ -177,6 +180,7 @@ const prop = defineProps({
|
|
|
177
180
|
const emit = defineEmits(["cancel", "imported"]);
|
|
178
181
|
|
|
179
182
|
const { bulkImportSiteCameras } = useSiteSettings();
|
|
183
|
+
const { downloadTemplate: downloadLiveTemplate } = useImportTemplates();
|
|
180
184
|
|
|
181
185
|
const file = ref<File | File[] | null>(null);
|
|
182
186
|
const fileError = ref("");
|
|
@@ -240,14 +244,22 @@ function close() {
|
|
|
240
244
|
emit("cancel");
|
|
241
245
|
}
|
|
242
246
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
247
|
+
/** The Excel template (Yes/No dropdowns, an example row, an Instructions sheet); the CSV headings if it cannot be made. */
|
|
248
|
+
async function downloadTemplate() {
|
|
249
|
+
try {
|
|
250
|
+
saveBlob(await downloadLiveTemplate("cameras", { site: prop.site }), "camera-survey-sheet.xlsx");
|
|
251
|
+
} catch {
|
|
252
|
+
saveBlob(new Blob([cameraCsvTemplate()], { type: "text/csv" }), "camera-survey-sheet.csv");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A filled-in Excel template, as the CSV text the survey-sheet reader takes; the example row left out. */
|
|
257
|
+
function gridToCsv(grid: string[][]) {
|
|
258
|
+
const quote = (v: string) => (/[",\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
|
|
259
|
+
return grid
|
|
260
|
+
.filter((line) => !isExampleLine(line))
|
|
261
|
+
.map((line) => line.map((cell) => quote(String(cell ?? ""))).join(","))
|
|
262
|
+
.join("\n");
|
|
251
263
|
}
|
|
252
264
|
|
|
253
265
|
/** What the sheet carries that a camera record has no field for. */
|
|
@@ -264,7 +276,24 @@ async function readFile(selected: File | File[] | null) {
|
|
|
264
276
|
const chosen = Array.isArray(selected) ? selected[0] : selected;
|
|
265
277
|
if (!chosen) return;
|
|
266
278
|
|
|
267
|
-
|
|
279
|
+
let text: string;
|
|
280
|
+
if (isWorkbookName(chosen.name)) {
|
|
281
|
+
const workbook = await chosen.arrayBuffer();
|
|
282
|
+
const refusal = templateRefusal(await xlsxMeta(workbook), { kind: "cameras", site: prop.site });
|
|
283
|
+
if (refusal) {
|
|
284
|
+
fileError.value = refusal;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
text = gridToCsv(await xlsxToGrid(workbook));
|
|
289
|
+
} catch {
|
|
290
|
+
fileError.value = "This file could not be read. Save it as Excel (.xlsx) from the template and try again.";
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
text = await chosen.text();
|
|
295
|
+
}
|
|
296
|
+
const parsed = parseCameraCsv(text);
|
|
268
297
|
|
|
269
298
|
if (parsed.missingColumns.length) {
|
|
270
299
|
fileError.value = `This file is missing: ${parsed.missingColumns.join(", ")}. Download the blank sheet to see the columns it needs.`;
|
|
@@ -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>
|
|
@@ -114,7 +114,7 @@ function describe(t: TInventoryTransaction) {
|
|
|
114
114
|
t.reference || "",
|
|
115
115
|
);
|
|
116
116
|
if (t.type === "adjust") parts.push(t.remarks || "");
|
|
117
|
-
if (t.type === "opening") parts.push(t.remarks || "");
|
|
117
|
+
if (t.type === "opening") parts.push(t.supplier || "", t.reference ? `PO ${t.reference}` : "", t.receivedBy ? `received by ${t.receivedBy}` : "", t.remarks || "");
|
|
118
118
|
return parts.filter(Boolean).join(" · ");
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -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" },
|
|
@@ -10,9 +10,15 @@
|
|
|
10
10
|
</v-row>
|
|
11
11
|
|
|
12
12
|
<div class="d-flex flex-wrap ga-2 align-center mb-3">
|
|
13
|
-
<v-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
<v-menu v-if="store.access.canManage && !storeEmpty">
|
|
14
|
+
<template #activator="{ props: menu }">
|
|
15
|
+
<v-btn v-bind="menu" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-truck-delivery-outline" append-icon="mdi-chevron-down">Receive Stock</v-btn>
|
|
16
|
+
</template>
|
|
17
|
+
<v-list density="compact">
|
|
18
|
+
<v-list-item prepend-icon="mdi-truck-delivery-outline" title="Record a delivery" @click="open('receive')" />
|
|
19
|
+
<v-list-item prepend-icon="mdi-file-excel-outline" title="Import deliveries from Excel" @click="showReceiveImport = true" />
|
|
20
|
+
</v-list>
|
|
21
|
+
</v-menu>
|
|
16
22
|
<v-btn v-if="store.access.canApprove && !storeEmpty" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-hand-extended-outline" @click="open('issue')">
|
|
17
23
|
Issue Stock
|
|
18
24
|
</v-btn>
|
|
@@ -86,6 +92,7 @@
|
|
|
86
92
|
<InventoryLinesDialog v-model="lines.show" :mode="lines.mode" :store="store" :site-id="siteId" :sites="sites" @done="done" />
|
|
87
93
|
<InventoryItemDialog v-model="itemDialog.show" :store="store" :item="itemDialog.item" @done="done" />
|
|
88
94
|
<InventoryImportDialog v-model="showImport" :store="store" @done="done" />
|
|
95
|
+
<InventoryImportDialog v-model="showReceiveImport" :store="store" kind="receipts" @done="done" />
|
|
89
96
|
<InventoryLegacyImportDialog v-model="showLegacy" :store="store" @done="done" />
|
|
90
97
|
<InventoryItemDetail v-model="detail.show" :store="store" :item="detail.item" @edit="editItem" @done="done" />
|
|
91
98
|
</div>
|
|
@@ -167,6 +174,7 @@ function openItem(item: TInventoryItem) {
|
|
|
167
174
|
}
|
|
168
175
|
|
|
169
176
|
const showImport = ref(false);
|
|
177
|
+
const showReceiveImport = ref(false);
|
|
170
178
|
const showLegacy = ref(false);
|
|
171
179
|
|
|
172
180
|
function done() {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
<v-list-item-title class="text-wrap">
|
|
60
60
|
{{ noRolesText }}
|
|
61
61
|
</v-list-item-title>
|
|
62
|
-
<NuxtLink v-if="rolesPageLink" :to="rolesPageLink">
|
|
62
|
+
<NuxtLink v-if="rolesPageLink && !rolesError" :to="rolesPageLink">
|
|
63
63
|
Open Roles & Permissions
|
|
64
64
|
</NuxtLink>
|
|
65
65
|
</v-list-item>
|
|
@@ -474,7 +474,7 @@ const { getInvitableRoles } = useRole();
|
|
|
474
474
|
* templates are gone from any invitation that names an organisation - the
|
|
475
475
|
* server refuses them there (`core roleInvitable`).
|
|
476
476
|
*/
|
|
477
|
-
const { data: RolesData, refresh: refreshRoles } = await useLazyAsyncData(
|
|
477
|
+
const { data: RolesData, refresh: refreshRoles, error: rolesError } = await useLazyAsyncData(
|
|
478
478
|
"get-roles-by-type-" + props.org + "-" + props.app + "-" + fixedSite.value,
|
|
479
479
|
() =>
|
|
480
480
|
getInvitableRoles({
|
|
@@ -502,7 +502,9 @@ watchEffect(() => {
|
|
|
502
502
|
const roleOptions = computed(() => inviteRoleOptions(roles.value));
|
|
503
503
|
|
|
504
504
|
const noRolesText = computed(() =>
|
|
505
|
-
|
|
505
|
+
rolesError?.value
|
|
506
|
+
? "The roles could not be loaded. You may not have access to this organisation's roles; refresh, or sign in again."
|
|
507
|
+
: fixedSite.value
|
|
506
508
|
? "No roles have been created for this site yet. Create one in Roles & Permissions first."
|
|
507
509
|
: "No roles have been created yet. Create one in Roles & Permissions first."
|
|
508
510
|
);
|
package/components/UnitMain.vue
CHANGED
|
@@ -37,9 +37,8 @@
|
|
|
37
37
|
v-bind="t"
|
|
38
38
|
class="ma-1 screen-icon-btn"
|
|
39
39
|
variant="flat"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
download
|
|
40
|
+
:loading="templateDownloading"
|
|
41
|
+
@click="downloadLiveTemplate"
|
|
43
42
|
>
|
|
44
43
|
<v-icon>mdi-download</v-icon>
|
|
45
44
|
</v-btn>
|
|
@@ -183,16 +182,25 @@
|
|
|
183
182
|
:color="messageColor"
|
|
184
183
|
/>
|
|
185
184
|
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
185
|
+
<ImportExcelDialog
|
|
186
|
+
v-model="showImport"
|
|
187
|
+
title="Import Units from Excel"
|
|
188
|
+
noun="units"
|
|
189
|
+
name-label="Unit"
|
|
190
|
+
fill-hint="One unit per row, e.g. Toilet Bowl, Sofa."
|
|
191
|
+
duplicate-note="Units already on the list are skipped and left unchanged."
|
|
192
|
+
template-file-name="units-import-template.xlsx"
|
|
193
|
+
:download-template="() => downloadTemplate('units', importQuery)"
|
|
194
|
+
:check="(file: File) => checkImport('units', file, importQuery)"
|
|
195
|
+
:commit="(file: File) => commitImport('units', file, importQuery)"
|
|
196
|
+
@done="getUnitsRefresh()"
|
|
197
|
+
@error="(text: string) => showMessage(text, 'error')"
|
|
192
198
|
/>
|
|
193
199
|
</template>
|
|
194
200
|
|
|
195
201
|
<script setup lang="ts">
|
|
202
|
+
import useImportTemplates from "../composables/useImportTemplates";
|
|
203
|
+
import { saveBlob } from "../composables/useInventory";
|
|
196
204
|
import useUnits from "../composables/useUnits";
|
|
197
205
|
import useUtils from "../composables/useUtils";
|
|
198
206
|
|
|
@@ -220,7 +228,6 @@ const {
|
|
|
220
228
|
createUnit,
|
|
221
229
|
updateUnit,
|
|
222
230
|
deleteUnit,
|
|
223
|
-
uploadUnits,
|
|
224
231
|
downloadUnits,
|
|
225
232
|
} = useUnits();
|
|
226
233
|
|
|
@@ -271,7 +278,6 @@ const selectedUnit = ref<Record<string, any> | null>(null);
|
|
|
271
278
|
const message = ref("");
|
|
272
279
|
const messageSnackbar = ref(false);
|
|
273
280
|
const messageColor = ref("");
|
|
274
|
-
const fileInput = ref<HTMLInputElement | null>(null);
|
|
275
281
|
|
|
276
282
|
const dialogShowMoreActions = ref(false);
|
|
277
283
|
const dialogShowForm = ref(false);
|
|
@@ -341,30 +347,26 @@ async function handleDownloadExcel() {
|
|
|
341
347
|
}
|
|
342
348
|
|
|
343
349
|
function onImportData() {
|
|
344
|
-
|
|
350
|
+
showImport.value = true;
|
|
345
351
|
}
|
|
346
352
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
+
// The template is made live from this property's own data (dropdowns, instructions, the property named inside).
|
|
354
|
+
const { downloadTemplate, checkImport, commitImport } = useImportTemplates();
|
|
355
|
+
const importQuery = computed(() => ({ site: String(props.site ?? ""), serviceType: props.serviceType || undefined }));
|
|
356
|
+
const showImport = ref(false);
|
|
357
|
+
const templateDownloading = ref(false);
|
|
358
|
+
async function downloadLiveTemplate() {
|
|
359
|
+
templateDownloading.value = true;
|
|
353
360
|
try {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
showMessage(response?.message, "success");
|
|
358
|
-
|
|
359
|
-
await getUnitsRefresh();
|
|
360
|
-
} catch (err: any) {
|
|
361
|
-
showMessage(err?.data?.message, "error");
|
|
361
|
+
saveBlob(await downloadTemplate("units", importQuery.value), "units-import-template.xlsx");
|
|
362
|
+
} catch (error: any) {
|
|
363
|
+
showMessage(error?.data?.message || "Could not download the template.", "error");
|
|
362
364
|
} finally {
|
|
363
|
-
|
|
364
|
-
target.value = "";
|
|
365
|
+
templateDownloading.value = false;
|
|
365
366
|
}
|
|
366
367
|
}
|
|
367
368
|
|
|
369
|
+
|
|
368
370
|
function onCreateUnit() {
|
|
369
371
|
dialogMode.value = "add";
|
|
370
372
|
editingUnit.value = {};
|
|
@@ -284,6 +284,8 @@
|
|
|
284
284
|
|
|
285
285
|
<script setup lang="ts">
|
|
286
286
|
import useVehicle from "../composables/useVehicle";
|
|
287
|
+
import useImportTemplates from "../composables/useImportTemplates";
|
|
288
|
+
import { saveBlob } from "../composables/useInventory";
|
|
287
289
|
import useBuilding from "../composables/useBuilding";
|
|
288
290
|
import { errorConverter } from "../utils/data";
|
|
289
291
|
// @ts-ignore - the country list the one-vehicle form's phone field uses.
|
|
@@ -320,6 +322,8 @@ import {
|
|
|
320
322
|
mapUploadedGrid,
|
|
321
323
|
vehicleBulkTemplateCsv,
|
|
322
324
|
xlsxToGrid,
|
|
325
|
+
xlsxMeta,
|
|
326
|
+
templateRefusal,
|
|
323
327
|
} from "../utils/vehicle-bulk-sheet";
|
|
324
328
|
|
|
325
329
|
/**
|
|
@@ -348,6 +352,7 @@ const prop = defineProps({
|
|
|
348
352
|
const emit = defineEmits(["back", "close", "added"]);
|
|
349
353
|
|
|
350
354
|
const { addVehiclesBulk } = useVehicle();
|
|
355
|
+
const { downloadTemplate: downloadLiveTemplate } = useImportTemplates();
|
|
351
356
|
const { getBlocksSelection, getLevelsSelection, getUnitSelection } = useBuilding();
|
|
352
357
|
|
|
353
358
|
type Option = { title: string; value: string };
|
|
@@ -1043,18 +1048,19 @@ function statusColour(status: BulkRowStatus) {
|
|
|
1043
1048
|
|
|
1044
1049
|
/* --------------------------- template and file ---------------------------- */
|
|
1045
1050
|
|
|
1046
|
-
/**
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1051
|
+
/**
|
|
1052
|
+
* The sheet to fill in somewhere else: an Excel file made live from this
|
|
1053
|
+
* site's own blocks, levels and units, with them as dropdowns, an example row
|
|
1054
|
+
* and an Instructions sheet. If the server cannot make it, the plain CSV
|
|
1055
|
+
* headings are offered instead so the person is never stuck.
|
|
1056
|
+
*/
|
|
1057
|
+
async function downloadTemplate() {
|
|
1058
|
+
try {
|
|
1059
|
+
saveBlob(await downloadLiveTemplate("vehicles", { site: prop.site }), "vehicle-whitelist-template.xlsx");
|
|
1060
|
+
} catch {
|
|
1061
|
+
// A BOM, so Excel opens it as UTF-8 and a name with an accent survives.
|
|
1062
|
+
saveBlob(new Blob(["", vehicleBulkTemplateCsv()], { type: "text/csv;charset=utf-8;" }), "vehicle-whitelist-template.csv");
|
|
1063
|
+
}
|
|
1058
1064
|
}
|
|
1059
1065
|
|
|
1060
1066
|
/** The first row that is free to be written into. */
|
|
@@ -1083,9 +1089,15 @@ async function onFileChosen(event: Event) {
|
|
|
1083
1089
|
uploading.value = true;
|
|
1084
1090
|
|
|
1085
1091
|
try {
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
|
|
1092
|
+
const workbook = isWorkbookName(chosen.name) ? await chosen.arrayBuffer() : null;
|
|
1093
|
+
if (workbook) {
|
|
1094
|
+
const refusal = templateRefusal(await xlsxMeta(workbook), { kind: "vehicles", site: prop.site });
|
|
1095
|
+
if (refusal) {
|
|
1096
|
+
formError.value = refusal;
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
const grid = workbook ? await xlsxToGrid(workbook) : parsePastedGrid(await chosen.text());
|
|
1089
1101
|
const sheet = mapUploadedGrid(grid);
|
|
1090
1102
|
|
|
1091
1103
|
if (!sheet.matchesTemplate) {
|
|
@@ -1117,7 +1129,9 @@ async function onFileChosen(event: Event) {
|
|
|
1117
1129
|
moveTo(start, 0);
|
|
1118
1130
|
|
|
1119
1131
|
resultNote.value =
|
|
1120
|
-
`${sheet.rows.length} row(s) read from ${chosen.name}
|
|
1132
|
+
`${sheet.rows.length} row(s) read from ${chosen.name}` +
|
|
1133
|
+
(sheet.exampleRowIgnored ? " (the example row was left out)" : "") +
|
|
1134
|
+
`. Check anything ` +
|
|
1121
1135
|
`marked red - a block, level or unit that is not one of this site's own ` +
|
|
1122
1136
|
`is left blank - then press Submit.`;
|
|
1123
1137
|
} catch {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TImportResult } from "../utils/import-result";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Excel templates made live from the property's own data (`/api/import-templates`),
|
|
5
|
+
* and the check-then-save import behind building units, areas and units.
|
|
6
|
+
* Vehicles and cameras only download their template here; their files are read
|
|
7
|
+
* on screen.
|
|
8
|
+
*/
|
|
9
|
+
export type TTemplateKind = "building-units" | "areas" | "units" | "vehicles" | "cameras";
|
|
10
|
+
|
|
11
|
+
export default function useImportTemplates() {
|
|
12
|
+
const api = <T = any>(kind: TTemplateKind, path: string, options: Record<string, any> = {}) =>
|
|
13
|
+
useNuxtApp().$api<T>(`/api/import-templates/${kind}/${path}`, options);
|
|
14
|
+
const send = (kind: TTemplateKind, path: "check" | "commit", file: File, query: Record<string, string | undefined>) => {
|
|
15
|
+
const body = new FormData();
|
|
16
|
+
body.append("file", file);
|
|
17
|
+
return api<TImportResult>(kind, path, { method: "POST", body, query });
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
downloadTemplate: (kind: TTemplateKind, query: { site: string; serviceType?: string }) => api<Blob>(kind, "template", { query, responseType: "blob" }),
|
|
21
|
+
checkImport: (kind: TTemplateKind, file: File, query: { site: string; serviceType?: string }) => send(kind, "check", file, query),
|
|
22
|
+
commitImport: (kind: TTemplateKind, file: File, query: { site: string; serviceType?: string }) => send(kind, "commit", file, query),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TImportResult } from "../utils/import-result";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Inventory API (`/api/inventory`). What a person may do in a store comes back
|
|
3
5
|
* on the store itself (`store.access`), decided by the server — screens show
|
|
@@ -67,14 +69,19 @@ export default function useInventory() {
|
|
|
67
69
|
`/stores/${store}/legacy-equipment/import`,
|
|
68
70
|
{ method: "POST", body: { serviceType, commit } },
|
|
69
71
|
),
|
|
70
|
-
|
|
72
|
+
/** The store's own item template: live dropdowns (units, packing, categories) and the store named inside. */
|
|
73
|
+
importTemplate: (store: string) => api<Blob>(`/stores/${store}/import-template`, { responseType: "blob" }),
|
|
71
74
|
importItems: (store: string, file: File, commit: boolean) => {
|
|
72
75
|
const body = new FormData();
|
|
73
76
|
body.append("file", file);
|
|
74
|
-
return api<{
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
return api<TImportResult>(`/stores/${store}/import`, { method: "POST", body, query: { commit } });
|
|
78
|
+
},
|
|
79
|
+
/** Bulk deliveries: the store's items as a dropdown. */
|
|
80
|
+
receiveTemplate: (store: string) => api<Blob>(`/stores/${store}/receive-template`, { responseType: "blob" }),
|
|
81
|
+
importReceipts: (store: string, file: File, commit: boolean) => {
|
|
82
|
+
const body = new FormData();
|
|
83
|
+
body.append("file", file);
|
|
84
|
+
return api<TImportResult>(`/stores/${store}/receive-import`, { method: "POST", body, query: { commit } });
|
|
78
85
|
},
|
|
79
86
|
};
|
|
80
87
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.35.1-staging.
|
|
5
|
+
"version": "4.35.1-staging.427",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
package/types/inventory.d.ts
CHANGED
|
@@ -90,6 +90,8 @@ declare type TInventoryTransaction = {
|
|
|
90
90
|
photos?: { before: string[]; after: string[]; other: string[] };
|
|
91
91
|
remarks?: string;
|
|
92
92
|
reportedBy?: string;
|
|
93
|
+
/** Opening stock: who received it, when not the person who entered it. */
|
|
94
|
+
receivedBy?: string;
|
|
93
95
|
date: string;
|
|
94
96
|
createdByName: string;
|
|
95
97
|
};
|
package/utils/camera-csv.ts
CHANGED
|
@@ -103,6 +103,8 @@ function splitLine(line: string): string[] {
|
|
|
103
103
|
/** A header cell, normalised to the sheet's snake_case spelling. */
|
|
104
104
|
function normaliseColumn(value: string): string {
|
|
105
105
|
return value
|
|
106
|
+
// "Camera Name *" in the Excel template is camera_name: the * only marks it required.
|
|
107
|
+
.replace(/\*/g, "")
|
|
106
108
|
.trim()
|
|
107
109
|
.toLowerCase()
|
|
108
110
|
.replace(/^/, "")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an Excel import's check (or save) comes back with, and the plain words
|
|
3
|
+
* and error report built from it. Shared by every "Import from Excel" dialog.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type TImportIssue = { row: number; column: string; message: string };
|
|
7
|
+
|
|
8
|
+
export type TImportRow = { row: number; name: string; status: "ready" | "added" | "skipped" | "error"; message: string };
|
|
9
|
+
|
|
10
|
+
export type TImportResult = {
|
|
11
|
+
committed: boolean;
|
|
12
|
+
ready: number;
|
|
13
|
+
skipped: number;
|
|
14
|
+
errors: number;
|
|
15
|
+
exampleRowIgnored?: boolean;
|
|
16
|
+
deliveries?: number;
|
|
17
|
+
issues?: TImportIssue[];
|
|
18
|
+
rows: TImportRow[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** One sentence for the top of the preview. `noun` is plural, e.g. "items". */
|
|
22
|
+
export function importSummary(result: TImportResult, noun = "rows"): string {
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
if (result.committed) parts.push(`${result.ready} ${noun} saved.`);
|
|
25
|
+
else if (result.errors) parts.push(`${result.errors} row(s) need fixing — nothing will be saved until they are fixed.`);
|
|
26
|
+
else parts.push(`${result.ready} ${noun} ready to save.`);
|
|
27
|
+
if (!result.committed && result.errors && result.ready) parts.push(`${result.ready} other row(s) are fine.`);
|
|
28
|
+
if (result.skipped) parts.push(`${result.skipped} already in the system or repeated — they will be left unchanged.`);
|
|
29
|
+
if (result.exampleRowIgnored) parts.push("The example row was ignored.");
|
|
30
|
+
return parts.join(" ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const IMPORT_STATUS_LABEL: Record<TImportRow["status"], string> = {
|
|
34
|
+
ready: "Ready",
|
|
35
|
+
added: "Saved",
|
|
36
|
+
skipped: "Skipped",
|
|
37
|
+
error: "Needs fixing",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const csvCell = (v: unknown) => {
|
|
41
|
+
const s = String(v ?? "");
|
|
42
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The error report: one line per problem — the row number in the Excel file,
|
|
47
|
+
* the column, and what to fix. Rows with an error but no column-level issue
|
|
48
|
+
* are listed with their message. Opens in Excel.
|
|
49
|
+
*/
|
|
50
|
+
export function importErrorReportCsv(result: TImportResult): string {
|
|
51
|
+
const lines: string[][] = [["Row in your file", "Column", "What to fix", "Item"]];
|
|
52
|
+
const nameOf = new Map(result.rows.map((r) => [r.row, r.name]));
|
|
53
|
+
const covered = new Set<number>();
|
|
54
|
+
for (const i of result.issues ?? []) {
|
|
55
|
+
lines.push([String(i.row), i.column, i.message, nameOf.get(i.row) ?? ""]);
|
|
56
|
+
covered.add(i.row);
|
|
57
|
+
}
|
|
58
|
+
for (const r of result.rows) {
|
|
59
|
+
if (r.status === "error" && !covered.has(r.row)) lines.push([String(r.row), "", r.message, r.name]);
|
|
60
|
+
}
|
|
61
|
+
return "" + lines.map((l) => l.map(csvCell).join(",")).join("\r\n") + "\r\n";
|
|
62
|
+
}
|
|
@@ -97,6 +97,8 @@ export type UploadedSheet = {
|
|
|
97
97
|
matchesTemplate: boolean;
|
|
98
98
|
/** The heading line as the file spells it - for telling a person what it has. */
|
|
99
99
|
header: string[];
|
|
100
|
+
/** A template example row was left in and was not read. */
|
|
101
|
+
exampleRowIgnored?: boolean;
|
|
100
102
|
};
|
|
101
103
|
|
|
102
104
|
/**
|
|
@@ -138,14 +140,16 @@ export function mapUploadedGrid(grid: string[][]): UploadedSheet {
|
|
|
138
140
|
// Every line under the header is somebody's car. Nothing is dropped for
|
|
139
141
|
// looking like something else - two people in one household can have the
|
|
140
142
|
// same details but for the plate, and a repeated row is the person's to fix.
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
+
const body = lines.slice(1);
|
|
144
|
+
// The example row of a template the server made is marked beside it; it is nobody's car.
|
|
145
|
+
const rows = body
|
|
146
|
+
.filter((line) => !isExampleLine(line))
|
|
143
147
|
.map((line) =>
|
|
144
148
|
BULK_SHEET_HEADINGS.map((_, column) => String(line[column] ?? "").trim()),
|
|
145
149
|
)
|
|
146
150
|
.filter((line) => !isEmptyLine(line));
|
|
147
151
|
|
|
148
|
-
return { rows, matchesTemplate: true, header };
|
|
152
|
+
return { rows, matchesTemplate: true, header, exampleRowIgnored: body.some(isExampleLine) };
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
/**
|
|
@@ -385,3 +389,55 @@ export async function xlsxToGrid(data: ArrayBuffer | Uint8Array): Promise<string
|
|
|
385
389
|
export function isWorkbookName(name: string): boolean {
|
|
386
390
|
return /\.(xlsx|xlsm)$/i.test(String(name ?? ""));
|
|
387
391
|
}
|
|
392
|
+
|
|
393
|
+
/* ------------------------------ live templates ----------------------------- */
|
|
394
|
+
|
|
395
|
+
/** Beside the example row of every template the server makes. */
|
|
396
|
+
export const TEMPLATE_EXAMPLE_MARK = "Example – delete this row";
|
|
397
|
+
|
|
398
|
+
/** Does this line carry the template's example-row mark? */
|
|
399
|
+
export function isExampleLine(cells: string[]): boolean {
|
|
400
|
+
return (cells ?? []).some((cell) => String(cell ?? "").includes(TEMPLATE_EXAMPLE_MARK));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The hidden "_meta" sheet of a template the server made: which kind of
|
|
405
|
+
* template it is and the property it was made for (`kind`, `site`,
|
|
406
|
+
* `siteName`). Empty for any other workbook.
|
|
407
|
+
*/
|
|
408
|
+
export async function xlsxMeta(data: ArrayBuffer | Uint8Array): Promise<Record<string, string>> {
|
|
409
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
410
|
+
const entries = readZipEntries(bytes);
|
|
411
|
+
const book = entries.get("xl/workbook.xml");
|
|
412
|
+
const rels = entries.get("xl/_rels/workbook.xml.rels");
|
|
413
|
+
if (!book || !rels) return {};
|
|
414
|
+
const sheet = (await readPart(bytes, book)).match(/<sheet\b[^>]*\bname="_meta"[^>]*>/)?.[0];
|
|
415
|
+
const rid = sheet?.match(/\br:id="([^"]+)"/)?.[1];
|
|
416
|
+
if (!rid) return {};
|
|
417
|
+
const relation = ((await readPart(bytes, rels)).match(/<Relationship\b[^>]*>/g) ?? []).find((tag) => tag.includes(` Id="${rid}"`));
|
|
418
|
+
const target = relation?.match(/\bTarget="([^"]+)"/)?.[1];
|
|
419
|
+
const part = target ? entries.get(target.startsWith("/") ? target.slice(1) : `xl/${target}`) : undefined;
|
|
420
|
+
if (!part) return {};
|
|
421
|
+
const sharedEntry = entries.get("xl/sharedStrings.xml");
|
|
422
|
+
const strings = sharedEntry ? sharedStringsOf(await readPart(bytes, sharedEntry)) : [];
|
|
423
|
+
const meta: Record<string, string> = {};
|
|
424
|
+
for (const line of sheetToGrid(await readPart(bytes, part), strings).slice(1)) {
|
|
425
|
+
if (line[0]) meta[String(line[0])] = String(line[1] ?? "");
|
|
426
|
+
}
|
|
427
|
+
return meta;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Why a template may not be used here, or "" when it may: a template made for
|
|
432
|
+
* another property, or a different kind of template. A workbook without the
|
|
433
|
+
* hidden sheet (an older template, a person's own file) is allowed.
|
|
434
|
+
*/
|
|
435
|
+
export function templateRefusal(meta: Record<string, string>, expected: { kind: string; site: string }): string {
|
|
436
|
+
if (meta.kind && meta.kind !== expected.kind) {
|
|
437
|
+
return "This is a different kind of template. Download the template on this screen and copy your rows into it.";
|
|
438
|
+
}
|
|
439
|
+
if (meta.site && expected.site && meta.site !== expected.site) {
|
|
440
|
+
return `This file was made for ${meta.siteName ? `"${meta.siteName}"` : "another property"}. Download the template on this screen and copy your rows into it.`;
|
|
441
|
+
}
|
|
442
|
+
return "";
|
|
443
|
+
}
|