@7365admin1/layer-common 4.97.1-staging.506 → 4.97.1-staging.508

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.
@@ -30,6 +30,14 @@
30
30
  access — switch somebody off to close the door to them without removing
31
31
  their face, card or PIN.
32
32
  </p>
33
+ <!-- Why a tab is missing. Left unsaid, an absent tab reads as a broken
34
+ screen rather than as a setting on the reader. -->
35
+ <p
36
+ v-if="audienceNote"
37
+ class="text-body-2 text-medium-emphasis mb-0 hid-access-permissions__audience-note"
38
+ >
39
+ {{ audienceNote }}
40
+ </p>
33
41
  </div>
34
42
 
35
43
  <v-card-text class="hid-access-permissions__body">
@@ -56,7 +64,7 @@
56
64
  draft, so it follows an unsaved tick. -->
57
65
  <template #tabs>
58
66
  <button
59
- v-for="tab in TABS"
67
+ v-for="tab in visibleTabs"
60
68
  :key="tab.value"
61
69
  type="button"
62
70
  class="app-tab"
@@ -149,6 +157,9 @@ import {
149
157
  toAssignmentPayload,
150
158
  type HidAssignmentState,
151
159
  } from "../utils/hid-permission-assignments";
160
+ /* Mirrors core's audience rules — core is what enforces them; this only decides
161
+ which tabs are worth drawing. */
162
+ import { visibleAudienceTabs } from "../utils/hid-reader-audiences";
152
163
 
153
164
  /**
154
165
  * WHO MAY PASS THIS READER — a review and revoke screen, not a picker.
@@ -213,8 +224,35 @@ const TABS = [
213
224
  }>;
214
225
 
215
226
  const activeTab = ref<(typeof TABS)[number]["value"]>("resident");
227
+
228
+ /*
229
+ * ONLY THE TABS THIS READER SERVES.
230
+ *
231
+ * A reader whose audiences exclude a category cannot hold a grant in it - core
232
+ * refuses the save - so a tab for it would be a list the operator can look at
233
+ * and never act on. See `visibleAudienceTabs`, which also keeps one tab when a
234
+ * reader serves nobody, because a dialog with no tabs and no explanation reads
235
+ * as a broken screen.
236
+ *
237
+ * A reader saved before audiences existed has none, and that reads as all four -
238
+ * so this changes nothing for one.
239
+ */
240
+ const audienceTabs = computed(() => visibleAudienceTabs(props.reader, TABS));
241
+ const visibleTabs = computed(() => audienceTabs.value.tabs);
242
+ const audienceNote = computed(() => {
243
+ if (audienceTabs.value.emptied) {
244
+ return "This reader is not set to serve anybody. Switch an audience on in the reader's settings before granting access here.";
245
+ }
246
+ if (audienceTabs.value.narrowed) {
247
+ return "Only the kinds of people this reader serves are shown. The rest are switched off in the reader's settings.";
248
+ }
249
+ return "";
250
+ });
251
+
216
252
  const currentTab = computed(
217
- () => TABS.find((tab) => tab.value === activeTab.value) ?? TABS[0],
253
+ () => visibleTabs.value.find((tab) => tab.value === activeTab.value)
254
+ ?? visibleTabs.value[0]
255
+ ?? TABS[0],
218
256
  );
219
257
 
220
258
  /** Granted counts per category, for the tab strip. One call, all three. */
@@ -272,7 +310,10 @@ watch(
272
310
  if (!isOpen || !readerId.value) return;
273
311
  search.value = "";
274
312
  page.value = 1;
275
- activeTab.value = "resident";
313
+ // The first tab this reader actually serves, not always Resident: opening on
314
+ // one it does not serve would draw an empty list under a heading that cannot
315
+ // accept a grant.
316
+ activeTab.value = visibleTabs.value[0]?.value ?? "resident";
276
317
  void loadAll();
277
318
  },
278
319
  { immediate: true },
