@7365admin1/layer-common 4.90.1 → 4.90.2-staging.484

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 (55) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +57 -0
  3. package/assets/css/robot-hero.css +3 -0
  4. package/assets/css/screens.css +8 -4
  5. package/components/AccessCardDetailsDialog.vue +57 -4
  6. package/components/AccessCardPreviewDialog.vue +291 -50
  7. package/components/AppSelect.vue +143 -4
  8. package/components/BuildingUnitFormEdit.vue +6 -0
  9. package/components/BuildingUnitManagement.vue +298 -0
  10. package/components/ClientMain.vue +56 -14
  11. package/components/Dialog/UpdateMoreAction.vue +11 -1
  12. package/components/DocumentForm.vue +407 -112
  13. package/components/DocumentManagement.vue +213 -12
  14. package/components/Facility/BookingSetup.vue +63 -0
  15. package/components/HidAccessLogDashboard.vue +74 -17
  16. package/components/HidAccessPermissions.vue +424 -0
  17. package/components/HidIntercomManagement.vue +183 -14
  18. package/components/HidProfileQrCode.vue +332 -0
  19. package/components/HidQrCodeConfiguration.vue +17 -195
  20. package/components/HidReaderManagement.vue +398 -0
  21. package/components/HidReaderUserRoster.vue +44 -3
  22. package/components/HidUserEnrollment.vue +2239 -276
  23. package/components/InvitationClientForm.vue +19 -1
  24. package/components/MemberInformation.vue +4 -4
  25. package/components/PassInformation.vue +2 -2
  26. package/components/TableMain.vue +22 -7
  27. package/components/VisitorForm.vue +65 -19
  28. package/components/VisitorManagement.vue +267 -5
  29. package/composables/useDocument.ts +7 -0
  30. package/composables/useFacility.ts +11 -0
  31. package/composables/useHidAmico.ts +165 -0
  32. package/composables/useHidNavigation.ts +25 -1
  33. package/composables/useHidReaderSelection.ts +50 -0
  34. package/composables/useMember.ts +12 -0
  35. package/composables/useNFCPatrolReportFilters.ts +21 -3
  36. package/composables/useNFCPatrolRoute.ts +8 -1
  37. package/composables/usePeople.ts +19 -0
  38. package/composables/useServiceProvider.ts +45 -2
  39. package/composables/useSiteCategory.ts +45 -0
  40. package/package.json +1 -1
  41. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  42. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  43. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  44. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  45. package/types/document.d.ts +14 -0
  46. package/types/facility.d.ts +4 -0
  47. package/types/member.d.ts +4 -0
  48. package/types/people.d.ts +28 -1
  49. package/types/service-provider.d.ts +5 -0
  50. package/types/site.d.ts +7 -1
  51. package/utils/hid-enrolment-subject.ts +344 -0
  52. package/utils/hid-permission-assignments.ts +198 -0
  53. package/utils/hid-reader-selection.ts +52 -0
  54. package/utils/occupancy-role.ts +109 -0
  55. package/utils/service-type.ts +30 -0
@@ -18,7 +18,8 @@
18
18
  <template>
19
19
  <v-menu
20
20
  v-if="!multiple"
21
- :close-on-content-click="true"
21
+ v-model="singleOpen"
22
+ :close-on-content-click="!searchable"
22
23
  :disabled="disabled"
23
24
  location="bottom start"
24
25
  offset="4"
@@ -52,9 +53,27 @@
52
53
  </div>
53
54
  </template>
54
55
 
55
- <div class="app-select__menu" role="listbox">
56
+ <div
57
+ ref="singleMenu"
58
+ class="app-select__menu"
59
+ role="listbox"
60
+ @scroll.passive="onMenuScroll"
61
+ >
62
+ <div v-if="searchable" class="app-select__search">
63
+ <v-icon icon="mdi-magnify" size="16" />
64
+ <input
65
+ ref="singleSearchInput"
66
+ v-model="search"
67
+ type="text"
68
+ class="app-select__search-input"
69
+ :placeholder="searchPlaceholder"
70
+ aria-label="Search options"
71
+ @keydown.enter.prevent="selectFirstMatch"
72
+ @keydown.esc.stop="singleOpen = false"
73
+ >
74
+ </div>
56
75
  <button
