@7365admin1/layer-common 4.97.0 → 4.97.1-staging.501

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 (62) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +57 -0
  3. package/assets/css/screens.css +8 -4
  4. package/components/AccessCardDetailsDialog.vue +59 -6
  5. package/components/AccessCardPreviewDialog.vue +371 -53
  6. package/components/AppSelect.vue +143 -4
  7. package/components/BuildingUnitFormEdit.vue +6 -0
  8. package/components/BuildingUnitManagement.vue +298 -0
  9. package/components/Dialog/UpdateMoreAction.vue +11 -1
  10. package/components/DocumentForm.vue +407 -112
  11. package/components/DocumentManagement.vue +213 -12
  12. package/components/Facility/BookingSetup.vue +63 -0
  13. package/components/HidAccessLogDashboard.vue +74 -17
  14. package/components/HidAccessPermissions.vue +532 -0
  15. package/components/HidIntercomManagement.vue +183 -14
  16. package/components/HidProfileQrCode.vue +332 -0
  17. package/components/HidQrCodeConfiguration.vue +17 -195
  18. package/components/HidReaderManagement.vue +398 -0
  19. package/components/HidReaderUserRoster.vue +44 -3
  20. package/components/HidUserEnrollment.vue +2242 -279
  21. package/components/InvitationClientForm.vue +19 -1
  22. package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
  23. package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
  24. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
  25. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
  26. package/components/ServiceProviderMain.vue +48 -0
  27. package/components/SiteProviderChange.vue +371 -0
  28. package/components/TableMain.vue +22 -7
  29. package/components/VisitorForm.vue +12 -0
  30. package/components/VisitorManagement.vue +267 -5
  31. package/composables/useAccessManagement.ts +17 -1
  32. package/composables/useCustomerSite.ts +29 -0
  33. package/composables/useDocument.ts +7 -0
  34. package/composables/useFacility.ts +11 -0
  35. package/composables/useHidAmico.ts +165 -0
  36. package/composables/useHidNavigation.ts +25 -1
  37. package/composables/useHidReaderSelection.ts +50 -0
  38. package/composables/useMember.ts +12 -0
  39. package/composables/useNFCPatrolReport.ts +36 -32
  40. package/composables/useNFCPatrolReportFilters.ts +21 -3
  41. package/composables/useNFCPatrolRoute.ts +10 -3
  42. package/composables/usePeople.ts +19 -0
  43. package/composables/useServiceProvider.ts +45 -2
  44. package/composables/useSiteCategory.ts +45 -0
  45. package/package.json +1 -1
  46. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  47. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  48. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  49. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  50. package/types/document.d.ts +14 -0
  51. package/types/facility.d.ts +4 -0
  52. package/types/member.d.ts +4 -0
  53. package/types/nfc-patrol-report.ts +0 -2
  54. package/types/people.d.ts +28 -1
  55. package/types/service-provider.d.ts +5 -0
  56. package/types/site.d.ts +7 -1
  57. package/utils/hid-enrolment-subject.ts +366 -0
  58. package/utils/hid-permission-assignments.ts +259 -0
  59. package/utils/hid-reader-selection.ts +52 -0
  60. package/utils/nfc-patrol-time.ts +99 -0
  61. package/utils/occupancy-role.ts +109 -0
  62. package/utils/service-type.ts +30 -0
