@7365admin1/layer-common 4.97.1-staging.500 → 4.97.1-staging.502
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 +146 -38
- package/components/HidUserEnrollment.vue +15 -15
- package/components/VehicleQrStickerDialog.vue +88 -43
- package/package.json +1 -1
- package/utils/hid-enrolment-subject.ts +22 -0
- package/utils/hid-permission-assignments.ts +66 -5
- package/utils/vehicle-qr-design.ts +312 -144
- package/utils/vehicle-qr-sticker.ts +119 -34
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
<template v-else>
|
|
27
27
|
<div class="hid-access-permissions__intro">
|
|
28
28
|
<p class="text-body-2 text-medium-emphasis mb-0">
|
|
29
|
-
|
|
30
|
-
somebody off to close the door to them without removing
|
|
31
|
-
or PIN.
|
|
29
|
+
Everybody enrolled on this reader, by type. Enrolling already grants
|
|
30
|
+
access — switch somebody off to close the door to them without removing
|
|
31
|
+
their face, card or PIN.
|
|
32
32
|
</p>
|
|
33
33
|
</div>
|
|
34
34
|
|
|
@@ -45,12 +45,36 @@
|
|
|
45
45
|
@refresh="loadEnrolled"
|
|
46
46
|
@update:page="goToPage"
|
|
47
47
|
>
|
|
48
|
+
<!-- WHICH OF THE READER'S PEOPLE TO SHOW.
|
|
49
|
+
The reader holds residents, members and contractors; each tab is
|
|
50
|
+
one `type`, filtered and paged by the server. The strip is
|
|
51
|
+
`TableMain`'s own slot and the shared `.app-tab` markup, the same
|
|
52
|
+
as every other tabbed table here - not a `v-tabs`, whose selected
|
|
53
|
+
state is drawn from Vuetify's `on-surface` rather than the
|
|
54
|
+
design's accent underline.
|
|
55
|
+
The count beside each label is the whole reader's, from the
|
|
56
|
+
draft, so it follows an unsaved tick. -->
|
|
57
|
+
<template #tabs>
|
|
58
|
+
<button
|
|
59
|
+
v-for="tab in TABS"
|
|
60
|
+
:key="tab.value"
|
|
61
|
+
type="button"
|
|
62
|
+
class="app-tab"
|
|
63
|
+
:class="{ 'app-tab--active': activeTab === tab.value }"
|
|
64
|
+
:aria-pressed="activeTab === tab.value"
|
|
65
|
+
@click="selectTab(tab.value)"
|
|
66
|
+
>
|
|
67
|
+
{{ tab.label }}
|
|
68
|
+
<span class="hid-access-permissions__tab-count">{{ tabCount(tab) }}</span>
|
|
69
|
+
</button>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
48
72
|
<template #extension>
|
|
49
73
|
<div class="app-filter-row">
|
|
50
74
|
<AppField
|
|
51
75
|
v-model="search"
|
|
52
76
|
search
|
|
53
|
-
placeholder="
|
|
77
|
+
:placeholder="searchPlaceholder"
|
|
54
78
|
@keyup.enter="searchFromFirstPage"
|
|
55
79
|
/>
|
|
56
80
|
</div>
|
|
@@ -121,6 +145,7 @@ import {
|
|
|
121
145
|
readCandidatePage,
|
|
122
146
|
seedAssignments,
|
|
123
147
|
setAssignment,
|
|
148
|
+
subjectOfIdentity,
|
|
124
149
|
toAssignmentPayload,
|
|
125
150
|
type HidAssignmentState,
|
|
126
151
|
} from "../utils/hid-permission-assignments";
|
|
@@ -156,11 +181,52 @@ const emit = defineEmits<{
|
|
|
156
181
|
|
|
157
182
|
const { getSitePermissions, getIdentities, updateSitePermissions } = useHidAmico();
|
|
158
183
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
184
|
+
/**
|
|
185
|
+
* WHO EACH TAB LISTS, AND WHAT A GRANT MADE FROM IT IS FILED UNDER.
|
|
186
|
+
*
|
|
187
|
+
* `type` is the identity's own, filtered by the SERVER (`listIdentities` does an
|
|
188
|
+
* exact match on it), and it is what the enrolment writes and what
|
|
189
|
+
* `permissionIdentityType` re-confirms on every reconcile. `category` is the
|
|
190
|
+
* permission category a NEW grant from this tab belongs to.
|
|
191
|
+
*
|
|
192
|
+
* The two are a pair rather than one derived from the other, because the tab is
|
|
193
|
+
* the only thing on this screen that knows which of them the operator means: a
|
|
194
|
+
* contractor and a property-management member are both a `member` link, and
|
|
195
|
+
* guessing between them from the record is exactly what went wrong before.
|
|
196
|
+
*/
|
|
197
|
+
const TABS = [
|
|
198
|
+
{ value: "resident", label: "Resident", noun: "Resident", type: "resident", category: "resident" },
|
|
199
|
+
{ value: "member", label: "Member", noun: "Member", type: "staff", category: "property_management" },
|
|
200
|
+
{
|
|
201
|
+
value: "provider",
|
|
202
|
+
label: "Service provider",
|
|
203
|
+
noun: "Person",
|
|
204
|
+
type: "contractor",
|
|
205
|
+
category: "service_provider_member",
|
|
206
|
+
},
|
|
207
|
+
] as const satisfies ReadonlyArray<{
|
|
208
|
+
value: string;
|
|
209
|
+
label: string;
|
|
210
|
+
noun: string;
|
|
211
|
+
type: string;
|
|
212
|
+
category: THidPermissionCategory;
|
|
213
|
+
}>;
|
|
214
|
+
|
|
215
|
+
const activeTab = ref<(typeof TABS)[number]["value"]>("resident");
|
|
216
|
+
const currentTab = computed(
|
|
217
|
+
() => TABS.find((tab) => tab.value === activeTab.value) ?? TABS[0],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
/** Granted counts per category, for the tab strip. One call, all three. */
|
|
221
|
+
const counts = ref<Record<string, number>>({});
|
|
222
|
+
const tabCount = (tab: (typeof TABS)[number]) => counts.value[tab.category] ?? 0;
|
|
223
|
+
|
|
224
|
+
/* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`.
|
|
225
|
+
The first column follows the tab; "Door access" is the same action for all. */
|
|
226
|
+
const headers = computed(() => [
|
|
227
|
+
{ title: currentTab.value.noun, value: "name", sortable: false },
|
|
162
228
|
{ title: "Door access", value: "selected", sortable: false, width: 190 },
|
|
163
|
-
];
|
|
229
|
+
]);
|
|
164
230
|
|
|
165
231
|
const search = ref("");
|
|
166
232
|
const page = ref(1);
|
|
@@ -187,6 +253,13 @@ const readerLabel = computed(() => {
|
|
|
187
253
|
return readerLocation.value ? `${name} · ${readerLocation.value}` : name;
|
|
188
254
|
});
|
|
189
255
|
|
|
256
|
+
/* "unit" only means something for a resident. */
|
|
257
|
+
const searchPlaceholder = computed(() =>
|
|
258
|
+
activeTab.value === "resident"
|
|
259
|
+
? "Search name, unit or registration"
|
|
260
|
+
: "Search name or registration",
|
|
261
|
+
);
|
|
262
|
+
|
|
190
263
|
const assignedTotal = computed(() => draft.value.size);
|
|
191
264
|
const dirty = computed(() => hasChanges(savedState.value, draft.value));
|
|
192
265
|
/* The draft is the truth once the dialog is open, so `selected` is recomputed
|
|
@@ -199,6 +272,7 @@ watch(
|
|
|
199
272
|
if (!isOpen || !readerId.value) return;
|
|
200
273
|
search.value = "";
|
|
201
274
|
page.value = 1;
|
|
275
|
+
activeTab.value = "resident";
|
|
202
276
|
void loadAll();
|
|
203
277
|
},
|
|
204
278
|
{ immediate: true },
|
|
@@ -221,6 +295,13 @@ async function loadAssignments() {
|
|
|
221
295
|
const assignments = response?.data?.assignments ?? [];
|
|
222
296
|
savedState.value = seedAssignments(assignments);
|
|
223
297
|
draft.value = seedAssignments(assignments);
|
|
298
|
+
/*
|
|
299
|
+
* Counted from the assignments themselves rather than the server's `counts`
|
|
300
|
+
* block, so the strip follows the DRAFT and a tick updates the number it
|
|
301
|
+
* sits beside. `countByCategory` is the same helper the candidate screens
|
|
302
|
+
* use.
|
|
303
|
+
*/
|
|
304
|
+
counts.value = countByCategory(draft.value);
|
|
224
305
|
} catch (error) {
|
|
225
306
|
showToast(getErrorMessage(error, "Unable to load this reader's permissions."), "error");
|
|
226
307
|
}
|
|
@@ -230,27 +311,6 @@ function toRecord(value: unknown): Record<string, unknown> {
|
|
|
230
311
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
231
312
|
}
|
|
232
313
|
|
|
233
|
-
/**
|
|
234
|
-
* The subject an identity names, in the order `createIdentity` validates them.
|
|
235
|
-
*
|
|
236
|
-
* A visitor returns null and is left out of the list: visitor access comes from
|
|
237
|
-
* `user_access_rules`, not from a group, so there is nothing here to switch
|
|
238
|
-
* off. An identity linked to nobody — a device user the reader holds that was
|
|
239
|
-
* never tied to a person — is dropped for the same reason.
|
|
240
|
-
*/
|
|
241
|
-
function subjectOf(identity: Record<string, unknown>) {
|
|
242
|
-
if (identity.person) {
|
|
243
|
-
return { subjectId: String(identity.person), category: "resident" as const };
|
|
244
|
-
}
|
|
245
|
-
if (identity.member) {
|
|
246
|
-
return { subjectId: String(identity.member), category: "property_management" as const };
|
|
247
|
-
}
|
|
248
|
-
if (identity.serviceProvider) {
|
|
249
|
-
return { subjectId: String(identity.serviceProvider), category: "service_provider" as const };
|
|
250
|
-
}
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
314
|
/**
|
|
255
315
|
* Why a row cannot be switched, in words the reader of the screen can act on.
|
|
256
316
|
*
|
|
@@ -294,10 +354,10 @@ async function loadEnrolled() {
|
|
|
294
354
|
page: page.value,
|
|
295
355
|
limit: 10,
|
|
296
356
|
search: search.value.trim(),
|
|
297
|
-
//
|
|
298
|
-
// count comes back from the same query, so narrowing the list on
|
|
299
|
-
// would print a total that never matched the rows under it.
|
|
300
|
-
type:
|
|
357
|
+
// THIS TAB's people, and filtered by the SERVER rather than here. The
|
|
358
|
+
// pager's count comes back from the same query, so narrowing the list on
|
|
359
|
+
// this side would print a total that never matched the rows under it.
|
|
360
|
+
type: currentTab.value.type,
|
|
301
361
|
});
|
|
302
362
|
const result = readCandidatePage(response);
|
|
303
363
|
/*
|
|
@@ -309,11 +369,13 @@ async function loadEnrolled() {
|
|
|
309
369
|
*/
|
|
310
370
|
enrolled.value = result.items.map((row) => {
|
|
311
371
|
const identity = toRecord(row);
|
|
312
|
-
const subject =
|
|
372
|
+
const subject = subjectOfIdentity(identity);
|
|
313
373
|
const metadata = toRecord(identity.metadata);
|
|
314
374
|
return {
|
|
315
375
|
subjectId: subject?.subjectId ?? "",
|
|
316
|
-
|
|
376
|
+
// The record's own category where it has one; otherwise the tab's,
|
|
377
|
+
// which is the only thing that knows what the operator means.
|
|
378
|
+
category: subject?.category ?? currentTab.value.category,
|
|
317
379
|
// The row's own key. `subjectId` is empty for the rows below, and two
|
|
318
380
|
// empty keys would collapse into one row in the table.
|
|
319
381
|
rowKey: String(identity._id ?? identity.hidUserId ?? Math.random()),
|
|
@@ -342,6 +404,24 @@ async function loadEnrolled() {
|
|
|
342
404
|
}
|
|
343
405
|
}
|
|
344
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Switching tabs refetches, because the filter is the SERVER's.
|
|
409
|
+
*
|
|
410
|
+
* The draft is untouched: it is the whole reader's, and the save sends all of it
|
|
411
|
+
* whatever tab is in front of you. Clearing it here - or building the payload
|
|
412
|
+
* from the loaded rows - is what would revoke everybody on the other two tabs.
|
|
413
|
+
*
|
|
414
|
+
* Search is cleared with the tab. It is a server-side search within one type, so
|
|
415
|
+
* carrying it across would land on an empty list and look like the tab was empty.
|
|
416
|
+
*/
|
|
417
|
+
function selectTab(value: (typeof TABS)[number]["value"]) {
|
|
418
|
+
if (activeTab.value === value) return;
|
|
419
|
+
activeTab.value = value;
|
|
420
|
+
search.value = "";
|
|
421
|
+
page.value = 1;
|
|
422
|
+
void loadEnrolled();
|
|
423
|
+
}
|
|
424
|
+
|
|
345
425
|
function goToPage(value: number) {
|
|
346
426
|
page.value = Number(value) || 1;
|
|
347
427
|
void loadEnrolled();
|
|
@@ -352,15 +432,34 @@ function searchFromFirstPage() {
|
|
|
352
432
|
void loadEnrolled();
|
|
353
433
|
}
|
|
354
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Grant or revoke one person.
|
|
437
|
+
*
|
|
438
|
+
* The category comes from the row where the server gave it one, and otherwise
|
|
439
|
+
* from the TAB - never from re-reading the identity's links on this side. A
|
|
440
|
+
* contractor and a property-management member are both a `member` link, and the
|
|
441
|
+
* stale local copy of that reading is what used to file contractors as staff: the
|
|
442
|
+
* reconcile then derived `staff` from that category and rewrote the person's
|
|
443
|
+
* type, moving them into the Member tab.
|
|
444
|
+
*
|
|
445
|
+
* An ALREADY GRANTED person keeps the category their assignment holds, so merely
|
|
446
|
+
* visiting a tab and toggling somebody off and on cannot re-file them.
|
|
447
|
+
*/
|
|
355
448
|
function toggle(item: THidPermissionCandidate, selected: boolean) {
|
|
356
|
-
|
|
449
|
+
const subjectId = String(item.subjectId ?? "");
|
|
450
|
+
if (!subjectId) return;
|
|
451
|
+
const category = savedState.value.get(subjectId)?.category
|
|
452
|
+
?? item.category
|
|
453
|
+
?? currentTab.value.category;
|
|
357
454
|
draft.value = setAssignment(
|
|
358
455
|
draft.value,
|
|
359
456
|
savedState.value,
|
|
360
|
-
|
|
361
|
-
|
|
457
|
+
subjectId,
|
|
458
|
+
category,
|
|
362
459
|
selected,
|
|
363
460
|
);
|
|
461
|
+
// The strip sits beside the rows, so it follows the tick rather than the save.
|
|
462
|
+
counts.value = countByCategory(draft.value);
|
|
364
463
|
}
|
|
365
464
|
|
|
366
465
|
async function save() {
|
|
@@ -401,6 +500,15 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|
|
401
500
|
</script>
|
|
402
501
|
|
|
403
502
|
<style scoped lang="scss">
|
|
503
|
+
/* The granted count beside a tab label. Quieter than the label and set in
|
|
504
|
+
tabular figures so the three tabs do not shift width as numbers change. */
|
|
505
|
+
.hid-access-permissions__tab-count {
|
|
506
|
+
margin-left: 6px;
|
|
507
|
+
color: var(--muted);
|
|
508
|
+
font-weight: 600;
|
|
509
|
+
font-variant-numeric: tabular-nums;
|
|
510
|
+
}
|
|
511
|
+
|
|
404
512
|
.hid-access-permissions {
|
|
405
513
|
&__title { padding-block: 16px; }
|
|
406
514
|
|
|
@@ -969,6 +969,7 @@ import {
|
|
|
969
969
|
memberCandidateOf,
|
|
970
970
|
memberHasAccount,
|
|
971
971
|
HID_ALL_SERVICES,
|
|
972
|
+
enrolmentSubjectOfCategory,
|
|
972
973
|
permissionCategoryOf,
|
|
973
974
|
providerAccountsKnown,
|
|
974
975
|
providerCandidateOf,
|
|
@@ -993,6 +994,7 @@ import {
|
|
|
993
994
|
import {
|
|
994
995
|
seedAssignments,
|
|
995
996
|
setAssignment,
|
|
997
|
+
subjectOfIdentity,
|
|
996
998
|
toAssignmentPayload,
|
|
997
999
|
type HidAssignmentState,
|
|
998
1000
|
} from "../utils/hid-permission-assignments";
|
|
@@ -2570,22 +2572,20 @@ async function openEdit(user: HidUser) {
|
|
|
2570
2572
|
form.photoPreview = "";
|
|
2571
2573
|
facialEnrolled.value = hasFacialEnrollment(user);
|
|
2572
2574
|
/*
|
|
2573
|
-
*
|
|
2574
|
-
*
|
|
2575
|
-
* service provider's own employee are stored
|
|
2576
|
-
*
|
|
2577
|
-
*
|
|
2575
|
+
* ONE reading of the links, shared with the Access Permissions screen.
|
|
2576
|
+
* `subjectOfIdentity` knows that a `member` link is how BOTH a
|
|
2577
|
+
* property-management member and a service provider's own employee are stored,
|
|
2578
|
+
* and uses the identity's `type` to tell them apart - without which a
|
|
2579
|
+
* contractor's edit would be headed "Member".
|
|
2580
|
+
*
|
|
2581
|
+
* This was the same ternary written out inline. The other copy of it went
|
|
2582
|
+
* stale, so neither is written out any more.
|
|
2578
2583
|
*/
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
? "service_provider_staff"
|
|
2585
|
-
: "property_management";
|
|
2586
|
-
form.subjectId = String(
|
|
2587
|
-
user.person || user.member || user.serviceProvider || ""
|
|
2588
|
-
);
|
|
2584
|
+
const editedSubject = subjectOfIdentity(user as Record<string, unknown>);
|
|
2585
|
+
form.subjectCategory = enrolmentSubjectOfCategory(
|
|
2586
|
+
editedSubject?.category
|
|
2587
|
+
) as TEnrolmentSubjectValue;
|
|
2588
|
+
form.subjectId = editedSubject?.subjectId ?? "";
|
|
2589
2589
|
// The resident this user already holds is not a conflict with itself. A real
|
|
2590
2590
|
// one only appears if the operator reassigns the row to somebody else.
|
|
2591
2591
|
residentConflict.value = null;
|
|
@@ -78,48 +78,66 @@
|
|
|
78
78
|
</p>
|
|
79
79
|
<div class="text-subtitle-2 mt-3 mb-1">Sticker design</div>
|
|
80
80
|
<p class="text-caption text-medium-emphasis mb-2">
|
|
81
|
-
Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365
|
|
82
|
-
|
|
81
|
+
Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365. Your
|
|
82
|
+
choice is remembered on this computer.
|
|
83
83
|
</p>
|
|
84
|
-
<
|
|
85
|
-
|
|
84
|
+
<div class="text-body-2 mb-1">QR code shape</div>
|
|
85
|
+
<p class="text-caption text-medium-emphasis mb-1">
|
|
86
|
+
In a shaped code the dots fill the whole shape - the real code sits in the middle with a thin white ring round
|
|
87
|
+
it, and the plate number goes on the dark tag at the bottom.
|
|
88
|
+
</p>
|
|
89
|
+
<div class="shape-picker" role="radiogroup" aria-label="QR code shape" data-test="vehicle-qr-shapes">
|
|
90
|
+
<button v-for="s in shapeThumbs" :key="s.value" type="button" role="radio" class="shape-option"
|
|
91
|
+
:class="{ 'shape-option--on': design.shape === s.value }" :aria-checked="design.shape === s.value"
|
|
92
|
+
:data-shape="s.value" @click="design.shape = s.value">
|
|
93
|
+
<img class="shape-thumb" :src="s.src" alt="" />
|
|
94
|
+
<span class="text-caption">{{ s.title }}</span>
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
<v-row dense class="mt-2" data-test="vehicle-qr-design">
|
|
98
|
+
<v-col cols="12" sm="6" md="4">
|
|
99
|
+
<v-select v-model="design.size" :items="sizeItems" label="Sticker size" density="compact" hide-details
|
|
100
|
+
data-test="vehicle-qr-size" />
|
|
101
|
+
</v-col>
|
|
102
|
+
<v-col cols="12" sm="6" md="4">
|
|
103
|
+
<v-select v-model="design.frame" :items="frameItems" label="Frame" density="compact" hide-details
|
|
104
|
+
data-test="vehicle-qr-frame" />
|
|
105
|
+
</v-col>
|
|
106
|
+
<v-col cols="12" sm="6" md="4">
|
|
86
107
|
<v-select v-model="design.dots" :items="DOT_OPTIONS" label="QR dots" density="compact" hide-details />
|
|
87
108
|
</v-col>
|
|
88
|
-
<v-col cols="12" sm="6" md="4"
|
|
109
|
+
<v-col cols="12" sm="6" md="4">
|
|
89
110
|
<v-select v-model="design.eyes" :items="EYE_OPTIONS" label="QR corners" density="compact" hide-details />
|
|
90
111
|
</v-col>
|
|
91
|
-
<v-col cols="12" sm="6" md="4"
|
|
112
|
+
<v-col cols="12" sm="6" md="4">
|
|
92
113
|
<v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
|
|
93
114
|
hide-details />
|
|
94
115
|
</v-col>
|
|
95
|
-
<v-col cols="12" sm="6" md="4"
|
|
116
|
+
<v-col cols="12" sm="6" md="4">
|
|
96
117
|
<v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
|
|
97
118
|
hide-details />
|
|
98
119
|
</v-col>
|
|
99
|
-
<v-col cols="12" sm="6" md="4" lg="auto" class="d-flex align-center">
|
|
100
|
-
<v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
|
|
101
|
-
</v-col>
|
|
102
120
|
</v-row>
|
|
103
|
-
<div class="
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
:data-shape="s.value" @click="design.frame = s.value">
|
|
108
|
-
<img class="shape-thumb" :src="s.src" alt="" />
|
|
109
|
-
<span class="text-caption">{{ s.title }}</span>
|
|
110
|
-
</button>
|
|
121
|
+
<div class="d-flex align-center flex-wrap">
|
|
122
|
+
<v-switch v-model="design.cutLines" label="Show cut lines" color="primary" density="compact" hide-details
|
|
123
|
+
inset class="mt-1 mr-4" data-test="vehicle-qr-cut-lines" />
|
|
124
|
+
<v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
|
|
111
125
|
</div>
|
|
112
|
-
<v-
|
|
113
|
-
class="
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
<v-alert type="warning" variant="tonal" density="compact" class="mt-2" data-test="vehicle-qr-size-info">
|
|
127
|
+
<div class="font-weight-medium">Sizes - check before you print</div>
|
|
128
|
+
<ul class="size-list">
|
|
129
|
+
<li v-for="line in sizeLines" :key="line">{{ line }}</li>
|
|
130
|
+
</ul>
|
|
117
131
|
<div>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
or a cutting machine. Cut off the strip with the unit and owner before pasting.
|
|
132
|
+
Every sheet also has a small "Size check" bar at the bottom that must measure exactly 10 mm with a ruler. If
|
|
133
|
+
it does not, the printer shrank or stretched the page - print again at 100%.
|
|
121
134
|
</div>
|
|
122
135
|
</v-alert>
|
|
136
|
+
<v-alert type="info" variant="tonal" density="compact" class="mt-2" data-test="vehicle-qr-cut-guide">
|
|
137
|
+
How to cut: first cut the sheet into pieces through the middle of the gaps (the small + marks show where),
|
|
138
|
+
then cut round each sticker {{ design.cutLines ? "on the light-grey line" : "along its edge" }} - with scissors
|
|
139
|
+
or a cutting machine. Cut off the strip with the unit and owner before pasting.
|
|
140
|
+
</v-alert>
|
|
123
141
|
<iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
|
|
124
142
|
</template>
|
|
125
143
|
</v-card-text>
|
|
@@ -152,9 +170,11 @@ import {
|
|
|
152
170
|
defaultStickerIds,
|
|
153
171
|
idsForRequest,
|
|
154
172
|
installerLine,
|
|
173
|
+
STICKER_QR_MODULES,
|
|
155
174
|
setIds,
|
|
156
|
-
sheetSummary,
|
|
157
175
|
stickerPlateRows,
|
|
176
|
+
stickerSizeInfo,
|
|
177
|
+
stickerSizeLines,
|
|
158
178
|
stickerWarning,
|
|
159
179
|
type StickerItem,
|
|
160
180
|
type StickerPlateRow,
|
|
@@ -164,10 +184,12 @@ import {
|
|
|
164
184
|
DOT_OPTIONS,
|
|
165
185
|
EYE_OPTIONS,
|
|
166
186
|
LABEL_OPTIONS,
|
|
167
|
-
|
|
187
|
+
QR_SHAPE_OPTIONS,
|
|
168
188
|
QR_ECC,
|
|
169
189
|
STICKER_THEMES,
|
|
190
|
+
frameOptionsFor,
|
|
170
191
|
normalizeDesign,
|
|
192
|
+
sizeOptionsFor,
|
|
171
193
|
stickerFaceSvg,
|
|
172
194
|
type StickerDesign,
|
|
173
195
|
} from "../utils/vehicle-qr-design";
|
|
@@ -224,18 +246,39 @@ function resetDesign() {
|
|
|
224
246
|
Object.assign(design, DEFAULT_STICKER_DESIGN);
|
|
225
247
|
}
|
|
226
248
|
|
|
227
|
-
// A small live picture of every shape in the current dots / corners / colours.
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
}).modules;
|
|
249
|
+
// A small live picture of every QR shape in the current dots / corners / colours.
|
|
250
|
+
const SAMPLE = "S365V:SamplePreviewToken00000";
|
|
251
|
+
const sampleCode = QRCode.create(SAMPLE, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules;
|
|
231
252
|
const shapeThumbs = computed(() => {
|
|
232
253
|
const look = normalizeDesign(design);
|
|
233
|
-
return
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
254
|
+
return QR_SHAPE_OPTIONS.map((o) => {
|
|
255
|
+
const frame = o.value === "square" ? (look.shape === "square" ? look.frame : "rounded") : "none";
|
|
256
|
+
const svg = stickerFaceSvg(sampleCode, { ...look, shape: o.value, frame, size: "standard", cutLines: false }, SAMPLE);
|
|
257
|
+
return { ...o, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` };
|
|
258
|
+
});
|
|
237
259
|
});
|
|
238
260
|
|
|
261
|
+
// A shape change can leave a frame or size that shape does not offer (the badge
|
|
262
|
+
// frame, or a size whose QR would print under 25 mm): fall back to one it does.
|
|
263
|
+
watch(
|
|
264
|
+
() => [design.shape, design.frame, design.size],
|
|
265
|
+
() => {
|
|
266
|
+
const fixed = normalizeDesign(design);
|
|
267
|
+
if (!sizeOptionsFor(fixed, STICKER_QR_MODULES).some((o) => o.value === fixed.size)) fixed.size = "standard";
|
|
268
|
+
if (fixed.frame !== design.frame) design.frame = fixed.frame;
|
|
269
|
+
if (fixed.size !== design.size) design.size = fixed.size;
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
const frameItems = computed(() => frameOptionsFor(design.shape));
|
|
273
|
+
const sizeItems = computed(() => {
|
|
274
|
+
const look = normalizeDesign(design);
|
|
275
|
+
return sizeOptionsFor(look, STICKER_QR_MODULES).map((o) => {
|
|
276
|
+
const info = stickerSizeInfo({ ...look, size: o.value });
|
|
277
|
+
return { value: o.value, title: `${o.title} - ${Math.round(info.shapeW)} mm wide, QR ${Math.round(info.qrMm)} mm` };
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
const sizeLines = computed(() => stickerSizeLines(normalizeDesign(design)));
|
|
281
|
+
|
|
239
282
|
const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
|
|
240
283
|
const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
|
|
241
284
|
|
|
@@ -307,9 +350,9 @@ async function goReview() {
|
|
|
307
350
|
}
|
|
308
351
|
}
|
|
309
352
|
|
|
310
|
-
// The same document is the preview and the print job. The QR content
|
|
311
|
-
// correction (Q)
|
|
312
|
-
//
|
|
353
|
+
// The same document is the preview and the print job. The QR content and error
|
|
354
|
+
// correction (Q) are the same on every design; the shape, size and look change,
|
|
355
|
+
// and every sheet carries the sizes and a 10 mm size check.
|
|
313
356
|
watch(
|
|
314
357
|
[items, ticked, design, siteName],
|
|
315
358
|
() => {
|
|
@@ -319,10 +362,9 @@ watch(
|
|
|
319
362
|
return;
|
|
320
363
|
}
|
|
321
364
|
const look = normalizeDesign(design);
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
);
|
|
325
|
-
docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value });
|
|
365
|
+
const codes = chosen.map((i) => QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules);
|
|
366
|
+
const faces = chosen.map((i, k) => stickerFaceSvg(codes[k], look, i.qr));
|
|
367
|
+
docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value, modules: codes[0].size });
|
|
326
368
|
},
|
|
327
369
|
{ deep: true },
|
|
328
370
|
);
|
|
@@ -336,6 +378,9 @@ function print() {
|
|
|
336
378
|
</script>
|
|
337
379
|
|
|
338
380
|
<style scoped>
|
|
381
|
+
.size-list {
|
|
382
|
+
margin: 2px 0 4px 18px;
|
|
383
|
+
}
|
|
339
384
|
.shape-picker {
|
|
340
385
|
display: grid;
|
|
341
386
|
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
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.502",
|
|
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.",
|
|
@@ -240,6 +240,28 @@ export function permissionCategoryOf(category: unknown): string {
|
|
|
240
240
|
return "property_management";
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The dropdown value for a grant's category — the inverse of
|
|
245
|
+
* `permissionCategoryOf`.
|
|
246
|
+
*
|
|
247
|
+
* An edit STATES the subject type rather than offering it, because an identity's
|
|
248
|
+
* category cannot change after it is created. This is how a stored record is
|
|
249
|
+
* turned back into the value the form holds, and it is wider than the dropdown on
|
|
250
|
+
* purpose: records in the wild carry `service_provider`, which names a COMPANY
|
|
251
|
+
* and which the dropdown does not offer.
|
|
252
|
+
*
|
|
253
|
+
* Paired with `subjectOfIdentity` so the mapping lives in one place. `openEdit`
|
|
254
|
+
* used to repeat the whole links-and-type ternary inline, which is how the copy
|
|
255
|
+
* in `HidAccessPermissions.vue` was able to go stale without anything noticing.
|
|
256
|
+
*/
|
|
257
|
+
export function enrolmentSubjectOfCategory(category: unknown): string {
|
|
258
|
+
const value = String(category ?? "");
|
|
259
|
+
if (value === "resident") return "resident";
|
|
260
|
+
if (value === "service_provider_member") return "service_provider_staff";
|
|
261
|
+
if (value === "service_provider") return "service_provider";
|
|
262
|
+
return "property_management";
|
|
263
|
+
}
|
|
264
|
+
|
|
243
265
|
/* ── PROVIDER STAFF AS PICKER ROWS ─────────────────────────────────────── */
|
|
244
266
|
|
|
245
267
|
/**
|