@7365admin1/layer-common 4.149.2 → 4.151.0

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/components/EntryPassInformation.vue +13 -0
  3. package/components/HidAccessPermissions.vue +202 -40
  4. package/components/HidEnabledGate.vue +29 -10
  5. package/components/HidIntercomManagement.vue +26 -13
  6. package/components/HidProfileQrCode.vue +6 -2
  7. package/components/HidQrCodeConfiguration.vue +2 -2
  8. package/components/HidReaderForm.vue +239 -1
  9. package/components/HidReaderManagement.vue +26 -8
  10. package/components/HidReaderUserRoster.vue +3 -1
  11. package/components/HidServiceSettingsPanel.vue +2 -2
  12. package/components/HidUserEnrollment.vue +1662 -162
  13. package/components/OvernightParkingAvailability.vue +123 -11
  14. package/components/Robot/Detail.vue +23 -4
  15. package/components/SiteSettings.vue +5 -2
  16. package/components/VisitorForm.vue +39 -11
  17. package/composables/useCleaningPermission.ts +11 -0
  18. package/composables/useHidAccessPermission.ts +176 -81
  19. package/composables/useHidAmico.ts +37 -0
  20. package/composables/useHidNavigation.ts +44 -23
  21. package/composables/useLandscapingPermission.ts +11 -0
  22. package/composables/useMechanicalElectricalPermission.ts +5 -0
  23. package/composables/usePestControlPermission.ts +11 -0
  24. package/composables/usePoolMaintenancePermission.ts +11 -0
  25. package/composables/usePropertyPermission.ts +31 -7
  26. package/composables/useSecurityPermission.ts +33 -13
  27. package/composables/useServiceProvider.ts +9 -4
  28. package/constants/permissions.ts +73 -21
  29. package/package.json +1 -1
  30. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +3 -0
  31. package/types/overnight-parking.d.ts +14 -0
  32. package/types/site.d.ts +7 -1
  33. package/utils/hid-enrolment-subject.ts +226 -4
  34. package/utils/hid-permission-assignments.ts +75 -6
  35. package/utils/hid-qr-validity.ts +25 -0
  36. package/utils/hid-reader-audiences.ts +557 -0
  37. package/utils/hid-registration.ts +24 -0
  38. package/utils/module-routes.ts +15 -2
  39. package/utils/nav-sections.ts +1 -1
  40. package/utils/robot-credential-form.ts +4 -2
  41. package/utils/robot-display.ts +28 -6
  42. package/utils/robot-setup.ts +1 -0
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Minutes from `now` to the next local midnight, so a visitor HID QR runs to
3
+ * the end of the visit day. The resident app's invite form does the same
4
+ * (`minutesUntilEndOfDay` in its VisitorForm.tsx). Never less than 1: the API
5
+ * refuses a zero validity.
6
+ */
7
+ /**
8
+ * How long the site's HID visitor pass lasts: the `usage` on the HID entry of
9
+ * `visitorQrCodes` (Site Settings > Resident Visitors). Anything but
10
+ * "singleUse" is the whole visit day - the same reading as core's
11
+ * `getHidVisitorQrUsage` and the resident app's invite form.
12
+ */
13
+ export function hidQrUsageFromSettings(settings: unknown): "wholeDay" | "singleUse" {
14
+ const codes = (settings as { visitorQrCodes?: unknown } | null)?.visitorQrCodes;
15
+ const hid = Array.isArray(codes)
16
+ ? codes.find((code) => (code as { type?: unknown } | null)?.type === "hid")
17
+ : undefined;
18
+ return (hid as { usage?: unknown } | undefined)?.usage === "singleUse" ? "singleUse" : "wholeDay";
19
+ }
20
+
21
+ export function minutesUntilEndOfDay(now: Date = new Date()) {
22
+ const midnight = new Date(now);
23
+ midnight.setHours(24, 0, 0, 0);
24
+ return Math.max(1, Math.floor((midnight.getTime() - now.getTime()) / 60_000));
25
+ }
@@ -0,0 +1,557 @@
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
+ }
180
+
181
+ /* ── WHAT SWITCHING ONE OFF COSTS ──────────────────────────────────────── */
182
+
183
+ /** `counts` as `getSitePermissions` returns it, per category. */
184
+ export type THidPermissionCounts = {
185
+ resident?: number;
186
+ propertyManagement?: number;
187
+ serviceProvider?: number;
188
+ serviceProviderMember?: number;
189
+ };
190
+
191
+ /**
192
+ * How many people on this reader lose access if an account type is switched off.
193
+ *
194
+ * Both service-provider categories answer to the one switch, so both are
195
+ * counted - a company grant and a named contractor are the same door to the
196
+ * person walking through it.
197
+ *
198
+ * RESIDENT VISITOR IS ALWAYS ZERO, and that is not an oversight. A visitor pass
199
+ * is not a permission assignment: it is bound through `user_access_rules` with
200
+ * its own expiry, so switching the account type off stops new passes being
201
+ * issued and leaves any already issued to run out on their own. Reporting a
202
+ * number there would promise a revocation that does not happen.
203
+ */
204
+ export function peopleLosingAccess(
205
+ audience: THidReaderAudience,
206
+ counts: THidPermissionCounts | null | undefined,
207
+ ): number {
208
+ const read = (value: unknown) => {
209
+ const count = Number(value ?? 0);
210
+ return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
211
+ };
212
+ switch (audience) {
213
+ case "resident":
214
+ return read(counts?.resident);
215
+ case "member":
216
+ return read(counts?.propertyManagement);
217
+ case "serviceProvider":
218
+ return read(counts?.serviceProvider) + read(counts?.serviceProviderMember);
219
+ case "residentVisitor":
220
+ default:
221
+ return 0;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * The account types being switched OFF by this edit, with what each one costs.
227
+ *
228
+ * Only on-to-off transitions. An account type that was already off costs
229
+ * nothing to leave off, and listing it would make every save of an unrelated
230
+ * field look like a revocation.
231
+ */
232
+ export function audienceRevocations(
233
+ before: THidReaderAudiences,
234
+ after: THidReaderAudiences,
235
+ counts: THidPermissionCounts | null | undefined,
236
+ ): Array<{ audience: THidReaderAudience; label: string; people: number }> {
237
+ return HID_READER_AUDIENCE_FIELDS
238
+ .filter((field) => before[field.value] === true && after[field.value] === false)
239
+ .map((field) => ({
240
+ audience: field.value,
241
+ label: field.label,
242
+ people: peopleLosingAccess(field.value, counts),
243
+ }));
244
+ }
245
+
246
+ /**
247
+ * Account types this edit would leave with NO reader at the site.
248
+ *
249
+ * A warning rather than a refusal: a site may genuinely have no door for
250
+ * contractors, and the last reader is sometimes switched off because it is being
251
+ * decommissioned. But doing it unknowingly means residents - or a whole category
252
+ * of people - quietly have no way in, and nothing else on the screen would say so.
253
+ *
254
+ * The reader being edited is judged by `after`, not by what is stored, so the
255
+ * warning describes the save about to happen rather than the state before it.
256
+ */
257
+ export function audiencesLeftWithNoReader(
258
+ readerId: unknown,
259
+ after: THidReaderAudiences,
260
+ siteReaders: ReadonlyArray<Record<string, unknown>>,
261
+ ): Array<{ audience: THidReaderAudience; label: string }> {
262
+ const editing = String(readerId ?? "").trim();
263
+ return HID_READER_AUDIENCE_FIELDS
264
+ .filter((field) => {
265
+ const servedElsewhere = siteReaders.some((reader) => {
266
+ const id = String(reader?._id ?? "").trim();
267
+ // The reader being edited is judged by the pending values, not the stored
268
+ // ones - otherwise saving its own switch-off would never warn.
269
+ if (editing && id === editing) return false;
270
+ return readerServesAudience(reader, field.value);
271
+ });
272
+ return !servedElsewhere && after[field.value] === false;
273
+ })
274
+ .map((field) => ({ audience: field.value, label: field.label }));
275
+ }
276
+
277
+ /* ── WHICH READERS AN ENROLMENT LANDS ON ───────────────────────────────── */
278
+
279
+ /**
280
+ * One reader's standing as a target for enrolling a person of some category.
281
+ *
282
+ * `reason` is what to tell the operator about a reader left out; `warning` what
283
+ * to tell them about one included that may not answer. Sentences, not codes —
284
+ * the only consumer is a list somebody reads before pressing Save.
285
+ */
286
+ export type THidEnrolmentTarget = {
287
+ readerId: string;
288
+ name: string;
289
+ location: string;
290
+ included: boolean;
291
+ reason?: string;
292
+ warning?: string;
293
+ };
294
+
295
+ /**
296
+ * SPLIT A SITE'S READERS INTO THE ONES THIS PERSON BELONGS ON AND THE REST.
297
+ *
298
+ * Mirrors `partitionEnrolmentTargets` in core, which is the authority — core
299
+ * refuses a grant on a reader that does not serve the category, so this only
300
+ * decides what to draw. The copy exists because layer-common cannot import it.
301
+ *
302
+ * Three conditions, checked in the order that produces the most useful sentence
303
+ * rather than the order they are stored in. A reader that is switched off should
304
+ * say so; telling the operator it "does not serve residents" would be true of
305
+ * nothing and send them to the audience switches instead of the enable switch.
306
+ *
307
+ * AN OFFLINE READER IS STILL A TARGET. `offline` means we have not heard from
308
+ * it, not that it is unconfigured: the access grant is a database write that
309
+ * succeeds and the device write is retried. Excluding it would quietly deny
310
+ * somebody a door because a reader was asleep.
311
+ */
312
+ export function partitionEnrolmentTargets(
313
+ readers: ReadonlyArray<Record<string, unknown> | null | undefined>,
314
+ category: unknown,
315
+ ): { targets: THidEnrolmentTarget[]; excluded: THidEnrolmentTarget[] } {
316
+ const targets: THidEnrolmentTarget[] = [];
317
+ const excluded: THidEnrolmentTarget[] = [];
318
+
319
+ for (const reader of readers) {
320
+ if (!reader) continue;
321
+ const readerId = String(reader._id ?? "").trim();
322
+ if (!readerId) continue;
323
+ const base = {
324
+ readerId,
325
+ name: String(reader.name ?? "").trim() || "HID Reader",
326
+ location: String(reader.location ?? "").trim(),
327
+ };
328
+
329
+ const status = String(reader.status ?? "");
330
+ if (reader.enabled === false || status === "inactive" || status === "deleted") {
331
+ excluded.push({ ...base, included: false, reason: "This reader is switched off." });
332
+ continue;
333
+ }
334
+ if (!reader.portalId || !String(reader.portalName ?? "").trim()) {
335
+ excluded.push({
336
+ ...base,
337
+ included: false,
338
+ reason: "No access portal is selected on this reader.",
339
+ });
340
+ continue;
341
+ }
342
+ const audience = audienceOfCategory(category);
343
+ if (!audience) {
344
+ excluded.push({
345
+ ...base,
346
+ included: false,
347
+ reason: "This kind of person cannot be granted a reader.",
348
+ });
349
+ continue;
350
+ }
351
+ if (!readerServesAudience(reader, audience)) {
352
+ excluded.push({
353
+ ...base,
354
+ included: false,
355
+ reason: `This reader does not serve ${audienceLabel(audience)}.`,
356
+ });
357
+ continue;
358
+ }
359
+
360
+ targets.push({
361
+ ...base,
362
+ included: true,
363
+ ...(status === "offline"
364
+ ? { warning: "Not heard from recently. Access is granted now and the reader updated when it answers." }
365
+ : {}),
366
+ });
367
+ }
368
+
369
+ return { targets, excluded };
370
+ }
371
+
372
+ /** What each account type is called in a sentence about a door. */
373
+ export function audienceLabel(audience: unknown): string {
374
+ const labels: Record<string, string> = {
375
+ resident: "residents",
376
+ residentVisitor: "resident visitors",
377
+ member: "members",
378
+ serviceProvider: "service providers",
379
+ };
380
+ return labels[String(audience ?? "")] ?? "that kind of person";
381
+ }
382
+
383
+ /* ── HOW A FAN-OUT WENT ────────────────────────────────────────────────── */
384
+
385
+ export type THidReaderStep = "waiting" | "working" | "done" | "failed";
386
+
387
+ export type THidReaderOutcome = {
388
+ readerId: string;
389
+ name: string;
390
+ step: THidReaderStep;
391
+ /** What failed, in words the operator can act on. */
392
+ message?: string;
393
+ /** Whether a stored face is available, so Retry can be offered without the person. */
394
+ retryable?: boolean;
395
+ };
396
+
397
+ /** "2 of 4 readers" — what the progress line says while a save is running. */
398
+ export function fanOutProgressText(outcomes: ReadonlyArray<THidReaderOutcome>): string {
399
+ const total = outcomes.length;
400
+ if (!total) return "";
401
+ const settled = outcomes.filter((o) => o.step === "done" || o.step === "failed").length;
402
+ return `${settled} of ${total} ${total === 1 ? "reader" : "readers"}`;
403
+ }
404
+
405
+ /**
406
+ * What to say once a fan-out has finished.
407
+ *
408
+ * A PARTIAL RESULT IS NOT A FAILURE, and must not read as one: the person has
409
+ * access on the readers that worked, and the rest can be retried without them
410
+ * being present. Saying "enrolment failed" would send the operator to re-do work
411
+ * that is already done.
412
+ */
413
+ export function fanOutSummary(outcomes: ReadonlyArray<THidReaderOutcome>): {
414
+ tone: "success" | "partial" | "error";
415
+ message: string;
416
+ } {
417
+ const total = outcomes.length;
418
+ const done = outcomes.filter((o) => o.step === "done").length;
419
+ const failed = outcomes.filter((o) => o.step === "failed").length;
420
+
421
+ if (!total) return { tone: "error", message: "No reader at this site serves this person." };
422
+ if (!failed) {
423
+ return {
424
+ tone: "success",
425
+ message: `Enrolled on ${done} ${done === 1 ? "reader" : "readers"}.`,
426
+ };
427
+ }
428
+ if (!done) {
429
+ return {
430
+ tone: "error",
431
+ message: `Could not enroll on any of the ${total} ${total === 1 ? "reader" : "readers"}. Nothing was changed on them.`,
432
+ };
433
+ }
434
+ return {
435
+ tone: "partial",
436
+ message: `Enrolled on ${done} of ${total} readers. ${failed} can be retried — the person does not need to be present.`,
437
+ };
438
+ }
439
+
440
+ /**
441
+ * What to say after an EDIT has been applied across readers.
442
+ *
443
+ * Different from an enrolment's summary in one way that matters: a PIN.
444
+ *
445
+ * A face that reached three readers of four is incomplete - the person opens
446
+ * three doors. A PIN that reached three of four is WORSE than incomplete: the
447
+ * old PIN still works on the fourth, so they hold two PINs and have no way to
448
+ * know which door wants which. That is the one partial state worth naming
449
+ * explicitly rather than folding into "2 of 3".
450
+ */
451
+ export function editFanOutSummary(
452
+ outcomes: ReadonlyArray<THidReaderOutcome>,
453
+ pinChanged: boolean,
454
+ ): { tone: "success" | "partial" | "error"; message: string } {
455
+ const total = outcomes.length;
456
+ const done = outcomes.filter((o) => o.step === "done").length;
457
+ const failed = outcomes.filter((o) => o.step === "failed").length;
458
+
459
+ if (!total) return { tone: "error", message: "This person is not on any reader." };
460
+ if (!failed) {
461
+ return {
462
+ tone: "success",
463
+ message: total === 1
464
+ ? "Updated on this reader."
465
+ : `Updated on all ${total} readers.`,
466
+ };
467
+ }
468
+ if (!done) {
469
+ return {
470
+ tone: "error",
471
+ message: `Could not update any of the ${total} ${total === 1 ? "reader" : "readers"}.`,
472
+ };
473
+ }
474
+
475
+ const stale = outcomes.filter((o) => o.step === "failed").map((o) => o.name).join(", ");
476
+ return {
477
+ tone: "partial",
478
+ message: pinChanged
479
+ ? `Updated on ${done} of ${total} readers. ${stale} still ${failed === 1 ? "has" : "have"} the OLD PIN —`
480
+ + " this person now has two different PINs. Save again to finish."
481
+ : `Updated on ${done} of ${total} readers. ${stale} ${failed === 1 ? "was" : "were"} not reached. Save again to finish.`,
482
+ };
483
+ }
484
+
485
+ /* ── DELETING SOMEBODY WHO IS ON SEVERAL READERS ───────────────────────── */
486
+
487
+ /**
488
+ * A delete used to be read as "remove this row", which was true when a person
489
+ * lived on one reader. Once enrolment fans out, the same person is on several,
490
+ * and "delete" is ambiguous in the one direction that matters: removing them
491
+ * everywhere when the operator meant one door is an access change they did not
492
+ * ask for and cannot see from this screen.
493
+ *
494
+ * So the dialog asks, and the default is the narrow one - this reader. Widening
495
+ * is a deliberate click.
496
+ */
497
+ export function deleteScopeChoices(
498
+ readerCount: number,
499
+ hereName: string,
500
+ ): Array<{ label: string; value: "here" | "all" }> {
501
+ const here = { label: `This reader only (${hereName || "this reader"})`, value: "here" as const };
502
+ if (readerCount < 2) return [here];
503
+ return [here, { label: `All ${readerCount} readers`, value: "all" as const }];
504
+ }
505
+
506
+ /** "This person is also on Gym and Car Park." - said before, not after. */
507
+ export function deleteReachText(
508
+ places: ReadonlyArray<{ readerId: string; name: string }>,
509
+ hereReaderId: string,
510
+ ): string {
511
+ const others = places
512
+ .filter((p) => p.readerId !== hereReaderId)
513
+ .map((p) => p.name || "a reader");
514
+ if (!others.length) return "This person is only on this reader.";
515
+ const names = others.length === 1
516
+ ? others[0]
517
+ : `${others.slice(0, -1).join(", ")} and ${others[others.length - 1]}`;
518
+ return `This person is also on ${names}.`;
519
+ }
520
+
521
+ /**
522
+ * What to say after a delete has run across readers.
523
+ *
524
+ * The failure here is the mirror of the edit's: a delete that reached three
525
+ * readers of four leaves the person still able to open the fourth door. Naming
526
+ * that reader is the whole message - "2 of 3" alone tells an operator a door is
527
+ * still open without telling them which.
528
+ */
529
+ export function deleteFanOutSummary(
530
+ outcomes: ReadonlyArray<THidReaderOutcome>,
531
+ ): { tone: "success" | "partial" | "error"; message: string } {
532
+ const total = outcomes.length;
533
+ const done = outcomes.filter((o) => o.step === "done").length;
534
+ const failed = outcomes.filter((o) => o.step === "failed");
535
+
536
+ if (!total) return { tone: "error", message: "There was nothing to delete." };
537
+ if (!failed.length) {
538
+ return {
539
+ tone: "success",
540
+ message: total === 1
541
+ ? "Deleted from this reader."
542
+ : `Deleted from all ${total} readers.`,
543
+ };
544
+ }
545
+ if (!done) {
546
+ return {
547
+ tone: "error",
548
+ message: `Could not delete from any of the ${total} ${total === 1 ? "reader" : "readers"}.`,
549
+ };
550
+ }
551
+ const left = failed.map((o) => o.name).join(", ");
552
+ return {
553
+ tone: "partial",
554
+ message: `Deleted from ${done} of ${total} readers. This person can STILL open`
555
+ + ` ${left}. Delete again to finish.`,
556
+ };
557
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * THE NEXT REGISTRATION NUMBER ON ONE READER.
3
+ *
4
+ * Each reader numbers its own users, so the input is that reader's existing
5
+ * registration numbers (from the device, plus the roster on screen when it is
6
+ * the selected reader). Numeric ones count; the next is the highest plus one,
7
+ * zero padded to six digits or to the widest number already in use, whichever
8
+ * is wider. Anything that is not a plain number (`USR-...`, blank, undefined)
9
+ * is ignored rather than breaking the allocation.
10
+ *
11
+ * Pure. `HidUserEnrollment.vue` reads the lists and calls this.
12
+ */
13
+ export function nextRegistration(registrations: readonly unknown[]): string {
14
+ let max = 0;
15
+ let width = 6;
16
+ for (const value of registrations) {
17
+ const text = String(value ?? "").trim();
18
+ const match = text.match(/^(\d+)$/);
19
+ if (!match) continue;
20
+ max = Math.max(max, Number(match[1]));
21
+ width = Math.max(width, match[1].length);
22
+ }
23
+ return String(max + 1).padStart(width, "0");
24
+ }
@@ -11,8 +11,10 @@
11
11
  * Only prefixes whose page each app gates on exactly one module are listed
12
12
  * (checked against every app's own `useLocalPermission` nav gate, 2026-09-11).
13
13
  * Shared or ambiguous pages stay out: calendar, keys (gated on `site-settings`),
14
- * access-mgmt (`site-settings:manage-entry-pass` also opens it), equipment and
15
- * inventory, service-providers and support tickets.
14
+ * the Cards and QR Tagging pages under access-mgmt (each app's own
15
+ * `access-card-mgmt` gate), equipment and inventory, service-providers and
16
+ * support tickets. The HID pages under access-mgmt ARE listed, one by one,
17
+ * because each is gated on `hid-mgmt` alone (`useHidAccessPermission`).
16
18
  *
17
19
  * NEVER MAPPED: the dashboard, settings, members, invitations and roles. A client
18
20
  * must always be able to run its own people and roles; the server never denies
@@ -68,6 +70,17 @@ export const MODULE_ROUTES: Readonly<Record<string, string>> = Object.freeze({
68
70
  "org-site-pool-schedule-area": "area-mgmt",
69
71
  "org-site-pool-schedule-unit": "unit-mgmt",
70
72
  "org-site-service-provider-mgmt": "service-provider-mgmt",
73
+ // HID access management (decision of 2026-10-16): the seven HID pages under
74
+ // `access-mgmt`, named one by one so the Cards and QR Tagging pages beside
75
+ // them stay unmapped. `hid-mgmt` is a governed module in core, so a client
76
+ // that was not given it loses the pages and the drawer group together.
77
+ "org-site-access-mgmt-hid-readers": "hid-mgmt",
78
+ "org-site-access-mgmt-administrator": "hid-mgmt",
79
+ "org-site-access-mgmt-hid-users": "hid-mgmt",
80
+ "org-site-access-mgmt-hid-qr-code": "hid-mgmt",
81
+ "org-site-access-mgmt-hid-cards": "hid-mgmt",
82
+ "org-site-access-mgmt-intercom": "hid-mgmt",
83
+ "org-site-access-mgmt-access-logs": "hid-mgmt",
71
84
  "org-site-online-forms": "online-form-configuration",
72
85
  "org-site-nfc": "nfc-patrol",
73
86
  "org-site-document-mgmt": "document-mgmt",
@@ -77,7 +77,7 @@ export const NAV_SECTIONS: Record<string, string> = {
77
77
  "SOA Mgmt": "PROPERTY",
78
78
 
79
79
  // SECURITY OPERATIONS
80
- "HID Face Reader": "SECURITY OPERATIONS",
80
+ "HID Reader": "SECURITY OPERATIONS",
81
81
  "Daily Occurrence Books": "SECURITY OPERATIONS",
82
82
  Manpower: "SECURITY OPERATIONS",
83
83
  "Virtual Patrol": "SECURITY OPERATIONS",
@@ -16,6 +16,8 @@ export function credentialLabels(brand: unknown): { key: string; token: string }
16
16
  if (brand === "demo") return { key: "App key (any value)", token: "Token (any value)" };
17
17
  // OneWay (Anbot RMS): one API key per company; the second field is the RMS server's address.
18
18
  if (brand === "oneway") return { key: "API key", token: "Server address" };
19
+ // VIGGO (Velobotics): an OAuth access token; its API has no robot list, so the robots' VIN numbers are the second field.
20
+ if (brand === "viggo") return { key: "Access token", token: "Robot VIN numbers (comma separated)" };
19
21
  return { key: "App key", token: "Token" };
20
22
  }
21
23
 
@@ -29,12 +31,12 @@ export function credentialTokenDefault(brand: unknown): string {
29
31
 
30
32
  /** False when the second field is not a secret (OneWay's server address): shown as plain text. */
31
33
  export function credentialTokenIsSecret(brand: unknown): boolean {
32
- return brand !== "oneway";
34
+ return brand !== "oneway" && brand !== "viggo";
33
35
  }
34
36
 
35
37
  /** Brands whose one company credential lists its robots, so Add robot picks the serial (Find robots). */
36
38
  export function credentialDiscoverable(brand: unknown): boolean {
37
- return brand === "ounitech" || brand === "padbot" || brand === "oneway";
39
+ return brand === "ounitech" || brand === "padbot" || brand === "oneway" || brand === "viggo";
38
40
  }
39
41
 
40
42
  /** "Enter the AppID and the Secret ..." - `credentialPatch`'s refusal, in the brand's words. */