@7365admin1/layer-common 4.81.1-staging.467 → 4.81.1

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 (46) hide show
  1. package/CHANGELOG.md +10 -4
  2. package/assets/css/primitives.css +0 -57
  3. package/components/AccessCardDetailsDialog.vue +4 -57
  4. package/components/AccessCardPreviewDialog.vue +50 -291
  5. package/components/AppSelect.vue +4 -143
  6. package/components/BuildingUnitFormEdit.vue +0 -6
  7. package/components/Dialog/UpdateMoreAction.vue +1 -11
  8. package/components/DocumentForm.vue +112 -407
  9. package/components/DocumentManagement.vue +12 -213
  10. package/components/Facility/BookingSetup.vue +0 -63
  11. package/components/HidAccessLogDashboard.vue +17 -74
  12. package/components/HidIntercomManagement.vue +14 -183
  13. package/components/HidQrCodeConfiguration.vue +195 -17
  14. package/components/HidReaderManagement.vue +0 -398
  15. package/components/HidReaderUserRoster.vue +3 -44
  16. package/components/HidUserEnrollment.vue +248 -1521
  17. package/components/InvitationClientForm.vue +1 -19
  18. package/components/VisitorForm.vue +0 -12
  19. package/components/VisitorManagement.vue +5 -267
  20. package/composables/useDocument.ts +0 -7
  21. package/composables/useFacility.ts +0 -11
  22. package/composables/useHidAmico.ts +0 -165
  23. package/composables/useHidNavigation.ts +1 -25
  24. package/composables/useMember.ts +0 -12
  25. package/composables/usePeople.ts +0 -19
  26. package/composables/useServiceProvider.ts +2 -40
  27. package/package.json +1 -1
  28. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  29. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  30. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  31. package/types/document.d.ts +0 -14
  32. package/types/facility.d.ts +0 -4
  33. package/types/member.d.ts +0 -4
  34. package/types/people.d.ts +1 -28
  35. package/types/service-provider.d.ts +0 -5
  36. package/components/BuildingUnitManagement.vue +0 -298
  37. package/components/HidAccessPermissions.vue +0 -424
  38. package/components/HidProfileQrCode.vue +0 -332
  39. package/composables/useHidReaderSelection.ts +0 -50
  40. package/composables/useSiteCategory.ts +0 -45
  41. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  42. package/utils/hid-enrolment-subject.ts +0 -144
  43. package/utils/hid-permission-assignments.ts +0 -190
  44. package/utils/hid-reader-selection.ts +0 -52
  45. package/utils/occupancy-role.ts +0 -109
  46. package/utils/service-type.ts +0 -30
