@7365admin1/layer-common 4.97.1-staging.507 → 4.97.1-staging.509

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;
@@ -18,8 +18,8 @@
18
18
  <p class="spc-intro">
19
19
  <template v-if="kind === 'owner'">
20
20
  {{ siteName || "This site" }} moves to a new owner, who takes charge of all of its records.
21
- Every service provider at the site keeps working as it is. Only the new owner must already be working at the site
22
- (invite them first; Seven365 can pick any company).
21
+ Every service provider at the site keeps working as it is, and the new owner needs no invitation. Only Seven365
22
+ can do this.
23
23
  </template>
24
24
  <template v-else>
25
25
  Only {{ preview?.service || "this service" }} at {{ siteName || "this site" }} changes. Other services at the site,
@@ -28,7 +28,7 @@
28
28
  </p>
29
29
 
30
30
  <label class="spc-label" for="spc-to">{{ kind === "owner" ? "New owner" : "New company (optional)" }}</label>
31
- <v-select
31
+ <v-autocomplete
32
32
  id="spc-to"
33
33
  v-model="to"
34
34
  :items="toItems"
@@ -38,7 +38,7 @@
38
38
  variant="outlined"
39
39
  class="filter-field"
40
40
  :disabled="saving"
41
- :no-data-text="kind === 'owner' ? 'Invite the new owner to the site first.' : 'Invite the new company to the site first.'"
41
+ :no-data-text="kind === 'owner' ? 'No company found.' : 'Invite the new company to the site first.'"
42
42
  data-test="spc-to"
43
43
  @update:model-value="refresh"
44
44
  />
@@ -96,7 +96,7 @@
96
96
  Provider changes at {{ siteName || "this site" }}
97
97
  <v-spacer />
98
98
  <v-btn
99
- v-if="ownerOrgId"
99
+ v-if="ownerOrgId && isStaff"
100
100
  variant="outlined"
101
101
  class="screen-btn-ghost mr-2"
102
102
  data-test="spc-change-owner"
@@ -186,11 +186,24 @@ const snackText = ref("");
186
186
  const snackColor = ref("success");
187
187
 
188
188
  const tomorrow = computed(() => new Date(Date.now() + 864e5).toISOString().slice(0, 10));
189
- const replacements = ref<Array<{ org: string; name: string }>>([]);
189
+ const replacements = ref<Array<{ org: string; name: string; nature?: string }>>([]);
190
+ const OWNER_KIND: Record<string, string> = {
191
+ property_management_agency: "Property management",
192
+ real_estate_developer: "Developer",
193
+ property_owner: "Owner",
194
+ };
190
195
  const toItems = computed(() => [
191
196
  ...(kind.value === "provider" ? [{ title: "No new company yet - just end", value: null }] : []),
192
- ...replacements.value.map((r) => ({ title: r.name, value: r.org })),
197
+ ...replacements.value.map((r) => ({
198
+ title: kind.value === "owner" && OWNER_KIND[r.nature ?? ""] ? `${r.name} - ${OWNER_KIND[r.nature ?? ""]}` : r.name,
199
+ value: r.org,
200
+ })),
193
201
  ]);
202
+
203
+ // Changing the site's owner is Seven365's job only (owner, 2026-10-06): a
204
+ // property manager must never see which other property managers use the system.
205
+ const { isStaff, load: loadTier } = useConsoleTier();
206
+ onMounted(() => loadTier());
194
207
  const canConfirm = computed(
195
208
  () =>
196
209
  !loading.value &&
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.507",
5
+ "version": "4.97.1-staging.509",
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
+ }