57
- v-for="opt in normalized"
76
+ v-for="opt in visibleOptions"
58
77
  :key="String(opt.value)"
59
78
  type="button"
60
79
  class="app-select__option"
@@ -66,6 +85,14 @@
66
85
  <span class="app-select__option-text">{{ opt.title }}</span>
67
86
  <v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
68
87
  </button>
88
+ <p v-if="searchable && !visibleOptions.length" class="app-select__empty">
89
+ {{ normalized.length ? "No matches" : "Nothing to choose from" }}
90
+ </p>
91
+ <!-- In the list, not under the field: the wait belongs where the reader's
92
+ eye already is, at the end of what they just scrolled through. -->
93
+ <p v-if="loadingMore" class="app-select__loading" role="status">
94
+ Loading more...
95
+ </p>
69
96
  </div>
70
97
  </v-menu>
71
98
 
@@ -122,8 +149,18 @@
122
149
  </template>
123
150
 
124
151
  <div class="app-select__menu">
152
+ <div v-if="searchable" class="app-select__search">
153
+ <v-icon icon="mdi-magnify" size="16" />
154
+ <input
155
+ v-model="search"
156
+ type="text"
157
+ class="app-select__search-input"
158
+ :placeholder="searchPlaceholder"
159
+ aria-label="Search options"
160
+ >
161
+ </div>
125
162
  <button
126
- v-for="opt in normalized"
163
+ v-for="opt in visibleOptions"
127
164
  :key="String(opt.value)"
128
165
  type="button"
129
166
  class="app-select__option"
@@ -138,6 +175,9 @@
138
175
  />
139
176
  <span class="app-select__option-text">{{ opt.title }}</span>
140
177
  </button>
178
+ <p v-if="searchable && !visibleOptions.length" class="app-select__empty">
179
+ {{ normalized.length ? "No matches" : "Nothing to choose from" }}
180
+ </p>
141
181
  </div>
142
182
  </v-menu>
143
183
  </template>
@@ -154,6 +194,34 @@ defineOptions({ inheritAttrs: false });
154
194
  */
155
195
  const model = defineModel<any>({ default: "" });
156
196
 
