@7365admin1/layer-common 4.64.4 → 4.65.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +50 -4
  2. package/assets/css/primitives.css +13 -0
  3. package/components/AccessCardAddForm.vue +5 -4
  4. package/components/AccessCardAssignToUnitForm.vue +3 -2
  5. package/components/AccessCardPreviewDialog.vue +4 -3
  6. package/components/AreaMain.vue +33 -30
  7. package/components/BuildingManagement/units.vue +30 -21
  8. package/components/CameraBulkImportDialog.vue +41 -12
  9. package/components/DashboardMain.vue +14 -7
  10. package/components/EntryPass/QrTemplatePreview.vue +21 -25
  11. package/components/EntryPassInformation.vue +119 -24
  12. package/components/ImportExcelDialog.vue +136 -0
  13. package/components/InventoryImportDialog.vue +33 -104
  14. package/components/InventoryItemDetail.vue +3 -2
  15. package/components/InventoryItemDialog.vue +62 -7
  16. package/components/InventoryReportsTab.vue +2 -1
  17. package/components/InventorySettingsTab.vue +2 -5
  18. package/components/InventoryStockTab.vue +13 -4
  19. package/components/NotificationBell.vue +1 -1
  20. package/components/NotificationSettings.vue +1 -1
  21. package/components/Robot/MiniMap.vue +1 -1
  22. package/components/UnitMain.vue +30 -28
  23. package/components/VehicleBulkAddForm.vue +30 -16
  24. package/composables/useAccessManagement.ts +5 -0
  25. package/composables/useImportTemplates.ts +24 -0
  26. package/composables/useInventory.ts +12 -5
  27. package/composables/useSiteEntryPassSettings.ts +26 -0
  28. package/composables/useWebUsb.ts +14 -11
  29. package/package.json +1 -1
  30. package/types/inventory.d.ts +2 -0
  31. package/utils/camera-csv.ts +2 -0
  32. package/utils/dashboard.ts +11 -0
  33. package/utils/import-result.ts +62 -0
  34. package/utils/inventory-store-label.ts +27 -0
  35. package/utils/vehicle-bulk-sheet.ts +59 -3
@@ -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-btn v-if="store.access.canManage && !storeEmpty" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-truck-delivery-outline" @click="open('receive')">
14
- Receive Stock
15
- </v-btn>
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>
@@ -61,7 +67,7 @@
61
67
  </v-avatar>
62
68
  <div>
63
69
  <div class="font-weight-medium">{{ item.name }}</div>
64
- <div class="text-caption text-medium-emphasis">{{ [item.code, item.category, item.packing].filter(Boolean).join(" · ") }}</div>
70
+ <div class="text-caption text-medium-emphasis">{{ inventoryItemMeta(item) }}</div>
65
71
  </div>
66
72
  </div>
67
73
  </template>
@@ -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>
@@ -95,6 +102,7 @@
95
102
  import useInventory from "../composables/useInventory";
96
103
  import useFile from "../composables/useFile";
97
104
  import useUtils from "../composables/useUtils";
105
+ import { inventoryItemMeta } from "../utils/inventory-store-label";
98
106
 