@@ -502,6 +543,13 @@ function getErrorMessage(error: unknown, fallback: string) {
502
543
  <style scoped lang="scss">
503
544
  /* The granted count beside a tab label. Quieter than the label and set in
504
545
  tabular figures so the three tabs do not shift width as numbers change. */
546
+ /* Quieter than the intro above it, and set apart so it reads as a condition of
547
+ this reader rather than part of the screen's own description. */
548
+ .hid-access-permissions__audience-note {
549
+ margin-top: 6px;
550
+ font-style: italic;
551
+ }
552
+
505
553
  .hid-access-permissions__tab-count {
506
554
  margin-left: 6px;
507
555
  color: var(--muted);
@@ -49,6 +49,43 @@
49
49
  Disabling keeps the reader in Standalone mode and uses manual log pulls instead.
50
50
  </p>
51
51
 
52
+ <!-- WHO THIS READER IS FOR.
53
+ A site with several doors rarely wants every door open to everybody:
54
+ a service entrance for contractors, a lobby for residents. Switching
55
+ an audience off stops new grants in that category - the server
56
+ refuses them, so this is not only a hint - and takes the matching tab
57
+ off the Access Permissions screen.
58
+ Each switch says what it DOES rather than only what it is called: a
59
+ row of four labels with no sentences is a guess about what they
60
+ control. -->
61
+ <InputLabel title="Who may use this reader" required class="mb-1" />
62
+ <div class="reader-audiences mb-1">
63
+ <div
64
+ v-for="field in audienceFields"
65
+ :key="field.value"
66
+ class="reader-audience"
67
+ >
68
+ <v-switch
69
+ v-model="audiences[field.value]"
70
+ :label="field.label"
71
+ :aria-label="field.label"
72
+ hide-details
73
+ inset
74
+ density="compact"
75
+ :color="audiences[field.value] ? 'success' : 'muted'"
76
+ />
77
+ <p class="reader-audience__hint">{{ field.hint }}</p>
78
+ </div>
79
+ </div>
80
+ <p v-if="noAudienceSelected" class="reader-discovery-hint reader-audiences__empty mb-3">
81
+ Nobody can be enrolled on this reader while all four are off. Existing
82
+ people keep the access they already have.
83
+ </p>
84
+ <p v-else class="reader-discovery-hint mt-1 mb-3">
85
+ Switching one off stops new people of that kind being given this reader.
86
+ It does not remove anybody who already has it.
87
+ </p>
88
+
52
89
  <InputLabel title="Username" required class="mb-1" />
53
90
  <v-text-field
54
91
  v-model="draft.username"
@@ -214,6 +251,15 @@
214
251
  </template>
215
252
 
216
253
  <script setup lang="ts">
254
+ /* Mirrors core's `hid-reader-audience.util.ts`, which is what enforces this —
255
+ kept in a util with tests because the absent-means-all default is the part
256
+ that could close a door by accident. */
257
+ import {
258
+ HID_READER_AUDIENCE_FIELDS,
259
+ readerAudiences,
260
+ type THidReaderAudiences,
261
+ } from "../utils/hid-reader-audiences";
262
+
217
263
  type HidReaderFormRecord = {
218
264
  name?: string;
219
265
  baseUrl?: string;
@@ -248,6 +294,7 @@ type HidReaderFormPayload = {
248
294
  pacsFormat: "raw" | "wiegand";
249
295
  operatingMode: "standalone" | "online";
250
296
  monitorEnabled: boolean;
297
+ audiences: THidReaderAudiences;
251
298
  };
252
299
 
253
300
  const props = defineProps({
@@ -314,6 +361,24 @@ const draft = reactive({
314
361
  operatingMode: "standalone" as "standalone" | "online",
315
362
  });
316
363
 
364
+ /*
365
+ * WHO THIS READER IS FOR. All four on by default, which is the same reading core
366
+ * gives an absent field - a reader being added should not quietly be closed to
367
+ * somebody because a switch defaulted off.
368
+ */
369
+ const audiences = reactive<THidReaderAudiences>({
370
+ resident: true,
371
+ residentVisitor: true,
372
+ member: true,
373
+ serviceProvider: true,
374
+ });
375
+ const audienceFields = HID_READER_AUDIENCE_FIELDS;
376
+ /* Switching every audience off leaves a reader nobody can be enrolled on, which
377
+ is legal but almost never meant - so it is said out loud rather than refused. */
378
+ const noAudienceSelected = computed(() =>
379
+ HID_READER_AUDIENCE_FIELDS.every((field) => audiences[field.value] === false)
380
+ );
381
+
317
382
  const cardModeOptions = [
318
383
  { title: "PACS (facility code + card number)", value: "pacs" },
319
384
  { title: "CSN", value: "csn" },
@@ -352,6 +417,10 @@ watch(
352
417
  discoveryError.value = "";
353
418
  discoveredConnection.value = "";
354
419
  draft.location = props.reader?.location ?? "";
420
+ // Read through `readerAudiences`, so a reader saved before this field
421
+ // existed opens with all four on rather than all four off - and a save from
422
+ // this form therefore cannot close it by accident.
423
+ Object.assign(audiences, readerAudiences(props.reader as Record<string, unknown> | null));
355
424
  draft.username = props.reader?.username ?? "";
356
425
  draft.password = props.reader?.password ?? "";
357
426
  monitorEnabled.value = props.reader?.monitorStatus !== "disabled";
@@ -451,6 +520,9 @@ function submit() {
451
520
  ...draft,
452
521
  portalName: portal?.name || draft.portalName,
453
522
  monitorEnabled: monitorEnabled.value,
523
+ // Spread, not passed by reference: the payload leaves this dialog and the
524
+ // reactive object keeps being edited behind it.
525
+ audiences: { ...audiences },
454
526
  };
455
527
 
456
528
  emit("submit", payload);
@@ -462,6 +534,28 @@ function submit() {
462
534
  overflow: hidden;
463
535
  }
464
536
 
537
+ /* Four rows rather than a row of four: each switch carries a sentence, and at
538
+ phone width a horizontal strip would either wrap badly or clip the hints. */
539
+ .reader-audiences {
540
+ display: flex;
541
+ flex-direction: column;
542
+ gap: 2px;
543
+ }
544
+
545
+ .reader-audience__hint {
546
+ margin: -4px 0 0;
547
+ padding-left: 2px;
548
+ color: var(--muted);
549
+ font-size: 12px;
550
+ line-height: 1.4;
551
+ }
552
+
553
+ /* Said in the warning colour, not refused: a reader nobody may use is legal and
554
+ occasionally deliberate - a door being decommissioned. */
555
+ .reader-audiences__empty {
556
+ color: var(--err);
557
+ }
558
+
465
559
  /* Type comes from `.screen-modal .v-card-title`; only the header strip's own
466
560
  padding and rule stay here. `#ececec` was a fixed near-white line, i.e.
467
561
  invisible on a light card and a bright scratch across a dark one. */
@@ -274,13 +274,26 @@
274
274
  it. -->
275
275
  <div class="field-label">Enrolling <span>*</span></div>
276
276
  <AppSelect
277
- v-if="!selectedUser"
277
+ v-if="!selectedUser && subjectCategoryOptions.length"
278
278
  v-model="form.subjectCategory"
279
279
  :items="subjectCategoryOptions"
280
280
  klass="mb-3"
281
281
  @update:model-value="onSubjectCategoryChanged"
282
282
  />
283
- <p v-else class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
283
+ <p v-else-if="selectedUser" class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
284
+ <!-- WHY THERE IS NOTHING TO PICK. Drawn in place of the dropdown when
285
+ the reader serves nobody, and beneath it when only some types were
286
+ removed. Without it, a missing option reads as a screen that failed
287
+ to load rather than as a setting on the reader.
288
+ Nothing extra is needed to stop the save: with no type there is no
289
+ subject, and the save already refuses that. -->
290
+ <p
291
+ v-if="!selectedUser && subjectCategoryNote"
292
+ class="field-hint mb-3"
293
+ :class="{ 'field-hint--error': enrolmentSubjects.emptied }"
294
+ >
295
+ {{ subjectCategoryNote }}
296
+ </p>
284
297
 
285
298
  <!-- Block / Level / Unit / Resident. Enrolment only.
286
299
  Enrollment is resident-only and the resident must already exist
@@ -980,6 +993,9 @@ import {
980
993
  subjectLinkOf,
981
994
  type HidEnrolmentSubject,
982
995
  } from "../utils/hid-enrolment-subject";
996
+ /* Mirrors core's audience rules — core is what enforces them; this only decides
997
+ which types are worth offering. */
998
+ import { availableEnrolmentSubjects } from "../utils/hid-reader-audiences";
983
999
  /* The SAME filter the Members > Service Providers screen applies, not a second
984
1000
  copy of the rule: a picker that disagrees with the screen the operator was
985
1001
  just looking at is a picker they cannot trust. Core applies it to the Excel
@@ -1367,7 +1383,36 @@ const unitResidentNote = computed(() => {
1367
1383
 
1368
1384
  /* ── THE SUBJECT TYPE ──────────────────────────────────────────────────── */
1369
1385
 
1370
- const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
1386
+ /**
1387
+ * ONLY THE TYPES THE TARGETED READER WILL ACCEPT.
1388
+ *
1389
+ * Offering one it refuses is not merely untidy: the server rejects the grant on
1390
+ * SAVE, after a person has been chosen and a face captured - and the capture is
1391
+ * the expensive step at a reader.
1392
+ *
1393
+ * A SET of readers rather than one, so the fan-out enrolment that lands a person
1394
+ * on every matching reader asks the same question without this being rewritten.
1395
+ * Today the set holds whichever reader the screen has selected.
1396
+ */
1397
+ const enrolmentSubjects = computed(() => availableEnrolmentSubjects(
1398
+ [selectedReader.value as Record<string, unknown> | null],
1399
+ HID_ENROLMENT_SUBJECTS,
1400
+ (option) => permissionCategoryOf(option.value),
1401
+ ));
1402
+ const subjectCategoryOptions = computed(() => enrolmentSubjects.value.options);
1403
+
1404
+ /* Why a type is missing, and - when none is left - why there is nothing to pick.
1405
+ An absent option with no explanation reads as a broken screen. */
1406
+ const subjectCategoryNote = computed(() => {
1407
+ if (enrolmentSubjects.value.emptied) {
1408
+ return "This reader has no types switched on. Switch Resident, Member or"
1409
+ + " Service provider on in the reader's settings before enrolling anybody here.";
1410
+ }
1411
+ if (enrolmentSubjects.value.narrowed) {
1412
+ return "Only the kinds of people this reader serves are listed.";
1413
+ }
1414
+ return "";
1415
+ });
1371
1416
 
1372
1417
  const isResidentSubject = computed(() => form.subjectCategory === "resident");
1373
1418
  /* Members and provider staff both pick ONE PERSON from a flat list, but from
@@ -2184,7 +2229,14 @@ async function init() {
2184
2229
  await loadUsers();
2185
2230
  }
2186
2231
 
2187
- async function loadReaders() {
2232
+ /**
2233
+ * `resolveSelection` is off when the enrolment dialog refreshes this.
2234
+ *
2235
+ * The tail of this function re-resolves the SHARED reader selection, which is
2236
+ * right on first load and wrong from a dialog: opening Enroll should not be able
2237
+ * to move the reader the operator picked on the screen behind it.
2238
+ */
2239
+ async function loadReaders(options: { resolveSelection?: boolean } = {}) {
2188
2240
  if (!props.site) return;
2189
2241
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
2190
2242
  const responseRecord = toRecord(response);
@@ -2198,7 +2250,9 @@ async function loadReaders() {
2198
2250
  resolver refuses a remembered reader that is not this site's, so a site
2199
2251
  switch or a deleted reader falls back to the first rather than querying
2200
2252
  an id the site does not own. */
2201
- selectedReaderId.value = resolveForSite(props.site, readers.value);
2253
+ if (options.resolveSelection !== false) {
2254
+ selectedReaderId.value = resolveForSite(props.site, readers.value);
2255
+ }
2202
2256
  }
2203
2257
 
2204
2258
  /**
@@ -2334,9 +2388,19 @@ function resetForm() {
2334
2388
  selectedPhotoFile.value = null;
2335
2389
  removePhotoRequested.value = false;
2336
2390
  facialEnrolled.value = false;
2337
- // The type the operator last enrolled, not a hard "resident": somebody adding
2338
- // a shift of guards should not re-pick Member for each one.
2339
- form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
2391
+ /*
2392
+ * The type the operator last enrolled, not a hard "resident": somebody adding
2393
+ * a shift of guards should not re-pick Member for each one.
2394
+ *
2395
+ * UNLESS this reader does not serve it. `AppSelect` draws blank when its value
2396
+ * is not among its items - the same way "All services" drew blank when its
2397
+ * value was "" - so the remembered type falls back to the first one on offer.
2398
+ */
2399
+ const offered = subjectCategoryOptions.value;
2400
+ const remembered = REMEMBERED_SUBJECT_CATEGORY.value;
2401
+ form.subjectCategory = offered.some((option) => option.value === remembered)
2402
+ ? remembered
2403
+ : (offered[0]?.value ?? remembered);
2340
2404
  form.subjectId = "";
2341
2405
  form.subjectRole = "";
2342
2406
  residentConflict.value = null;
@@ -2533,11 +2597,25 @@ async function checkSubjectEnrollment() {
2533
2597
 
2534
2598
  async function openEnroll() {
2535
2599
  selectedUser.value = null;
2536
- resetForm();
2600
+ /*
2601
+ * READERS FIRST, and before `resetForm`.
2602
+ *
2603
+ * The audiences decide which types are offered and which one the form opens
2604
+ * on, and they are edited on a different screen - so the copy loaded when this
2605
+ * screen mounted can be stale by the time somebody clicks Enroll. Without this
2606
+ * the dropdown offers a type the reader no longer serves until the page is
2607
+ * reloaded.
2608
+ *
2609
+ * Folded into the same wait as the id allocations so it costs no extra delay,
2610
+ * and with the shared selection left alone: opening Enroll must not move the
2611
+ * reader chosen on the screen behind it.
2612
+ */
2537
2613
  const [hidUserId, registration] = await Promise.all([
2538
2614
  getNextUid(),
2539
2615
  getNextRegistration(),
2616
+ loadReaders({ resolveSelection: false }),
2540
2617
  ]);
2618
+ resetForm();
2541
2619
  form.hidUserId = hidUserId;
2542
2620
  form.registration = registration;
2543
2621
  formDialog.value = true;
@@ -144,8 +144,9 @@
144
144
  </v-alert>
145
145
  <v-alert type="info" variant="tonal" density="compact" class="mt-2" data-test="vehicle-qr-save-guide">
146
146
  To print the same stickers again later: <b>Save sheet file</b> keeps this exact sheet on your computer - open
147
- it any time and print (at 100%). <b>Save as PNG</b> gives one picture per sticker (300 or 600 dpi, real size),
148
- e.g. for a print shop; several stickers come in one ZIP file. You can also come back to Vehicles any time and
147
+ it any time and print (at 100%). <b>Save as image</b> gives one PNG or JPEG picture per sticker (300 or 600 dpi,
148
+ real size), e.g. for a print shop; several stickers come in one ZIP file. PNG keeps the outside of a shaped
149
+ sticker see-through; JPEG puts it on white. You can also come back to Vehicles any time and
149
150
  print again - a vehicle keeps the same QR code until it is regenerated.
150
151
  </v-alert>
151
152
  <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
@@ -168,13 +169,14 @@
168
169
  <v-menu location="top">
169
170
  <template #activator="{ props: menu }">
170
171
  <v-btn v-bind="menu" variant="tonal" class="text-none" prepend-icon="mdi-file-image"
171
- :disabled="!ticked.length || !docHtml" :loading="savingPng" data-test="vehicle-qr-save-png">
172
- Save as PNG
172
+ :disabled="!ticked.length || !docHtml" :loading="savingImage" data-test="vehicle-qr-save-image">
173
+ Save as image
173
174
  </v-btn>
174
175
  </template>
175
176
  <v-list density="compact">
176
- <v-list-item v-for="d in [300, 600]" :key="d" :title="`${d} dpi${d === 600 ? ' (sharpest)' : ''}`"
177
- :data-test="`vehicle-qr-png-${d}`" @click="savePng(d)" />
177
+ <v-list-item v-for="o in IMAGE_CHOICES" :key="o.format + o.dpi"
178
+ :title="`${o.format.toUpperCase()} - ${o.dpi} dpi${o.dpi === 600 ? ' (sharpest)' : ''}`"
179
+ :data-test="`vehicle-qr-${o.format}-${o.dpi}`" @click="saveImages(o.format, o.dpi)" />
178
180
  </v-list>
179
181
  </v-menu>
180
182
  </template>
@@ -191,7 +193,7 @@
191
193
  import QRCode from "qrcode";
192
194
  import useVehicle from "../composables/useVehicle";
193
195
  import { errorConverter } from "../utils/data";
194
- import { pngWithDpi, saveFile, stickerBundleName, stickerFileNames, svgToPng, zipFiles } from "../utils/vehicle-qr-export";
196
+ import { saveFile, stickerBundleName, stickerFileNames, svgToImage, zipFiles, type ImageFormat } from "../utils/vehicle-qr-export";
195
197
  import {
196
198
  STICKER_MAX_PER_REQUEST,
197
199
  buildStickerDocument,
@@ -423,26 +425,33 @@ function saveSheet() {
423
425
  saveFile(`${stickerBundleName(siteName.value)}.html`, docHtml.value, "text/html");
424
426
  }
425
427
 
426
- const savingPng = ref(false);
427
- /** One PNG per sticker at real size; more than one come in a ZIP. */
428
- async function savePng(dpi: number) {
428
+ const IMAGE_CHOICES: { format: ImageFormat; dpi: number }[] = [
429
+ { format: "png", dpi: 300 },
430
+ { format: "png", dpi: 600 },
431
+ { format: "jpeg", dpi: 300 },
432
+ { format: "jpeg", dpi: 600 },
433
+ ];
434
+ const savingImage = ref(false);
435
+ /** One PNG or JPEG per sticker at real size; more than one come in a ZIP. */
436
+ async function saveImages(format: ImageFormat, dpi: number) {
429
437
  const chosen = chosenItems();
430
438
  if (!chosen.length) return;
431
- savingPng.value = true;
439
+ savingImage.value = true;
432
440
  error.value = "";
433
441
  try {
434
442
  const [faces, opts] = faceArgs(chosen);
435
443
  const cards = stickerCardSvgs(chosen, faces, { ...opts, dpi });
436
- const names = stickerFileNames(chosen.map((i) => i.plateNumber));
444
+ const ext = format === "jpeg" ? "jpg" : "png";
445
+ const names = stickerFileNames(chosen.map((i) => i.plateNumber), ext);
437
446
  const files = [];
438
- for (const [k, c] of cards.entries()) files.push({ name: names[k], data: pngWithDpi(await svgToPng(c.svg, c.width, c.height), dpi) });
439
- if (files.length === 1) saveFile(files[0].name, files[0].data, "image/png");
447
+ for (const [k, c] of cards.entries()) files.push({ name: names[k], data: await svgToImage(c.svg, c.width, c.height, format, dpi) });
448
+ if (files.length === 1) saveFile(files[0].name, files[0].data, `image/${format}`);
440
449
  else saveFile(`${stickerBundleName(siteName.value)}.zip`, zipFiles(files), "application/zip");
441
450
  } catch (e: any) {
442
451
  console.error(e);
443
- error.value = "The PNG files could not be made in this browser - use Save sheet file instead, or try Chrome or Edge.";
452
+ error.value = "The pictures could not be made in this browser - use Save sheet file instead, or try Chrome or Edge.";
444
453
  } finally {
445
- savingPng.value = false;
454
+ savingImage.value = false;
446
455
  }
447
456
  }
448
457
 
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.97.1-staging.506",
5
+ "version": "4.97.1-staging.508",
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,179 @@
1
+ /**
2
+ * WHO A READER IS FOR — the frontend's reading of it.
3
+ *
4
+ * Mirrors `hid-reader-audience.util.ts` in core, which is the authority and the
5
+ * only thing that enforces it. This repository cannot import core's copy, so
6
+ * there is one here — and it lives in a util with tests rather than inside a
7
+ * component, because the DEFAULT is the part that can take a door offline.
8
+ *
9
+ * A site with several doors rarely wants every door open to everybody: a service
10
+ * entrance for contractors, a lobby for residents. Declaring that on the reader
11
+ * is what lets an enrolment work out which readers a person belongs on.
12
+ */
13
+
14
+ export const HID_READER_AUDIENCES = [
15
+ "resident",
16
+ "residentVisitor",
17
+ "member",
18
+ "serviceProvider",
19
+ ] as const;
20
+
21
+ export type THidReaderAudience = (typeof HID_READER_AUDIENCES)[number];
22
+ export type THidReaderAudiences = Record<THidReaderAudience, boolean>;
23
+
24
+ /** What each switch is called, and what it means, on the reader form. */
25
+ export const HID_READER_AUDIENCE_FIELDS: ReadonlyArray<{
26
+ value: THidReaderAudience;
27
+ label: string;
28
+ hint: string;
29
+ }> = [
30
+ {
31
+ value: "resident",
32
+ label: "Resident",
33
+ hint: "Residents of this site may be enrolled on this reader.",
34
+ },
35
+ {
36
+ value: "residentVisitor",
37
+ label: "Resident visitor",
38
+ hint: "A resident may issue a visitor QR pass for this reader.",
39
+ },
40
+ {
41
+ value: "member",
42
+ label: "Member",
43
+ hint: "Property management staff may be enrolled on this reader.",
44
+ },
45
+ {
46
+ value: "serviceProvider",
47
+ label: "Service provider",
48
+ hint: "A service provider's people may be enrolled on this reader.",
49
+ },
50
+ ];
51
+
52
+ /**
53
+ * ABSENT MEANS ALL FOUR, and that is the whole backward-compatibility story.
54
+ *
55
+ * Every reader registered before this field existed has no `audiences`. Reading
56
+ * that as "serves nobody" would show every existing reader as closed to
57
+ * everyone, and - worse - a save from that screen would then write those
58
+ * falsehoods and actually close them.
59
+ *
60
+ * A field that is present but missing a key is read the same way, so a partial
61
+ * record cannot quietly close a door either. Only a real `false` closes one.
62
+ */
63
+ export function readerAudiences(
64
+ reader: Record<string, unknown> | null | undefined,
65
+ ): THidReaderAudiences {
66
+ const stored = reader?.audiences;
67
+ const record = stored && typeof stored === "object"
68
+ ? stored as Record<string, unknown>
69
+ : {};
70
+ return HID_READER_AUDIENCES.reduce((audiences, audience) => ({
71
+ ...audiences,
72
+ [audience]: record[audience] === undefined ? true : record[audience] === true,
73
+ }), {} as THidReaderAudiences);
74
+ }
75
+
76
+ /** Does this reader serve that audience? */
77
+ export function readerServesAudience(
78
+ reader: Record<string, unknown> | null | undefined,
79
+ audience: THidReaderAudience,
80
+ ): boolean {
81
+ return readerAudiences(reader)[audience] === true;
82
+ }
83
+
84
+ /**
85
+ * The audience a permission category answers to.
86
+ *
87
+ * Both service-provider categories share one switch: a property manager thinks
88
+ * "may contractors use this door", not "may contractor companies use it as
89
+ * distinct from contractor people". Unrecognised returns null, so a category
90
+ * added later is visibly unmapped rather than silently lumped in with one.
91
+ */
92
+ export function audienceOfCategory(category: unknown): THidReaderAudience | null {
93
+ switch (String(category ?? "")) {
94
+ case "resident":
95
+ return "resident";
96
+ case "property_management":
97
+ return "member";
98
+ case "service_provider":
99
+ case "service_provider_member":
100
+ return "serviceProvider";
101
+ default:
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Which of the Access Permissions tabs this reader is worth showing.
108
+ *
109
+ * A reader that serves nobody in a category cannot hold a grant in it - core
110
+ * refuses the save - so a tab for it would be a list the operator can look at
111
+ * and never act on.
112
+ *
113
+ * NEVER RETURNS NOTHING. A reader with every audience switched off would leave a
114
+ * dialog with no tabs and no explanation, which reads as a broken screen; the
115
+ * first tab is kept so there is always something to show, and it will simply be
116
+ * empty. The caller is told whether that happened so it can say why.
117
+ */
118
+ export function visibleAudienceTabs<T extends { category: unknown }>(
119
+ reader: Record<string, unknown> | null | undefined,
120
+ tabs: readonly T[],
121
+ ): { tabs: T[]; narrowed: boolean; emptied: boolean } {
122
+ const served = tabs.filter((tab) => {
123
+ const audience = audienceOfCategory(tab.category);
124
+ return audience ? readerServesAudience(reader, audience) : false;
125
+ });
126
+ if (!served.length) {
127
+ return { tabs: tabs.slice(0, 1), narrowed: true, emptied: true };
128
+ }
129
+ return { tabs: served, narrowed: served.length !== tabs.length, emptied: false };
130
+ }
131
+
132
+ /**
133
+ * WHICH SUBJECT TYPES CAN BE ENROLLED on the readers being targeted.
134
+ *
135
+ * Takes a SET of readers rather than one, deliberately. Today an enrolment goes
136
+ * to the reader selected on the screen, so the set has one member; once
137
+ * enrolment fans out to every reader whose audiences match, the same question is
138
+ * asked of several. Shaping it this way now means the call site changes later
139
+ * and this does not.
140
+ *
141
+ * Offering a type the reader will not accept is not merely untidy: the server
142
+ * refuses the grant, and it refuses it on SAVE - after the operator has chosen a
143
+ * person and captured a face, which on a reader is the expensive step.
144
+ *
145
+ * `category` on each option is the dropdown's own value, mapped to a permission
146
+ * category and then to an audience by composing the two functions that already
147
+ * exist, rather than by a third mapping that could drift from them.
148
+ */
149
+ export function availableEnrolmentSubjects<T extends { value: unknown }>(
150
+ readers: ReadonlyArray<Record<string, unknown> | null | undefined>,
151
+ options: readonly T[],
152
+ categoryOfOption: (option: T) => unknown,
153
+ ): { options: T[]; narrowed: boolean; emptied: boolean } {
154
+ /*
155
+ * NO READER YET IS NOT "NO AUDIENCES". The dialog can be mounted before a
156
+ * reader has been chosen, and showing nothing then would read as a reader that
157
+ * serves nobody rather than as a question not yet answered.
158
+ */
159
+ const known = readers.filter(Boolean) as Array<Record<string, unknown>>;
160
+ if (!known.length) {
161
+ return { options: [...options], narrowed: false, emptied: false };
162
+ }
163
+
164
+ const served = options.filter((option) => {
165
+ const audience = audienceOfCategory(categoryOfOption(option));
166
+ if (!audience) return false;
167
+ // ANY of the targeted readers is enough: the enrolment lands on whichever
168
+ // of them serve this person, not on all of them.
169
+ return known.some((reader) => readerServesAudience(reader, audience));
170
+ });
171
+
172
+ return {
173
+ options: served,
174
+ narrowed: served.length !== options.length,
175
+ // Nothing offered at all. The caller says why rather than drawing an empty
176
+ // dropdown, which looks like a screen that failed to load.
177
+ emptied: !served.length,
178
+ };
179
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Vehicle QR stickers - saving them as files (PNG images, a ZIP of PNGs, or the
2
+ * Vehicle QR stickers - saving them as files (PNG / JPEG images, a ZIP of them, or the
3
3
  * print sheet itself) so the same stickers can be printed again later.
4
4
  *
5
- * The byte work (PNG print size, ZIP) is pure so `node --test` can run it; the
5
+ * The byte work (PNG / JPEG print size, ZIP) is pure so `node --test` can run it; the
6
6
  * browser part (SVG -> canvas -> PNG, download) is at the bottom.
7
7
  */
8
8
 
@@ -41,6 +41,26 @@ export function pngWithDpi(png: Uint8Array, dpi: number): Uint8Array {
41
41
  return out;
42
42
  }
43
43
 
44
+ /**
45
+ * Stamp the print size into a JPEG's JFIF header (dots per inch), adding the
46
+ * header when the encoder left it out.
47
+ */
48
+ export function jpegWithDpi(jpeg: Uint8Array, dpi: number): Uint8Array {
49
+ const isJfif = jpeg[2] === 0xff && jpeg[3] === 0xe0 && String.fromCharCode(...jpeg.subarray(6, 11)) === "JFIF\0";
50
+ const out = isJfif ? jpeg.slice() : new Uint8Array(jpeg.length + 18);
51
+ if (!isJfif) {
52
+ out.set([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46, 0x49, 0x46, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0]);
53
+ out.set(jpeg.subarray(2), 20);
54
+ }
55
+ const v = new DataView(out.buffer);
56
+ out[13] = 1; // unit: dots per inch
57
+ v.setUint16(14, dpi);
58
+ v.setUint16(16, dpi);
59
+ return out;
60
+ }
61
+
62
+ export type ImageFormat = "png" | "jpeg";
63
+
44
64
  /**
45
65
  * A plain ZIP (stored, no compression - PNGs are already compressed) that
46
66
  * Windows, macOS and phones open without extra software.
@@ -103,14 +123,14 @@ export function fileNamePart(value: unknown, fallback = "sticker"): string {
103
123
  return s || fallback;
104
124
  }
105
125
 
106
- /** One PNG name per sticker, by plate; the same plate twice gets -2, -3. */
107
- export function stickerFileNames(plates: unknown[]): string[] {
126
+ /** One image name per sticker, by plate; the same plate twice gets -2, -3. */
127
+ export function stickerFileNames(plates: unknown[], ext = "png"): string[] {
108
128
  const seen = new Map<string, number>();
109
129
  return plates.map((p) => {
110
130
  const base = fileNamePart(p).toUpperCase();
111
131
  const n = (seen.get(base) ?? 0) + 1;
112
132
  seen.set(base, n);
113
- return `${base}${n > 1 ? `-${n}` : ""}.png`;
133
+ return `${base}${n > 1 ? `-${n}` : ""}.${ext}`;
114
134
  });
115
135
  }
116
136
 
@@ -122,8 +142,12 @@ export function stickerBundleName(siteName: unknown, when = new Date()): string
122
142
 
123
143
  // ---- browser only -------------------------------------------------------
124
144
 
125
- /** Draw an SVG image (sized in pixels) onto a canvas and return the PNG bytes. */
126
- export async function svgToPng(svg: string, width: number, height: number): Promise<Uint8Array> {
145
+ /**
146
+ * Draw an SVG image (sized in pixels) onto a canvas and return the PNG or JPEG
147
+ * bytes with the print size stamped in. JPEG has no see-through, so it gets
148
+ * white paper behind the sticker; quality 0.95 keeps the QR edges clean.
149
+ */
150
+ export async function svgToImage(svg: string, width: number, height: number, format: ImageFormat, dpi: number): Promise<Uint8Array> {
127
151
  const img = new Image();
128
152
  img.decoding = "sync";
129
153
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
@@ -131,10 +155,16 @@ export async function svgToPng(svg: string, width: number, height: number): Prom
131
155
  const canvas = document.createElement("canvas");
132
156
  canvas.width = width;
133
157
  canvas.height = height;
134
- canvas.getContext("2d")!.drawImage(img, 0, 0, width, height);
135
- const blob = await new Promise<Blob | null>((ok) => canvas.toBlob(ok, "image/png"));
158
+ const ctx = canvas.getContext("2d")!;
159
+ if (format === "jpeg") {
160
+ ctx.fillStyle = "#fff";
161
+ ctx.fillRect(0, 0, width, height);
162
+ }
163
+ ctx.drawImage(img, 0, 0, width, height);
164
+ const blob = await new Promise<Blob | null>((ok) => canvas.toBlob(ok, `image/${format}`, 0.95));
136
165
  if (!blob) throw new Error("This browser could not make the picture.");
137
- return new Uint8Array(await blob.arrayBuffer());
166
+ const bytes = new Uint8Array(await blob.arrayBuffer());
167
+ return format === "jpeg" ? jpegWithDpi(bytes, dpi) : pngWithDpi(bytes, dpi);
138
168
  }
139
169
 
140
170
  /** Hand the browser a file to save (it goes to the Downloads folder). */