@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.
- package/components/HidAccessPermissions.vue +51 -3
- package/components/HidReaderForm.vue +94 -0
- package/components/HidUserEnrollment.vue +87 -9
- package/components/VehicleQrStickerDialog.vue +25 -16
- package/package.json +1 -1
- package/utils/hid-reader-audiences.ts +179 -0
- package/utils/vehicle-qr-export.ts +40 -10
|
@@ -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;
|
|
@@ -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
|
|
148
|
-
e.g. for a print shop; several stickers come in one ZIP file.
|
|
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="
|
|
172
|
-
Save as
|
|
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="
|
|
177
|
-
:
|
|
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 {
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
439
|
-
if (files.length === 1) saveFile(files[0].name, files[0].data,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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}` : ""}
|
|
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
|
-
/**
|
|
126
|
-
|
|
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")
|
|
135
|
-
|
|
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
|
-
|
|
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). */
|