99
107
  const props = defineProps({
100
108
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -167,6 +175,7 @@ function openItem(item: TInventoryItem) {
167
175
  }
168
176
 
169
177
  const showImport = ref(false);
178
+ const showReceiveImport = ref(false);
170
179
  const showLegacy = ref(false);
171
180
 
172
181
  function done() {
@@ -30,7 +30,7 @@
30
30
 
31
31
  <v-card :width="440" elevation="0" class="notif-panel">
32
32
  <div class="notif-panel__head">
33
- <span>Notification</span>
33
+ <span>Notifications</span>
34
34
  <v-btn
35
35
  v-if="unread > 0"
36
36
  variant="text"
@@ -8,7 +8,7 @@
8
8
 
9
9
  <v-col cols="12" md="8" class="screen-hint intro-copy">
10
10
  Choose what you want to hear about, and how. These settings are yours
11
- alone and apply everywhere you use iService — on this site and on your
11
+ alone and apply everywhere you use iService365 — on this site and on your
12
12
  phone.
13
13
  </v-col>
14
14
 
@@ -49,7 +49,7 @@
49
49
  </RobotFloorPlan>
50
50
 
51
51
  <div class="hero-mini__foot">
52
- <span>{{ shownMap ? `Robot map · ${shownMap.label}` : "Floor plan from robot (placeholder)" }}</span>
52
+ <span>{{ shownMap ? `Robot map · ${shownMap.label}` : "Sample layout, not this building's map" }}</span>
53
53
  <span v-if="type === 'security'"><span class="hero-mini__legend" aria-hidden="true" />Patrol routes</span>
54
54
  <span v-if="showRoutes && hasRoutes"><span class="hero-mini__legend hero-mini__legend--lane" aria-hidden="true" />Robot lanes</span>
55
55
  <span v-if="floor !== null" class="hero-mini__count">{{ robots.length }} on {{ floorName(floor) }}</span>
@@ -37,9 +37,8 @@
37
37
  v-bind="t"
38
38
  class="ma-1 screen-icon-btn"
39
39
  variant="flat"
40
- href="/files/unit-sample-data.xlsx"
41
- target="_blank"
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
- <input
187
- ref="fileInput"
188
- type="file"
189
- accept=".csv,.xlsx,.xls"
190
- style="display: none"
191
- @change="onFileSelected"
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
- fileInput.value?.click();
350
+ showImport.value = true;
345
351
  }
346
352
 
347
- async function onFileSelected(event: Event) {
348
- const target = event.target as HTMLInputElement;
349
- const file = target.files?.[0];
350
-
351
- if (!file) return;
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
- submitting.value = true;
355
-
356
- const response = await uploadUnits(file, props.site, props.serviceType);
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
- submitting.value = false;
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
- /** The blank sheet, for filling in somewhere else. Opens in Excel or Sheets. */
1047
- function downloadTemplate() {
1048
- // A BOM, so Excel opens it as UTF-8 and a name with an accent survives.
1049
- const blob = new Blob(["", vehicleBulkTemplateCsv()], {
1050
- type: "text/csv;charset=utf-8;",
1051
- });
1052
- const url = URL.createObjectURL(blob);
1053
- const link = document.createElement("a");
1054
- link.href = url;
1055
- link.download = "vehicle-whitelist-template.csv";
1056
- link.click();
1057
- URL.revokeObjectURL(url);
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 grid = isWorkbookName(chosen.name)
1087
- ? await xlsxToGrid(await chosen.arrayBuffer())
1088
- : parsePastedGrid(await chosen.text());
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}. Check anything ` +
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 {
@@ -228,7 +228,11 @@ export default function useAccessManagement() {
228
228
  unitId: string;
229
229
  page?: number;
230
230
  limit?: number;
231
+ /** Card-number search. Sent only when non-empty; API-core does not read
232
+ * it yet, so until it does the list comes back unfiltered. */
233
+ search?: string;
231
234
  }) {
235
+ const search = params.search?.trim();
232
236
  return useNuxtApp().$api<Record<string, any>>(
233
237
  `/api/access-management/available-card-contractor/${params.type}`,
234
238
  {
@@ -238,6 +242,7 @@ export default function useAccessManagement() {
238
242
  unitId: params.unitId,
239
243
  page: params.page ?? 1,
240
244
  limit: params.limit ?? 100,
245
+ ...(search ? { search } : {}),
241
246
  },
242
247
  }
243
248
  );
@@ -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
- importTemplate: () => api<Blob>("/import-template", { responseType: "blob" }),
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<{ committed: boolean; ready: number; skipped: number; errors: number; rows: { row: number; name: string; status: string; message: string }[] }>(
75
- `/stores/${store}/import`,
76
- { method: "POST", body, query: { commit } },
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
  }
@@ -27,9 +27,35 @@ export default function useSiteEntryPassSettings() {
27
27
  );
28
28
  }
29
29
 
30
+ /**
31
+ * The encrypted ACM URL the access-card screens hand to API-core, which
32
+ * decrypts it and sends the ACM commands to that host.
33
+ *
34
+ * Resolves the site's own "Enter entrypass URL" setting first, the way
35
+ * `VisitorForm` does for contractor passes. The access-card forms used to
36
+ * call `/api/encrypt-acm-url` with no `url`, so every site's door/lift
37
+ * levels and access groups came from the route's hardcoded default host
38
+ * whatever the site had configured. A site with no URL set, or whose
39
+ * settings cannot be read, still gets that default.
40
+ */
41
+ async function getEncryptedAcmUrl(siteId: string): Promise<string> {
42
+ let rawUrl = "";
43
+ try {
44
+ const settings: any = await getBySiteId(siteId);
45
+ rawUrl = String(settings?.data?.settings?.url ?? "").trim();
46
+ } catch {
47
+ // No entry pass settings for this site yet - the default host applies.
48
+ }
49
+ const { encrypted } = await $fetch<{ encrypted: string }>(
50
+ `/api/encrypt-acm-url${rawUrl ? `?url=${encodeURIComponent(rawUrl)}` : ""}`,
51
+ );
52
+ return encrypted;
53
+ }
54
+
30
55
  return {
31
56
  getBySiteId,
32
57
  getById,
33
58
  save,
59
+ getEncryptedAcmUrl,
34
60
  };
35
61
  }
@@ -345,16 +345,19 @@ export default function useWebUsb() {
345
345
  70,
346
346
  );
347
347
 
348
- const qrSmall = 140;
349
- const qrCenter = 250;
350
- const margin = 20;
351
- const qrCanvas = await QRCode.toCanvas(qrUrlImage, { width: qrCenter });
348
+ // The pass used to print five copies of the same QR - four 140px ones at
349
+ // the corners around a 250px centre - so a strip could be cut apart. It now
350
+ // prints a single large QR filling that same band (y 90 to 440, above the
351
+ // company name at y 470), which scans far more reliably on a thermal print.
352
+ // Generated at the size it is drawn at: the old corner copies were
353
+ // downscaled from the 250px canvas, which softened their modules.
354
+ const qrSize = 350;
355
+ const qrCanvas = await QRCode.toCanvas(qrUrlImage, {
356
+ width: qrSize,
357
+ margin: 1,
358
+ });
352
359
 
353
- ctx.drawImage(qrCanvas, margin, 90, qrSmall, qrSmall);
354
- ctx.drawImage(qrCanvas, canvas.width - qrSmall - margin, 90, qrSmall, qrSmall);
355
- ctx.drawImage(qrCanvas, (canvas.width - qrCenter) / 2, 110, qrCenter, qrCenter);
356
- ctx.drawImage(qrCanvas, margin, 300, qrSmall, qrSmall);
357
- ctx.drawImage(qrCanvas, canvas.width - qrSmall - margin, 300, qrSmall, qrSmall);
360
+ ctx.drawImage(qrCanvas, (canvas.width - qrSize) / 2, 90, qrSize, qrSize);
358
361
 
359
362
  ctx.font = "25px Arial";
360
363
  ctx.fillText(companyName || "", canvas.width / 2, 470);
@@ -401,8 +404,8 @@ export default function useWebUsb() {
401
404
  /**
402
405
  * The self-service onboarding QR: one large QR code, a "SCAN QR CODE"-style
403
406
  * subtext, and a dark site-name banner along the bottom - a single-purpose
404
- * gate sign, not the multi-copy visitor-pass receipt `createReceiptLayout`
405
- * produces (that one prints several QRs to be cut apart and handed out).
407
+ * gate sign, not the visitor-pass receipt `createReceiptLayout` produces
408
+ * (that one pairs its QR with company, date, access and instruction lines).
406
409
  */
407
410
  async function createOnboardingQrLayout(
408
411
  qrUrlImage: string,
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.64.4",
5
+ "version": "4.65.1",
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.",
@@ -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
  };
@@ -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(/^/, "")
@@ -51,3 +51,14 @@ export function buildWorkOrderStatus(
51
51
  { label: "Pending", value: pending, percent: (pending / max) * 100, tone: "warn" },
52
52
  ];
53
53
  }
54
+
55
+ /**
56
+ * The activity chart's top value. The six y-axis labels are max x 0, .2 ... 1
57
+ * rounded, so a raw max of 1 printed "0 0 0 1 1 1" - the same number on four
58
+ * lines. Rounding the top up to a multiple of 5 makes every label a distinct
59
+ * whole number (0 1 2 3 4 5, 0 2 4 6 8 10, ...).
60
+ */
61
+ export function chartTopValue(values: number[]) {
62
+ const max = Math.max(0, ...values.filter((v) => Number.isFinite(v)));
63
+ return Math.max(5, Math.ceil(max / 5) * 5);
64
+ }
@@ -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
+ }
@@ -18,3 +18,30 @@ export function inventoryStoreLabel(store: TStoreLike, all: TStoreLike[]) {
18
18
  const sameKind = all.filter((s) => s.ownerType === store.ownerType).length;
19
19
  return sameKind > 1 && store.orgName ? `${kind} · ${store.orgName}` : kind;
20
20
  }
21
+
22
+ /**
23
+ * The properties a store can be set to supply, by name. The organisation's own
24
+ * site list does not always contain the store's current property (a manager
25
+ * can run a store at a site another organisation owns), so the store's own
26
+ * resolved names fill the gap - a chip must never read "Current property"
27
+ * when the store already knows it is Seventh Condominium.
28
+ */
29
+ export function inventorySiteOptions(
30
+ orgSites: { _id: string; name: string }[],
31
+ store: { sites: string[]; siteNames?: { _id: string; name?: string }[] },
32
+ ) {
33
+ const known = new Map(orgSites.map((s) => [s._id, s.name]));
34
+ for (const id of store.sites) {
35
+ if (!known.has(id)) known.set(id, store.siteNames?.find((s) => s._id === id)?.name || "Another property");
36
+ }
37
+ return [...known].map(([_id, name]) => ({ _id, name }));
38
+ }
39
+
40
+ type TItemLike = { code?: string; category?: string; packing?: string };
41
+
42
+ /** "Code 001 · Bulb · Packing: 25" - each figure says what it is, never a bare number. */
43
+ export function inventoryItemMeta(item: TItemLike) {
44
+ return [item.code && `Code ${item.code}`, item.category, item.packing && `Packing: ${item.packing}`]
45
+ .filter(Boolean)
46
+ .join(" · ");
47
+ }