@7365admin1/layer-common 4.64.3 → 4.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.65.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2d6671a: Excel templates made live from the property: every import (inventory items, receive stock, building units, areas, units, vehicles, cameras) downloads a template with dropdowns, an example row and an Instructions sheet, and the server-checked imports show every row before saving and save nothing while a row needs fixing. Add Item records opening stock like a delivery (date, supplier, PO no., received by).
8
+
9
+ ### Patch Changes
10
+
11
+ - d7302ad: Make the access card screens use the site's own Entrypass URL. Add Access
12
+ Card, the card preview dialog and Assign to Unit all called
13
+ `/api/encrypt-acm-url` with no `url`, so their door/lift levels, access
14
+ groups and card assignments went to the route's hardcoded default host
15
+ whatever the site had configured under Entry Pass settings. Only the
16
+ contractor pass in `VisitorForm` honoured the setting. A new
17
+ `useSiteEntryPassSettings().getEncryptedAcmUrl(siteId)` resolves the site's
18
+ URL first and falls back to the default when none is set or the settings
19
+ cannot be read; the three screens now call it.
20
+ - 955c89c: Let the entry pass card picker be searched and paged. The "Select Cards"
21
+ field was a `v-select` over the first 100 available cards, which only let
22
+ the operator scroll. It is now a `v-autocomplete`: typing narrows the list
23
+ at once and, after a 500ms debounce, sends the term to the available-cards
24
+ endpoint as `search`; reaching the end of the list fetches the next page and
25
+ appends it. Responses that arrive after a newer request are dropped, so a slow
26
+ search cannot overwrite a fast one and a late page cannot be appended onto a
27
+ list that was replaced. API-core does not read `search` yet; until it does,
28
+ Vuetify's client-side match on `cardNo` is what narrows the list.
29
+ - b80bfdf: Print the entry pass with one large QR code instead of five. The pass carried
30
+ four small copies at the corners around a centre one so a strip could be cut
31
+ apart; the small copies were downscaled from the centre render and scanned
32
+ poorly on thermal paper. `createReceiptLayout` now draws a single 350px QR,
33
+ generated at the size it is printed, in the same band above the company name.
34
+ The `QrTemplatePreview` dialog shows the same single 260px QR so the preview
35
+ matches what comes out of the printer.
36
+
37
+ ## 4.64.4
38
+
39
+ ### Patch Changes
40
+
41
+ - d47f886: Invite member: when the Role list cannot be loaded (for example no access to the organisation's roles), it says so instead of "No roles have been created for this site yet".
42
+
3
43
  ## 4.64.3
4
44
 
5
45
  ### Patch Changes
@@ -1715,11 +1755,11 @@
1715
1755
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1716
1756
  own rule from two endpoints the console already calls, unprojected:
1717
1757
 
1718
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1719
- GET /api/roles/id/:role that membership's role document
1758
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1759
+ GET /api/roles/id/:role that membership's role document
1720
1760
 
1721
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1722
- staff = member.type === "admin" && role.type === "admin"
1761
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1762
+ staff = member.type === "admin" && role.type === "admin"
1723
1763
 
1724
1764
  `role.default` is the marker because it is the only property of a platform
1725
1765
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -433,7 +433,10 @@ const {
433
433
  bulkPhysicalAccessCard: _bulkPhysicalAccessCard,
434
434
  getBlockLevelUnitList: _getBlockLevelUnitList,
435
435
  } = useAccessManagement();
436
- const { getBySiteId: _getEntryPassSettings } = useSiteEntryPassSettings();
436
+ const {
437
+ getBySiteId: _getEntryPassSettings,
438
+ getEncryptedAcmUrl: _getEncryptedAcmUrl,
439
+ } = useSiteEntryPassSettings();
437
440
  const { getFileUrl } = useFile();
438
441
 
439
442
  const config = useRuntimeConfig();
@@ -588,9 +591,7 @@ watch(uploadMode, (mode) => {
588
591
 
589
592
  onMounted(async () => {
590
593
  try {
591
- const { encrypted: acmUrl } = await $fetch<{ encrypted: string }>(
592
- "/api/encrypt-acm-url"
593
- );
594
+ const acmUrl = await _getEncryptedAcmUrl(siteId);
594
595
  encryptedAcmUrl.value = acmUrl;
595
596
  const [doorLevels, liftLevels, groups] = await Promise.all([
596
597
  _getDoorAccessLevels(acmUrl),
@@ -203,6 +203,7 @@ const {
203
203
  getBlockLevelUnitList: _getBlockLevelUnitList,
204
204
  } = useAccessManagement();
205
205
  const { requiredRule } = useUtils();
206
+ const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
206
207
 
207
208
  const route = useRoute();
208
209
  const siteId = computed(() => props.siteId || (route.params.site as string));
@@ -294,7 +295,7 @@ onMounted(async () => {
294
295
 
295
296
  const [blockLevelResult, acmResult] = await Promise.allSettled([
296
297
  _getBlockLevelUnitList(siteId.value),
297
- $fetch<{ encrypted: string }>("/api/encrypt-acm-url"),
298
+ getEncryptedAcmUrl(siteId.value),
298
299
  ]);
299
300
 
300
301
  // Blocks / Levels / Units
@@ -309,7 +310,7 @@ onMounted(async () => {
309
310
 
310
311
  // ACM levels (door + lift)
311
312
  if (acmResult.status === "fulfilled") {
312
- const acmUrl = acmResult.value.encrypted;
313
+ const acmUrl = acmResult.value;
313
314
  try {
314
315
  const [doorLevels, liftLevels] = await Promise.all([
315
316
  _getDoorAccessLevels(acmUrl),
@@ -403,6 +403,7 @@ const emit = defineEmits<{
403
403
  }>();
404
404
 
405
405
  const { getResidents, assignUser } = useAccessManagement();
406
+ const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
406
407
 
407
408
  const historyDialog = ref(false);
408
409
  const showAssignForm = ref(false);
@@ -475,12 +476,12 @@ async function openAssignForm() {
475
476
  if (!props.unit?._id || !props.orgId || !props.siteId) return;
476
477
  peopleLoading.value = true;
477
478
  try {
478
- const [residentsRes, acmRes] = await Promise.all([
479
+ const [residentsRes, acmUrl] = await Promise.all([
479
480
  getResidents({ orgId: props.orgId, siteId: props.siteId, unitId: props.unit._id }),
480
- $fetch<{ encrypted: string }>("/api/encrypt-acm-url"),
481
+ getEncryptedAcmUrl(props.siteId),
481
482
  ]);
482
483
  peopleItems.value = residentsRes?.data ?? [];
483
- encryptedAcmUrl.value = acmRes?.encrypted ?? "";
484
+ encryptedAcmUrl.value = acmUrl ?? "";
484
485
  } catch {
485
486
  peopleItems.value = [];
486
487
  } finally {
@@ -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.`;
@@ -32,28 +32,18 @@
32
32
  </v-col>
33
33
 
34
34
  <v-col cols="12" class="mt-5">
35
- <!-- UPPER QR -->
36
- <v-row no-gutters>
37
- <v-col cols="6">
38
- <img v-if="qrSmall" :src="qrSmall" width="80" height="80" />
39
- </v-col>
40
- <v-col cols="6">
41
- <img v-if="qrSmall" :src="qrSmall" width="80" height="80" />
42
- </v-col>
43
- </v-row>
44
-
45
- <!-- CENTER QR -->
46
- <img v-if="qrLarge" :src="qrLarge" width="130" height="130" class="my-2" />
47
-
48
- <!-- LOWER QR -->
49
- <v-row no-gutters>
50
- <v-col cols="6">
51
- <img v-if="qrSmall" :src="qrSmall" width="80" height="80" />
52
- </v-col>
53
- <v-col cols="6">
54
- <img v-if="qrSmall" :src="qrSmall" width="80" height="80" />
55
- </v-col>
56
- </v-row>
35
+ <!-- The pass used to print five QRs - four 80px copies at the
36
+ corners around a 130px centre one - so a strip could be cut
37
+ apart. It now carries a single large QR, matching what
38
+ `createReceiptLayout` sends to the thermal printer. -->
39
+ <img
40
+ v-if="qrLarge"
41
+ :src="qrLarge"
42
+ width="260"
43
+ height="260"
44
+ class="qr-preview__code my-2"
45
+ alt="Entry pass QR code"
46
+ />
57
47
  </v-col>
58
48
 
59
49
  <v-col cols="12" class="mt-5">
@@ -90,12 +80,11 @@ defineProps({
90
80
 
91
81
  const emit = defineEmits(["update:modelValue"]);
92
82
 
93
- const qrSmall = ref<string>("");
94
83
  const qrLarge = ref<string>("");
95
84
 
96
85
  onMounted(async () => {
97
- qrSmall.value = await QRCode.toDataURL("123123", { width: 80, margin: 1 });
98
- qrLarge.value = await QRCode.toDataURL("123123", { width: 130, margin: 1 });
86
+ // Rendered at the size it is displayed at, so the modules stay crisp.
87
+ qrLarge.value = await QRCode.toDataURL("123123", { width: 260, margin: 1 });
99
88
  });
100
89
 
101
90
  const now = new Date();
@@ -112,4 +101,11 @@ const timeStr = now.toLocaleTimeString();
112
101
  font-weight: var(--fw-card-title);
113
102
  color: var(--text);
114
103
  }
104
+
105
+ /* The dialog is 550px wide but narrows on small screens, so the QR is capped
106
+ at its own width rather than a fixed 260px that would overflow the card. */
107
+ .qr-preview__code {
108
+ max-width: 100%;
109
+ height: auto;
110
+ }
115
111
  </style>