@@ -1,332 +0,0 @@
1
- <!--
2
- MY HID QR CODE.
3
-
4
- The signed-in member's own pass for one reader. Every other HID screen acts on
5
- somebody else - a resident being enrolled, a visitor being issued a pass. This
6
- one has no subject picker at all, because the server takes the caller from the
7
- session and there is no user id to send.
8
-
9
- Issuing a code does NOT grant access. A member's door permission comes from the
10
- HID permissions screen, and minting an access rule here would let anyone who
11
- can press Generate admit themselves. So the screen reports what the permissions
12
- screen says (`assigned`) and warns when a code would be refused at the door,
13
- rather than handing out something that silently fails.
14
- -->
15
- <template>
16
- <div class="hid-profile-qr">
17
- <div class="hid-profile-qr__header">
18
- <h2 class="hid-profile-qr__title">HID QR Code</h2>
19
- <AppSelect
20
- v-if="readerOptions.length"
21
- v-model="selectedReaderId"
22
- class="reader-select"
23
- :items="readerOptions"
24
- @update:model-value="loadProfile"
25
- />
26
- </div>
27
-
28
- <v-card class="screen-modal hid-profile-qr__card">
29
- <v-card-text class="screen-modal__body">
30
- <div v-if="loading" class="hid-profile-qr__state">Loading...</div>
31
-
32
- <div v-else-if="!readerOptions.length" class="hid-profile-qr__state">
33
- No HID reader is set up for this site yet.
34
- </div>
35
-
36
- <template v-else>
37
- <p class="hid-profile-qr__reader">
38
- {{ profile?.readerName || "HID reader" }}
39
- <span>{{ profile?.portalName || "Portal not configured" }}</span>
40
- </p>
41
-
42
- <!-- A code this reader cannot read is worse than no code, so the
43
- capability is checked before anything is offered. -->
44
- <v-alert
45
- v-if="profile && !profile.qrEnabled"
46
- type="warning"
47
- variant="tonal"
48
- density="compact"
49
- class="mb-4"
50
- >
51
- This reader is not set up to read QR codes.
52
- </v-alert>
53
-
54
- <!-- The honest part: a code opens the door only for a member the
55
- permissions screen already admits. `assigned` is null when the
56
- lookup failed, which is not the same as "no". -->
57
- <v-alert
58
- v-else-if="profile && profile.assigned === false"
59
- type="warning"
60
- variant="tonal"
61
- density="compact"
62
- class="mb-4"
63
- >
64
- You are not assigned to this reader, so this code will be refused at
65
- the door. Ask a property manager to add you on the HID permissions
66
- screen.
67
- </v-alert>
68
-
69
- <div v-if="qrImage" class="hid-profile-qr__code">
70
- <img :src="qrImage" alt="Your HID QR code" width="220" height="220" />
71
- <p class="hid-profile-qr__issued">
72
- Issued {{ formatIssuedAt(profile?.issuedAt) }}
73
- </p>
74
- </div>
75
- <p v-else class="hid-profile-qr__state">
76
- You have no QR code on this reader yet.
77
- </p>
78
-
79
- <div class="hid-profile-qr__actions">
80
- <AppButton
81
- :disabled="working || !profile?.qrEnabled"
82
- @click="generate"
83
- >
84
- {{ profile?.issued ? "Regenerate" : "Generate QR Code" }}
85
- </AppButton>
86
- <AppButton
87
- v-if="qrImage"
88
- variant="ghost"
89
- icon="mdi-download"
90
- @click="downloadQr"
91
- >
92
- Download
93
- </AppButton>
94
- <AppButton
95
- v-if="profile?.issued"
96
- variant="ghost"
97
- :disabled="working"
98
- @click="remove"
99
- >
100
- Remove
101
- </AppButton>
102
- </div>
103
-
104
- <p class="field-hint">
105
- Regenerating replaces the previous code. The old one stops working
106
- immediately.
107
- </p>
108
- </template>
109
- </v-card-text>
110
- </v-card>
111
-
112
- <v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="4000">
113
- {{ snackbar.message }}
114
- </v-snackbar>
115
- </div>
116
- </template>
117
-
118
- <script setup lang="ts">
119
- import QRCode from "qrcode";
120
- import type { HidProfileQr } from "../composables/useHidAmico";
121
-
122
- const props = defineProps<{ site: string }>();
123
-
124
- const { getReaders, getProfileQr, issueProfileQr, deleteProfileQr } = useHidAmico();
125
-
126
- type HidReaderOption = { _id: string; name?: string; portalName?: string };
127
-
128
- const readers = ref<HidReaderOption[]>([]);
129
- const { selectHidReader, resolveForSite } = useHidReaderSelection();
130
- const selectedReaderId = ref("");
131
- const profile = ref<HidProfileQr | null>(null);
132
- const qrImage = ref("");
133
- const loading = ref(false);
134
- const working = ref(false);
135
- const snackbar = reactive({ show: false, color: "success", message: "" });
136
-
137
- const readerOptions = computed(() => readers.value.map((reader) => ({
138
- title: `${reader.name || reader._id} — ${reader.portalName || "Portal not configured"}`,
139
- value: reader._id,
140
- })));
141
-
142
- function showToast(message: string, color: "success" | "error" = "success") {
143
- snackbar.message = message;
144
- snackbar.color = color;
145
- snackbar.show = true;
146
- }
147
-
148
- function toRecord(value: unknown): Record<string, any> {
149
- return value && typeof value === "object" ? (value as Record<string, any>) : {};
150
- }
151
-
152
- function errorMessage(error: unknown) {
153
- const record = toRecord(error);
154
- return String(toRecord(record.data).message || record.message || "Something went wrong.");
155
- }
156
-
157
- function formatIssuedAt(value?: string) {
158
- if (!value) return "";
159
- const date = new Date(value);
160
- return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
161
- }
162
-
163
- /**
164
- * The image is drawn here, never fetched. The value is the credential itself,
165
- * so keeping it on this page and out of any URL is the point.
166
- */
167
- async function renderQr(value: string) {
168
- if (!value) {
169
- qrImage.value = "";
170
- return;
171
- }
172
- try {
173
- qrImage.value = await QRCode.toDataURL(value, { width: 440, margin: 1 });
174
- } catch (error) {
175
- console.error("Unable to render the HID QR code:", error);
176
- qrImage.value = "";
177
- }
178
- }
179
-
180
- /**
181
- * Saves the rendered code as a PNG.
182
- *
183
- * Straight off the data URL already in the page - no request, so the credential
184
- * never travels again to be downloaded. Same anchor-click approach `VisitorForm`
185
- * uses for a visitor pass, and the reader name is in the file name because a
186
- * member can hold one code per reader.
187
- */
188
- function downloadQr() {
189
- if (!qrImage.value) return;
190
- const slug = String(profile.value?.readerName || "reader")
191
- .trim()
192
- .replace(/[^a-z0-9]+/gi, "-")
193
- .replace(/^-|-$/g, "")
194
- .toLowerCase();
195
- const link = document.createElement("a");
196
- link.href = qrImage.value;
197
- link.download = `hid-qr-${slug || "reader"}.png`;
198
- link.click();
199
- }
200
-
201
- async function loadReaders() {
202
- if (!props.site) return;
203
- const response = toRecord(await getReaders({ site: props.site, page: 1, limit: 100 }));
204
- const data = toRecord(response.data);
205
- const items = response.items ?? data.items ?? data.readers ?? [];
206
- readers.value = (Array.isArray(items) ? items : []).filter((reader) => reader?._id);
207
- /* Shared with every other HID screen — see `useHidReaderSelection`. The
208
- resolver refuses a remembered reader that is not this site's, so a site
209
- switch or a deleted reader falls back to the first rather than querying
210
- an id the site does not own. */
211
- selectedReaderId.value = resolveForSite(props.site, readers.value);
212
- }
213
-
214
- async function loadProfile() {
215
- if (!selectedReaderId.value) return;
216
- loading.value = true;
217
- try {
218
- const response = await getProfileQr(selectedReaderId.value);
219
- profile.value = response?.data ?? null;
220
- await renderQr(profile.value?.qrValue || "");
221
- } catch (error: unknown) {
222
- profile.value = null;
223
- qrImage.value = "";
224
- showToast(errorMessage(error), "error");
225
- } finally {
226
- loading.value = false;
227
- }
228
- }
229
-
230
- async function generate() {
231
- if (!selectedReaderId.value) return;
232
- working.value = true;
233
- try {
234
- const response = await issueProfileQr(selectedReaderId.value);
235
- profile.value = response?.data ?? null;
236
- await renderQr(profile.value?.qrValue || "");
237
- showToast("QR code generated.");
238
- } catch (error: unknown) {
239
- showToast(errorMessage(error), "error");
240
- } finally {
241
- working.value = false;
242
- }
243
- }
244
-
245
- async function remove() {
246
- if (!selectedReaderId.value) return;
247
- if (!window.confirm("Remove your QR code from this reader?")) return;
248
- working.value = true;
249
- try {
250
- const response = await deleteProfileQr(selectedReaderId.value);
251
- profile.value = response?.data ?? null;
252
- await renderQr("");
253
- showToast("QR code removed.");
254
- } catch (error: unknown) {
255
- showToast(errorMessage(error), "error");
256
- } finally {
257
- working.value = false;
258
- }
259
- }
260
-
261
- watch(
262
- () => props.site,
263
- async () => {
264
- await loadReaders();
265
- await loadProfile();
266
- },
267
- { immediate: true },
268
- );
269
-
270
- /* Publish every change, including the seed above: a screen that had to fall
271
- back to the first reader should leave the others agreeing with it. */
272
- watch(selectedReaderId, (value) => {
273
- if (value) selectHidReader(props.site, value);
274
- });
275
- </script>
276
-
277
- <style scoped>
278
- .hid-profile-qr__header {
279
- display: flex;
280
- align-items: center;
281
- gap: 12px;
282
- flex-wrap: wrap;
283
- }
284
-
285
- .hid-profile-qr__title {
286
- margin: 0;
287
- font-size: 18px;
288
- font-weight: 600;
289
- }
290
-
291
- .hid-profile-qr__card {
292
- margin-top: 16px;
293
- max-width: 460px;
294
- }
295
-
296
- .hid-profile-qr__reader {
297
- margin: 0 0 14px;
298
- font-size: 14px;
299
- font-weight: 600;
300
- }
301
-
302
- .hid-profile-qr__reader span {
303
- display: block;
304
- color: var(--muted);
305
- font-size: 12px;
306
- font-weight: 500;
307
- }
308
-
309
- .hid-profile-qr__code {
310
- display: grid;
311
- place-items: center;
312
- margin-bottom: 14px;
313
- }
314
-
315
- .hid-profile-qr__issued {
316
- margin: 8px 0 0;
317
- color: var(--muted);
318
- font-size: 12px;
319
- }
320
-
321
- .hid-profile-qr__state {
322
- color: var(--muted);
323
- font-size: 13.5px;
324
- padding: 8px 0 14px;
325
- }
326
-
327
- .hid-profile-qr__actions {
328
- display: flex;
329
- gap: 8px;
330
- flex-wrap: wrap;
331
- }
332
- </style>
@@ -1,50 +0,0 @@
1
- import {
2
- EMPTY_HID_READER_CHOICE,
3
- resolveHidReaderId,
4
- type HidReaderChoice,
5
- } from "../utils/hid-reader-selection";
6
-
7
- /**
8
- * ONE HID READER CHOICE, SHARED BY EVERY HID SCREEN.
9
- *
10
- * Declared at MODULE scope on purpose. Each screen held its own `ref("")`, and
11
- * because the HID screens are separate pages the ref was rebuilt on every
12
- * navigation — HID Reader Users, HID Cards, Access Logs and HID QR Code each
13
- * reset to the first reader no matter what had just been picked next door.
14
- *
15
- * A module-scoped ref is enough because moving between those pages is a ROUTE
16
- * change, not a page load: the menu's items carry a `route` and
17
- * `NavigationItem.vue` renders them through `:to`, so vue-router handles them
18
- * and this module is never re-evaluated.
19
- *
20
- * Deliberately NOT persisted. `localStorage` would carry the choice through a
21
- * hard refresh as well, which is a real but much smaller benefit than the
22
- * navigation case, and it is worth adding only once somebody has felt the
23
- * absence. See `utils/hid-reader-selection.ts` for the staleness rule that
24
- * would have to hold either way.
25
- *
26
- * Intercom keeps its own reader and is not wired to this. Its question is which
27
- * panel you are calling, not which door you are administering, and joining them
28
- * would mean switching reader on Access Logs changed who you would dial.
29
- */
30
- const choice = ref<HidReaderChoice>({ ...EMPTY_HID_READER_CHOICE });
31
-
32
- export default function useHidReaderSelection() {
33
- /** Remember a choice. The site travels with it; an id alone means nothing. */
34
- function selectHidReader(siteId: string, readerId: string) {
35
- choice.value = { siteId: String(siteId ?? ""), readerId: String(readerId ?? "") };
36
- }
37
-
38
- /**
39
- * The reader this screen should show, given the readers it just loaded.
40
- *
41
- * Call it after the reader list arrives, and write the answer back with
42
- * `selectHidReader` so the screens agree on what "current" means even when
43
- * one of them had to fall back to the first reader.
44
- */
45
- function resolveForSite(siteId: string, readers: readonly { _id?: unknown }[]) {
46
- return resolveHidReaderId(siteId, readers, choice.value);
47
- }
48
-
49
- return { choice, selectHidReader, resolveForSite };
50
- }
@@ -1,45 +0,0 @@
1
- /**
2
- * THE CATEGORY OF THE SITE THE USER IS CURRENTLY IN.
3
- *
4
- * One shared answer, published once by the application's layout from the site
5
- * it already loaded for the switcher, and read by anything that has to behave
6
- * differently on a commercial or industrial site - the business Add Tenant
7
- * form, the tenant list. Publishing it rather than re-fetching it per
8
- * component means no two screens can disagree about which site this is.
9
- *
10
- * READ FROM THE CUSTOMER-SITE RECORD, NOT THE SITE RECORD. Both carry a
11
- * `category` and `customer-site.service` keeps them equal (it writes the site
12
- * on create and on update), but `siteSchema` DEFAULTS the site's own copy to
13
- * `commercial` - so a site created without an explicit category reads back as
14
- * commercial there even when it is a residential estate. The customer-site
15
- * copy has no default: it is either the category somebody chose or nothing.
16
- *
17
- * An empty category means "not published yet" or "never set", and every
18
- * caller must behave as it did before this existed - never assume commercial
19
- * from silence.
20
- */
21
- const BUSINESS_SITE_CATEGORIES: string[] = ["commercial", "industrial"];
22
-
23
- export default function useSiteCategory() {
24
- const siteCategory = useState<string>("current-site-category", () => "");
25
-
26
- function setSiteCategory(value?: string | null) {
27
- siteCategory.value = typeof value === "string" ? value : "";
28
- }
29
-
30
- /**
31
- * The categories whose units are LET TO A BUSINESS rather than lived in.
32
- *
33
- * These are the sites where a unit has an owner, an occupier and often a
34
- * person in charge who is neither, so they are the sites that ask for a
35
- * tenant's standing and print it back (see `OCCUPANCY_ROLE_OPTIONS`).
36
- *
37
- * The category never changes what the APPLICATION is called: the rail says
38
- * Property Management on every site it serves.
39
- */
40
- const isBusinessSite = computed(() =>
41
- BUSINESS_SITE_CATEGORIES.includes(siteCategory.value),
42
- );
43
-
44
- return { siteCategory, isBusinessSite, setSiteCategory };
45
- }
@@ -1,23 +0,0 @@
1
- <template>
2
- <v-container fluid>
3
- <HidEnabledGate
4
- :site="siteId"
5
- :org="orgId"
6
- message="Enable HID as a service for this site before generating a QR code."
7
- >
8
- <HidProfileQrCode :site="siteId" />
9
- </HidEnabledGate>
10
- </v-container>
11
- </template>
12
-
13
- <script setup lang="ts">
14
- definePageMeta({
15
- layout: "default",
16
- middleware: ["01-auth", "02-org"],
17
- memberOnly: true,
18
- });
19
-
20
- const route = useRoute();
21
- const siteId = computed(() => String(route.params.site ?? ""));
22
- const orgId = computed(() => String(route.params.org ?? ""));
23
- </script>
@@ -1,144 +0,0 @@
1
- /**
2
- * WHO A HID ENROLMENT IS FOR — the pure decisions behind the subject picker.
3
- *
4
- * `HidUserEnrollment.vue` can enrol a resident or a staff member. The write path
5
- * always carried both (`subjectLink` maps them onto `person` or `member`); what
6
- * was missing was a way to choose. These are the parts of that choice worth
7
- * testing on their own, kept here because a `.vue` component has no test harness
8
- * in this repo.
9
- */
10
-
11
- export type HidEnrolmentSubject = "resident" | "property_management";
12
-
13
- /**
14
- * The dropdown's options, in the order they are drawn.
15
- *
16
- * `title`/`value` because `AppSelect` takes that shape. A service-provider
17
- * option is expected next; adding it here is most of the work, since the write
18
- * path already maps `service_provider` onto `serviceProvider`.
19
- */
20
- export const HID_ENROLMENT_SUBJECTS: ReadonlyArray<{
21
- title: string;
22
- value: HidEnrolmentSubject;
23
- }> = [
24
- { title: "Resident", value: "resident" },
25
- { title: "Member", value: "property_management" },
26
- ];
27
-
28
- /**
29
- * What an EDIT calls the subject it is already attached to.
30
- *
31
- * Wider than the dropdown on purpose. An identity's category cannot change after
32
- * it is created, so an edit states it rather than offering it — and the records
33
- * in the wild include `service_provider`, which the dropdown does not offer yet
34
- * but `openEdit` does resolve. Falling back to the first option would label a
35
- * contractor "Resident", which is worse than saying nothing useful.
36
- */
37
- export function subjectCategoryLabel(category: unknown): string {
38
- const labels: Record<string, string> = {
39
- resident: "Resident",
40
- property_management: "Member",
41
- service_provider: "Service provider",
42
- visitor: "Visitor",
43
- };
44
- return labels[String(category ?? "")] ?? "Unknown";
45
- }
46
-
47
- /**
48
- * One page of `GET /api/members`, read defensively.
49
- *
50
- * `paginate` puts `{ items, pages }` at the root, but `items` sits under `data`
51
- * on some of this product's endpoints, so both are read — the same allowance
52
- * `readCandidatePage` makes. A response that carries neither is an empty page
53
- * rather than a thrown error: an empty staff picker with a placeholder beats a
54
- * broken dialog.
55
- */
56
- export function readMemberPage(response: unknown): {
57
- items: Record<string, unknown>[];
58
- pages: number;
59
- total: number;
60
- } {
61
- const source = (response && typeof response === "object" ? response : {}) as Record<string, unknown>;
62
- const nested = (source.data && typeof source.data === "object" ? source.data : {}) as Record<string, unknown>;
63
- const raw = Array.isArray(source.items)
64
- ? source.items
65
- : Array.isArray(nested.items)
66
- ? nested.items
67
- : [];
68
- const pages = Number(source.pages ?? nested.pages ?? 1);
69
- const items = raw.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object");
70
- /*
71
- * `total` drives "showing 10 of 23" and the Load more button. It falls back to
72
- * the page's own length, not to zero, so a response without it simply reads as
73
- * "this is everything" — a missing count must never make the button offer a
74
- * page that is not there.
75
- */
76
- const total = Number(source.total ?? nested.total ?? items.length);
77
- return {
78
- items,
79
- pages: Number.isFinite(pages) && pages > 0 ? Math.floor(pages) : 1,
80
- total: Number.isFinite(total) && total >= 0 ? Math.floor(total) : items.length,
81
- };
82
- }
83
-
84
- /**
85
- * Can this member actually be enrolled?
86
- *
87
- * Reader access is bound by USER id — `resolvePermissionUserBindings` in
88
- * `iservice365-core` drops every subject without one — so a member with no app
89
- * account cannot be given access and is not a candidate. The id arrives as a
90
- * string from JSON but may be an object if a caller passes a raw document, so
91
- * both are read rather than trusting the shape.
92
- */
93
- export function memberHasAccount(row: Record<string, unknown> | null | undefined): boolean {
94
- const user = row?.user;
95
- if (!user) return false;
96
- if (typeof user === "object") {
97
- const id = (user as { _id?: unknown; toString?: () => string })._id ?? user;
98
- return Boolean(String(id ?? "").trim());
99
- }
100
- return Boolean(String(user).trim());
101
- }
102
-
103
- /**
104
- * How a member reads in the picker: their name, and their role beneath it.
105
- *
106
- * THE ACCOUNT'S NAME FIRST, then the membership's. The two diverge — a
107
- * membership is created with whatever name was typed at invite time, and the
108
- * person may have set their own on the account since — and the account name is
109
- * the one they are known by. `userName` comes from the members endpoint's own
110
- * lookup into `users`; a member with no account has none, but such a row is not
111
- * a candidate anyway (`memberHasAccount`).
112
- *
113
- * Every step falls back, because a row with no name at all still has to be
114
- * selectable rather than blank, or it cannot be told apart from the next one.
115
- */
116
- export function memberCandidateOf(row: Record<string, unknown>): {
117
- subjectId: string;
118
- name: string;
119
- subtitle: string;
120
- } {
121
- const text = (value: unknown) => String(value ?? "").trim();
122
- return {
123
- subjectId: text(row._id),
124
- name: text(row.userName) || text(row.name) || text(row.email) || "Member",
125
- subtitle: text(row.roleName) || text(row.email) || "",
126
- };
127
- }
128
-
129
- /**
130
- * Why somebody the operator can see elsewhere is not in this list.
131
- *
132
- * Left unexplained, an absence reads as a broken screen — the same reason the
133
- * resident cascade carries `unitResidentNote`. Returns "" when there is nothing
134
- * to say, so the caller can render it unconditionally.
135
- */
136
- export function hiddenSubjectNote(count: number, noun: "resident" | "member"): string {
137
- if (!Number.isFinite(count) || count <= 0) return "";
138
- const whole = Math.floor(count);
139
- const subject = whole === 1 ? `1 ${noun}` : `${whole} ${noun}s`;
140
- const verb = whole === 1 ? "is" : "are";
141
- const holder = whole === 1 ? "that person has" : "they have";
142
- return `${subject} at this site ${verb} not listed:`
143
- + ` HID enrollment needs an app account, and ${holder} none yet.`;
144
- }