@7365admin1/layer-common 4.35.1-staging.426 → 4.35.1-staging.428

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.
@@ -982,6 +982,19 @@ select option {
982
982
  background: var(--accent);
983
983
  }
984
984
 
985
+ /*
986
+ * A plain `v-switch` (no `.app-switch`). Vuetify paints its OFF track with the
987
+ * theme's surface-variant, which this theme sets to near-white (251,251,248),
988
+ * so on a white card an off switch showed only its thumb and did not read as
989
+ * a switch at all (Inventory > Settings, Site Settings, Online Forms...). Give
990
+ * the off track a visible tint in both themes; when ON, Vuetify's `bg-*`
991
+ * colour class (`!important`) still wins, so the checked colour is unchanged.
992
+ */
993
+ .v-switch:not(.app-switch) .v-switch__track {
994
+ background-color: rgba(var(--v-theme-on-surface), 0.26);
995
+ opacity: 1;
996
+ }
997
+
985
998
  .app-switch .v-selection-control__input {
986
999
  position: absolute;
987
1000
  inset: 0;
@@ -64,9 +64,8 @@
64
64
  v-bind="t"
65
65
  class="ma-1 screen-icon-btn"
66
66
  variant="flat"
67
- href="/files/area-sample-data.xlsx"
68
- target="_blank"
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
- <input
314
- ref="fileInput"
315
- type="file"
316
- accept=".csv,.xlsx,.xls"
317
- style="display: none"
318
- @change="onFileSelected"
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
- fileInput.value?.click();
582
+ showImport.value = true;
577
583
  }
578
584
 
579
- async function onFileSelected(event: Event) {
580
- const target = event.target as HTMLInputElement;
581
- const file = target.files?.[0];
582
-
583
- if (!file) return;
584
-
585
- try {
586
- submitting.value = true;
587
- const response = await uploadAreas(file, props.site, props.serviceType);
588
- showMessage(response?.message, "success");
589
-
590
- await getTaskRefresh();
591
- } catch (err: any) {
592
- showMessage(err?.data?.message, "error");
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
- <input ref="bulkUploadFileInput" type="file" accept=".xlsx,.xls,.csv" class="d-none"
29
- @change="handleBulkUploadFileChange" />
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
- function openBulkUploadFilePicker() {
343
- if (bulkUploadLoading.value) {
344
- return;
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
- bulkUploadFile.value = null;
348
- bulkUploadSkippedErrors.value = [];
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
- const link = document.createElement("a");
359
- link.href = "/files/Bulk-Upload-Unit-Template.xlsx";
360
- link.download = "Bulk-Upload-Unit-Template.xlsx";
361
- document.body.appendChild(link);
362
- link.click();
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 as a CSV file. Each row is one camera:
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
- function downloadTemplate() {
244
- const blob = new Blob([cameraCsvTemplate()], { type: "text/csv" });
245
- const url = URL.createObjectURL(blob);
246
- const link = document.createElement("a");
247
- link.href = url;
248
- link.download = "camera-survey-sheet.csv";
249
- link.click();
250
- URL.revokeObjectURL(url);
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
- const parsed = parseCameraCsv(await chosen.text());
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.`;
@@ -31,7 +31,9 @@
31
31
  <span class="text-blue-grey">Balance: {{ item.qty }}</span>
32
32
  </div>
33
33
  </div>
34
- <div v-else class="print-kpi-meta mt-2">No supplies recorded</div>
34
+ <div v-else class="print-kpi-meta mt-2">
35
+ {{ card.raw?.basis === "below-alert-level" ? "0 · every item is above its alert level" : "No supplies recorded" }}
36
+ </div>
35
37
  </template>
36
38
  <template v-else>
37
39
  <div class="print-kpi-value">{{ card.value }}</div>
@@ -401,6 +403,11 @@
401
403
  <span class="text-medium-emphasis">Balance: {{ item.qty }}</span>
402
404
  </div>
403
405
  </div>
406
+ <!-- Nothing low: say so with the number, not "No data" - that reads as "not set up". -->
407
+ <div v-else-if="card.raw?.basis === 'below-alert-level'" class="d-flex flex-column">
408
+ <span class="text-h5 font-weight-bold text-high-emphasis">0</span>
409
+ <span class="text-body-2 text-medium-emphasis">Every item is above its alert level</span>
410
+ </div>
404
411
  <DashboardEmptyState v-else compact />
405
412
  </template>
406
413
  </AppKpiTile>
@@ -1230,7 +1237,7 @@ import type { CookieOptions } from "#app";
1230
1237
  // Explicitly, not by auto-import: every other component in this layer reaches
1231
1238
  // its utils by relative path (`StatusChip.vue`, `CameraWall.vue`,
1232
1239
  // `plugins/vuetify.ts`), and a bare call resolved to nothing when driven.
1233
- import { buildWorkOrderStatus } from "../utils/dashboard";
1240
+ import { buildWorkOrderStatus, chartTopValue } from "../utils/dashboard";
1234
1241
  import { permissionsDenied } from "../utils/module-routes";
1235
1242
  import { grantsPermission } from "../utils/permission-spellings";
1236
1243
  import { workerTerm, workerTitle } from "../utils/worker-terms";
@@ -2155,8 +2162,8 @@ const customizeWidgets = computed(() => {
2155
2162
  },
2156
2163
  {
2157
2164
  key: "activity",
2158
- title: "This Week Activity (Line Chart)",
2159
- description: "Compare incidents and work orders over time.",
2165
+ title: "Activity chart",
2166
+ description: "Work orders opened and closed over the chosen period.",
2160
2167
  },
2161
2168
  {
2162
2169
  key: "upcomingEvents",
@@ -2217,8 +2224,8 @@ const customizeWidgets = computed(() => {
2217
2224
  },
2218
2225
  {
2219
2226
  key: "activity",
2220
- title: "This Week Activity (Line Chart)",
2221
- description: "Compare incidents and work orders over time.",
2227
+ title: "Activity chart",
2228
+ description: "Work orders opened and closed over the chosen period.",
2222
2229
  },
2223
2230
  {
2224
2231
  key: "activePatrol",
@@ -2714,7 +2721,7 @@ const secondarySeries = computed(() =>
2714
2721
  const primaryLegendLabel = computed(() => activityChart.value.primaryLabel);
2715
2722
  const secondaryLegendLabel = computed(() => activityChart.value.secondaryLabel);
2716
2723
  const chartMax = computed(() =>
2717
- Math.max(1, ...primarySeries.value, ...secondarySeries.value)
2724
+ chartTopValue([...primarySeries.value, ...secondarySeries.value])
2718
2725
  );
2719
2726
  const hasChartData = computed(
2720
2727
  () => primarySeries.value.some(Boolean) || secondarySeries.value.some(Boolean)
@@ -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>