@@ -0,0 +1,532 @@
1
+ <template>
2
+ <v-dialog v-model="open" max-width="760" scrollable>
3
+ <v-card class="hid-access-permissions">
4
+ <v-card-title class="hid-access-permissions__title">
5
+ <div>
6
+ <div class="text-h6">Access Permissions</div>
7
+ <div class="text-caption text-medium-emphasis">{{ readerLabel }}</div>
8
+ </div>
9
+ </v-card-title>
10
+
11
+ <v-divider />
12
+
13
+ <!-- The one precondition the server enforces before it will talk about
14
+ permissions at all. Saying it here beats letting the first request
15
+ fail with the same sentence in a snackbar. -->
16
+ <v-alert
17
+ v-if="!readerLocation"
18
+ type="warning"
19
+ variant="tonal"
20
+ density="compact"
21
+ class="ma-4"
22
+ >
23
+ Set a location on this HID Face Reader before managing permissions.
24
+ </v-alert>
25
+
26
+ <template v-else>
27
+ <div class="hid-access-permissions__intro">
28
+ <p class="text-body-2 text-medium-emphasis mb-0">
29
+ Everybody enrolled on this reader, by type. Enrolling already grants
30
+ access — switch somebody off to close the door to them without removing
31
+ their face, card or PIN.
32
+ </p>
33
+ </div>
34
+
35
+ <v-card-text class="hid-access-permissions__body">
36
+ <TableMain
37
+ :headers="headers"
38
+ :items="rows"
39
+ :loading="loading"
40
+ :items-per-page="-1"
41
+ :page="page"
42
+ :pages="pages"
43
+ :page-range="pageRange"
44
+ item-value="rowKey"
45
+ @refresh="loadEnrolled"
46
+ @update:page="goToPage"
47
+ >
48
+ <!-- WHICH OF THE READER'S PEOPLE TO SHOW.
49
+ The reader holds residents, members and contractors; each tab is
50
+ one `type`, filtered and paged by the server. The strip is
51
+ `TableMain`'s own slot and the shared `.app-tab` markup, the same
52
+ as every other tabbed table here - not a `v-tabs`, whose selected
53
+ state is drawn from Vuetify's `on-surface` rather than the
54
+ design's accent underline.
55
+ The count beside each label is the whole reader's, from the
56
+ draft, so it follows an unsaved tick. -->
57
+ <template #tabs>
58
+ <button
59
+ v-for="tab in TABS"
60
+ :key="tab.value"
61
+ type="button"
62
+ class="app-tab"
63
+ :class="{ 'app-tab--active': activeTab === tab.value }"
64
+ :aria-pressed="activeTab === tab.value"
65
+ @click="selectTab(tab.value)"
66
+ >
67
+ {{ tab.label }}
68
+ <span class="hid-access-permissions__tab-count">{{ tabCount(tab) }}</span>
69
+ </button>
70
+ </template>
71
+
72
+ <template #extension>
73
+ <div class="app-filter-row">
74
+ <AppField
75
+ v-model="search"
76
+ search
77
+ :placeholder="searchPlaceholder"
78
+ @keyup.enter="searchFromFirstPage"
79
+ />
80
+ </div>
81
+ </template>
82
+
83
+ <template #[`item.name`]="{ item }">
84
+ <div class="hid-access-permissions__person">
85
+ <div>{{ item.name || "—" }}</div>
86
+ <div class="text-caption text-medium-emphasis">{{ item.subtitle || "—" }}</div>
87
+ </div>
88
+ </template>
89
+
90
+ <template #[`item.selected`]="{ item }">
91
+ <div v-if="!item.assignable" class="hid-access-permissions__toggle">
92
+ <span class="text-caption text-medium-emphasis">{{ item.reason }}</span>
93
+ </div>
94
+ <div v-else class="hid-access-permissions__toggle">
95
+ <v-switch
96
+ :model-value="item.selected"
97
+ :disabled="saving"
98
+ color="primary"
99
+ density="compact"
100
+ hide-details
101
+ :aria-label="`Door access for ${item.name}`"
102
+ @update:model-value="(value) => toggle(item, value === true)"
103
+ />
104
+ <span
105
+ class="text-caption"
106
+ :class="item.selected ? 'text-success' : 'text-medium-emphasis'"
107
+ >
108
+ {{ item.selected ? "Has access" : "No access" }}
109
+ </span>
110
+ </div>
111
+ </template>
112
+ </TableMain>
113
+ </v-card-text>
114
+ </template>
115
+
116
+ <v-divider />
117
+
118
+ <v-card-actions class="hid-access-permissions__actions">
119
+ <span v-if="readerLocation" class="text-caption text-medium-emphasis">
120
+ {{ assignedTotal }} with access on this reader
121
+ </span>
122
+ <v-spacer />
123
+ <AppButton variant="ghost" :disabled="saving" @click="open = false">Cancel</AppButton>
124
+ <AppButton
125
+ icon="mdi-content-save-outline"
126
+ :loading="saving"
127
+ :disabled="!readerLocation || !dirty || saving"
128
+ @click="save"
129
+ >
130
+ Save Permissions
131
+ </AppButton>
132
+ </v-card-actions>
133
+ </v-card>
134
+
135
+ <v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3500">
136
+ {{ snackbar.message }}
137
+ </v-snackbar>
138
+ </v-dialog>
139
+ </template>
140
+
141
+ <script setup lang="ts">
142
+ import {
143
+ applySelection,
144
+ hasChanges,
145
+ readCandidatePage,
146
+ seedAssignments,
147
+ setAssignment,
148
+ subjectOfIdentity,
149
+ toAssignmentPayload,
150
+ type HidAssignmentState,
151
+ } from "../utils/hid-permission-assignments";
152
+
153
+ /**
154
+ * WHO MAY PASS THIS READER — a review and revoke screen, not a picker.
155
+ *
156
+ * Enrolling somebody now grants them access: `createIdentity` writes the
157
+ * assignment, and the reconcile puts them in the reader's group in the same
158
+ * pass. A property manager therefore has ONE thing to do, and this screen
159
+ * exists for the other direction — taking access away again.
160
+ *
161
+ * So it lists the people ENROLLED on this reader rather than everybody at the
162
+ * site. Switching somebody off removes their assignment, and the reconcile then
163
+ * deletes their `user_groups` row; their identity, face, card and PIN are left
164
+ * alone, so they stay recognisable at the door and are simply refused.
165
+ *
166
+ * The editing model lives in `utils/hid-permission-assignments.ts`, because the
167
+ * save is whole-reader while the list is one page at a time — and getting that
168
+ * wrong silently revokes everybody who is not on screen. See the tests there.
169
+ */
170
+ const props = defineProps<{
171
+ modelValue: boolean;
172
+ site: string;
173
+ org: string;
174
+ reader: Record<string, unknown> | null;
175
+ }>();
176
+
177
+ const emit = defineEmits<{
178
+ (event: "update:modelValue", value: boolean): void;
179
+ (event: "saved"): void;
180
+ }>();
181
+
182
+ const { getSitePermissions, getIdentities, updateSitePermissions } = useHidAmico();
183
+
184
+ /**
185
+ * WHO EACH TAB LISTS, AND WHAT A GRANT MADE FROM IT IS FILED UNDER.
186
+ *
187
+ * `type` is the identity's own, filtered by the SERVER (`listIdentities` does an
188
+ * exact match on it), and it is what the enrolment writes and what
189
+ * `permissionIdentityType` re-confirms on every reconcile. `category` is the
190
+ * permission category a NEW grant from this tab belongs to.
191
+ *
192
+ * The two are a pair rather than one derived from the other, because the tab is
193
+ * the only thing on this screen that knows which of them the operator means: a
194
+ * contractor and a property-management member are both a `member` link, and
195
+ * guessing between them from the record is exactly what went wrong before.
196
+ */
197
+ const TABS = [
198
+ { value: "resident", label: "Resident", noun: "Resident", type: "resident", category: "resident" },
199
+ { value: "member", label: "Member", noun: "Member", type: "staff", category: "property_management" },
200
+ {
201
+ value: "provider",
202
+ label: "Service provider",
203
+ noun: "Person",
204
+ type: "contractor",
205
+ category: "service_provider_member",
206
+ },
207
+ ] as const satisfies ReadonlyArray<{
208
+ value: string;
209
+ label: string;
210
+ noun: string;
211
+ type: string;
212
+ category: THidPermissionCategory;
213
+ }>;
214
+
215
+ const activeTab = ref<(typeof TABS)[number]["value"]>("resident");
216
+ const currentTab = computed(
217
+ () => TABS.find((tab) => tab.value === activeTab.value) ?? TABS[0],
218
+ );
219
+
220
+ /** Granted counts per category, for the tab strip. One call, all three. */
221
+ const counts = ref<Record<string, number>>({});
222
+ const tabCount = (tab: (typeof TABS)[number]) => counts.value[tab.category] ?? 0;
223
+
224
+ /* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`.
225
+ The first column follows the tab; "Door access" is the same action for all. */
226
+ const headers = computed(() => [
227
+ { title: currentTab.value.noun, value: "name", sortable: false },
228
+ { title: "Door access", value: "selected", sortable: false, width: 190 },
229
+ ]);
230
+
231
+ const search = ref("");
232
+ const page = ref(1);
233
+ const pages = ref(1);
234
+ const pageRange = ref("");
235
+ const loading = ref(false);
236
+ const saving = ref(false);
237
+ const enrolled = ref<THidPermissionCandidate[]>([]);
238
+ const snackbar = reactive({ show: false, color: "success", message: "" });
239
+
240
+ /** What the server holds, and the working copy. The pair drives `dirty`. */
241
+ const savedState = ref<HidAssignmentState>(new Map());
242
+ const draft = ref<HidAssignmentState>(new Map());
243
+
244
+ const open = computed({
245
+ get: () => props.modelValue,
246
+ set: (value: boolean) => emit("update:modelValue", value),
247
+ });
248
+
249
+ const readerId = computed(() => String(props.reader?._id ?? ""));
250
+ const readerLocation = computed(() => String(props.reader?.location ?? "").trim());
251
+ const readerLabel = computed(() => {
252
+ const name = String(props.reader?.name ?? "").trim() || "HID Face Reader";
253
+ return readerLocation.value ? `${name} · ${readerLocation.value}` : name;
254
+ });
255
+
256
+ /* "unit" only means something for a resident. */
257
+ const searchPlaceholder = computed(() =>
258
+ activeTab.value === "resident"
259
+ ? "Search name, unit or registration"
260
+ : "Search name or registration",
261
+ );
262
+
263
+ const assignedTotal = computed(() => draft.value.size);
264
+ const dirty = computed(() => hasChanges(savedState.value, draft.value));
265
+ /* The draft is the truth once the dialog is open, so `selected` is recomputed
266
+ on every render rather than trusted from the row. */
267
+ const rows = computed(() => applySelection(enrolled.value, draft.value));
268
+
269
+ watch(
270
+ () => [props.modelValue, readerId.value] as const,
271
+ ([isOpen]) => {
272
+ if (!isOpen || !readerId.value) return;
273
+ search.value = "";
274
+ page.value = 1;
275
+ activeTab.value = "resident";
276
+ void loadAll();
277
+ },
278
+ { immediate: true },
279
+ );
280
+
281
+ async function loadAll() {
282
+ if (!readerLocation.value) return;
283
+ await loadAssignments();
284
+ await loadEnrolled();
285
+ }
286
+
287
+ /**
288
+ * `getSitePermissions` returns the reader's assignments across ALL categories.
289
+ * That is what makes a whole-reader payload possible from a paged view, so it
290
+ * is loaded once per opening rather than once per page.
291
+ */
292
+ async function loadAssignments() {
293
+ try {
294
+ const response = await getSitePermissions(props.site, props.org, readerId.value);
295
+ const assignments = response?.data?.assignments ?? [];
296
+ savedState.value = seedAssignments(assignments);
297
+ draft.value = seedAssignments(assignments);
298
+ /*
299
+ * Counted from the assignments themselves rather than the server's `counts`
300
+ * block, so the strip follows the DRAFT and a tick updates the number it
301
+ * sits beside. `countByCategory` is the same helper the candidate screens
302
+ * use.
303
+ */
304
+ counts.value = countByCategory(draft.value);
305
+ } catch (error) {
306
+ showToast(getErrorMessage(error, "Unable to load this reader's permissions."), "error");
307
+ }
308
+ }
309
+
310
+ function toRecord(value: unknown): Record<string, unknown> {
311
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
312
+ }
313
+
314
+ /**
315
+ * Why a row cannot be switched, in words the reader of the screen can act on.
316
+ *
317
+ * The row is still DRAWN. Dropping one silently is what made the pager say
318
+ * "1-9 of 9" over seven rows: the count comes from the server and the rows came
319
+ * from a filtered list, so people vanished with nothing to say they had.
320
+ *
321
+ * What lands here is a resident identity holding only an account link - no
322
+ * person, member or service-provider record - which leaves no subject to
323
+ * assign. Visitors never reach it: they are excluded by the query above,
324
+ * because their access comes from `user_access_rules` rather than a group and
325
+ * there would be nothing here to switch.
326
+ */
327
+ function unassignableReason() {
328
+ return "Not linked to a person";
329
+ }
330
+
331
+ /**
332
+ * The unit, when the enrolment captured one.
333
+ *
334
+ * `unitLabel` and NOT `block`/`level`/`unit`: those three hold ObjectIds, and
335
+ * the enrolment form resolves them through the occupancy tree before storing
336
+ * the readable label beside them. `buildUnitLabel` carries the warning this
337
+ * ignored once already — joining the raw values stamps
338
+ * "665f.../665f.../665f..." into the column.
339
+ *
340
+ * Falls back to the registration number, which is what the rows created by an
341
+ * assignment rather than an enrolment carry instead of a unit.
342
+ */
343
+ function subtitleOf(identity: Record<string, unknown>) {
344
+ const metadata = toRecord(identity.metadata);
345
+ return String(metadata.unitLabel ?? "").trim()
346
+ || String(identity.registration ?? "").trim();
347
+ }
348
+
349
+ async function loadEnrolled() {
350
+ if (!readerId.value || !readerLocation.value) return;
351
+ loading.value = true;
352
+ try {
353
+ const response = await getIdentities(readerId.value, {
354
+ page: page.value,
355
+ limit: 10,
356
+ search: search.value.trim(),
357
+ // THIS TAB's people, and filtered by the SERVER rather than here. The
358
+ // pager's count comes back from the same query, so narrowing the list on
359
+ // this side would print a total that never matched the rows under it.
360
+ type: currentTab.value.type,
361
+ });
362
+ const result = readCandidatePage(response);
363
+ /*
364
+ * EVERY row the server returned, including the ones this screen cannot act
365
+ * on. A visitor's access comes from `user_access_rules` rather than a group,
366
+ * and an identity linked to nobody has no subject to assign - but both are
367
+ * still enrolled on this reader, and the count in the pager counts them. They
368
+ * are drawn with the switch disabled and a reason, instead of disappearing.
369
+ */
370
+ enrolled.value = result.items.map((row) => {
371
+ const identity = toRecord(row);
372
+ const subject = subjectOfIdentity(identity);
373
+ const metadata = toRecord(identity.metadata);
374
+ return {
375
+ subjectId: subject?.subjectId ?? "",
376
+ // The record's own category where it has one; otherwise the tab's,
377
+ // which is the only thing that knows what the operator means.
378
+ category: subject?.category ?? currentTab.value.category,
379
+ // The row's own key. `subjectId` is empty for the rows below, and two
380
+ // empty keys would collapse into one row in the table.
381
+ rowKey: String(identity._id ?? identity.hidUserId ?? Math.random()),
382
+ assignable: Boolean(subject),
383
+ reason: subject ? "" : unassignableReason(),
384
+ name: String(metadata.name ?? "").trim()
385
+ || String(identity.registration ?? "").trim()
386
+ || "—",
387
+ subtitle: subtitleOf(identity),
388
+ intercom: false,
389
+ selected: false,
390
+ } as THidPermissionCandidate;
391
+ });
392
+ // `pages` only. The page number stays whatever the pager set — this
393
+ // endpoint's `paginate` never echoes it back, so reading one here pinned
394
+ // the screen to page one on every fetch.
395
+ pages.value = result.pages;
396
+ pageRange.value = result.pageRange;
397
+ } catch (error) {
398
+ enrolled.value = [];
399
+ pages.value = 1;
400
+ pageRange.value = "";
401
+ showToast(getErrorMessage(error, "Unable to load the people enrolled on this reader."), "error");
402
+ } finally {
403
+ loading.value = false;
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Switching tabs refetches, because the filter is the SERVER's.
409
+ *
410
+ * The draft is untouched: it is the whole reader's, and the save sends all of it
411
+ * whatever tab is in front of you. Clearing it here - or building the payload
412
+ * from the loaded rows - is what would revoke everybody on the other two tabs.
413
+ *
414
+ * Search is cleared with the tab. It is a server-side search within one type, so
415
+ * carrying it across would land on an empty list and look like the tab was empty.
416
+ */
417
+ function selectTab(value: (typeof TABS)[number]["value"]) {
418
+ if (activeTab.value === value) return;
419
+ activeTab.value = value;
420
+ search.value = "";
421
+ page.value = 1;
422
+ void loadEnrolled();
423
+ }
424
+
425
+ function goToPage(value: number) {
426
+ page.value = Number(value) || 1;
427
+ void loadEnrolled();
428
+ }
429
+
430
+ function searchFromFirstPage() {
431
+ page.value = 1;
432
+ void loadEnrolled();
433
+ }
434
+
435
+ /**
436
+ * Grant or revoke one person.
437
+ *
438
+ * The category comes from the row where the server gave it one, and otherwise
439
+ * from the TAB - never from re-reading the identity's links on this side. A
440
+ * contractor and a property-management member are both a `member` link, and the
441
+ * stale local copy of that reading is what used to file contractors as staff: the
442
+ * reconcile then derived `staff` from that category and rewrote the person's
443
+ * type, moving them into the Member tab.
444
+ *
445
+ * An ALREADY GRANTED person keeps the category their assignment holds, so merely
446
+ * visiting a tab and toggling somebody off and on cannot re-file them.
447
+ */
448
+ function toggle(item: THidPermissionCandidate, selected: boolean) {
449
+ const subjectId = String(item.subjectId ?? "");
450
+ if (!subjectId) return;
451
+ const category = savedState.value.get(subjectId)?.category
452
+ ?? item.category
453
+ ?? currentTab.value.category;
454
+ draft.value = setAssignment(
455
+ draft.value,
456
+ savedState.value,
457
+ subjectId,
458
+ category,
459
+ selected,
460
+ );
461
+ // The strip sits beside the rows, so it follows the tick rather than the save.
462
+ counts.value = countByCategory(draft.value);
463
+ }
464
+
465
+ async function save() {
466
+ if (!dirty.value || saving.value) return;
467
+ saving.value = true;
468
+ try {
469
+ await updateSitePermissions(
470
+ props.site,
471
+ props.org,
472
+ readerId.value,
473
+ toAssignmentPayload(draft.value),
474
+ );
475
+ // The server reconciles the device inside this call: anybody switched off
476
+ // loses their `user_groups` row before it returns, so a success here means
477
+ // the door has already been updated.
478
+ savedState.value = new Map(draft.value);
479
+ showToast("HID permissions updated.");
480
+ emit("saved");
481
+ open.value = false;
482
+ } catch (error) {
483
+ showToast(getErrorMessage(error, "Unable to update HID permissions."), "error");
484
+ } finally {
485
+ saving.value = false;
486
+ }
487
+ }
488
+
489
+ function showToast(message: string, color = "success") {
490
+ snackbar.message = message;
491
+ snackbar.color = color;
492
+ snackbar.show = true;
493
+ }
494
+
495
+ function getErrorMessage(error: unknown, fallback: string) {
496
+ const record = toRecord(error);
497
+ const data = toRecord(record.data);
498
+ return String(data.message ?? "") || String(record.message ?? "") || fallback;
499
+ }
500
+ </script>
501
+
502
+ <style scoped lang="scss">
503
+ /* The granted count beside a tab label. Quieter than the label and set in
504
+ tabular figures so the three tabs do not shift width as numbers change. */
505
+ .hid-access-permissions__tab-count {
506
+ margin-left: 6px;
507
+ color: var(--muted);
508
+ font-weight: 600;
509
+ font-variant-numeric: tabular-nums;
510
+ }
511
+
512
+ .hid-access-permissions {
513
+ &__title { padding-block: 16px; }
514
+
515
+ &__intro { padding: 12px 16px 0; }
516
+
517
+ &__body {
518
+ padding-top: 8px;
519
+ min-height: 320px;
520
+ }
521
+
522
+ &__actions { padding: 12px 16px; }
523
+
524
+ &__person > div:first-child { font-weight: 500; }
525
+
526
+ &__toggle {
527
+ display: flex;
528
+ align-items: center;
529
+ gap: 8px;
530
+ }
531
+ }
532
+ </style>