@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 +44 -4
- package/components/AccessCardAddForm.vue +5 -4
- package/components/AccessCardAssignToUnitForm.vue +3 -2
- package/components/AccessCardPreviewDialog.vue +4 -3
- package/components/AreaMain.vue +33 -30
- package/components/BuildingManagement/units.vue +30 -21
- package/components/CameraBulkImportDialog.vue +41 -12
- package/components/EntryPass/QrTemplatePreview.vue +21 -25
- package/components/EntryPassInformation.vue +119 -24
- package/components/ImportExcelDialog.vue +136 -0
- package/components/InventoryImportDialog.vue +33 -104
- package/components/InventoryItemDetail.vue +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/useAccessManagement.ts +5 -0
- package/composables/useImportTemplates.ts +24 -0
- package/composables/useInventory.ts +12 -5
- package/composables/useSiteEntryPassSettings.ts +26 -0
- package/composables/useWebUsb.ts +14 -11
- package/package.json +1 -1
- package/types/inventory.d.ts +2 -0
- package/utils/camera-csv.ts +2 -0
- package/utils/import-result.ts +62 -0
- package/utils/vehicle-bulk-sheet.ts +59 -3
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
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
479
|
+
const [residentsRes, acmUrl] = await Promise.all([
|
|
479
480
|
getResidents({ orgId: props.orgId, siteId: props.siteId, unitId: props.unit._id }),
|
|
480
|
-
|
|
481
|
+
getEncryptedAcmUrl(props.siteId),
|
|
481
482
|
]);
|
|
482
483
|
peopleItems.value = residentsRes?.data ?? [];
|
|
483
|
-
encryptedAcmUrl.value =
|
|
484
|
+
encryptedAcmUrl.value = acmUrl ?? "";
|
|
484
485
|
} catch {
|
|
485
486
|
peopleItems.value = [];
|
|
486
487
|
} finally {
|
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.`;
|
|
@@ -32,28 +32,18 @@
|
|
|
32
32
|
</v-col>
|
|
33
33
|
|
|
34
34
|
<v-col cols="12" class="mt-5">
|
|
35
|
-
<!--
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
98
|
-
qrLarge.value = await QRCode.toDataURL("123123", { width:
|
|
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>
|