@7365admin1/layer-common 4.73.1-staging.464 → 4.73.1-staging.466

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.
@@ -115,32 +115,86 @@
115
115
  <template #item.nature="{ item }">
116
116
  {{ replaceMatch(item.nature, "_", " ") }}
117
117
  </template>
118
- <!-- The role is a soft pill in the design, not a plain word. The
119
- member's REAL role name; a deleted / other-organisation / missing
120
- role says so instead of reading as a working grant
121
- (memberRoleChip in utils/role.ts, server roleState). -->
122
- <template #item.roleName="{ item }">
123
- <StatusChip
124
- v-if="memberRoleChip(item, orgLevel)"
125
- :dot="false"
126
- pill
127
- :tone="memberRoleChip(item, orgLevel)?.tone"
128
- class="member-role-chip"
129
- data-test="member-role-chip"
130
- >
131
- {{ memberRoleChip(item, orgLevel)?.text }}
132
- </StatusChip>
118
+ <!-- Name on top, email underneath in grey - only when it is not the
119
+ name again (owner report 2026-09-30). Search still matches the
120
+ email (server side). -->
121
+ <template #item.name="{ item }">
122
+ <div class="member-person" data-test="member-person">
123
+ <div class="member-person__name" :title="memberPerson(item).name">{{ memberPerson(item).name }}</div>
124
+ <div
125
+ v-if="memberPerson(item).email"
126
+ class="member-person__email"
127
+ :title="memberPerson(item).email"
128
+ >{{ memberPerson(item).email }}</div>
129
+ </div>
133
130
  </template>
