@7365admin1/layer-common 4.1.3-staging.250 → 4.1.3-staging.252
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/CameraBulkImportDialog.vue +330 -0
- package/components/CameraMain.vue +125 -9
- package/components/PublicOnboarding/ContractorRegistrationForm.vue +17 -5
- package/components/PublicOnboarding/ShareDialog.vue +93 -0
- package/components/PublicOnboarding/VisitorQrCodeLinksCard.vue +113 -0
- package/components/VisitorManagement.vue +1 -1
- package/composables/usePublicVisitorOnboarding.ts +14 -0
- package/composables/useSiteSettings.ts +37 -0
- package/package.json +1 -1
- package/utils/camera-csv.ts +169 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card width="100%" class="screen-modal">
|
|
3
|
+
<v-card-title class="text-capitalize">Import Cameras</v-card-title>
|
|
4
|
+
|
|
5
|
+
<v-card-text class="screen-modal__body pa-5">
|
|
6
|
+
<!-- Step 1 — the file. -->
|
|
7
|
+
<template v-if="!results.length">
|
|
8
|
+
<p class="text-body-2 mb-4">
|
|
9
|
+
Upload the camera survey sheet as a CSV file. Each row is one camera:
|
|
10
|
+
its name, the recorder it is on, and its channel number on that
|
|
11
|
+
recorder. The live-view address is worked out for you — do not type
|
|
12
|
+
one.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<v-file-input
|
|
16
|
+
v-model="file"
|
|
17
|
+
accept=".csv,text/csv"
|
|
18
|
+
density="comfortable"
|
|
19
|
+
variant="outlined"
|
|
20
|
+
prepend-icon=""
|
|
21
|
+
prepend-inner-icon="mdi-file-delimited-outline"
|
|
22
|
+
label="Camera survey sheet (.csv)"
|
|
23
|
+
:disabled="busy"
|
|
24
|
+
@update:model-value="readFile"
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
<v-btn
|
|
28
|
+
variant="text"
|
|
29
|
+
size="small"
|
|
30
|
+
class="text-none px-0"
|
|
31
|
+
prepend-icon="mdi-download"
|
|
32
|
+
:disabled="busy"
|
|
33
|
+
@click="downloadTemplate"
|
|
34
|
+
>
|
|
35
|
+
Download a blank sheet
|
|
36
|
+
</v-btn>
|
|
37
|
+
|
|
38
|
+
<v-alert
|
|
39
|
+
v-if="fileError"
|
|
40
|
+
type="error"
|
|
41
|
+
variant="tonal"
|
|
42
|
+
density="comfortable"
|
|
43
|
+
class="mt-4"
|
|
44
|
+
:text="fileError"
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
<v-alert
|
|
48
|
+
v-if="notes.length"
|
|
49
|
+
type="info"
|
|
50
|
+
variant="tonal"
|
|
51
|
+
density="comfortable"
|
|
52
|
+
class="mt-4"
|
|
53
|
+
>
|
|
54
|
+
<div v-for="note in notes" :key="note">{{ note }}</div>
|
|
55
|
+
</v-alert>
|
|
56
|
+
|
|
57
|
+
<p class="text-caption mt-4 mb-0">
|
|
58
|
+
Required columns: camera_name, recorder_id, channel_number,
|
|
59
|
+
for_cctv_wall, for_virtual_patrol, is_anpr. ANPR cameras are set up in
|
|
60
|
+
the ANPR section — mark them <strong>is_anpr = Yes</strong> and they
|
|
61
|
+
will be left out of this import.
|
|
62
|
+
</p>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<!-- Step 2 — what will happen, or what happened. -->
|
|
66
|
+
<template v-else>
|
|
67
|
+
<v-alert
|
|
68
|
+
:type="committed ? (summary.rejected ? 'warning' : 'success') : 'info'"
|
|
69
|
+
variant="tonal"
|
|
70
|
+
density="comfortable"
|
|
71
|
+
class="mb-4"
|
|
72
|
+
>
|
|
73
|
+
{{ summaryText }}
|
|
74
|
+
</v-alert>
|
|
75
|
+
|
|
76
|
+
<v-alert
|
|
77
|
+
v-if="!committed && notes.length"
|
|
78
|
+
type="info"
|
|
79
|
+
variant="tonal"
|
|
80
|
+
density="comfortable"
|
|
81
|
+
class="mb-4"
|
|
82
|
+
>
|
|
83
|
+
<div v-for="note in notes" :key="note">{{ note }}</div>
|
|
84
|
+
</v-alert>
|
|
85
|
+
|
|
86
|
+
<v-data-table
|
|
87
|
+
:headers="resultHeaders"
|
|
88
|
+
:items="results"
|
|
89
|
+
density="compact"
|
|
90
|
+
fixed-header
|
|
91
|
+
hide-default-footer
|
|
92
|
+
:items-per-page="-1"
|
|
93
|
+
style="max-height: 420px"
|
|
94
|
+
>
|
|
95
|
+
<template #item.outcome="{ value }">
|
|
96
|
+
<v-chip size="small" :color="outcomeColour(value)" variant="tonal">
|
|
97
|
+
{{ outcomeLabel(value) }}
|
|
98
|
+
</v-chip>
|
|
99
|
+
</template>
|
|
100
|
+
<template #item.channel="{ value }">
|
|
101
|
+
{{ value ?? "—" }}
|
|
102
|
+
</template>
|
|
103
|
+
<template #item.reason="{ value }">
|
|
104
|
+
<span class="text-body-2">{{ value ?? "" }}</span>
|
|
105
|
+
</template>
|
|
106
|
+
</v-data-table>
|
|
107
|
+
</template>
|
|
108
|
+
</v-card-text>
|
|
109
|
+
|
|
110
|
+
<v-card-actions class="screen-modal__footer">
|
|
111
|
+
<v-row no-gutters>
|
|
112
|
+
<v-col cols="6" class="pr-2">
|
|
113
|
+
<v-btn
|
|
114
|
+
block
|
|
115
|
+
variant="outlined"
|
|
116
|
+
class="text-none screen-btn-ghost"
|
|
117
|
+
:disabled="busy"
|
|
118
|
+
@click="close"
|
|
119
|
+
:text="committed ? 'Close' : 'Cancel'"
|
|
120
|
+
/>
|
|
121
|
+
</v-col>
|
|
122
|
+
<v-col cols="6" class="pl-2">
|
|
123
|
+
<v-btn
|
|
124
|
+
v-if="!committed"
|
|
125
|
+
block
|
|
126
|
+
variant="flat"
|
|
127
|
+
class="text-none screen-btn-primary"
|
|
128
|
+
:disabled="busy || !summary.ok"
|
|
129
|
+
:loading="busy"
|
|
130
|
+
@click="commit"
|
|
131
|
+
:text="summary.ok ? `Add ${summary.ok} camera(s)` : 'Nothing to add'"
|
|
132
|
+
/>
|
|
133
|
+
<v-btn
|
|
134
|
+
v-else
|
|
135
|
+
block
|
|
136
|
+
variant="flat"
|
|
137
|
+
class="text-none screen-btn-primary"
|
|
138
|
+
@click="startOver"
|
|
139
|
+
text="Import another file"
|
|
140
|
+
/>
|
|
141
|
+
</v-col>
|
|
142
|
+
</v-row>
|
|
143
|
+
</v-card-actions>
|
|
144
|
+
</v-card>
|
|
145
|
+
</template>
|
|
146
|
+
|
|
147
|
+
<script setup lang="ts">
|
|
148
|
+
import useSiteSettings from "../composables/useSiteSettings";
|
|
149
|
+
import { cameraCsvTemplate, parseCameraCsv } from "../utils/camera-csv";
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Adding a site's cameras from the technician's survey sheet.
|
|
153
|
+
*
|
|
154
|
+
* A ~150-camera site could only ever be entered one camera at a time, through a
|
|
155
|
+
* two-field modal, using a live-view address that nobody outside the video
|
|
156
|
+
* service can work out. This screen takes the survey sheet instead.
|
|
157
|
+
*
|
|
158
|
+
* **The server decides everything.** This component parses the file and shows
|
|
159
|
+
* what came back; it does not judge a row. The preview and the commit are the
|
|
160
|
+
* same server call with `commit` flipped, so what a person approves here cannot
|
|
161
|
+
* disagree with what is then written — the failure that makes a bulk import
|
|
162
|
+
* frightening. That is also why there is no "fix it here" editing: a corrected
|
|
163
|
+
* sheet is re-uploaded, and the file on disk stays the record of what was
|
|
164
|
+
* asked for.
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
/** The rows one file may carry. The server enforces the same number. */
|
|
168
|
+
const MAX_ROWS = 200;
|
|
169
|
+
|
|
170
|
+
const prop = defineProps({
|
|
171
|
+
site: {
|
|
172
|
+
type: String,
|
|
173
|
+
required: true,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const emit = defineEmits(["cancel", "imported"]);
|
|
178
|
+
|
|
179
|
+
const { bulkImportSiteCameras } = useSiteSettings();
|
|
180
|
+
|
|
181
|
+
const file = ref<File | File[] | null>(null);
|
|
182
|
+
const fileError = ref("");
|
|
183
|
+
const notes = ref<string[]>([]);
|
|
184
|
+
const rows = ref<Array<Record<string, string>>>([]);
|
|
185
|
+
const results = ref<Array<Record<string, any>>>([]);
|
|
186
|
+
const summary = ref<Record<string, number>>({
|
|
187
|
+
total: 0,
|
|
188
|
+
ok: 0,
|
|
189
|
+
created: 0,
|
|
190
|
+
skipped: 0,
|
|
191
|
+
rejected: 0,
|
|
192
|
+
});
|
|
193
|
+
const committed = ref(false);
|
|
194
|
+
const busy = ref(false);
|
|
195
|
+
|
|
196
|
+
const resultHeaders = [
|
|
197
|
+
{ title: "Row", key: "row", width: 70 },
|
|
198
|
+
{ title: "Camera Name", key: "name" },
|
|
199
|
+
{ title: "Channel", key: "channel", width: 90 },
|
|
200
|
+
{ title: "Result", key: "outcome", width: 140 },
|
|
201
|
+
{ title: "Reason", key: "reason" },
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
const summaryText = computed(() => {
|
|
205
|
+
if (!committed.value) {
|
|
206
|
+
return `${summary.value.ok} camera(s) will be added, ${summary.value.skipped} already at this site, ${summary.value.rejected} cannot be added. Nothing has been saved yet.`;
|
|
207
|
+
}
|
|
208
|
+
return `${summary.value.created} camera(s) added, ${summary.value.skipped} already at this site, ${summary.value.rejected} not added.`;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
function outcomeLabel(outcome: string) {
|
|
212
|
+
if (outcome === "ok") return "Will be added";
|
|
213
|
+
if (outcome === "created") return "Added";
|
|
214
|
+
if (outcome === "skipped-duplicate") return "Already there";
|
|
215
|
+
return "Not added";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function outcomeColour(outcome: string) {
|
|
219
|
+
if (outcome === "created") return "success";
|
|
220
|
+
if (outcome === "ok") return "info";
|
|
221
|
+
if (outcome === "skipped-duplicate") return "warning";
|
|
222
|
+
return "error";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function reset() {
|
|
226
|
+
fileError.value = "";
|
|
227
|
+
notes.value = [];
|
|
228
|
+
rows.value = [];
|
|
229
|
+
results.value = [];
|
|
230
|
+
summary.value = { total: 0, ok: 0, created: 0, skipped: 0, rejected: 0 };
|
|
231
|
+
committed.value = false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function startOver() {
|
|
235
|
+
reset();
|
|
236
|
+
file.value = null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function close() {
|
|
240
|
+
emit("cancel");
|
|
241
|
+
}
|
|
242
|
+
|
|
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);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** What the sheet carries that a camera record has no field for. */
|
|
254
|
+
function describeUnstored(columns: string[]) {
|
|
255
|
+
if (!columns.length) return [];
|
|
256
|
+
return [
|
|
257
|
+
`These columns are read but not stored, because a camera record has nowhere to keep them: ${columns.join(", ")}. Use them to name the cameras and to build the patrol route in the right order.`,
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function readFile(selected: File | File[] | null) {
|
|
262
|
+
reset();
|
|
263
|
+
|
|
264
|
+
const chosen = Array.isArray(selected) ? selected[0] : selected;
|
|
265
|
+
if (!chosen) return;
|
|
266
|
+
|
|
267
|
+
const parsed = parseCameraCsv(await chosen.text());
|
|
268
|
+
|
|
269
|
+
if (parsed.missingColumns.length) {
|
|
270
|
+
fileError.value = `This file is missing: ${parsed.missingColumns.join(", ")}. Download the blank sheet to see the columns it needs.`;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!parsed.rows.length) {
|
|
275
|
+
fileError.value = "This file has column headings but no cameras in it.";
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (parsed.rows.length > MAX_ROWS) {
|
|
280
|
+
fileError.value = `This file has ${parsed.rows.length} rows. Import up to ${MAX_ROWS} at a time — split it and import each part.`;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
notes.value = [
|
|
285
|
+
...describeUnstored([...parsed.unstoredColumns]),
|
|
286
|
+
...(parsed.unknownColumns.length
|
|
287
|
+
? [
|
|
288
|
+
`These columns are not recognised and are ignored: ${parsed.unknownColumns.join(", ")}.`,
|
|
289
|
+
]
|
|
290
|
+
: []),
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
rows.value = parsed.rows;
|
|
294
|
+
await send(false);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function send(commitRows: boolean) {
|
|
298
|
+
busy.value = true;
|
|
299
|
+
try {
|
|
300
|
+
const response = await bulkImportSiteCameras({
|
|
301
|
+
site: prop.site,
|
|
302
|
+
rows: rows.value,
|
|
303
|
+
commit: commitRows,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
results.value = response?.results ?? [];
|
|
307
|
+
summary.value = response?.results
|
|
308
|
+
? response.summary
|
|
309
|
+
: { total: 0, ok: 0, created: 0, skipped: 0, rejected: 0 };
|
|
310
|
+
committed.value = Boolean(response?.committed);
|
|
311
|
+
|
|
312
|
+
if (committed.value) emit("imported", summary.value);
|
|
313
|
+
} catch (error: any) {
|
|
314
|
+
// A refusal from the server is shown as it was written — it is a sentence
|
|
315
|
+
// for the person at this screen, and rewriting it here would lose the one
|
|
316
|
+
// thing that tells them what to do next.
|
|
317
|
+
fileError.value =
|
|
318
|
+
error?.data?.message ||
|
|
319
|
+
error?.message ||
|
|
320
|
+
"The cameras could not be checked. Try again.";
|
|
321
|
+
results.value = [];
|
|
322
|
+
} finally {
|
|
323
|
+
busy.value = false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function commit() {
|
|
328
|
+
return send(true);
|
|
329
|
+
}
|
|
330
|
+
</script>
|
|
@@ -16,9 +16,29 @@
|
|
|
16
16
|
<AppButton v-if="!prop.readOnly" @click="dialogAdd = true">
|
|
17
17
|
Add
|
|
18
18
|
</AppButton>
|
|
19
|
+
<AppButton
|
|
20
|
+
v-if="!prop.readOnly && prop.type === 'ip'"
|
|
21
|
+
variant="ghost"
|
|
22
|
+
@click="dialogImport = true"
|
|
23
|
+
>
|
|
24
|
+
Import
|
|
25
|
+
</AppButton>
|
|
19
26
|
</template>
|
|
20
27
|
|
|
21
28
|
<template #right>
|
|
29
|
+
<!-- Searching the loaded list, not the server: the endpoint takes
|
|
30
|
+
no search parameter, and every camera at the site is already
|
|
31
|
+
here. -->
|
|
32
|
+
<v-text-field
|
|
33
|
+
v-model.trim="search"
|
|
34
|
+
class="filter-field mr-3"
|
|
35
|
+
density="compact"
|
|
36
|
+
hide-details
|
|
37
|
+
variant="outlined"
|
|
38
|
+
prepend-inner-icon="mdi-magnify"
|
|
39
|
+
placeholder="Search name or address"
|
|
40
|
+
style="max-width: 260px"
|
|
41
|
+
/>
|
|
22
42
|
<local-pagination
|
|
23
43
|
v-model="page"
|
|
24
44
|
:length="pages"
|
|
@@ -29,7 +49,7 @@
|
|
|
29
49
|
|
|
30
50
|
<v-data-table
|
|
31
51
|
:headers="computedHeaders"
|
|
32
|
-
:items="
|
|
52
|
+
:items="visibleItems"
|
|
33
53
|
fixed-header
|
|
34
54
|
hide-default-footer
|
|
35
55
|
@click:row="handleRowClick"
|
|
@@ -55,6 +75,14 @@
|
|
|
55
75
|
/>
|
|
56
76
|
</v-dialog>
|
|
57
77
|
|
|
78
|
+
<v-dialog v-model="dialogImport" persistent width="900" scrollable>
|
|
79
|
+
<CameraBulkImportDialog
|
|
80
|
+
:site="prop.site"
|
|
81
|
+
@cancel="dialogImport = false"
|
|
82
|
+
@imported="handleImported"
|
|
83
|
+
/>
|
|
84
|
+
</v-dialog>
|
|
85
|
+
|
|
58
86
|
<v-dialog v-model="dialogEdit" persistent width="450">
|
|
59
87
|
<CameraForm
|
|
60
88
|
title="Edit Camera"
|
|
@@ -312,31 +340,112 @@ const computedHeaders = computed(() => {
|
|
|
312
340
|
return prop.headers;
|
|
313
341
|
});
|
|
314
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Every camera at the site, paged locally.
|
|
345
|
+
*
|
|
346
|
+
* This asked the server for page 1 with no `limit`, against a server default of
|
|
347
|
+
* 10 — so a site with more than ten cameras showed ten, and the pager below was
|
|
348
|
+
* driven by the server's page count while the search box did not exist. At 150
|
|
349
|
+
* cameras that is fifteen server pages and no way to find one by name.
|
|
350
|
+
*
|
|
351
|
+
* Now the list is fetched once, in server pages of `PAGE_SIZE`, and searched
|
|
352
|
+
* and paged in the browser. Deliberately NOT one unbounded request: the
|
|
353
|
+
* endpoint accepts a `limit` with no upper bound, and asking it for everything
|
|
354
|
+
* is how one large site becomes a slow page for every site. `MAX_PAGES` is the
|
|
355
|
+
* backstop, and when it is reached the toolbar says so rather than quietly
|
|
356
|
+
* showing part of the list.
|
|
357
|
+
*
|
|
358
|
+
* ponytail: searching and paging in the browser, because the endpoint has no
|
|
359
|
+
* search parameter and a site's camera list is small enough to hold. If a site
|
|
360
|
+
* ever passes `PAGE_SIZE * MAX_PAGES`, this wants a server-side search instead.
|
|
361
|
+
*/
|
|
362
|
+
const PAGE_SIZE = 100;
|
|
363
|
+
const MAX_PAGES = 5;
|
|
364
|
+
const ROWS_PER_PAGE = 10;
|
|
365
|
+
|
|
315
366
|
const items = ref<Array<TCamera>>([]);
|
|
316
367
|
const page = ref(1);
|
|
317
368
|
const pages = ref(0);
|
|
318
369
|
const pageRange = ref("-- - -- of --");
|
|
370
|
+
const search = ref("");
|
|
371
|
+
const truncated = ref(false);
|
|
319
372
|
|
|
320
373
|
const { getAllSiteCameras, deleteSiteCameraById } = useSiteSettings();
|
|
374
|
+
|
|
375
|
+
async function getAllCamerasAtSite() {
|
|
376
|
+
const all: Array<TCamera> = [];
|
|
377
|
+
let serverPages = 1;
|
|
378
|
+
let serverPage = 1;
|
|
379
|
+
|
|
380
|
+
while (serverPage <= serverPages && serverPage <= MAX_PAGES) {
|
|
381
|
+
const response = await getAllSiteCameras({
|
|
382
|
+
site: prop.site,
|
|
383
|
+
type: prop.type,
|
|
384
|
+
page: serverPage,
|
|
385
|
+
limit: PAGE_SIZE,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
all.push(...((response?.items ?? []) as Array<TCamera>));
|
|
389
|
+
serverPages = Number(response?.pages) || 1;
|
|
390
|
+
serverPage += 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
truncated.value = serverPages > MAX_PAGES;
|
|
394
|
+
return all;
|
|
395
|
+
}
|
|
396
|
+
|
|
321
397
|
const { data: getCameraReq, refresh: getCameraRefresh } =
|
|
322
398
|
await useLazyAsyncData(
|
|
323
399
|
`get-site-${prop.site}-${prop.type}-cameras`,
|
|
324
|
-
|
|
325
|
-
getAllSiteCameras({ site: prop.site, type: prop.type, page: page.value }),
|
|
326
|
-
{
|
|
327
|
-
watch: [page],
|
|
328
|
-
}
|
|
400
|
+
getAllCamerasAtSite
|
|
329
401
|
);
|
|
330
402
|
|
|
403
|
+
/** The site's cameras matching the search box, in the order they came back. */
|
|
404
|
+
const matchingItems = computed(() => {
|
|
405
|
+
const needle = search.value.toLowerCase();
|
|
406
|
+
if (!needle) return items.value;
|
|
407
|
+
|
|
408
|
+
return items.value.filter((camera: any) =>
|
|
409
|
+
[camera?.name, camera?.host].some((field) =>
|
|
410
|
+
String(field ?? "").toLowerCase().includes(needle)
|
|
411
|
+
)
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/** One page of them. */
|
|
416
|
+
const visibleItems = computed(() =>
|
|
417
|
+
matchingItems.value.slice(
|
|
418
|
+
(page.value - 1) * ROWS_PER_PAGE,
|
|
419
|
+
page.value * ROWS_PER_PAGE
|
|
420
|
+
)
|
|
421
|
+
);
|
|
422
|
+
|
|
331
423
|
watchEffect(() => {
|
|
332
424
|
if (getCameraReq.value) {
|
|
333
|
-
items.value = getCameraReq.value
|
|
334
|
-
pageRange.value = getCameraReq.value.pageRange;
|
|
335
|
-
pages.value = getCameraReq.value.pages;
|
|
425
|
+
items.value = getCameraReq.value as Array<TCamera>;
|
|
336
426
|
}
|
|
337
427
|
});
|
|
338
428
|
|
|
429
|
+
// A search that leaves fewer pages than the one being looked at would otherwise
|
|
430
|
+
// show an empty table with no explanation.
|
|
431
|
+
watch(search, () => {
|
|
432
|
+
page.value = 1;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
watchEffect(() => {
|
|
436
|
+
const total = matchingItems.value.length;
|
|
437
|
+
pages.value = Math.max(1, Math.ceil(total / ROWS_PER_PAGE));
|
|
438
|
+
|
|
439
|
+
const first = total ? (page.value - 1) * ROWS_PER_PAGE + 1 : 0;
|
|
440
|
+
const last = Math.min(page.value * ROWS_PER_PAGE, total);
|
|
441
|
+
|
|
442
|
+
pageRange.value = total
|
|
443
|
+
? `${first} - ${last} of ${total}${truncated.value ? "+" : ""}`
|
|
444
|
+
: "0 - 0 of 0";
|
|
445
|
+
});
|
|
446
|
+
|
|
339
447
|
const dialogAdd = ref(false);
|
|
448
|
+
const dialogImport = ref(false);
|
|
340
449
|
const dialogEdit = ref(false);
|
|
341
450
|
const dialogPreview = ref(false);
|
|
342
451
|
const dialogDelete = ref(false);
|
|
@@ -363,6 +472,13 @@ function openDialogDelete() {
|
|
|
363
472
|
if (dialogPreview.value) dialogPreview.value = false;
|
|
364
473
|
}
|
|
365
474
|
|
|
475
|
+
function handleImported(summary: { created?: number }) {
|
|
476
|
+
// The dialog stays open on purpose — it is showing the per-row result, which
|
|
477
|
+
// is the only record of what happened to a 150-row file. The list behind it
|
|
478
|
+
// is refreshed so closing it shows the truth.
|
|
479
|
+
if (summary?.created) getCameraRefresh();
|
|
480
|
+
}
|
|
481
|
+
|
|
366
482
|
function handleError(msg: string) {
|
|
367
483
|
showMessage(msg, "error");
|
|
368
484
|
}
|
|
@@ -146,7 +146,14 @@
|
|
|
146
146
|
hide-details
|
|
147
147
|
class="mb-2"
|
|
148
148
|
/>
|
|
149
|
-
<InputPhoneNumberV2 v-model="member.contact" density="comfortable" hide-details />
|
|
149
|
+
<InputPhoneNumberV2 v-model="member.contact" density="comfortable" hide-details class="mb-2" />
|
|
150
|
+
<v-text-field
|
|
151
|
+
v-model="member.plateNumber"
|
|
152
|
+
label="Vehicle Number (Optional)"
|
|
153
|
+
density="comfortable"
|
|
154
|
+
variant="outlined"
|
|
155
|
+
hide-details
|
|
156
|
+
/>
|
|
150
157
|
</div>
|
|
151
158
|
|
|
152
159
|
<v-btn variant="outlined" block class="mb-3" prepend-icon="mdi-plus" @click="addDraftMember">
|
|
@@ -192,7 +199,7 @@ const visitor = reactive({
|
|
|
192
199
|
remarks: "",
|
|
193
200
|
});
|
|
194
201
|
|
|
195
|
-
type TDraftMember = { name: string; nric: string; contact: string };
|
|
202
|
+
type TDraftMember = { name: string; nric: string; contact: string; plateNumber: string };
|
|
196
203
|
const members = ref<TDraftMember[]>([]);
|
|
197
204
|
const draftMembers = ref<TDraftMember[]>([]);
|
|
198
205
|
const showMembersDialog = ref(false);
|
|
@@ -201,12 +208,12 @@ watch(showMembersDialog, (open) => {
|
|
|
201
208
|
if (open) {
|
|
202
209
|
draftMembers.value = members.value.length
|
|
203
210
|
? members.value.map((m) => ({ ...m }))
|
|
204
|
-
: [{ name: "", nric: "", contact: "" }];
|
|
211
|
+
: [{ name: "", nric: "", contact: "", plateNumber: "" }];
|
|
205
212
|
}
|
|
206
213
|
});
|
|
207
214
|
|
|
208
215
|
function addDraftMember() {
|
|
209
|
-
draftMembers.value.push({ name: "", nric: "", contact: "" });
|
|
216
|
+
draftMembers.value.push({ name: "", nric: "", contact: "", plateNumber: "" });
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
function saveMembers() {
|
|
@@ -234,6 +241,7 @@ async function submit() {
|
|
|
234
241
|
contractorType: visitor.contractorType || undefined,
|
|
235
242
|
name: visitor.name,
|
|
236
243
|
contact: visitor.contact,
|
|
244
|
+
expectedCheckIn: visitor.expectedCheckIn || undefined,
|
|
237
245
|
nric: visitor.nric || undefined,
|
|
238
246
|
email: visitor.email || undefined,
|
|
239
247
|
block: visitor.block || undefined,
|
|
@@ -246,7 +254,11 @@ async function submit() {
|
|
|
246
254
|
};
|
|
247
255
|
|
|
248
256
|
const res: any = await createVisitor(prop.site, payload);
|
|
249
|
-
emit("done", res?.status || "pending"
|
|
257
|
+
emit("done", res?.status || "pending", res?._id, {
|
|
258
|
+
name: visitor.name,
|
|
259
|
+
expectedCheckIn: visitor.expectedCheckIn,
|
|
260
|
+
members: res?.members || [],
|
|
261
|
+
});
|
|
250
262
|
} catch (error: any) {
|
|
251
263
|
submitError.value =
|
|
252
264
|
error?.data?.message || error?.message || "Unable to submit your registration. Please try again.";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog :model-value="modelValue" max-width="380" @update:model-value="emit('update:modelValue', $event)">
|
|
3
|
+
<v-card class="pa-4">
|
|
4
|
+
<div class="d-flex justify-space-between align-center mb-1">
|
|
5
|
+
<span class="text-subtitle-1 font-weight-bold">Share</span>
|
|
6
|
+
<v-btn icon variant="text" size="small" @click="emit('update:modelValue', false)">
|
|
7
|
+
<v-icon>mdi-close</v-icon>
|
|
8
|
+
</v-btn>
|
|
9
|
+
</div>
|
|
10
|
+
<p class="text-caption text-medium-emphasis mb-4">As a message</p>
|
|
11
|
+
|
|
12
|
+
<div class="d-flex ga-4 mb-5">
|
|
13
|
+
<a :href="whatsappUrl" target="_blank" rel="noopener" class="share-icon" aria-label="Share via WhatsApp">
|
|
14
|
+
<v-avatar color="#25D366" size="44"><v-icon color="white">mdi-whatsapp</v-icon></v-avatar>
|
|
15
|
+
</a>
|
|
16
|
+
<a :href="telegramUrl" target="_blank" rel="noopener" class="share-icon" aria-label="Share via Telegram">
|
|
17
|
+
<v-avatar color="#229ED9" size="44"><v-icon color="white">mdi-telegram</v-icon></v-avatar>
|
|
18
|
+
</a>
|
|
19
|
+
<button
|
|
20
|
+
v-if="canNativeShare"
|
|
21
|
+
type="button"
|
|
22
|
+
class="share-icon share-icon--btn"
|
|
23
|
+
aria-label="More share options"
|
|
24
|
+
@click="nativeShare"
|
|
25
|
+
>
|
|
26
|
+
<v-avatar color="grey-darken-2" size="44"><v-icon color="white">mdi-dots-horizontal</v-icon></v-avatar>
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<p class="text-caption text-medium-emphasis mb-2">Or copy message</p>
|
|
31
|
+
<div class="d-flex ga-2 align-start">
|
|
32
|
+
<v-textarea :model-value="message" readonly density="compact" variant="outlined" rows="3" hide-details auto-grow />
|
|
33
|
+
<v-btn color="error" variant="flat" @click="copy">COPY</v-btn>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<v-snackbar v-model="copied" timeout="1800" location="bottom">Copied</v-snackbar>
|
|
37
|
+
</v-card>
|
|
38
|
+
</v-dialog>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<script lang="ts" setup>
|
|
42
|
+
const props = defineProps<{
|
|
43
|
+
modelValue: boolean;
|
|
44
|
+
message: string;
|
|
45
|
+
link: string;
|
|
46
|
+
}>();
|
|
47
|
+
|
|
48
|
+
const emit = defineEmits<{ (e: "update:modelValue", value: boolean): void }>();
|
|
49
|
+
|
|
50
|
+
// No generic web-share URL exists for Messenger/WeChat without registering
|
|
51
|
+
// an app (Facebook's Send Dialog needs an App ID, WeChat sharing needs its
|
|
52
|
+
// own SDK and is region-gated) - WhatsApp and Telegram both have simple,
|
|
53
|
+
// no-auth share URLs, and the native share sheet (mobile browsers) covers
|
|
54
|
+
// "any other app installed" without us hand-integrating each one.
|
|
55
|
+
const whatsappUrl = computed(() => `https://wa.me/?text=${encodeURIComponent(props.message)}`);
|
|
56
|
+
const telegramUrl = computed(
|
|
57
|
+
() => `https://t.me/share/url?url=${encodeURIComponent(props.link)}&text=${encodeURIComponent(props.message)}`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const canNativeShare = computed(() => import.meta.client && typeof navigator !== "undefined" && !!navigator.share);
|
|
61
|
+
|
|
62
|
+
async function nativeShare() {
|
|
63
|
+
try {
|
|
64
|
+
await navigator.share({ text: props.message, url: props.link });
|
|
65
|
+
} catch {
|
|
66
|
+
// User cancelled the share sheet, or the browser refused - no action
|
|
67
|
+
// needed, the dialog stays open with the other options still available.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const copied = ref(false);
|
|
72
|
+
|
|
73
|
+
async function copy() {
|
|
74
|
+
try {
|
|
75
|
+
await navigator.clipboard.writeText(props.message);
|
|
76
|
+
copied.value = true;
|
|
77
|
+
} catch {
|
|
78
|
+
// Clipboard API can be unavailable - copy-icon fallback isn't worth
|
|
79
|
+
// the added complexity here, View Link on the card still works.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<style scoped>
|
|
85
|
+
.share-icon {
|
|
86
|
+
display: inline-flex;
|
|
87
|
+
text-decoration: none;
|
|
88
|
+
border: none;
|
|
89
|
+
background: none;
|
|
90
|
+
padding: 0;
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
}
|
|
93
|
+
</style>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card class="pa-6" elevation="2">
|
|
3
|
+
<div class="d-flex flex-column align-center mb-4">
|
|
4
|
+
<v-avatar color="grey-darken-3" size="48" class="mb-3">
|
|
5
|
+
<v-icon icon="mdi-qrcode" color="white" size="24" />
|
|
6
|
+
</v-avatar>
|
|
7
|
+
<h1 class="text-subtitle-1 font-weight-bold text-center">Visitor QR Code Links</h1>
|
|
8
|
+
<p class="text-caption text-medium-emphasis text-center mt-1">
|
|
9
|
+
{{ status === "approved" ? "Invitation generated successful!" : "Registration submitted - awaiting approval" }}
|
|
10
|
+
</p>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<p class="text-body-2 mb-4">
|
|
14
|
+
Hi {{ leadName }}, here {{ members.length ? "are the check-in links for you and your team" : "is your check-in link" }}.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<div class="link-row pa-3 mb-3">
|
|
18
|
+
<p class="text-body-2 font-weight-medium mb-2">{{ leadName }}</p>
|
|
19
|
+
<div class="d-flex ga-2">
|
|
20
|
+
<v-btn variant="outlined" color="error" size="small" class="flex-grow-1" @click="copyLink(leadLink)">
|
|
21
|
+
Copy Link
|
|
22
|
+
</v-btn>
|
|
23
|
+
<v-btn variant="outlined" color="error" size="small" class="flex-grow-1" :href="leadLink" target="_blank">
|
|
24
|
+
View Link
|
|
25
|
+
</v-btn>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<template v-if="members.length">
|
|
30
|
+
<p class="text-caption text-medium-emphasis mb-2">Members</p>
|
|
31
|
+
<div v-for="m in members" :key="m._id" class="link-row pa-3 mb-3">
|
|
32
|
+
<p class="text-body-2 font-weight-medium mb-2">{{ m.name }}</p>
|
|
33
|
+
<div class="d-flex ga-2">
|
|
34
|
+
<v-btn variant="outlined" color="error" size="small" class="flex-grow-1" @click="copyLink(linkFor(m._id))">
|
|
35
|
+
Copy Link
|
|
36
|
+
</v-btn>
|
|
37
|
+
<v-btn variant="outlined" color="error" size="small" class="flex-grow-1" :href="linkFor(m._id)" target="_blank">
|
|
38
|
+
View Link
|
|
39
|
+
</v-btn>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<v-btn color="error" variant="flat" block size="large" class="mt-2" @click="showShare = true">
|
|
45
|
+
Share
|
|
46
|
+
</v-btn>
|
|
47
|
+
|
|
48
|
+
<v-snackbar v-model="copied" timeout="1800" location="bottom">Link copied</v-snackbar>
|
|
49
|
+
|
|
50
|
+
<PublicOnboardingShareDialog v-model="showShare" :message="shareMessage" :link="leadLink" />
|
|
51
|
+
</v-card>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<script lang="ts" setup>
|
|
55
|
+
const props = defineProps<{
|
|
56
|
+
leadId: string;
|
|
57
|
+
leadName: string;
|
|
58
|
+
status: string;
|
|
59
|
+
expectedCheckIn?: string | null;
|
|
60
|
+
members: { _id: string; name: string }[];
|
|
61
|
+
}>();
|
|
62
|
+
|
|
63
|
+
function linkFor(id: string) {
|
|
64
|
+
const origin = import.meta.client ? window.location.origin : "";
|
|
65
|
+
return `${origin}/public-view/visitor-onboarding/pass/${id}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const leadLink = computed(() => linkFor(props.leadId));
|
|
69
|
+
|
|
70
|
+
const arrivalDateDisplay = computed(() => {
|
|
71
|
+
if (!props.expectedCheckIn) return "";
|
|
72
|
+
const d = new Date(props.expectedCheckIn);
|
|
73
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
74
|
+
return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}`;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const shareMessage = computed(() => {
|
|
78
|
+
const lines = [
|
|
79
|
+
`Hi ${props.leadName},`,
|
|
80
|
+
"",
|
|
81
|
+
`Your visit registration has been submitted${props.members.length ? " along with your team" : ""}.`,
|
|
82
|
+
];
|
|
83
|
+
if (arrivalDateDisplay.value) lines.push("", `Arrival: ${arrivalDateDisplay.value}`);
|
|
84
|
+
lines.push("", `Link: ${leadLink.value}`);
|
|
85
|
+
if (props.members.length) {
|
|
86
|
+
lines.push("", "Team Members:");
|
|
87
|
+
for (const m of props.members) {
|
|
88
|
+
lines.push(m.name, `Link: ${linkFor(m._id)}`, "");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const showShare = ref(false);
|
|
95
|
+
const copied = ref(false);
|
|
96
|
+
|
|
97
|
+
async function copyLink(link: string) {
|
|
98
|
+
try {
|
|
99
|
+
await navigator.clipboard.writeText(link);
|
|
100
|
+
copied.value = true;
|
|
101
|
+
} catch {
|
|
102
|
+
// Clipboard API can be unavailable (insecure context, permission
|
|
103
|
+
// denied) - the visitor still has View Link as a fallback.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<style scoped>
|
|
109
|
+
.link-row {
|
|
110
|
+
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
111
|
+
border-radius: 4px;
|
|
112
|
+
}
|
|
113
|
+
</style>
|
|
@@ -428,7 +428,7 @@
|
|
|
428
428
|
{{ item.arrivalTime ?? "N/A" }}
|
|
429
429
|
</span>
|
|
430
430
|
|
|
431
|
-
<span v-if="!item.checkIn && canUpdateVisitor">
|
|
431
|
+
<span v-if="!item.checkIn && item?.status !== 'pending' && item?.status !== 'rejected' && canUpdateVisitor">
|
|
432
432
|
<span class="d-flex align-center ga-2 cursor-pointer">
|
|
433
433
|
<v-icon
|
|
434
434
|
icon="mdi-clock-time-eight-outline"
|
|
@@ -61,6 +61,19 @@ export default function usePublicVisitorOnboarding() {
|
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// The QR the preview page renders once approved - the SAME `qrCodeId`
|
|
65
|
+
// check-in credential a staff-invited visitor gets (scanned by a guard
|
|
66
|
+
// through ScanVisitorQRCode.vue, same check-in dialog/flow). It's minted
|
|
67
|
+
// (or reused if still valid) on every call and expires 5 minutes after
|
|
68
|
+
// being minted, so the preview page re-calls this periodically rather
|
|
69
|
+
// than fetching it once.
|
|
70
|
+
function getSelfServiceQrCode(id: string) {
|
|
71
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
72
|
+
`/api/visitors/self-service/qr-code/${id}`,
|
|
73
|
+
{ method: "GET" },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
return {
|
|
65
78
|
getOnboardingSettings,
|
|
66
79
|
getBlocks,
|
|
@@ -69,5 +82,6 @@ export default function usePublicVisitorOnboarding() {
|
|
|
69
82
|
createVisitor,
|
|
70
83
|
getOvernightParkingHours,
|
|
71
84
|
getSelfServicePreview,
|
|
85
|
+
getSelfServiceQrCode,
|
|
72
86
|
};
|
|
73
87
|
}
|
|
@@ -88,10 +88,20 @@ export default function () {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* One page of a site's cameras.
|
|
93
|
+
*
|
|
94
|
+
* `limit` is the reason this signature changed: the server defaults to 10,
|
|
95
|
+
* and every caller that omitted it was showing the first ten cameras of a
|
|
96
|
+
* site and no way to reach the rest. The server accepts a `limit` of 10 or
|
|
97
|
+
* more with no upper bound, so a caller picks a real page size rather than
|
|
98
|
+
* asking for everything.
|
|
99
|
+
*/
|
|
91
100
|
async function getAllSiteCameras(payload: {
|
|
92
101
|
site: string;
|
|
93
102
|
type?: string;
|
|
94
103
|
page?: number;
|
|
104
|
+
limit?: number;
|
|
95
105
|
}) {
|
|
96
106
|
return await useNuxtApp().$api<Record<string, any>>(`/api/site-cameras`, {
|
|
97
107
|
method: "GET",
|
|
@@ -99,6 +109,32 @@ export default function () {
|
|
|
99
109
|
});
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Many cameras in one request, from the technician's survey sheet.
|
|
114
|
+
*
|
|
115
|
+
* The same call previews and commits, and the server decides both — `commit:
|
|
116
|
+
* false` validates every row and writes nothing, `commit: true` writes the
|
|
117
|
+
* rows that passed. Every row comes back with an outcome and, when it was not
|
|
118
|
+
* written, a sentence saying why.
|
|
119
|
+
*
|
|
120
|
+
* Rows carry the sheet's own column names; the server derives each camera's
|
|
121
|
+
* live-view address from the recorder and channel number, because that
|
|
122
|
+
* address is a relay page nobody outside the video service can work out.
|
|
123
|
+
*/
|
|
124
|
+
async function bulkImportSiteCameras(payload: {
|
|
125
|
+
site: string;
|
|
126
|
+
rows: Array<Record<string, string>>;
|
|
127
|
+
commit: boolean;
|
|
128
|
+
}) {
|
|
129
|
+
return await useNuxtApp().$api<Record<string, any>>(
|
|
130
|
+
`/api/site-cameras/bulk`,
|
|
131
|
+
{
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: payload,
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
102
138
|
/**
|
|
103
139
|
* The monitoring wall for one site.
|
|
104
140
|
*
|
|
@@ -223,6 +259,7 @@ export default function () {
|
|
|
223
259
|
updateSite,
|
|
224
260
|
addCamera,
|
|
225
261
|
getAllSiteCameras,
|
|
262
|
+
bulkImportSiteCameras,
|
|
226
263
|
getSiteWall,
|
|
227
264
|
getSiteHealth,
|
|
228
265
|
setSiteGuardPosts,
|
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.1.3-staging.
|
|
5
|
+
"version": "4.1.3-staging.252",
|
|
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.",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the technician's camera survey sheet.
|
|
3
|
+
*
|
|
4
|
+
* The file a person uploads is the file they were asked to fill in: the column
|
|
5
|
+
* names here are the survey sheet's own, so nothing has to be renamed by hand
|
|
6
|
+
* between surveying a site and importing it.
|
|
7
|
+
*
|
|
8
|
+
* This does one job — turn text into rows — and deliberately judges nothing.
|
|
9
|
+
* Whether a row is valid, whether it is a duplicate, whether its recorder is
|
|
10
|
+
* one the video service knows about: all of that is decided by the server, once,
|
|
11
|
+
* and shown back per row. A second opinion here would be a second opinion to
|
|
12
|
+
* disagree with the first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The columns the sheet is asked for. Order is the order shown in a preview. */
|
|
16
|
+
export const CAMERA_CSV_COLUMNS = [
|
|
17
|
+
"camera_name",
|
|
18
|
+
"recorder_id",
|
|
19
|
+
"channel_number",
|
|
20
|
+
"physical_location",
|
|
21
|
+
"for_cctv_wall",
|
|
22
|
+
"for_virtual_patrol",
|
|
23
|
+
"patrol_order",
|
|
24
|
+
"zone_or_block",
|
|
25
|
+
"is_anpr",
|
|
26
|
+
"notes",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/** Columns a camera record has no field for. Surveyed, shown, never stored. */
|
|
30
|
+
export const CAMERA_CSV_UNSTORED_COLUMNS = [
|
|
31
|
+
"physical_location",
|
|
32
|
+
"patrol_order",
|
|
33
|
+
"zone_or_block",
|
|
34
|
+
"notes",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/** Columns the sheet cannot do without. */
|
|
38
|
+
export const CAMERA_CSV_REQUIRED_COLUMNS = [
|
|
39
|
+
"camera_name",
|
|
40
|
+
"recorder_id",
|
|
41
|
+
"channel_number",
|
|
42
|
+
"for_cctv_wall",
|
|
43
|
+
"for_virtual_patrol",
|
|
44
|
+
"is_anpr",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type CameraCsvRow = Record<string, string>;
|
|
48
|
+
|
|
49
|
+
export type CameraCsvParse = {
|
|
50
|
+
/** The header, as spelled in the file, in file order. */
|
|
51
|
+
columns: string[];
|
|
52
|
+
rows: CameraCsvRow[];
|
|
53
|
+
/** Required columns the file does not have. Empty means it is usable. */
|
|
54
|
+
missingColumns: string[];
|
|
55
|
+
/** Columns present that we will read but cannot store. */
|
|
56
|
+
unstoredColumns: string[];
|
|
57
|
+
/** Columns present that mean nothing to us at all. Harmless, worth saying. */
|
|
58
|
+
unknownColumns: string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One line of CSV, respecting quotes.
|
|
63
|
+
*
|
|
64
|
+
* Written rather than pulled in, because the whole of it is this: a quoted
|
|
65
|
+
* field may contain commas and `""` is an escaped quote. A spreadsheet export
|
|
66
|
+
* of ten short columns needs nothing else, and a parser dependency here would
|
|
67
|
+
* be a build-time cost for a hundred lines of text typed by a technician.
|
|
68
|
+
*/
|
|
69
|
+
function splitLine(line: string): string[] {
|
|
70
|
+
const values: string[] = [];
|
|
71
|
+
let value = "";
|
|
72
|
+
let quoted = false;
|
|
73
|
+
|
|
74
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
75
|
+
const char = line[index];
|
|
76
|
+
|
|
77
|
+
if (quoted) {
|
|
78
|
+
if (char !== '"') {
|
|
79
|
+
value += char;
|
|
80
|
+
} else if (line[index + 1] === '"') {
|
|
81
|
+
value += '"';
|
|
82
|
+
index += 1;
|
|
83
|
+
} else {
|
|
84
|
+
quoted = false;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (char === '"') {
|
|
90
|
+
quoted = true;
|
|
91
|
+
} else if (char === ",") {
|
|
92
|
+
values.push(value);
|
|
93
|
+
value = "";
|
|
94
|
+
} else {
|
|
95
|
+
value += char;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
values.push(value);
|
|
100
|
+
return values.map((item) => item.trim());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A header cell, normalised to the sheet's snake_case spelling. */
|
|
104
|
+
function normaliseColumn(value: string): string {
|
|
105
|
+
return value
|
|
106
|
+
.trim()
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.replace(/^/, "")
|
|
109
|
+
.replace(/[\s-]+/g, "_")
|
|
110
|
+
.replace(/[^a-z0-9_]/g, "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Turn an uploaded CSV into rows.
|
|
115
|
+
*
|
|
116
|
+
* Tolerant where tolerance is free — a UTF-8 BOM from Excel, `Camera Name` for
|
|
117
|
+
* `camera_name`, CRLF endings, a trailing blank line — and silent about
|
|
118
|
+
* nothing: a missing required column or an extra unrecognised one is reported
|
|
119
|
+
* rather than quietly worked around.
|
|
120
|
+
*/
|
|
121
|
+
export function parseCameraCsv(text: string): CameraCsvParse {
|
|
122
|
+
const lines = String(text ?? "")
|
|
123
|
+
.replace(/^/, "")
|
|
124
|
+
.split(/\r?\n/)
|
|
125
|
+
.filter((line) => line.trim().length > 0);
|
|
126
|
+
|
|
127
|
+
if (!lines.length) {
|
|
128
|
+
return {
|
|
129
|
+
columns: [],
|
|
130
|
+
rows: [],
|
|
131
|
+
missingColumns: [...CAMERA_CSV_REQUIRED_COLUMNS],
|
|
132
|
+
unstoredColumns: [],
|
|
133
|
+
unknownColumns: [],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const columns = splitLine(lines[0]).map(normaliseColumn);
|
|
138
|
+
|
|
139
|
+
const rows = lines.slice(1).map((line) => {
|
|
140
|
+
const values = splitLine(line);
|
|
141
|
+
const row: CameraCsvRow = {};
|
|
142
|
+
columns.forEach((column, index) => {
|
|
143
|
+
if (column) row[column] = values[index] ?? "";
|
|
144
|
+
});
|
|
145
|
+
return row;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const known = new Set<string>(CAMERA_CSV_COLUMNS);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
columns,
|
|
152
|
+
rows,
|
|
153
|
+
missingColumns: CAMERA_CSV_REQUIRED_COLUMNS.filter(
|
|
154
|
+
(column) => !columns.includes(column),
|
|
155
|
+
),
|
|
156
|
+
unstoredColumns: CAMERA_CSV_UNSTORED_COLUMNS.filter((column) =>
|
|
157
|
+
columns.includes(column),
|
|
158
|
+
),
|
|
159
|
+
unknownColumns: columns.filter((column) => column && !known.has(column)),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A blank sheet, so nobody has to guess the column names. */
|
|
164
|
+
export function cameraCsvTemplate(): string {
|
|
165
|
+
return [
|
|
166
|
+
CAMERA_CSV_COLUMNS.join(","),
|
|
167
|
+
"Block A Lobby,nvr-1.example.org,7,\"Block A, Level 1 lift lobby\",Yes,Yes,1,Block A,No,Faces west",
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|