@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.
@@ -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
- Residents enrolled on this reader. Enrolling already grants access — switch
30
- somebody off to close the door to them without removing their face, card
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="Search name, unit or registration"
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
- /* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`. */
160
- const headers = [
161
- { title: "Resident", value: "name", sortable: false },
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
- // Residents only, and filtered by the SERVER rather than here. The pager's
298
- // count comes back from the same query, so narrowing the list on this side
299
- // would print a total that never matched the rows under it.
300
- type: "resident",
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 = subjectOf(identity);
372
+ const subject = subjectOfIdentity(identity);
313
373
  const metadata = toRecord(identity.metadata);
314
374
  return {
315
375
  subjectId: subject?.subjectId ?? "",
316
- category: subject?.category ?? "resident",
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
- if (!String(item.subjectId ?? "")) return;
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
- String(item.subjectId ?? ""),
361
- item.category,
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
- * Read in the order `createIdentity` validates the links, with one extra
2574
- * test. A `member` link is how BOTH a property-management member and a
2575
- * service provider's own employee are stored - the person's membership at
2576
- * this site - so the identity's `type` is what tells them apart. Without
2577
- * that test a contractor's edit would be headed "Member".
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
- form.subjectCategory = user.person
2580
- ? "resident"
2581
- : user.serviceProvider
2582
- ? "service_provider"
2583
- : user.member && String(user.type ?? "") === "contractor"
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, and the
82
- QR code stays the same size. Your choice is remembered on this computer.
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
- <v-row dense data-test="vehicle-qr-design">
85
- <v-col cols="12" sm="6" md="4" lg>
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" lg>
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" lg>
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" lg>
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="text-body-2 mt-3 mb-1">Sticker shape</div>
104
- <div class="shape-picker" role="radiogroup" aria-label="Sticker shape" data-test="vehicle-qr-shapes">
105
- <button v-for="s in shapeThumbs" :key="s.value" type="button" role="radio" class="shape-option"
106
- :class="{ 'shape-option--on': design.frame === s.value }" :aria-checked="design.frame === s.value"
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-switch v-model="design.cutLines" label="Show cut lines" color="primary" density="compact" hide-details inset
113
- class="mt-1" data-test="vehicle-qr-cut-lines" />
114
- <v-alert type="info" variant="tonal" density="compact" class="mt-1" data-test="vehicle-qr-cut-guide">
115
- <div class="font-weight-medium">{{ sheetSummary(design.frame) }}.</div>
116
- <div>Print at 100% ("Actual size" in the print window), not "Fit to page", so the QR code stays full size.</div>
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
- How to cut: first cut the sheet into pieces through the middle of the gaps (the small + marks show where),
119
- then cut round each sticker {{ design.cutLines ? "on the light-grey line" : "along its edge" }} - with scissors
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
- SHAPE_OPTIONS,
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 sampleCode = QRCode.create("S365V:SamplePreviewToken0000", {
229
- errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel,
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 SHAPE_OPTIONS.map((o) => ({
234
- ...o,
235
- src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(stickerFaceSvg(sampleCode, { ...look, frame: o.value, cutLines: false }))}`,
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, error
311
- // correction (Q), 4-module quiet zone and 32 mm size are the same on every
312
- // design; only the look of the dots, corners, colours and frame changes.
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 faces = chosen.map((i) =>
323
- stickerFaceSvg(QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules, look),
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.500",
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
  /**