134
- <!-- Organization app: every person, and where they may go
135
- (owner report 2026-09-29: a site-limited person stays listed). -->
136
- <template v-if="orgLevel" #item.siteName="{ item }">
137
- <span data-test="member-site-scope">{{ siteAccessLabel(item) }}</span>
131
+ <!-- ONE compact "Role & access" cell (owner report 2026-09-30, Aigle:
132
+ an 18-site person's roles and sites were two long paragraphs).
133
+ Each role once; one site by name, several as "N sites" that opens
134
+ the full site -> role list (utils/member-row.ts). A deleted /
135
+ other-organisation / missing role still says so (memberRoleChip,
136
+ server roleState). -->
137
+ <template #item.roleName="{ item }">
138
+ <div class="member-access" data-test="member-access">
139
+ <StatusChip
140
+ v-for="r in memberAccess(item, orgLevel).roles.slice(0, 2)"
141
+ :key="r"
142
+ :dot="false"
143
+ pill
144
+ :tone="memberAccess(item, orgLevel).tone"
145
+ class="member-role-chip"
146
+ :class="{ 'member-role-chip--warn': memberAccess(item, orgLevel).tone === 'warn' }"
147
+ :title="r"
148
+ data-test="member-role-chip"
149
+ >
150
+ {{ r }}
151
+ </StatusChip>
152
+ <span
153
+ v-if="memberAccess(item, orgLevel).roles.length > 2"
154
+ class="member-access__more"
155
+ >+{{ memberAccess(item, orgLevel).roles.length - 2 }}</span>
156
+ <v-menu
157
+ v-if="memberAccess(item, orgLevel).sites.length > 1"
158
+ location="bottom start"
159
+ :close-on-content-click="false"
160
+ >
161
+ <template #activator="{ props: siteProps }">
162
+ <button
163
+ v-bind="siteProps"
164
+ type="button"
165
+ class="member-access__sites"
166
+ data-test="member-site-count"
167
+ >
168
+ {{ memberAccess(item, orgLevel).scope }}
169
+ </button>
170
+ </template>
171
+ <v-card class="member-sites-card" data-test="member-site-list">
172
+ <v-card-title class="text-subtitle-2">
173
+ {{ memberPerson(item).name }} - {{ memberAccess(item, orgLevel).scope }}
174
+ </v-card-title>
175
+ <v-list density="compact">
176
+ <v-list-item
177
+ v-for="s in memberAccess(item, orgLevel).sites"
178
+ :key="s.siteName"
179
+ :title="s.siteName"
180
+ :subtitle="s.roleName"
181
+ />
182
+ </v-list>
183
+ </v-card>
184
+ </v-menu>
185
+ <span
186
+ v-else-if="memberAccess(item, orgLevel).scope"
187
+ class="member-access__scope"
188
+ :title="memberAccess(item, orgLevel).scope"
189
+ data-test="member-site-scope"
190
+ >{{ memberAccess(item, orgLevel).scope }}</span>
191
+ </div>
138
192
  </template>
139
193
  <template #item.dateInvited="{ item }">
140
- {{ formatDateInvited(item.dateInvited) }}
194
+ <span class="member-nowrap">{{ formatMemberDate(item.dateInvited) }}</span>
141
195
  </template>
142
196
  <template #item.lastLoginAt="{ item }">
143
- <span data-test="member-last-active">{{ formatLastActive(item.lastLoginAt) }}</span>
197
+ <span class="member-nowrap" data-test="member-last-active">{{ formatLastActive(item.lastLoginAt) }}</span>
144
198
  </template>
145
199
  <template #item.action-table="{ item }">
146
200
  <div class="d-flex align-center justify-end ga-1">
@@ -371,11 +425,11 @@ import {
371
425
  otherSiteBannerText,
372
426
  roleUsableHere,
373
427
  safeReturnTo,
374
- siteAccessLabel,
375
428
  limitedSites,
376
429
  withCurrentRole,
377
430
  } from "../utils/role";
378
431
  import { formatLastActive } from "../utils/last-active";
432
+ import { formatMemberDate, memberAccess, memberPerson } from "../utils/member-row";
379
433
 
380
434
  const props = defineProps({
381
435
  /** The design's page title. Every app that mounts this screen calls it
@@ -434,40 +488,14 @@ const props = defineProps({
434
488
  },
435
489
  headers: {
436
490
  type: Array as PropType<Array<Record<string, string>>>,
491
+ // Email sits under the name and the sites inside "Role & access" (owner
492
+ // report 2026-09-30): two long columns became one compact cell.
437
493
  default: () => [
438
- {
439
- title: "Name",
440
-
441
- value: "name",
442
- },
443
- {
444
- title: "Email",
445
-
446
- value: "email",
447
- },
448
- {
449
- title: "Roles and Permission",
450
-
451
- value: "roleName",
452
- },
453
- {
454
- title: "Site Invited To",
455
-
456
- value: "siteName",
457
- },
458
- {
459
- title: "Date",
460
-
461
- value: "dateInvited",
462
- },
463
- {
464
- title: "Last active",
465
- value: "lastLoginAt",
466
- },
467
- {
468
- title: "Action",
469
- value: "action-table",
470
- },
494
+ { title: "Name", value: "name" },
495
+ { title: "Role & access", value: "roleName" },
496
+ { title: "Date", value: "dateInvited" },
497
+ { title: "Last active", value: "lastLoginAt" },
498
+ { title: "Action", value: "action-table" },
471
499
  ],
472
500
  },
473
501
  /**
@@ -809,25 +837,6 @@ function showMessage(msg: string, color: string) {
809
837
  messageSnackbar.value = true;
810
838
  }
811
839
 
812
- function formatDateInvited(value?: string | null) {
813
- if (!value) return "";
814
-
815
- const date = new Date(value);
816
- if (Number.isNaN(date.getTime())) return "";
817
-
818
- const parts = new Intl.DateTimeFormat("en-US", {
819
- month: "short",
820
- day: "numeric",
821
- year: "numeric",
822
- }).formatToParts(date);
823
-
824
- const getPart = (type: Intl.DateTimeFormatPartTypes) =>
825
- parts.find((part) => part.type === type)?.value ?? "";
826
-
827
- // Short ("Sep 29, 2026") so the table fits and the actions stay in view.
828
- return `${getPart("month")} ${getPart("day")}, ${getPart("year")}`;
829
- }
830
-
831
840
  async function handleUpdateMemberStatus() {
832
841
  if (!selectedMemberId.value) return;
833
842
  updateLoading.value = true;
@@ -876,8 +885,74 @@ const updateActionText = computed(() => {
876
885
  .member-table--pin-action :deep(thead th:last-child) {
877
886
  z-index: 3;
878
887
  }
888
+ /* One line per cell, the same height on every row (owner report 2026-09-30). */
889
+ .member-table :deep(td) {
890
+ height: 60px;
891
+ vertical-align: middle;
892
+ }
893
+ .member-table :deep(th) {
894
+ white-space: nowrap;
895
+ }
896
+ .member-nowrap {
897
+ white-space: nowrap;
898
+ }
899
+ .member-person {
900
+ max-width: 260px;
901
+ min-width: 160px;
902
+ }
903
+ .member-person__name,
904
+ .member-person__email {
905
+ overflow: hidden;
906
+ text-overflow: ellipsis;
907
+ white-space: nowrap;
908
+ }
909
+ .member-person__email {
910
+ font-size: 12px;
911
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
912
+ }
913
+ .member-access {
914
+ display: flex;
915
+ align-items: center;
916
+ flex-wrap: nowrap;
917
+ gap: 6px;
918
+ max-width: 380px;
919
+ min-width: 180px;
920
+ }
879
921
  .member-table :deep(.member-role-chip) {
922
+ max-width: 220px;
923
+ overflow: hidden;
924
+ text-overflow: ellipsis;
925
+ white-space: nowrap;
926
+ display: inline-block;
927
+ flex-shrink: 1;
928
+ }
929
+ /* A warning names the fix; it may take two lines rather than be cut off. */
930
+ .member-table :deep(.member-role-chip--warn) {
880
931
  white-space: normal;
881
- height: auto;
932
+ max-width: 300px;
933
+ }
934
+ .member-access__more,
935
+ .member-access__scope {
936
+ font-size: 12px;
937
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
938
+ white-space: nowrap;
939
+ overflow: hidden;
940
+ text-overflow: ellipsis;
941
+ }
942
+ .member-access__sites {
943
+ font-size: 12px;
944
+ font-weight: 600;
945
+ white-space: nowrap;
946
+ color: rgb(var(--v-theme-primary));
947
+ text-decoration: underline;
948
+ background: none;
949
+ border: 0;
950
+ padding: 0;
951
+ cursor: pointer;
952
+ }
953
+ .member-sites-card {
954
+ max-height: 360px;
955
+ overflow-y: auto;
956
+ min-width: 280px;
882
957
  }
883
958
  </style>
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.73.1-staging.464",
5
+ "version": "4.73.1-staging.466",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,144 @@
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
+ }
@@ -1,3 +1,5 @@
1
+ import { formatMemberDate } from "./member-row.ts";
2
+
1
3
  /**
2
4
  * "Last active" on the Members lists: the last time the person signed in
3
5
  * (server field `lastLoginAt`). Never signed in since the field existed -> "—".
@@ -12,5 +14,5 @@ export function formatLastActive(value?: string | Date | null, now: Date = new D
12
14
  const days = Math.round((dayStart(now) - dayStart(date)) / 86400000);
13
15
  if (days === 0) return `Today, ${time}`;
14
16
  if (days === 1) return `Yesterday, ${time}`;
15
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
17
+ return formatMemberDate(date);
16
18
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * How one row of the Members table reads (owner report 2026-09-30, Aigle
3
+ * Members: an 18-site person's row was two ~250px paragraphs, role names were
4
+ * printed twice, the date wrapped onto three lines and the name was repeated
5
+ * as the email). Display only: nothing here changes what anybody may do.
6
+ */
7
+ import { limitedSites, memberRoleChip } from "./role.ts";
8
+
9
+ /** "Org - Admin ( For Site)" -> "Org Admin (per site)". Display only. */
10
+ export function cleanRoleName(name: unknown): string {
11
+ if (typeof name !== "string") return "";
12
+ return name
13
+ .replace(/\s+/g, " ")
14
+ .replace(/\(\s+/g, "(")
15
+ .replace(/\s+\)/g, ")")
16
+ .replace(/\(for site\)/gi, "(per site)")
17
+ .replace(/(\S) - (\S)/g, "$1 $2")
18
+ .trim();
19
+ }
20
+
21
+ /** One site's role(s), each named once: "Security, Security" -> "Security". */
22
+ function distinctRoles(roleName: unknown): string[] {
23
+ // ponytail: the server joins a site's roles with ", " (core peoplePage); a
24
+ // role name that itself holds ", " would be split here - display only.
25
+ const names = typeof roleName === "string" ? roleName.split(", ") : [];
26
+ return [...new Set(names.map(cleanRoleName).filter(Boolean))];
27
+ }
28
+
29
+ export type TMemberSite = { siteName: string; roleName: string };
30
+ export type TMemberAccess = {
31
+ /** Role chips, each name once. */
32
+ roles: string[];
33
+ tone: "info" | "warn";
34
+ /** Where the person may go: "All sites", one site's name, "18 sites", or "". */
35
+ scope: string;
36
+ /** Every site with its role(s) - the popover behind a "N sites" count. */
37
+ sites: TMemberSite[];
38
+ };
39
+
40
+ /**
41
+ * The Members table's one "Role & access" cell.
42
+ * - Organisation level, limited to sites: the distinct roles once each, plus
43
+ * the site's name (one site) or "N sites" (several; `sites` lists them all).
44
+ * - Organisation level, organisation-wide: the role, "All sites".
45
+ * - Site level: the role chip as before (it still warns about a role from
46
+ * another site), with the row's site name.
47
+ */
48
+ export function memberAccess(item: Record<string, any> | null | undefined, orgLevel = false): TMemberAccess {
49
+ const limited = limitedSites(item);
50
+ if (orgLevel && limited.length) {
51
+ const sites = limited.map((l) => ({
52
+ siteName: l.siteName || "A site",
53
+ roleName: distinctRoles(l.roleName).join(", ") || "No role",
54
+ }));
55
+ const roles = [...new Set(limited.flatMap((l) => distinctRoles(l.roleName)))];
56
+ return {
57
+ roles: roles.length ? roles : ["No role"],
58
+ tone: "info",
59
+ scope: sites.length === 1 ? sites[0].siteName : `${sites.length} sites`,
60
+ sites,
61
+ };
62
+ }
63
+ const chip = memberRoleChip(item ?? {}, orgLevel);
64
+ const text = chip ? (chip.tone === "info" ? cleanRoleName(chip.text) : chip.text) : "";
65
+ const scope = orgLevel ? "All sites" : typeof item?.siteName === "string" ? item.siteName : "";
66
+ return { roles: text ? [text] : [], tone: chip?.tone ?? "info", scope, sites: [] };
67
+ }
68
+
69
+ /** Name on top, email underneath - the email only when it adds something. */
70
+ export function memberPerson(item: Record<string, any> | null | undefined): { name: string; email: string } {
71
+ const name = typeof item?.name === "string" ? item.name.trim() : "";
72
+ const email = typeof item?.email === "string" ? item.email.trim() : "";
73
+ if (!name) return { name: email, email: "" };
74
+ return { name, email: email.toLowerCase() === name.toLowerCase() ? "" : email };
75
+ }
76
+
77
+ /** "30 Sep 2026" - one line. Bad or empty -> "". */
78
+ export function formatMemberDate(value?: string | Date | null): string {
79
+ if (!value) return "";
80
+ const date = new Date(value);
81
+ if (Number.isNaN(date.getTime())) return "";
82
+ // Not en-GB's "short" month: newer ICU prints "Sept".
83
+ return `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
84
+ }
85
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];