@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
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
|
|
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-
|
|
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' ? '
|
|
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) => ({
|
|
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.
|
|
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
|
+
}
|