@7365admin1/layer-common 4.1.3-staging.251 → 4.1.3-staging.253

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.
@@ -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="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.items;
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
  }
@@ -89,15 +89,9 @@
89
89
 
90
90
  <!-- Staging's hover-the-full-name tooltip, kept. -->
91
91
  <template #[`item.name`]="{ item }">
92
+ <!-- Client requirement (2026-08-26): no face photo on an access log row.
93
+ The reader keeps the image; we neither fetch nor hold one. -->
92
94
  <span class="hid-log-name app-cell--strong">
93
- <v-avatar
94
- v-if="item.faceImage"
95
- size="34"
96
- rounded="lg"
97
- class="hid-face-avatar"
98
- >
99
- <v-img :src="item.faceImage" cover />
100
- </v-avatar>
101
95
  <span>{{ item.name }}</span>
102
96
  <v-tooltip
103
97
  v-if="item.name && item.name !== 'N/A'"
@@ -314,7 +308,6 @@ type AccessRow = {
314
308
  name: string;
315
309
  hidUserId: string;
316
310
  facialData: string;
317
- faceImage?: string;
318
311
  unit: string;
319
312
  method: string;
320
313
  lastAccessTime: string;
@@ -371,7 +364,6 @@ type JsPdf = {
371
364
  const {
372
365
  getReaders,
373
366
  getLogs,
374
- getUserImage,
375
367
  getReaderUsers,
376
368
  getReaderAccessLogs,
377
369
  } = useHidAmico();
@@ -384,7 +376,6 @@ const visitorRecords = ref<Record<string, HidAccessRecord>>({});
384
376
  const logs = ref<HidAccessRecord[]>([]);
385
377
  const liveAccessLogs = ref<HidAccessRecord[]>([]);
386
378
  const administratorUserIds = ref<Set<number>>(new Set());
387
- const userImages = ref<Record<string, string>>({});
388
379
  const selectedReaderId = ref("");
389
380
  const activeTab = ref<AccessTab>("all");
390
381
  const search = ref("");
@@ -470,7 +461,6 @@ const selectedHistoryRows = computed<AccessRow[]>(() => {
470
461
  name: selectedHistoryRow.value?.name || "N/A",
471
462
  hidUserId: selectedHistoryRow.value?.hidUserId || "N/A",
472
463
  facialData: selectedHistoryRow.value?.facialData || "N/A",
473
- faceImage: selectedHistoryRow.value?.faceImage,
474
464
  unit: selectedHistoryRow.value?.unit || "N/A",
475
465
  method: getAccessMethod({}, log),
476
466
  lastAccessTime: formatDate(getLogAccessDateValue(log)),
@@ -486,7 +476,6 @@ const selectedHistoryRows = computed<AccessRow[]>(() => {
486
476
  name: selectedHistoryRow.value?.name || "N/A",
487
477
  hidUserId: selectedHistoryRow.value?.hidUserId || "N/A",
488
478
  facialData: selectedHistoryRow.value?.facialData || "N/A",
489
- faceImage: selectedHistoryRow.value?.faceImage,
490
479
  unit: selectedHistoryRow.value?.unit || "N/A",
491
480
  method: getAccessMethod({}, { payload: { rawAccessLog: log } }),
492
481
  lastAccessTime: formatDate(getAccessLogTime(log, {})),
@@ -700,14 +689,6 @@ async function loadDashboard() {
700
689
  serverPageRange.value = String(
701
690
  hidAccessLogResponse?.pageRange ?? hidAccessLogResponse?.data?.pageRange ?? "",
702
691
  );
703
- const accessLogUserIds = getAccessLogUserIds(liveItems);
704
- const accessLogImageCandidates = [...accessLogUserIds].map((hidUserId) => ({
705
- hidUserId: String(hidUserId),
706
- }));
707
- await loadUserImages(
708
- [...readerUsers, ...accessLogImageCandidates],
709
- accessLogUserIds,
710
- );
711
692
  } finally {
712
693
  if (sequence === loadSequence.value) {
713
694
  loading.value = false;
@@ -724,47 +705,8 @@ async function loadDashboardSource<T>(request: Promise<T>, fallback: T, label: s
724
705
  }
725
706
  }
726
707
 
727
- async function loadUserImages(items: HidAccessRecord[], forceUserIds = new Set<number>()) {
728
- if (!selectedReaderId.value) return;
729
-
730
- await Promise.all(items.map(async (identity) => {
731
- const hidUserId = toHidNumericId(identity.hidUserId);
732
- const key = String(hidUserId || "");
733
- if (
734
- !hidUserId ||
735
- key in userImages.value ||
736
- (!forceUserIds.has(hidUserId) && !hasHidImageHint(identity))
737
- ) return;
738
-
739
- try {
740
- const response = await getUserImage(selectedReaderId.value, hidUserId);
741
- const image = response?.data || response;
742
- userImages.value[key] = image?.base64
743
- ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
744
- : "";
745
- } catch {
746
- userImages.value[key] = "";
747
- }
748
- }));
749
- }
750
708
 
751
- function getAccessLogUserIds(items: HidAccessRecord[]) {
752
- return new Set(
753
- items
754
- .map((item) => toHidNumericId(item.user_id ?? item.userId ?? item.hidUserId))
755
- .filter((userId): userId is number => userId !== undefined),
756
- );
757
- }
758
709
 
759
- function hasHidImageHint(identity: HidAccessRecord) {
760
- return Boolean(
761
- identity?.metadata?.imageTimestamp ||
762
- identity?.metadata?.facialData ||
763
- identity?.imageTimestamp ||
764
- identity?.image_timestamp ||
765
- identity?.facialData,
766
- );
767
- }
768
710
 
769
711
  async function onTabChange(tab: AccessTab) {
770
712
  if (tab) {
@@ -783,7 +725,6 @@ function resetDashboardData() {
783
725
  visitorRecords.value = {};
784
726
  logs.value = [];
785
727
  liveAccessLogs.value = [];
786
- userImages.value = {};
787
728
  administratorUserIds.value = new Set();
788
729
  serverPages.value = 1;
789
730
  serverPageRange.value = "";
@@ -1128,7 +1069,6 @@ function mapAccessLogToRow(rawLog: HidAccessRecord, event: HidAccessRecord, inde
1128
1069
  name: getRawLogName(rawLog, event, identity),
1129
1070
  hidUserId: formatHidUid(rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId ?? identity?.hidUserId),
1130
1071
  facialData: identity ? getFacialData(identity) : "N/A",
1131
- faceImage: logHidUserId ? userImages.value[String(logHidUserId)] || "" : "",
1132
1072
  unit: identity ? getUnit(identity) : "N/A",
1133
1073
  method: getAccessMethod(identity || {}, { ...event, payload: { ...event.payload, rawAccessLog: rawLog } }),
1134
1074
  lastAccessTime: formatDate(rawAccessTime),
@@ -1773,10 +1713,6 @@ function escapeHtml(value: unknown) {
1773
1713
  border-radius: 0;
1774
1714
  }
1775
1715
 
1776
- .hid-face-avatar {
1777
- border: 1px solid var(--border);
1778
- background: var(--hover);
1779
- }
1780
1716
 
1781
1717
  .hid-log-name {
1782
1718
  align-items: center;
@@ -56,16 +56,10 @@
56
56
  <span class="app-cell--num">{{ item.registration || "N/A" }}</span>
57
57
  </template>
58
58
 
59
+ <!-- Client requirement (2026-08-26): the face photo is not shown once
60
+ registration is complete. Enrollment STATE only. -->
59
61
  <template #[`item.facialData`]="{ item }">
60
- <v-avatar
61
- v-if="getUserImageSrc(item)"
62
- size="34"
63
- rounded="lg"
64
- class="hid-face-avatar"
65
- >
66
- <v-img :src="getUserImageSrc(item)" cover />
67
- </v-avatar>
68
- <span v-else :class="hasFacialData(item) ? 'facial-data--available' : 'app-cell--muted'">
62
+ <span :class="hasFacialData(item) ? 'facial-data--available' : 'app-cell--muted'">
69
63
  <v-icon
70
64
  :icon="hasFacialData(item) ? 'mdi-face-recognition' : 'mdi-face-man-shimmer-outline'"
71
65
  size="19"
@@ -208,14 +202,12 @@ type HidReaderUser = Record<string, unknown> & {
208
202
  const {
209
203
  getReaders,
210
204
  getReaderUsers,
211
- getUserImage,
212
205
  getUserPinStatus,
213
206
  setUserPin,
214
207
  deleteUserPin,
215
208
  } = useHidAmico();
216
209
  const readers = ref<HidReader[]>([]);
217
210
  const users = ref<HidReaderUser[]>([]);
218
- const userImages = ref<Record<string, string>>({});
219
211
  const selectedReaderId = ref("");
220
212
  const search = ref("");
221
213
  const userType = ref<"all" | "user" | "visitor">("all");
@@ -328,7 +320,6 @@ async function loadUsers() {
328
320
  ? matchingItems.slice(pageStart, pageStart + 10)
329
321
  : matchingItems;
330
322
  users.value = pageItems.map(toReaderUser);
331
- await loadUserImages(users.value);
332
323
  total.value = clientFiltersUserType
333
324
  ? matchingItems.length
334
325
  : toNumber(response.total ?? data.total, users.value.length);
@@ -356,7 +347,6 @@ function goToPage(nextPage: number) {
356
347
 
357
348
  function reloadFromFirstPage() {
358
349
  page.value = 1;
359
- userImages.value = {};
360
350
  loadUsers();
361
351
  }
362
352
 
@@ -364,31 +354,6 @@ function hasFacialData(user: HidReaderUser) {
364
354
  return user.metadata?.facialEnrolled === true || Boolean(toText(user.metadata?.facialData));
365
355
  }
366
356
 
367
- async function loadUserImages(items: HidReaderUser[]) {
368
- if (!selectedReaderId.value) return;
369
-
370
- await Promise.all(items.map(async (user) => {
371
- const hidUserId = toHidNumericId(user.hidUserId);
372
- const key = String(hidUserId || "");
373
- if (!hidUserId || key in userImages.value) return;
374
-
375
- try {
376
- const response = toRecord(await getUserImage(selectedReaderId.value, hidUserId));
377
- const image = toRecord(response.data ?? response);
378
- const base64 = toText(image.base64);
379
- userImages.value[key] = base64
380
- ? `data:${toText(image.contentType) || "image/jpeg"};base64,${base64}`
381
- : "";
382
- } catch {
383
- userImages.value[key] = "";
384
- }
385
- }));
386
- }
387
-
388
- function getUserImageSrc(user: HidReaderUser) {
389
- const hidUserId = toHidNumericId(user.hidUserId);
390
- return (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
391
- }
392
357
 
393
358
  async function openPinDialog(user: HidReaderUser) {
394
359
  const hidUserId = toHidNumericId(user.hidUserId);
@@ -583,9 +548,6 @@ function toNumber(value: unknown, fallback: number) {
583
548
  color: var(--warning, #9a6b00);
584
549
  }
585
550
 
586
- .hid-face-avatar {
587
- border: 1px solid var(--border-color, #e0e3e8);
588
- }
589
551
 
590
552
  .facial-data--available {
591
553
  align-items: center;
@@ -85,18 +85,11 @@
85
85
  <span class="app-cell--strong">{{ getName(item) }}</span>
86
86
  </template>
87
87
 
88
- <!-- The face thumbnail, unchanged. It prints whatever `getUserImageSrc`
89
- already returned; this screen does not read or export a template. -->
88
+ <!-- Client requirement (2026-08-26): the face photo is not shown once
89
+ registration is complete. The column reports enrollment STATE only --
90
+ the reader holds the image and the biometric template, we hold neither. -->
90
91
  <template #[`item.facialData`]="{ item }">
91
- <v-avatar
92
- v-if="getUserImageSrc(item)"
93
- size="34"
94
- rounded="lg"
95
- class="hid-face-avatar"
96
- >
97
- <v-img :src="getUserImageSrc(item)" cover />
98
- </v-avatar>
99
- <span v-else class="app-cell--muted">{{ getFacialData(item) }}</span>
92
+ <span class="app-cell--muted">{{ getFacialData(item) }}</span>
100
93
  </template>
101
94
 
102
95
  <template #[`item.hidUserId`]="{ item }">
@@ -192,6 +185,12 @@
192
185
  <span v-if="form.photoPreview" class="photo-edit-icon">
193
186
  <v-icon icon="mdi-pencil" size="16" />
194
187
  </span>
188
+ <template v-else-if="facialEnrolled">
189
+ <!-- Enrolled, but the photo is deliberately not shown or held.
190
+ Re-enrolling needs a fresh capture. -->
191
+ <v-icon icon="mdi-face-recognition" size="40" />
192
+ <span>Face Enrolled</span>
193
+ </template>
195
194
  <template v-else>
196
195
  <!-- Was `color="#a4acb5"`, the same grey on both themes.
197
196
  `.photo-button` already sets `--muted`, so it inherits. -->
@@ -207,7 +206,7 @@
207
206
  @click="triggerCameraInput"
208
207
  />
209
208
  <v-list-item
210
- v-if="form.photoPreview"
209
+ v-if="form.photoPreview || facialEnrolled"
211
210
  title="Remove Photo"
212
211
  class="text-error"
213
212
  @click="removePhoto"
@@ -538,7 +537,6 @@ const {
538
537
  getReaders,
539
538
  getIdentities,
540
539
  getReaderUsers,
541
- getUserImage,
542
540
  setUserImage,
543
541
  deleteUserImage,
544
542
  getUserCards,
@@ -617,7 +615,6 @@ type HidIdentityPayload = Record<string, unknown> & {
617
615
 
618
616
  const readers = ref<HidReader[]>([]);
619
617
  const users = ref<HidUser[]>([]);
620
- const userImages = ref<Record<string, string>>({});
621
618
  const administratorUserIds = ref<Set<number>>(new Set());
622
619
  const selectedReaderId = ref("");
623
620
  const search = ref("");
@@ -650,6 +647,20 @@ const photoInput = ref<HTMLInputElement | null>(null);
650
647
  const cameraDialog = ref(false);
651
648
  const selectedPhotoFile = ref<File | null>(null);
652
649
  const removePhotoRequested = ref(false);
650
+ /**
651
+ * Client requirement (2026-08-26): the photo is neither shown nor kept once registration
652
+ * completes, so the form tracks only WHETHER this person is enrolled. Re-enrolling needs a
653
+ * fresh capture -- there is no stored image to re-send.
654
+ */
655
+ const facialEnrolled = ref(false);
656
+
657
+ function hasFacialEnrollment(user?: HidUser | null) {
658
+ return Boolean(
659
+ user?.metadata?.facialEnrolled ||
660
+ user?.metadata?.facialData ||
661
+ user?.metadata?.imageTimestamp
662
+ );
663
+ }
653
664
  const removePinRequested = ref(false);
654
665
  const snackbar = reactive({
655
666
  show: false,
@@ -832,8 +843,6 @@ async function loadUsers() {
832
843
  serverPageRange.value = String(responseRecord.pageRange ?? responseData.pageRange ?? "");
833
844
  if (props.cardManagement) {
834
845
  await loadVisibleUserCards(users.value);
835
- } else {
836
- await loadUserImages(users.value);
837
846
  }
838
847
  } catch (error) {
839
848
  console.error("Unable to load HID reader users:", error);
@@ -850,27 +859,6 @@ async function loadUsers() {
850
859
  }
851
860
  }
852
861
 
853
- async function loadUserImages(items: HidUser[]) {
854
- if (!selectedReaderId.value) return;
855
-
856
- await Promise.all(
857
- items.map(async (user) => {
858
- const hidUserId = toHidNumericId(user.hidUserId);
859
- const key = String(hidUserId || "");
860
- if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
861
-
862
- try {
863
- const response = await getUserImage(selectedReaderId.value, hidUserId);
864
- const image = response?.data || response;
865
- userImages.value[key] = image?.base64
866
- ? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
867
- : "";
868
- } catch {
869
- userImages.value[key] = "";
870
- }
871
- })
872
- );
873
- }
874
862
 
875
863
  function resetForm() {
876
864
  form.reader = selectedReaderId.value || readers.value[0]?._id || "";
@@ -888,6 +876,7 @@ function resetForm() {
888
876
  form.photoPreview = "";
889
877
  selectedPhotoFile.value = null;
890
878
  removePhotoRequested.value = false;
879
+ facialEnrolled.value = false;
891
880
  form.subjectCategory = "resident";
892
881
  form.subjectId = "";
893
882
  }
@@ -944,7 +933,8 @@ async function openEdit(user: HidUser) {
944
933
  form.accessPin = "";
945
934
  pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
946
935
  removePinRequested.value = false;
947
- form.photoPreview = getUserImageSrc(user);
936
+ form.photoPreview = "";
937
+ facialEnrolled.value = hasFacialEnrollment(user);
948
938
  form.subjectCategory = user.person
949
939
  ? "resident"
950
940
  : user.serviceProvider
@@ -966,12 +956,6 @@ async function openEdit(user: HidUser) {
966
956
  // Preserve the last known status if the reader is temporarily offline.
967
957
  }
968
958
 
969
- if (!form.photoPreview) {
970
- await loadUserImages([user]);
971
- if (selectedUser.value?._rowKey === user._rowKey) {
972
- form.photoPreview = getUserImageSrc(user);
973
- }
974
- }
975
959
  }
976
960
 
977
961
  function openView(user: HidUser) {
@@ -1017,6 +1001,7 @@ function removePhoto() {
1017
1001
  form.photoPreview = "";
1018
1002
  selectedPhotoFile.value = null;
1019
1003
  removePhotoRequested.value = true;
1004
+ facialEnrolled.value = false;
1020
1005
  }
1021
1006
 
1022
1007
  function onPhotoChange(event: Event) {
@@ -1094,7 +1079,12 @@ async function saveUser() {
1094
1079
  level: form.level,
1095
1080
  unit: form.unit,
1096
1081
  unitLabel: buildUnitLabel(),
1097
- facialData: form.photoPreview ? form.hidUserId : "",
1082
+ // Enrollment STATE carries forward on its own. It used to be derived from the
1083
+ // photo loaded into the form, which now stays empty -- editing a name would have
1084
+ // silently un-enrolled the person.
1085
+ facialEnrolled: hasFacialEnrollment(selectedUser.value),
1086
+ facialEnrolledAt: selectedUser.value?.metadata?.facialEnrolledAt || "",
1087
+ facialData: selectedUser.value?.metadata?.facialData || "",
1098
1088
  imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
1099
1089
  facialScores: selectedUser.value?.metadata?.facialScores || {},
1100
1090
  pinEnrolled: pinEnrolled.value,
@@ -1145,6 +1135,11 @@ async function saveUser() {
1145
1135
  showToast(getHidErrorMessage(error), "error");
1146
1136
  } finally {
1147
1137
  saving.value = false;
1138
+ // Discard the captured image on every path. On a part-way failure this is what stops
1139
+ // the photo lingering in the open form after a retry or a cancel.
1140
+ form.photoPreview = "";
1141
+ selectedPhotoFile.value = null;
1142
+ removePhotoRequested.value = false;
1148
1143
  }
1149
1144
  }
1150
1145
 
@@ -1185,13 +1180,11 @@ async function syncFacialImage(
1185
1180
  }
1186
1181
  );
1187
1182
  const result = response?.data;
1188
- userImages.value[String(hidUserId)] = form.photoPreview;
1189
1183
  return { action: "enrolled", timestamp, scores: result?.scores };
1190
1184
  }
1191
1185
 
1192
1186
  if (removePhotoRequested.value) {
1193
1187
  await deleteUserImage(readerId, hidUserId);
1194
- delete userImages.value[String(hidUserId)];
1195
1188
  return { action: "removed" };
1196
1189
  }
1197
1190
 
@@ -1203,12 +1196,15 @@ function applyFacialResult(
1203
1196
  result: FacialSyncResult
1204
1197
  ) {
1205
1198
  if (result.action === "enrolled") {
1199
+ metadata.facialEnrolled = true;
1200
+ metadata.facialEnrolledAt = new Date().toISOString();
1206
1201
  metadata.facialData = form.hidUserId;
1207
1202
  metadata.imageTimestamp = String(result.timestamp || "");
1208
1203
  metadata.facialScores = result.scores || {};
1209
1204
  } else if (result.action === "removed") {
1205
+ metadata.facialEnrolled = false;
1206
+ metadata.facialEnrolledAt = "";
1210
1207
  metadata.facialData = "";
1211
- metadata.photo = "";
1212
1208
  metadata.imageTimestamp = "";
1213
1209
  metadata.facialScores = {};
1214
1210
  }
@@ -1675,16 +1671,7 @@ function getFacialData(user: HidUser) {
1675
1671
  return (
1676
1672
  user?.metadata?.facialData ||
1677
1673
  user?.metadata?.imageTimestamp ||
1678
- (user?.metadata?.photo ? user.hidUserId : "N/A")
1679
- );
1680
- }
1681
-
1682
- function getUserImageSrc(user: HidUser) {
1683
- const hidUserId = toHidNumericId(user?.hidUserId);
1684
- return (
1685
- user?.metadata?.photo ||
1686
- (hidUserId ? userImages.value[String(hidUserId)] : "") ||
1687
- ""
1674
+ (user?.metadata?.facialEnrolled ? user.hidUserId : "N/A")
1688
1675
  );
1689
1676
  }
1690
1677
 
@@ -2016,10 +2003,6 @@ function toHidCard(value: unknown): HidCard {
2016
2003
  box-shadow: var(--shadow-menu);
2017
2004
  }
2018
2005
 
2019
- .hid-face-avatar {
2020
- border: 1px solid var(--border);
2021
- background: var(--hover);
2022
- }
2023
2006
 
2024
2007
  .admin-rule-indicator {
2025
2008
  display: inline-flex;
@@ -299,11 +299,6 @@ export default function useHidAmico() {
299
299
  });
300
300
  }
301
301
 
302
- function getUserImage(readerId: string, hidUserId: string | number) {
303
- return useNuxtApp().$api<HidApiRecord>(`${basePath}/readers/${readerId}/users/${hidUserId}/image`, {
304
- method: "GET",
305
- });
306
- }
307
302
 
308
303
  function setUserImage(
309
304
  readerId: string,
@@ -525,7 +520,6 @@ export default function useHidAmico() {
525
520
  getReaderConfiguration,
526
521
  setReaderConfiguration,
527
522
  getLogs,
528
- getUserImage,
529
523
  setUserImage,
530
524
  deleteUserImage,
531
525
  getUserCards,
@@ -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.251",
5
+ "version": "4.1.3-staging.253",
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
+ }