197
+ /**
198
+ * ASKING FOR THE NEXT PAGE, FROM INSIDE THE LIST.
199
+ *
200
+ * A dropdown that pages should load as it is scrolled - a button under the
201
+ * field asks the reader to leave the list they are reading to fetch more of it.
202
+ * The menu is already the scroll container (`max-height: 280px; overflow-y:
203
+ * auto` in `primitives.css`), so the end of the list is the natural trigger.
204
+ */
205
+ const emit = defineEmits<{ (e: "load-more"): void }>();
206
+
207
+ /** How close to the end counts as the end. About one row and a half. */
208
+ const LOAD_MORE_SLACK_PX = 48;
209
+
210
+ const singleMenu = ref<HTMLElement | null>(null);
211
+ /* Scroll fires per pixel. This makes the ask happen once per ARRIVAL at the
212
+ end, not once per event, and rearms when the list grows under the reader. */
213
+ const askedAtBottom = ref(false);
214
+
215
+ function onMenuScroll(event: Event) {
216
+ const el = event.target as HTMLElement | null;
217
+ if (!el || !props.hasMore || props.loadingMore) return;
218
+ const near = el.scrollHeight - el.scrollTop - el.clientHeight <= LOAD_MORE_SLACK_PX;
219
+ if (near && !askedAtBottom.value) emit("load-more");
220
+ askedAtBottom.value = near;
221
+ }
222
+
223
+
224
+
157
225
  const props = defineProps({
158
226
  label: { type: String, default: "" },
159
227
  /** Strings, or the `{ title, value }` shape `v-select` already takes. */
@@ -168,6 +236,22 @@ const props = defineProps({
168
236
  returnObject: { type: Boolean, default: false },
169
237
  clearable: { type: Boolean, default: false },
170
238
  placeholder: { type: String, default: "" },
239
+ /**
240
+ * A filter box at the top of the popover. Opt-in, so the 44 consumers that
241
+ * do not ask for it render exactly the DOM they render today - a list short
242
+ * enough to read does not want a search field in front of it.
243
+ */
244
+ searchable: { type: Boolean, default: false },
245
+ /**
246
+ * MORE OPTIONS EXIST THAN ARE LOADED - see the `load-more` emit.
247
+ *
248
+ * Opt-in, and single-select only for now. A caller that leaves these alone
249
+ * behaves exactly as it did: no footer, no listener, no emit.
250
+ */
251
+ hasMore: { type: Boolean, default: false },
252
+ /** A page is on its way; shows the footer and stops further asking. */
253
+ loadingMore: { type: Boolean, default: false },
254
+ searchPlaceholder: { type: String, default: "Search" },
171
255
  });
172
256
 
173
257
  const normalized = computed(() =>
@@ -178,6 +262,56 @@ const normalized = computed(() =>
178
262
  )
179
263
  );
180
264
 
265
+ /* ---- search ---------------------------------------------------------- */
266
+
267
+ const search = ref("");
268
+ const singleOpen = ref(false);
269
+ const singleSearchInput = ref<HTMLInputElement | null>(null);
270
+
271
+ /* Case-insensitive substring over what the option READS AS, not its value:
272
+ the title is the only part the user can see to type. */
273
+ const visibleOptions = computed(() => {
274
+ const term = search.value.trim().toLowerCase();
275
+ if (!props.searchable || !term) return normalized.value;
276
+ return normalized.value.filter((opt) =>
277
+ String(opt.title ?? "").toLowerCase().includes(term)
278
+ );
279
+ });
280
+
281
+ /*
282
+ * A page that does not fill the menu leaves nothing to scroll, and the reader
283
+ * would be stranded with no way to reach the rest. That is not hypothetical
284
+ * here: a page of ten whose rows are mostly filtered out by the caller can draw
285
+ * four options in a box built for eight. So whenever the list settles short of
286
+ * its own container, the next page is asked for straight away.
287
+ */
288
+ watch(
289
+ () => [visibleOptions.value.length, props.hasMore, props.loadingMore, singleOpen.value],
290
+ async () => {
291
+ if (!props.hasMore || props.loadingMore || !singleOpen.value) return;
292
+ await nextTick();
293
+ const el = singleMenu.value;
294
+ if (el && el.scrollHeight <= el.clientHeight) emit("load-more");
295
+ },
296
+ { flush: "post" },
297
+ );
298
+
299
+ /* A stale term would hide the list the next time the popover opens, so it is
300
+ dropped on every open - and the caret goes into the box, since opening a
301
+ searchable select is almost always a prelude to typing. */
302
+ watch(singleOpen, async (open) => {
303
+ if (!props.searchable) return;
304
+ search.value = "";
305
+ if (!open) return;
306
+ await nextTick();
307
+ singleSearchInput.value?.focus();
308
+ });
309
+
310
+ function selectFirstMatch() {
311
+ const [first] = visibleOptions.value;
312
+ if (first) selectSingle(first);
313
+ }
314
+
181
315
  /* A model entry is either the plain value or - under `return-object` - the
182
316
  source item, so everything compares on this one key. */
183
317
  const keyOf = (v: any) =>
@@ -205,6 +339,11 @@ const isSingleOn = (opt: any) =>
205
339
  function selectSingle(opt: any) {
206
340
  if (props.disabled) return;
207
341
  model.value = props.returnObject ? opt.raw : String(opt.value);
342
+ // `close-on-content-click` has to be OFF while searching - it fires on the
343
+ // click that puts the caret in the search box, so the popover would shut the
344
+ // moment it was typed into. With it off, picking an option has to close the
345
+ // popover by hand.
346
+ if (props.searchable) singleOpen.value = false;
208
347
  }
209
348
 
210
349
  function clearSingle() {
@@ -10,6 +10,7 @@
10
10
  <v-tab value="general">General</v-tab>
11
11
  <v-tab value="residents">Residents/Tenants</v-tab>
12
12
  <v-tab value="vehicles">Vehicles</v-tab>
13
+ <v-tab value="management">Management</v-tab>
13
14
  <v-tab value="others">Others</v-tab>
14
15
  </v-tabs>
15
16
  </v-toolbar>
@@ -170,6 +171,11 @@
170
171
  </TableMain>
171
172
  </template>
172
173
 
174
+ <template v-else-if="tab === 'management'">
175
+ <BuildingUnitManagement :unit-id="buildingUnit._id" :site-id="buildingUnit?.site?._id"
176
+ :can-update="canUpdateUnit" />
177
+ </template>
178
+
173
179
  <template v-else-if="tab === 'others'">
174
180
  <v-row>
175
181
  <!-- <v-col cols="12" md="6" class="mt-2">
@@ -0,0 +1,298 @@
1
+ <template>
2
+ <v-card width="100%">
3
+ <v-expansion-panels v-model="panels" variant="accordion" multiple>
4
+ <v-expansion-panel v-for="section in sections" :key="section.key" :value="section.key">
5
+ <v-expansion-panel-title class="bg-primary">
6
+ <span>{{ section.title }} ({{ section.items.length }})</span>
7
+ <v-spacer />
8
+ <!-- @click.stop: the button sits in the panel header, so a click must
9
+ not also open or close the panel. -->
10
+ <v-btn v-if="canUpdate" size="small" variant="tonal" class="text-none mr-4" prepend-icon="mdi-plus"
11
+ @click.stop="openAdd(section.key)">
12
+ Add
13
+ </v-btn>
14
+ </v-expansion-panel-title>
15
+
16
+ <v-expansion-panel-text>
17
+ <v-progress-linear v-if="section.loading" indeterminate color="primary" />
18
+ <v-list v-if="section.items.length" density="compact" class="bg-transparent">
19
+ <v-list-item v-for="item in section.items" :key="item._id" :title="item.name"
20
+ :subtitle="section.subtitle(item)">
21
+ <template v-if="canUpdate" #append>
22
+ <v-btn icon="mdi-close" size="small" variant="text" :loading="removing === item._id"
23
+ :aria-label="`Remove ${item.name} from this unit`" @click="confirmRemove(section.key, item)" />
24
+ </template>
25
+ </v-list-item>
26
+ </v-list>
27
+ <p v-else-if="!section.loading" class="font-weight-semibold text-caption ml-2 mt-5">
28
+ **No {{ section.empty }} to display**
29
+ </p>
30
+ </v-expansion-panel-text>
31
+ </v-expansion-panel>
32
+ </v-expansion-panels>
33
+
34
+ <p v-if="message" class="text-center text-subtitle-2 font-weight-medium text-error mt-4">
35
+ {{ message }}
36
+ </p>
37
+
38
+ <v-dialog v-model="removeDialog" width="450" persistent>
39
+ <v-card width="100%">
40
+ <v-toolbar density="compact" class="pl-4">
41
+ <span class="font-weight-medium text-h5">
42
+ Remove {{ pendingRemove?.section === "members" ? "Management" : "Service Provider" }}
43
+ </span>
44
+ </v-toolbar>
45
+
46
+ <v-card-text>
47
+ <p class="text-subtitle-2 text-center">
48
+ Are you sure you want to remove <strong>{{ pendingRemove?.name }}</strong> from this unit?
49
+ </p>
50
+ </v-card-text>
51
+
52
+ <v-toolbar density="compact">
53
+ <v-row no-gutters>
54
+ <v-col cols="6">
55
+ <v-btn tile block size="48" variant="text" class="text-none" :disabled="!!removing"
56
+ @click="removeDialog = false">
57
+ Cancel
58
+ </v-btn>
59
+ </v-col>
60
+ <v-col cols="6">
61
+ <v-btn tile block size="48" color="primary-button" variant="flat" class="text-none"
62
+ :loading="!!removing" @click="remove">
63
+ Remove
64
+ </v-btn>
65
+ </v-col>
66
+ </v-row>
67
+ </v-toolbar>
68
+ </v-card>
69
+ </v-dialog>
70
+
71
+ <v-dialog v-model="addDialog" width="520" persistent>
72
+ <v-card>
73
+ <v-toolbar density="compact" class="pl-4">
74
+ <span class="font-weight-medium text-h6">Add {{ adding === "members" ? "Management" : "Service Providers" }}</span>
75
+ </v-toolbar>
76
+ <v-card-text>
77
+ <p class="text-caption mb-3">
78
+ Only active {{ adding === "members" ? "members" : "service provider people" }} that are not assigned to a
79
+ unit yet are listed.
80
+ </p>
81
+ <v-autocomplete v-model="selected" v-model:search="candidateSearch" :items="candidates"
82
+ :loading="candidatesLoading" item-title="name" item-value="_id" multiple chips closable-chips
83
+ no-filter density="comfortable" placeholder="Search by name"
84
+ :no-data-text="candidatesLoading ? 'Loading...' : 'No one available'">
85
+ <template #item="{ props: itemProps, item }">
86
+ <v-list-item v-bind="itemProps" :subtitle="subtitleOf(item.raw)" />
87
+ </template>
88
+ </v-autocomplete>
89
+ </v-card-text>
90
+ <v-card-actions>
91
+ <v-spacer />
92
+ <v-btn class="text-none" :disabled="saving" @click="addDialog = false">Cancel</v-btn>
93
+ <v-btn class="text-none" color="primary" variant="flat" :loading="saving" :disabled="!selected.length"
94
+ @click="add">
95
+ Add
96
+ </v-btn>
97
+ </v-card-actions>
98
+ </v-card>
99
+ </v-dialog>
100
+ </v-card>
101
+ </template>
102
+
103
+ <script setup lang="ts">
104
+ /**
105
+ * A building unit's Management tab: the management members and the service
106
+ * provider people (Members > Service Providers, not the provider companies)
107
+ * attached to this unit. Each can be on one unit at a time, and only while
108
+ * active - the server detaches them the moment they are suspended or deleted,
109
+ * so they simply drop off this list.
110
+ */
111
+ type TSection = "members" | "providers";
112
+ type TRow = {
113
+ _id: string;
114
+ name: string;
115
+ email?: string;
116
+ roleName?: string;
117
+ type?: string;
118
+ company?: string;
119
+ role?: string;
120
+ status?: string;
121
+ };
122
+
123
+ const props = defineProps({
124
+ unitId: {
125
+ type: String,
126
+ required: true,
127
+ },
128
+ /** The unit's site. The route's `site` is used when this is empty. */
129
+ siteId: {
130
+ type: String,
131
+ default: "",
132
+ },
133
+ /** The membership type whose members manage the site. */
134
+ managementType: {
135
+ type: String,
136
+ default: "property_management_agency",
137
+ },
138
+ canUpdate: {
139
+ type: Boolean,
140
+ default: false,
141
+ },
142
+ });
143
+
144
+ const route = useRoute();
145
+ const org = computed(() => (route.params.org as string) ?? "");
146
+ const site = computed(() => props.siteId || ((route.params.site as string) ?? ""));
147
+
148
+ const { getAll: getMembers, updateMemberUnit } = useMember();
149
+ const { getSiteMembers: getProviderPeople, updateSiteMemberUnit: updateProviderUnit } = useServiceProvider();
150
+ const { debounce } = useUtils();
151
+
152
+ const panels = ref<TSection[]>(["members", "providers"]);
153
+ const message = ref("");
154
+ const removing = ref("");
155
+
156
+ function membersQuery(unit: string, search = "") {
157
+ return getMembers({ org: org.value, siteId: site.value, type: props.managementType, status: "active", unit, search, limit: 50 });
158
+ }
159
+
160
+ /**
161
+ * The site's provider people on `unit`, active only. The endpoint returns the
162
+ * whole list (no paging or search), so `search` is matched here.
163
+ */
164
+ async function providersQuery(unit: string, search = "") {
165
+ const res = await getProviderPeople(site.value, unit);
166
+ const q = search.toLowerCase();
167
+ const items = (res?.items ?? []).filter(
168
+ (p) => p.status === "active" && (!q || [p.name, p.email, p.company].some((v) => v?.toLowerCase().includes(q))),
169
+ );
170
+ return { items };
171
+ }
172
+
173
+ const { data: memberRes, pending: membersLoading, refresh: refreshMembers } = useLazyAsyncData(
174
+ `unit-management-members-${props.unitId}`,
175
+ () => membersQuery(props.unitId),
176
+ );
177
+ const { data: providerRes, pending: providersLoading, refresh: refreshProviders } = useLazyAsyncData(
178
+ `unit-management-providers-${props.unitId}`,
179
+ () => providersQuery(props.unitId),
180
+ );
181
+
182
+ function subtitleOf(row: TRow) {
183
+ return [row.company, row.roleName || row.role || row.type?.replace(/_/g, " "), row.email]
184
+ .filter(Boolean)
185
+ .join(" · ");
186
+ }
187
+
188
+ const sections = computed(() => [
189
+ {
190
+ key: "members" as TSection,
191
+ title: "Management",
192
+ empty: "management",
193
+ items: (memberRes.value?.items ?? []) as TRow[],
194
+ loading: membersLoading.value,
195
+ subtitle: subtitleOf,
196
+ },
197
+ {
198
+ key: "providers" as TSection,
199
+ title: "Service Providers",
200
+ empty: "service providers",
201
+ items: (providerRes.value?.items ?? []) as TRow[],
202
+ loading: providersLoading.value,
203
+ subtitle: subtitleOf,
204
+ },
205
+ ]);
206
+
207
+ function refresh(section: TSection) {
208
+ return section === "members" ? refreshMembers() : refreshProviders();
209
+ }
210
+
211
+ function assign(section: TSection, id: string, unit: string) {
212
+ return section === "members" ? updateMemberUnit(id, unit) : updateProviderUnit(id, unit);
213
+ }
214
+
215
+ function errorOf(error: any, fallback: string) {
216
+ return error?.response?._data?.message || error?.data?.message || fallback;
217
+ }
218
+
219
+ // --- Remove: confirmed first ---
220
+ const removeDialog = ref(false);
221
+ const pendingRemove = ref<{ section: TSection; id: string; name: string } | null>(null);
222
+
223
+ function confirmRemove(section: TSection, row: TRow) {
224
+ message.value = "";
225
+ pendingRemove.value = { section, id: row._id, name: row.name };
226
+ removeDialog.value = true;
227
+ }
228
+
229
+ async function remove() {
230
+ if (!pendingRemove.value) return;
231
+ const { section, id } = pendingRemove.value;
232
+ removing.value = id;
233
+ try {
234
+ await assign(section, id, "");
235
+ await refresh(section);
236
+ } catch (error: any) {
237
+ message.value = errorOf(error, "Failed to remove from unit.");
238
+ } finally {
239
+ removing.value = "";
240
+ removeDialog.value = false;
241
+ pendingRemove.value = null;
242
+ }
243
+ }
244
+
245
+ // --- Add dialog: active rows on no unit yet ---
246
+ const addDialog = ref(false);
247
+ const adding = ref<TSection>("members");
248
+ const selected = ref<string[]>([]);
249
+ const candidates = ref<TRow[]>([]);
250
+ const candidateSearch = ref("");
251
+ const candidatesLoading = ref(false);
252
+ const saving = ref(false);
253
+
254
+ async function loadCandidates() {
255
+ candidatesLoading.value = true;
256
+ try {
257
+ const search = candidateSearch.value?.trim() ?? "";
258
+ const res = adding.value === "members" ? await membersQuery("none", search) : await providersQuery("none", search);
259
+ // Keep what is already picked, so a new search does not drop the chips.
260
+ const picked = candidates.value.filter((c) => selected.value.includes(c._id));
261
+ const found = ((res?.items ?? []) as TRow[]).filter((c) => !selected.value.includes(c._id));
262
+ candidates.value = [...picked, ...found];
263
+ } catch (error: any) {
264
+ message.value = errorOf(error, "Failed to load the list.");
265
+ } finally {
266
+ candidatesLoading.value = false;
267
+ }
268
+ }
269
+
270
+ const debouncedLoad = debounce(loadCandidates, 400);
271
+ watch(candidateSearch, () => {
272
+ if (addDialog.value) debouncedLoad();
273
+ });
274
+
275
+ function openAdd(section: TSection) {
276
+ message.value = "";
277
+ adding.value = section;
278
+ selected.value = [];
279
+ candidates.value = [];
280
+ candidateSearch.value = "";
281
+ addDialog.value = true;
282
+ loadCandidates();
283
+ }
284
+
285
+ async function add() {
286
+ saving.value = true;
287
+ message.value = "";
288
+ const section = adding.value;
289
+ const results = await Promise.allSettled(selected.value.map((id) => assign(section, id, props.unitId)));
290
+ const failed = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
291
+ if (failed.length) {
292
+ message.value = errorOf(failed[0].reason, `${failed.length} could not be added.`);
293
+ }
294
+ await refresh(section);
295
+ saving.value = false;
296
+ addDialog.value = false;
297
+ }
298
+ </script>
@@ -141,17 +141,17 @@
141
141
  before, and which is the one thing the admin app's
142
142
  Organizations list showed that this one did not.
143
143
  -->
144
- <td class="td-name">
144
+ <td class="td-name" data-label="Organization">
145
145
  {{ item.name }}
146
146
  <span class="td-name-sub">{{ item.email }}</span>
147
147
  </td>
148
148
  <!-- A blank cell reads as a broken screen, so the one case
149
149
  with nothing to print says so. `--text2`, not `--muted`:
150
150
  this is text to be read, and `--muted` measures 3.24:1. -->
151
- <td :class="{ 'td-nature-none': !item.nature }">
151
+ <td data-label="Nature" :class="{ 'td-nature-none': !item.nature }">
152
152
  {{ item.nature ? formatNature(item.nature) : 'Not recorded' }}
153
153
  </td>
154
- <td>
154
+ <td data-label="Sites">
155
155
  <span class="sites-badge">{{ item.sites }}</span>
156
156
  </td>
157
157
  <!--
@@ -159,7 +159,7 @@
159
159
  which reads as a broken screen. It is one ordinary state, so it
160
160
  is said once, in words.
161
161
  -->
162
- <td v-if="item.sub.state === 'none'" colspan="4" class="sub-none">
162
+ <td v-if="item.sub.state === 'none'" colspan="4" class="sub-none" data-label="Subscription">
163
163
  No subscription set up
164
164
  <button class="sub-setup-btn" @click="openSubscription(item)">
165
165
  Set up subscription
@@ -167,13 +167,13 @@
167
167
  </td>
168
168
 
169
169
  <template v-else>
170
- <td>
170
+ <td data-label="Subscription">
171
171
  <span class="sub-label" :class="{ 'needs-attention': item.sub.needsAttention }">
172
172
  {{ item.sub.label }}
173
173
  </span>
174
174
  </td>
175
- <td class="td-muted">{{ item.sub.billingCycle }}</td>
176
- <td>
175
+ <td class="td-muted" data-label="Billing cycle">{{ item.sub.billingCycle }}</td>
176
+ <td data-label="Start date">
177
177
  <span class="date-cell">
178
178
  <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
179
179
  <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
@@ -183,7 +183,7 @@
183
183
  {{ item.sub.start }}
184
184
  </span>
185
185
  </td>
186
- <td>
186
+ <td data-label="End date">
187
187
  <span class="date-cell" :class="{ 'needs-attention': item.sub.needsAttention }">
188
188
  <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
189
189
  <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
@@ -194,7 +194,7 @@
194
194
  </span>
195
195
  </td>
196
196
  </template>
197
- <td>
197
+ <td data-label="Actions">
198
198
  <div class="action-wrap">
199
199
  <button class="dots-btn" @click.stop="toggleMenu(item._id, $event)">
200
200
  <span /><span /><span />
@@ -223,11 +223,11 @@
223
223
  <td colspan="5" class="empty">No pending invitations.</td>
224
224
  </tr>
225
225
  <tr v-for="item in items" :key="item._id">
226
- <td class="td-muted">{{ item.email }}</td>
227
- <td>{{ item.app }}</td>
228
- <td class="td-muted">{{ item.role }}</td>
229
- <td><span class="status-chip pending">{{ item.status }}</span></td>
230
- <td>
226
+ <td class="td-muted" data-label="Email">{{ item.email }}</td>
227
+ <td data-label="App">{{ item.app }}</td>
228
+ <td class="td-muted" data-label="Role">{{ item.role }}</td>
229
+ <td data-label="Status"><span class="status-chip pending">{{ item.status }}</span></td>
230
+ <td data-label="Action">
231
231
  <button
232
232
  class="cancel-btn"
233
233
  :disabled="cancelLoadingId === item._id"
@@ -1314,4 +1314,46 @@ tbody td { vertical-align: middle; }
1314
1314
  background: var(--err-bg);
1315
1315
  color: var(--err);
1316
1316
  }
1317
+
1318
+ /* Phones (owner 2026-10-02): the client table was 1085px wide inside a 390px
1319
+ screen. Below 600px each client is a card, one "label: value" line per cell. */
1320
+ @media (max-width: 599.98px) {
1321
+ .table-scroll table,
1322
+ .table-scroll tbody,
1323
+ .table-scroll tr,
1324
+ .table-scroll td {
1325
+ display: block;
1326
+ width: 100%;
1327
+ }
1328
+
1329
+ .table-scroll thead {
1330
+ display: none;
1331
+ }
1332
+
1333
+ .table-scroll tr {
1334
+ border-bottom: 1px solid var(--border);
1335
+ padding: 8px 0;
1336
+ }
1337
+
1338
+ .table-scroll td {
1339
+ display: grid;
1340
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
1341
+ column-gap: 12px;
1342
+ align-items: center;
1343
+ padding: 4px 16px !important;
1344
+ border: 0;
1345
+ white-space: normal;
1346
+ overflow-wrap: anywhere;
1347
+ }
1348
+
1349
+ .table-scroll td[data-label]::before {
1350
+ content: attr(data-label);
1351
+ color: var(--muted);
1352
+ font-size: 12px;
1353
+ }
1354
+
1355
+ .table-scroll td.empty {
1356
+ display: block;
1357
+ }
1358
+ }
1317
1359
  </style>
@@ -23,7 +23,7 @@
23
23
  Close
24
24
  </v-btn>
25
25
 
26
- <v-menu v-if="canUpdate || canDelete" contained>
26
+ <v-menu v-if="canUpdate || canDelete || $slots.actions" contained>
27
27
  <template #activator="{ props }">
28
28
  <v-btn
29
29
  variant="flat"
@@ -42,6 +42,16 @@
42
42
  render-verified either. Only the row type and the destructive ink
43
43
  change. -->
44
44
  <v-list class="pa-0 update-more__list">
45
+ <!--
46
+ Screen-specific rows, above Edit and the destructive one.
47
+
48
+ Additive: a screen that passes nothing gets the menu it always
49
+ had, and `$slots.actions` is undefined unless a caller actually
50
+ defines the slot - so the button does not appear over an empty
51
+ list when a screen has neither permission nor extra rows.
52
+ -->
53
+ <slot name="actions" />
54
+
45
55
  <v-list-item v-if="canUpdate" @click="emit('edit')">
46
56
  <v-list-item-title>
47
57
  {{ editButtonLabel }}