@redacto.io/consent-sdk-react 5.3.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/CHANGELOG.md +6 -0
  3. package/dist/privacy-center.d.mts +98 -38
  4. package/dist/privacy-center.d.ts +98 -38
  5. package/dist/privacy-center.js +3679 -1491
  6. package/dist/privacy-center.mjs +3614 -1423
  7. package/package.json +1 -1
  8. package/src/RedactoPrivacyCenter/api/actions.ts +52 -8
  9. package/src/RedactoPrivacyCenter/api/client.ts +58 -15
  10. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +722 -273
  11. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +187 -0
  12. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +65 -165
  13. package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +179 -0
  14. package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +318 -0
  15. package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +333 -0
  16. package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +102 -0
  17. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +6 -2
  18. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +8 -5
  19. package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +1 -1
  20. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +102 -53
  21. package/src/RedactoPrivacyCenter/components/types.ts +2 -5
  22. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +21 -1
  23. package/src/RedactoPrivacyCenter/lib/api.ts +70 -28
  24. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +4 -2
  25. package/src/RedactoPrivacyCenter/lib/types.ts +57 -36
  26. package/src/RedactoPrivacyCenter/locales/as/translation.json +12 -1
  27. package/src/RedactoPrivacyCenter/locales/bho/translation.json +12 -1
  28. package/src/RedactoPrivacyCenter/locales/bn/translation.json +12 -1
  29. package/src/RedactoPrivacyCenter/locales/brx/translation.json +12 -1
  30. package/src/RedactoPrivacyCenter/locales/doi/translation.json +12 -1
  31. package/src/RedactoPrivacyCenter/locales/en/translation.json +16 -1
  32. package/src/RedactoPrivacyCenter/locales/gom/translation.json +12 -1
  33. package/src/RedactoPrivacyCenter/locales/gu/translation.json +12 -1
  34. package/src/RedactoPrivacyCenter/locales/hi/translation.json +15 -1
  35. package/src/RedactoPrivacyCenter/locales/kn/translation.json +12 -1
  36. package/src/RedactoPrivacyCenter/locales/ks/translation.json +12 -1
  37. package/src/RedactoPrivacyCenter/locales/mai/translation.json +12 -1
  38. package/src/RedactoPrivacyCenter/locales/ml/translation.json +12 -1
  39. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +12 -1
  40. package/src/RedactoPrivacyCenter/locales/mr/translation.json +12 -1
  41. package/src/RedactoPrivacyCenter/locales/ne/translation.json +12 -1
  42. package/src/RedactoPrivacyCenter/locales/or/translation.json +12 -1
  43. package/src/RedactoPrivacyCenter/locales/pa/translation.json +12 -1
  44. package/src/RedactoPrivacyCenter/locales/sa/translation.json +12 -1
  45. package/src/RedactoPrivacyCenter/locales/sat/translation.json +12 -1
  46. package/src/RedactoPrivacyCenter/locales/sd/translation.json +12 -1
  47. package/src/RedactoPrivacyCenter/locales/ta/translation.json +12 -1
  48. package/src/RedactoPrivacyCenter/locales/te/translation.json +12 -1
  49. package/src/RedactoPrivacyCenter/locales/ur/translation.json +12 -1
  50. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +527 -5
  51. package/src/RedactoPrivacyCenter/styles/variables.css +5 -0
  52. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +36 -16
  53. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +4 -12
  54. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +1 -1
  55. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +51 -5
  56. package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +2 -4
  57. package/src/privacy-center.ts +7 -3
@@ -1,24 +1,47 @@
1
1
  import { useState, useEffect, useCallback, useMemo } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { PCInput, PCSelect, PCButton, PCText, PCModal, type SelectOption } from '../ui';
4
- import { Search, ArrowUpToLine } from 'lucide-react';
4
+ import { Search, ArrowUpToLine, Filter, Circle } from 'lucide-react';
5
5
  import { pcStyles } from '../styles/pcStyles';
6
6
  import { AdvancedTable, AdvancedTablePagination } from '../table';
7
- import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
7
+ import {
8
+ getCoreRowModel,
9
+ getExpandedRowModel,
10
+ useReactTable,
11
+ type ExpandedState,
12
+ type PaginationState,
13
+ type Row,
14
+ type Updater,
15
+ } from '@tanstack/react-table';
8
16
  import {
9
17
  fetchUserConsents,
18
+ fetchGroupPurposes,
10
19
  manageConsent,
11
20
  } from '../lib/api';
12
21
  import { useOrganization } from '../context/OrganizationContext';
13
22
  import { useAuth } from '../context/AuthContext';
14
23
  import { useBaseUrl } from '../context/BaseUrlContext';
15
- import type { UserConsent, UserConsentDetail, ProductConsentHistoryGroup, ConsentAction } from '../lib/types';
24
+ import { usePageChrome } from '../context/PageChromeContext';
25
+ import { ConsentStatusEnum } from '../lib/types';
26
+ import type {
27
+ ConsentAction,
28
+ ConsentGroup,
29
+ ConsentModalRecord,
30
+ DisplayProductGroup,
31
+ DisplayPurposeRow,
32
+ PurposeItem,
33
+ UserConsentDetail,
34
+ } from '../lib/types';
16
35
  import { useCurrentLanguage } from '../lib/useCurrentLanguage';
17
36
  import { exportTableToCSV, useStatusOptions } from '../lib/consent-manager/utils';
18
37
  import { toast } from '../utils/toast';
19
38
  import { ConsentManagerTableSkeleton } from './ConsentManagerTableSkeleton';
20
39
  import { ModifyConsentModal } from './ModifyConsentModal';
40
+ import { useConsentManagerGroupedColumns } from './ConsentManagerGroupedColumns';
21
41
  import { useConsentManagerColumns } from './ConsentManagerColumns';
42
+ import { ConsentManagerExpandedRow } from './ConsentManagerExpandedRow';
43
+ import { ConsentManagerProductCards } from './ConsentManagerProductCards';
44
+ import { ConsentManagerPurposeCards } from './ConsentManagerPurposeCards';
22
45
  import type {
23
46
  ConsentManagerProps,
24
47
  StatusFilterOption,
@@ -30,12 +53,15 @@ import {
30
53
  DEFAULT_PAGE_SIZE,
31
54
  } from './ConsentManagerConstants';
32
55
 
56
+ type ConsentViewMode = 'product' | 'purpose';
57
+
33
58
  export default function ConsentManager({ variant }: ConsentManagerProps) {
34
59
  const { t } = useTranslation();
35
60
  const statusOptions = useStatusOptions();
36
61
  const { organization } = useOrganization();
37
62
  const { accessToken, refreshToken, onError, updateTokens } = useAuth();
38
63
  const { consentClient } = useBaseUrl();
64
+ const { setHeaderStatus } = usePageChrome();
39
65
  const organizationId = organization?.uuid;
40
66
  const workspaceId = organization?.workspaces[0]?.uuid;
41
67
  const contact = sessionStorage.getItem('email') || '';
@@ -53,9 +79,10 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
53
79
 
54
80
  // State management
55
81
  const [activeTab, setActiveTab] = useState<'direct' | 'nominated'>('direct');
82
+ const [viewMode, setViewMode] = useState<ConsentViewMode>('product');
56
83
  const [consentDetail, setConsentDetail] = useState<UserConsentDetail | null>(null);
57
- const [consentData, setConsentData] = useState<UserConsent[]>([]);
58
- const [totalCount, setTotalCount] = useState(0);
84
+ const [consentGroups, setConsentGroups] = useState<DisplayProductGroup[]>([]);
85
+ const [consentRows, setConsentRows] = useState<DisplayPurposeRow[]>([]);
59
86
  const [isLoading, setIsLoading] = useState(true);
60
87
  const [searchQuery, setSearchQuery] = useState('');
61
88
  const [debouncedSearch, setDebouncedSearch] = useState('');
@@ -68,13 +95,45 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
68
95
  []
69
96
  );
70
97
  const [isModalOpen, setIsModalOpen] = useState(false);
71
- const [selectedRecord, setSelectedRecord] = useState<UserConsent | null>(
98
+ const [selectedRecord, setSelectedRecord] = useState<ConsentModalRecord | null>(
72
99
  null
73
100
  );
74
101
  const [exportModal, setExportModal] = useState(false);
75
102
  const [isManageLoading, setIsManageLoading] = useState(false);
76
103
  const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
77
104
  const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
105
+ const [expanded, setExpanded] = useState<ExpandedState>({});
106
+ const [extraPurposesByKey, setExtraPurposesByKey] = useState<Record<string, PurposeItem[]>>({});
107
+ const [hasMoreByKey, setHasMoreByKey] = useState<Record<string, boolean>>({});
108
+ const [loadingMoreByKey, setLoadingMoreByKey] = useState<Record<string, boolean>>({});
109
+ const composedSearchPlaceholder = useMemo(
110
+ () => `${t('search')} ${t('consent')}, ${t('product')}, ${t('purpose')}...`,
111
+ [t]
112
+ );
113
+ const allStatusOption = useMemo<StatusFilterOption>(
114
+ () => ({
115
+ label: `${t('all')} ${t('status')}`,
116
+ value: '__all__',
117
+ }),
118
+ [t]
119
+ );
120
+ const allProductOption = useMemo<ProductFilterOption>(
121
+ () => ({
122
+ label: `${t('all')} ${t('product')}`,
123
+ value: '__all__',
124
+ }),
125
+ [t]
126
+ );
127
+ const statusFilterOptions = useMemo<StatusFilterOption[]>(
128
+ () => [allStatusOption, ...statusOptions],
129
+ [allStatusOption, statusOptions]
130
+ );
131
+ const productFilterOptions = useMemo<ProductFilterOption[]>(
132
+ () => [allProductOption, ...productOptions],
133
+ [allProductOption, productOptions]
134
+ );
135
+ const statusSelectValue = statusFilter ?? allStatusOption;
136
+ const productSelectValue = productFilter ?? allProductOption;
78
137
 
79
138
  // Sync selected status filter label when language changes
80
139
  useEffect(() => {
@@ -96,6 +155,19 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
96
155
  }
97
156
  }, [productOptions, productFilter]);
98
157
 
158
+ // Push user_status into the page header so it renders inline with the page title
159
+ useEffect(() => {
160
+ const status = consentDetail?.user_status;
161
+ if (status === 'TRANSFERRED') {
162
+ setHeaderStatus({ label: t('statusTransferred'), variant: 'warning' });
163
+ } else if (status === 'ACTIVE') {
164
+ setHeaderStatus({ label: t('statusActive'), variant: 'success' });
165
+ } else {
166
+ setHeaderStatus(null);
167
+ }
168
+ return () => setHeaderStatus(null);
169
+ }, [consentDetail?.user_status, setHeaderStatus, t]);
170
+
99
171
  // Debounce search query
100
172
  useEffect(() => {
101
173
  const timer = setTimeout(() => {
@@ -104,25 +176,132 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
104
176
  return () => clearTimeout(timer);
105
177
  }, [searchQuery]);
106
178
 
107
- const handleOpenModal = useCallback((record: UserConsent) => {
179
+ const handleOpenModal = useCallback((record: ConsentModalRecord) => {
108
180
  setSelectedRecord(record);
109
181
  setIsModalOpen(true);
110
182
  }, []);
111
183
 
184
+ const getCardKey = useCallback(
185
+ (group: ConsentGroup): string =>
186
+ group.nominator
187
+ ? `nominated:${group.nominator.uuid}:${group.product_uuid}`
188
+ : `direct:${group.product_uuid}`,
189
+ []
190
+ );
191
+
112
192
  const handleCloseModal = useCallback(() => {
113
193
  setIsModalOpen(false);
114
194
  setSelectedRecord(null);
115
195
  }, []);
116
196
 
197
+ const matchesPurpose = useCallback(
198
+ (
199
+ purpose: PurposeItem,
200
+ group: ConsentGroup,
201
+ normalizedSearch: string,
202
+ statusValue?: string
203
+ ) => {
204
+ const matchesSearch =
205
+ !normalizedSearch ||
206
+ [
207
+ purpose.name,
208
+ purpose.description,
209
+ group.product_name,
210
+ group.product_description,
211
+ purpose.method,
212
+ purpose.status,
213
+ ]
214
+ .filter(Boolean)
215
+ .some((value) => String(value).toLowerCase().includes(normalizedSearch));
216
+
217
+ const matchesStatus =
218
+ !statusValue || purpose.status?.toLowerCase() === statusValue.toLowerCase();
219
+
220
+ return matchesSearch && matchesStatus;
221
+ },
222
+ []
223
+ );
224
+
225
+ const buildDisplayGroups = useCallback(
226
+ (
227
+ groups: ConsentGroup[] | undefined,
228
+ search: string,
229
+ statusValue?: string,
230
+ productUuid?: string
231
+ ): DisplayProductGroup[] => {
232
+ if (!groups || groups.length === 0) return [];
233
+ const normalizedSearch = search.trim().toLowerCase();
234
+
235
+ return groups.reduce<DisplayProductGroup[]>((acc, group) => {
236
+ if (productUuid && group.product_uuid !== productUuid) {
237
+ return acc;
238
+ }
239
+
240
+ const filteredPurposes = (group.purposes || []).filter((purpose) =>
241
+ matchesPurpose(purpose, group, normalizedSearch, statusValue)
242
+ );
243
+
244
+ if (filteredPurposes.length === 0) {
245
+ return acc;
246
+ }
247
+
248
+ const counts = filteredPurposes.reduce(
249
+ (totals, purpose) => {
250
+ const normalized = (typeof purpose.status === 'string' ? purpose.status : '').toUpperCase();
251
+ switch (normalized) {
252
+ case ConsentStatusEnum.ACTIVE:
253
+ totals.active += 1;
254
+ break;
255
+ case ConsentStatusEnum.WITHDRAWN:
256
+ case ConsentStatusEnum.REVOKED:
257
+ totals.withdrawn += 1;
258
+ break;
259
+ case ConsentStatusEnum.EXPIRED:
260
+ totals.expired += 1;
261
+ break;
262
+ case ConsentStatusEnum.DECLINED:
263
+ totals.declined += 1;
264
+ break;
265
+ default:
266
+ break;
267
+ }
268
+ return totals;
269
+ },
270
+ { active: 0, withdrawn: 0, expired: 0, declined: 0 }
271
+ );
272
+
273
+ const latestGivenDate = filteredPurposes.reduce<string | null>((latest, purpose) => {
274
+ if (!purpose.given_date) return latest;
275
+ if (!latest) return purpose.given_date;
276
+ return new Date(purpose.given_date).getTime() > new Date(latest).getTime()
277
+ ? purpose.given_date
278
+ : latest;
279
+ }, null);
280
+
281
+ acc.push({
282
+ ...group,
283
+ purposes: filteredPurposes,
284
+ withdrawn_purposes: counts.withdrawn,
285
+ expired_purposes: counts.expired,
286
+ declined_purposes: counts.declined,
287
+ latest_given_date: latestGivenDate,
288
+ });
289
+ return acc;
290
+ }, []);
291
+ },
292
+ [matchesPurpose]
293
+ );
294
+
117
295
  const flattenProductGroups = useCallback(
118
- (groups: ProductConsentHistoryGroup[] | undefined): UserConsent[] => {
296
+ (groups: ConsentGroup[] | undefined): DisplayPurposeRow[] => {
119
297
  if (!groups || groups.length === 0) return [];
120
298
  return groups.flatMap((group) =>
121
299
  (group.purposes || []).map((purpose) => ({
122
300
  ...purpose,
123
- product_uuid: purpose.product_uuid ?? group.product_uuid,
124
- product_name: purpose.product_name ?? group.product_name,
125
- product_description: purpose.product_description ?? group.product_description ?? null,
301
+ product_uuid: group.product_uuid,
302
+ product_name: group.product_name,
303
+ product_description: group.product_description,
304
+ nominator: group.nominator,
126
305
  }))
127
306
  );
128
307
  },
@@ -131,19 +310,18 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
131
310
 
132
311
  const filterConsentRows = useCallback(
133
312
  (
134
- rows: UserConsent[],
313
+ rows: DisplayPurposeRow[],
135
314
  search: string,
136
315
  statusValue?: string,
137
316
  productUuid?: string
138
- ) => {
317
+ ): DisplayPurposeRow[] => {
139
318
  const normalizedSearch = search.trim().toLowerCase();
140
-
141
319
  return rows.filter((row) => {
142
320
  const matchesSearch =
143
321
  !normalizedSearch ||
144
322
  [
145
- row.purpose,
146
- row.purpose_description,
323
+ row.name,
324
+ row.description,
147
325
  row.product_name,
148
326
  row.product_description,
149
327
  row.method,
@@ -156,9 +334,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
156
334
 
157
335
  const matchesStatus =
158
336
  !statusValue || row.status?.toLowerCase() === statusValue.toLowerCase();
159
-
160
- const matchesProduct =
161
- !productUuid || row.product_uuid === productUuid;
337
+ const matchesProduct = !productUuid || row.product_uuid === productUuid;
162
338
 
163
339
  return matchesSearch && matchesStatus && matchesProduct;
164
340
  });
@@ -167,7 +343,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
167
343
  );
168
344
 
169
345
  const buildProductOptionsFromGroups = useCallback(
170
- (groups: ProductConsentHistoryGroup[] | undefined) => {
346
+ (groups: ConsentGroup[] | undefined) => {
171
347
  if (!groups || groups.length === 0) return [];
172
348
  const productMap = new Map<string, { label: string; value: string }>();
173
349
  groups.forEach((group) => {
@@ -183,31 +359,42 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
183
359
  []
184
360
  );
185
361
 
186
- const getNominatedProductGroups = useCallback(
187
- (detail: UserConsentDetail | null | undefined): ProductConsentHistoryGroup[] => {
188
- if (!detail?.nominated_purposes || detail.nominated_purposes.length === 0) {
189
- return [];
190
- }
191
- return detail.nominated_purposes.flatMap((np) =>
192
- (np.product_groups || []).map((group) => ({
193
- ...group,
194
- purposes: group.purposes.map((purpose) => ({
195
- ...purpose,
196
- nominator_info: np.nominator_info,
197
- })),
198
- }))
199
- );
200
- },
362
+ const groupedColumns = useConsentManagerGroupedColumns();
363
+ const purposeColumns = useConsentManagerColumns({
364
+ onOpenModal: handleOpenModal,
365
+ });
366
+
367
+ const groupedTableClassNames = useMemo(
368
+ () => ({
369
+ container: 'pc-consent-table-container',
370
+ tableClassName: 'pc-consent-table',
371
+ headerClassName: 'pc-consent-table-header',
372
+ bodyClassName: 'pc-consent-table-body',
373
+ rowClassName: 'pc-consent-table-row',
374
+ cellClassName: 'pc-consent-table-cell',
375
+ headerCellClassName: 'pc-consent-table-head-cell',
376
+ expandedRowClassName: 'pc-consent-table-expanded-row',
377
+ expandedCellClassName: 'pc-consent-table-expanded-cell',
378
+ }),
201
379
  []
202
380
  );
203
381
 
204
- const columns = useConsentManagerColumns({
205
- onOpenModal: handleOpenModal,
206
- });
382
+ const purposeTableClassNames = useMemo(
383
+ () => ({
384
+ container: 'pc-consent-table-container',
385
+ tableClassName: 'pc-consent-table pc-consent-table-purpose',
386
+ headerClassName: 'pc-consent-table-header',
387
+ bodyClassName: 'pc-consent-table-body',
388
+ rowClassName: 'pc-consent-table-row',
389
+ cellClassName: 'pc-consent-table-cell',
390
+ headerCellClassName: 'pc-consent-table-head-cell',
391
+ }),
392
+ []
393
+ );
207
394
 
208
395
  // Single data fetch function
209
396
  const fetchData = useCallback(async () => {
210
- if (!organizationId || !workspaceId || !contact) {
397
+ if (!organizationId || !workspaceId) {
211
398
  return;
212
399
  }
213
400
 
@@ -218,31 +405,39 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
218
405
  const statusParam = statusFilter?.value;
219
406
  const productUuidParam = productFilter?.value;
220
407
 
221
- const response = await fetchUserConsents(
408
+ const response = await fetchUserConsents({
222
409
  organizationId,
223
410
  workspaceId,
224
- contact,
225
- offset,
226
- pageSize,
227
- searchParam,
228
- statusParam,
229
- productUuidParam,
411
+ offset: activeTab === 'direct' ? offset : undefined,
412
+ limit: activeTab === 'direct' ? pageSize : undefined,
413
+ nominatedOffset: activeTab === 'nominated' ? offset : undefined,
414
+ nominatedLimit: activeTab === 'nominated' ? pageSize : undefined,
415
+ search: searchParam,
416
+ status: statusParam,
417
+ product_uuid: productUuidParam,
418
+ language: langParam,
230
419
  authContext,
231
420
  consentClient,
232
- langParam
233
- );
421
+ });
234
422
 
235
423
  if (!response) {
236
424
  throw new Error('Failed to fetch consent data');
237
425
  }
238
426
 
239
427
  setConsentDetail(response);
240
-
241
- const directGroups = response?.direct_product_groups;
242
- const nominatedGroups = getNominatedProductGroups(response);
428
+ setExtraPurposesByKey({});
429
+ setHasMoreByKey({});
430
+ setLoadingMoreByKey({});
243
431
 
244
432
  const selectedGroups =
245
- activeTab === 'direct' ? directGroups : nominatedGroups;
433
+ activeTab === 'direct' ? response.direct : response.nominated;
434
+ const displayGroups = buildDisplayGroups(
435
+ selectedGroups,
436
+ debouncedSearch,
437
+ statusParam,
438
+ productUuidParam
439
+ );
440
+ setConsentGroups(displayGroups);
246
441
  const flattenedRows = flattenProductGroups(selectedGroups);
247
442
  const filteredRows = filterConsentRows(
248
443
  flattenedRows,
@@ -250,32 +445,23 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
250
445
  statusParam,
251
446
  productUuidParam
252
447
  );
253
- setConsentData(filteredRows);
254
-
255
- const directCount = response?.status_summary?.direct_purposes;
256
- const nominatedCount = response?.status_summary?.nominated_purposes;
257
- const fallbackTotal = response?.pagination?.total_count ?? 0;
258
- const hasActiveFilters = Boolean(searchParam || statusParam || productUuidParam);
259
- setTotalCount(
260
- hasActiveFilters
261
- ? filteredRows.length
262
- : activeTab === 'direct'
263
- ? (directCount ?? fallbackTotal)
264
- : (nominatedCount ?? fallbackTotal)
265
- );
448
+ setConsentRows(filteredRows);
266
449
 
267
450
  // Extract unique products from all groups
268
451
  const combinedGroups = [
269
- ...(response?.direct_product_groups || []),
270
- ...nominatedGroups,
452
+ ...(response.direct || []),
453
+ ...(response.nominated || []),
271
454
  ];
272
455
  setProductOptions(buildProductOptionsFromGroups(combinedGroups));
273
456
  } catch (error) {
274
457
  console.error(error);
275
458
  setConsentDetail(null);
276
- setConsentData([]);
277
- setTotalCount(0);
459
+ setConsentGroups([]);
460
+ setConsentRows([]);
278
461
  setProductOptions([]);
462
+ setExtraPurposesByKey({});
463
+ setHasMoreByKey({});
464
+ setLoadingMoreByKey({});
279
465
  toast.error(t('failedToFetchConsentData'));
280
466
  } finally {
281
467
  setIsLoading(false);
@@ -283,7 +469,6 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
283
469
  }, [
284
470
  organizationId,
285
471
  workspaceId,
286
- contact,
287
472
  currentPage,
288
473
  pageSize,
289
474
  debouncedSearch,
@@ -293,10 +478,10 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
293
478
  authContext,
294
479
  consentClient,
295
480
  langParam,
481
+ buildDisplayGroups,
296
482
  flattenProductGroups,
297
483
  filterConsentRows,
298
484
  buildProductOptionsFromGroups,
299
- getNominatedProductGroups,
300
485
  t,
301
486
  ]);
302
487
 
@@ -305,55 +490,234 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
305
490
  fetchData();
306
491
  }, [fetchData]);
307
492
 
308
- const table = useReactTable<UserConsent>({
309
- data: consentData,
310
- columns: columns,
493
+ const hasActiveFilters = Boolean(
494
+ debouncedSearch.trim() || statusFilter?.value || productFilter?.value
495
+ );
496
+
497
+ const groupedPurposeCount = useMemo(
498
+ () => consentGroups.reduce((sum, group) => sum + group.purposes.length, 0),
499
+ [consentGroups]
500
+ );
501
+
502
+ const visibleConsentCount =
503
+ viewMode === 'product' ? groupedPurposeCount : consentRows.length;
504
+
505
+ const visibleProductCount = useMemo(() => {
506
+ if (viewMode === 'product') {
507
+ return consentGroups.length;
508
+ }
509
+ const productIds = new Set(
510
+ consentRows.map((row) => row.product_uuid || row.product_name || '')
511
+ );
512
+ productIds.delete('');
513
+ return productIds.size;
514
+ }, [viewMode, consentGroups, consentRows]);
515
+
516
+ const tableTotalCount = useMemo(() => {
517
+ const pagination =
518
+ activeTab === 'direct'
519
+ ? consentDetail?.direct_pagination
520
+ : consentDetail?.nominated_pagination;
521
+ return pagination?.total_count ?? 0;
522
+ }, [activeTab, consentDetail]);
523
+
524
+ const tablePageCount = Math.ceil(tableTotalCount / pageSize) || 0;
525
+
526
+ const handlePaginationChange = useCallback(
527
+ (updater: Updater<PaginationState>) => {
528
+ const next =
529
+ typeof updater === 'function'
530
+ ? updater({ pageIndex: currentPage - 1, pageSize })
531
+ : updater;
532
+ const nextPage = next.pageIndex + 1;
533
+ const nextSize = next.pageSize;
534
+ if (nextPage === currentPage && nextSize === pageSize) return;
535
+ setCurrentPage(nextPage);
536
+ setPageSize(nextSize);
537
+ },
538
+ [currentPage, pageSize]
539
+ );
540
+
541
+ const productTable = useReactTable<DisplayProductGroup>({
542
+ data: consentGroups,
543
+ columns: groupedColumns,
311
544
  getCoreRowModel: getCoreRowModel(),
545
+ getExpandedRowModel: getExpandedRowModel(),
546
+ getRowCanExpand: () => true,
312
547
  manualPagination: true,
313
- pageCount: Math.ceil(totalCount / pageSize) || 0,
548
+ pageCount: tablePageCount,
314
549
  state: {
315
550
  pagination: {
316
551
  pageIndex: currentPage - 1,
317
552
  pageSize: pageSize,
318
553
  },
554
+ expanded,
555
+ },
556
+ onExpandedChange: (updater) => {
557
+ const next = typeof updater === 'function' ? updater(expanded) : updater;
558
+ if (next === true) {
559
+ setExpanded(next);
560
+ return;
561
+ }
562
+ if (!next) {
563
+ setExpanded({});
564
+ return;
565
+ }
566
+
567
+ const nextMap = next as Record<string, boolean>;
568
+ const nextOpenIds = Object.keys(nextMap).filter((rowId) => nextMap[rowId]);
569
+ if (nextOpenIds.length <= 1) {
570
+ setExpanded(nextOpenIds.length === 1 ? { [nextOpenIds[0]]: true } : {});
571
+ return;
572
+ }
573
+
574
+ const prevMap =
575
+ expanded && expanded !== true ? (expanded as Record<string, boolean>) : {};
576
+ const newlyOpenedId =
577
+ nextOpenIds.find((rowId) => !prevMap[rowId]) ??
578
+ nextOpenIds[nextOpenIds.length - 1];
579
+
580
+ setExpanded(newlyOpenedId ? { [newlyOpenedId]: true } : {});
319
581
  },
320
582
  onPaginationChange: (updater) => {
321
- const next =
322
- typeof updater === 'function'
323
- ? updater({ pageIndex: currentPage - 1, pageSize })
324
- : updater;
325
- const nextPage = (next as any).pageIndex + 1;
326
- const nextSize = (next as any).pageSize;
327
- if (nextPage === currentPage && nextSize === pageSize) return;
328
- setCurrentPage(nextPage);
329
- setPageSize(nextSize);
583
+ handlePaginationChange(updater);
584
+ setExpanded({});
585
+ },
586
+ meta: {
587
+ onRowClick: (_row: DisplayProductGroup, tableRow?: Row<DisplayProductGroup>) => {
588
+ tableRow?.toggleExpanded();
589
+ },
330
590
  },
331
591
  });
332
592
 
593
+ const purposeTable = useReactTable<DisplayPurposeRow>({
594
+ data: consentRows,
595
+ columns: purposeColumns,
596
+ getCoreRowModel: getCoreRowModel(),
597
+ manualPagination: true,
598
+ pageCount: tablePageCount,
599
+ state: {
600
+ pagination: {
601
+ pageIndex: currentPage - 1,
602
+ pageSize: pageSize,
603
+ },
604
+ },
605
+ onPaginationChange: handlePaginationChange,
606
+ });
607
+
333
608
  const handleStatusFilterChange = (data: SelectOption | null) => {
334
- setStatusFilter(data as StatusFilterOption | null);
609
+ if (!data || String(data.value) === allStatusOption.value) {
610
+ setStatusFilter(null);
611
+ } else {
612
+ setStatusFilter(data as StatusFilterOption);
613
+ }
335
614
  setCurrentPage(DEFAULT_PAGE);
615
+ setExpanded({});
336
616
  };
337
617
 
338
618
  const handleProductFilterChange = (data: SelectOption | null) => {
339
- setProductFilter(data as ProductFilterOption | null);
619
+ if (!data || String(data.value) === allProductOption.value) {
620
+ setProductFilter(null);
621
+ } else {
622
+ setProductFilter(data as ProductFilterOption);
623
+ }
340
624
  setCurrentPage(DEFAULT_PAGE);
625
+ setExpanded({});
341
626
  };
342
627
 
343
628
  const handleTabChange = (tab: 'direct' | 'nominated') => {
344
629
  setActiveTab(tab);
345
630
  setCurrentPage(DEFAULT_PAGE);
631
+ setExpanded({});
632
+ };
633
+
634
+ const handleViewModeChange = (mode: ConsentViewMode) => {
635
+ if (mode === viewMode) return;
636
+ setViewMode(mode);
637
+ setCurrentPage(DEFAULT_PAGE);
638
+ setExpanded({});
346
639
  };
347
640
 
348
641
  const handleExport = useCallback(() => {
349
- const value = table.getRowModel().rows.map((r) => {
350
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
351
- const { purpose_uuid, nominator_info, ...rowWithoutInternalFields } = r.original;
352
- return rowWithoutInternalFields;
353
- });
642
+ const value = viewMode === 'product'
643
+ ? productTable.getRowModel().rows.flatMap((row) =>
644
+ row.original.purposes.map((purpose) => {
645
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
646
+ const { purpose_uuid, link_reason, data_elements, ...rest } = purpose;
647
+ return {
648
+ ...rest,
649
+ product_name: row.original.product_name,
650
+ product_description: row.original.product_description,
651
+ };
652
+ })
653
+ )
654
+ : purposeTable.getRowModel().rows.map((row) => {
655
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
656
+ const { purpose_uuid, link_reason, nominator, data_elements, ...rest } = row.original;
657
+ return rest;
658
+ });
354
659
  exportTableToCSV(value);
355
660
  setExportModal(false);
356
- }, [table]);
661
+ }, [viewMode, productTable, purposeTable]);
662
+
663
+ const handleLoadMorePurposes = useCallback(
664
+ async (group: DisplayProductGroup) => {
665
+ if (!organizationId || !workspaceId) return;
666
+ const cardKey = getCardKey(group);
667
+ if (loadingMoreByKey[cardKey]) return;
668
+
669
+ const alreadyLoaded = extraPurposesByKey[cardKey]?.length ?? 0;
670
+ const inlineCount = group.purposes.length;
671
+ const offset = inlineCount + alreadyLoaded;
672
+ const statusParam = statusFilter?.value;
673
+ const searchParam = debouncedSearch.trim() || undefined;
674
+
675
+ setLoadingMoreByKey((prev) => ({ ...prev, [cardKey]: true }));
676
+ try {
677
+ const result = await fetchGroupPurposes({
678
+ organizationId,
679
+ workspaceId,
680
+ productUuid: group.product_uuid,
681
+ offset,
682
+ limit: 50,
683
+ status: statusParam,
684
+ search: searchParam,
685
+ language: langParam,
686
+ nominatorUuid: group.nominator?.uuid,
687
+ authContext,
688
+ consentClient,
689
+ });
690
+ if (!result) {
691
+ throw new Error('Failed to load more purposes');
692
+ }
693
+ setExtraPurposesByKey((prev) => ({
694
+ ...prev,
695
+ [cardKey]: [...(prev[cardKey] ?? []), ...result.purposes],
696
+ }));
697
+ const fetchedTotal = offset + result.purposes.length;
698
+ const hasMore = fetchedTotal < result.pagination.total_count;
699
+ setHasMoreByKey((prev) => ({ ...prev, [cardKey]: hasMore }));
700
+ } catch (error) {
701
+ console.error('Failed to load more purposes:', error);
702
+ toast.error(t('failedToFetchConsentData'));
703
+ } finally {
704
+ setLoadingMoreByKey((prev) => ({ ...prev, [cardKey]: false }));
705
+ }
706
+ },
707
+ [
708
+ organizationId,
709
+ workspaceId,
710
+ authContext,
711
+ consentClient,
712
+ langParam,
713
+ statusFilter?.value,
714
+ debouncedSearch,
715
+ extraPurposesByKey,
716
+ loadingMoreByKey,
717
+ getCardKey,
718
+ t,
719
+ ]
720
+ );
357
721
 
358
722
  const handleManageConsent = useCallback(
359
723
  async (
@@ -419,187 +783,219 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
419
783
 
420
784
  const getVar = (varName: string): string => `var(${varName})`;
421
785
 
422
- const directBadge =
423
- consentDetail?.status_summary?.direct_purposes ??
424
- (activeTab === 'direct' ? totalCount : '');
786
+ const directBadge: number | '' =
787
+ consentDetail?.status_summary?.direct_purposes ?? '';
425
788
 
426
789
  const nominatedBadge: number | '' =
427
- consentDetail?.status_summary?.nominated_purposes ??
428
- (activeTab === 'nominated' ? totalCount : '');
429
-
430
- const renderUserRelationshipStrip = () => {
431
- if (!consentDetail) return null;
432
-
433
- const status = consentDetail.user_status;
434
- let statusLabel: string | null = null;
435
- let statusBgColor: string | null = null;
436
- let statusTextColor: string | null = null;
437
- if (status === 'ACTIVE') {
438
- statusLabel = t('statusActive');
439
- statusBgColor = 'rgba(16, 185, 129, 0.1)';
440
- statusTextColor = getVar('--pc-success');
441
- } else if (status === 'TRANSFERRED') {
442
- statusLabel = t('statusTransferred');
443
- statusBgColor = 'rgba(245, 158, 11, 0.1)';
444
- statusTextColor = getVar('--pc-warning');
445
- }
790
+ consentDetail?.status_summary?.nominated_purposes ?? '';
446
791
 
447
- const nominators = consentDetail.nominators || [];
448
- const nominatorLabels = nominators
449
- .map((n) => n.email?.trim() || n.org_user_id?.trim() || n.uuid?.trim() || '')
450
- .filter(Boolean);
451
-
452
- const hasAnyInfo = Boolean(statusLabel) || nominatorLabels.length > 0;
453
- if (!hasAnyInfo) return null;
454
-
455
- return (
456
- <div style={{
457
- marginBottom: getVar('--pc-spacing-md'),
458
- borderRadius: getVar('--pc-border-radius-lg'),
459
- border: `1px solid ${getVar('--pc-border-color')}`,
460
- backgroundColor: getVar('--pc-bg-secondary'),
461
- padding: getVar('--pc-spacing-md'),
462
- }}>
463
- <div style={{
464
- display: 'flex',
465
- flexDirection: 'column',
466
- gap: getVar('--pc-spacing-sm'),
467
- }} className="pc-flex-responsive">
468
- <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
469
- <PCText size="sm" weight="semibold" style={{ margin: 0, color: getVar('--pc-text-primary') }}>
470
- {t('consentVisibility')}
471
- </PCText>
472
- {nominatorLabels.length > 0 && (
473
- <PCText size="sm" style={{ margin: 0, color: getVar('--pc-text-secondary') }}>
474
- {t('managingConsentsFor')}{' '}
475
- <span style={{ fontWeight: 600, color: getVar('--pc-text-primary') }}>
476
- {nominatorLabels.join(', ')}
477
- </span>
478
- </PCText>
479
- )}
480
- </div>
481
- {statusLabel && (
482
- <div style={{ flexShrink: 0 }}>
483
- <span style={{
484
- display: 'inline-flex',
485
- alignItems: 'center',
486
- borderRadius: '9999px',
487
- padding: '0.25rem 0.75rem',
488
- fontSize: getVar('--pc-font-size-xs'),
489
- fontWeight: 600,
490
- backgroundColor: statusBgColor!,
491
- color: statusTextColor!,
492
- }}>
493
- {statusLabel}
494
- </span>
495
- </div>
496
- )}
497
- </div>
498
- </div>
499
- );
500
- };
792
+ const nominatorLabels = (consentDetail?.nominators || [])
793
+ .map((n) => n.email?.trim() || n.org_user_id?.trim() || n.uuid?.trim() || '')
794
+ .filter(Boolean);
795
+ const summaryText = t('productsAndConsentsSummary', {
796
+ products: visibleProductCount,
797
+ consents: visibleConsentCount,
798
+ });
501
799
 
502
800
  return (
503
- <div style={{ width: '100%' }}>
504
- {/* User Relationship Strip */}
505
- {renderUserRelationshipStrip()}
506
-
801
+ <div className="pc-consent-manager-surface" style={{ width: '100%' }}>
507
802
  <div
803
+ className="pc-consent-shell-card"
508
804
  style={{
509
- borderRadius: getVar('--pc-border-radius-lg'),
510
- border: `1px solid ${getVar('--pc-border-color')}`,
511
- backgroundColor: getVar('--pc-bg-primary'),
512
- boxShadow: getVar('--pc-shadow-sm'),
513
- padding: getVar('--pc-spacing-md'),
514
- marginBottom: getVar('--pc-spacing-lg'),
805
+ backgroundColor: 'transparent',
515
806
  }}
516
807
  >
517
- {/* Tabs: My Consents / Managing */}
518
- <div style={{
519
- display: 'flex',
520
- borderBottom: `1px solid ${getVar('--pc-border-color')}`,
521
- marginBottom: getVar('--pc-spacing-md'),
522
- paddingBottom: '0.125rem',
523
- }}>
524
- <button
525
- type="button"
526
- onClick={() => handleTabChange('direct')}
808
+ {/* Tabs: My Consents / Managing + Product/Purpose toggle */}
809
+ <div
810
+ className="pc-consent-tabs-row"
811
+ style={{
812
+ display: 'flex',
813
+ alignItems: 'center',
814
+ justifyContent: 'space-between',
815
+ gap: getVar('--pc-spacing-sm'),
816
+ borderBottom: `1px solid ${getVar('--pc-border-color')}`,
817
+ padding: '0.125rem 0 0.375rem',
818
+ }}
819
+ >
820
+ <div className="pc-consent-tabs-left" style={{ display: 'flex', minWidth: 0 }}>
821
+ <button
822
+ type="button"
823
+ onClick={() => handleTabChange('direct')}
824
+ style={{
825
+ display: 'flex',
826
+ alignItems: 'center',
827
+ gap: getVar('--pc-spacing-sm'),
828
+ padding: `0.5rem ${getVar('--pc-spacing-sm')}`,
829
+ fontSize: '0.75rem',
830
+ fontWeight: 600,
831
+ background: 'none',
832
+ border: 'none',
833
+ cursor: 'pointer',
834
+ borderBottom: activeTab === 'direct' ? `2px solid ${getVar('--pc-primary')}` : '2px solid transparent',
835
+ color: activeTab === 'direct' ? getVar('--pc-primary') : getVar('--pc-text-secondary'),
836
+ transition: 'color 0.2s, border-color 0.2s',
837
+ }}
838
+ >
839
+ {t('myConsents')}
840
+ <span style={{
841
+ borderRadius: '9999px',
842
+ backgroundColor: getVar('--pc-bg-tertiary'),
843
+ padding: '0.0625rem 0.375rem',
844
+ fontSize: '0.6875rem',
845
+ color: getVar('--pc-text-secondary'),
846
+ }}>
847
+ {directBadge}
848
+ </span>
849
+ </button>
850
+ <button
851
+ type="button"
852
+ onClick={() => handleTabChange('nominated')}
853
+ style={{
854
+ display: 'flex',
855
+ alignItems: 'center',
856
+ gap: getVar('--pc-spacing-sm'),
857
+ padding: `0.5rem ${getVar('--pc-spacing-sm')}`,
858
+ fontSize: '0.75rem',
859
+ fontWeight: 600,
860
+ background: 'none',
861
+ border: 'none',
862
+ cursor: 'pointer',
863
+ borderBottom: activeTab === 'nominated' ? `2px solid ${getVar('--pc-primary')}` : '2px solid transparent',
864
+ color: activeTab === 'nominated' ? getVar('--pc-primary') : getVar('--pc-text-secondary'),
865
+ transition: 'color 0.2s, border-color 0.2s',
866
+ }}
867
+ >
868
+ {t('managing')}
869
+ <span style={{
870
+ borderRadius: '9999px',
871
+ backgroundColor: getVar('--pc-bg-tertiary'),
872
+ padding: '0.0625rem 0.375rem',
873
+ fontSize: '0.6875rem',
874
+ color: getVar('--pc-text-secondary'),
875
+ }}>
876
+ {nominatedBadge}
877
+ </span>
878
+ </button>
879
+ </div>
880
+
881
+ <div
882
+ className="pc-consent-view-toggle"
527
883
  style={{
884
+ marginLeft: 'auto',
528
885
  display: 'flex',
529
886
  alignItems: 'center',
530
887
  gap: getVar('--pc-spacing-sm'),
531
- padding: `${getVar('--pc-spacing-sm')} ${getVar('--pc-spacing-md')}`,
532
- fontSize: getVar('--pc-font-size-sm'),
533
- fontWeight: 600,
534
- background: 'none',
535
- border: 'none',
536
- cursor: 'pointer',
537
- borderBottom: activeTab === 'direct' ? `2px solid ${getVar('--pc-primary')}` : '2px solid transparent',
538
- color: activeTab === 'direct' ? getVar('--pc-primary') : getVar('--pc-text-secondary'),
539
- transition: 'color 0.2s, border-color 0.2s',
888
+ paddingRight: getVar('--pc-spacing-sm'),
540
889
  }}
541
890
  >
542
- {t('myConsents')}
543
- <span style={{
544
- borderRadius: '9999px',
545
- backgroundColor: getVar('--pc-bg-tertiary'),
546
- padding: '0.125rem 0.5rem',
547
- fontSize: getVar('--pc-font-size-xs'),
548
- color: getVar('--pc-text-secondary'),
549
- }}>
550
- {directBadge}
551
- </span>
552
- </button>
553
- <button
554
- type="button"
555
- onClick={() => handleTabChange('nominated')}
891
+ <PCText
892
+ size="sm"
893
+ color="secondary"
894
+ className="pc-consent-view-toggle-label"
895
+ style={{ margin: 0, fontSize: '0.75rem', fontWeight: 500 }}
896
+ >
897
+ {t('consentVisibility')}
898
+ </PCText>
899
+ <div
900
+ className="pc-consent-view-toggle-group"
901
+ style={{
902
+ display: 'inline-flex',
903
+ borderRadius: '9999px',
904
+ backgroundColor: getVar('--pc-bg-secondary'),
905
+ padding: '0.125rem',
906
+ }}
907
+ >
908
+ <button
909
+ type="button"
910
+ className={`pc-consent-view-toggle-btn ${viewMode === 'product' ? 'is-active' : ''}`}
911
+ onClick={() => handleViewModeChange('product')}
912
+ style={{
913
+ minWidth: '5.25rem',
914
+ minHeight: '1.75rem',
915
+ padding: '0.3125rem 0.75rem',
916
+ border: 'none',
917
+ backgroundColor:
918
+ viewMode === 'product' ? getVar('--pc-primary') : 'transparent',
919
+ color:
920
+ viewMode === 'product'
921
+ ? getVar('--pc-text-inverse')
922
+ : getVar('--pc-text-secondary'),
923
+ fontSize: '0.6875rem',
924
+ fontWeight: 600,
925
+ cursor: 'pointer',
926
+ borderRadius: '9999px',
927
+ transition: 'background-color 0.2s ease, color 0.2s ease',
928
+ }}
929
+ >
930
+ {t('product')}
931
+ </button>
932
+ <button
933
+ type="button"
934
+ className={`pc-consent-view-toggle-btn ${viewMode === 'purpose' ? 'is-active' : ''}`}
935
+ onClick={() => handleViewModeChange('purpose')}
936
+ style={{
937
+ minWidth: '5.25rem',
938
+ minHeight: '1.75rem',
939
+ padding: '0.3125rem 0.75rem',
940
+ border: 'none',
941
+ backgroundColor:
942
+ viewMode === 'purpose' ? getVar('--pc-primary') : 'transparent',
943
+ color:
944
+ viewMode === 'purpose'
945
+ ? getVar('--pc-text-inverse')
946
+ : getVar('--pc-text-secondary'),
947
+ fontSize: '0.6875rem',
948
+ fontWeight: 600,
949
+ cursor: 'pointer',
950
+ borderRadius: '9999px',
951
+ transition: 'background-color 0.2s ease, color 0.2s ease',
952
+ }}
953
+ >
954
+ {t('purpose')}
955
+ </button>
956
+ </div>
957
+ </div>
958
+ </div>
959
+
960
+ {activeTab === 'nominated' && nominatorLabels.length > 0 && (
961
+ <PCText
962
+ size="sm"
963
+ color="secondary"
556
964
  style={{
557
- display: 'flex',
558
- alignItems: 'center',
559
- gap: getVar('--pc-spacing-sm'),
560
- padding: `${getVar('--pc-spacing-sm')} ${getVar('--pc-spacing-md')}`,
561
- fontSize: getVar('--pc-font-size-sm'),
562
- fontWeight: 600,
563
- background: 'none',
564
- border: 'none',
565
- cursor: 'pointer',
566
- borderBottom: activeTab === 'nominated' ? `2px solid ${getVar('--pc-primary')}` : '2px solid transparent',
567
- color: activeTab === 'nominated' ? getVar('--pc-primary') : getVar('--pc-text-secondary'),
568
- transition: 'color 0.2s, border-color 0.2s',
965
+ margin: 0,
966
+ marginBottom: getVar('--pc-spacing-sm'),
967
+ paddingLeft: getVar('--pc-spacing-lg'),
968
+ paddingRight: getVar('--pc-spacing-lg'),
569
969
  }}
570
970
  >
571
- {t('managing')}
572
- <span style={{
573
- borderRadius: '9999px',
574
- backgroundColor: getVar('--pc-bg-tertiary'),
575
- padding: '0.125rem 0.5rem',
576
- fontSize: getVar('--pc-font-size-xs'),
577
- color: getVar('--pc-text-secondary'),
578
- }}>
579
- {nominatedBadge}
971
+ {t('managingConsentsFor')}{' '}
972
+ <span style={{ fontWeight: 600, color: getVar('--pc-text-primary') }}>
973
+ {nominatorLabels.join(', ')}
580
974
  </span>
581
- </button>
582
- </div>
975
+ </PCText>
976
+ )}
583
977
 
584
978
  {/* Filters Section: Search on left, filters and export on the right */}
585
979
  <div style={{
586
980
  display: 'flex',
587
981
  flexDirection: 'column',
588
- gap: getVar('--pc-spacing-md'),
982
+ gap: getVar('--pc-spacing-sm'),
589
983
  width: '100%',
984
+ padding: '0.75rem 0 0.625rem',
590
985
  }} className="pc-filters-responsive">
591
986
  <div style={{ width: '100%' }} className="pc-search-responsive">
592
987
  <PCInput
593
988
  type="search"
989
+ size="sm"
594
990
  clearable
595
991
  clearAriaLabel={t('clearInput')}
596
- placeholder={t('search')}
992
+ placeholder={composedSearchPlaceholder}
597
993
  value={searchQuery}
598
994
  onClear={() => {
599
995
  setSearchQuery('');
600
996
  setCurrentPage(DEFAULT_PAGE);
601
997
  }}
602
- prefix={<Search size={18} strokeWidth={1.8} />}
998
+ prefix={<Search size={14} strokeWidth={1.8} />}
603
999
  onChange={(e) => {
604
1000
  setSearchQuery(e.target.value);
605
1001
  setCurrentPage(DEFAULT_PAGE);
@@ -613,57 +1009,127 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
613
1009
  <div style={{
614
1010
  display: 'flex',
615
1011
  flexDirection: 'column',
616
- gap: getVar('--pc-spacing-md'),
1012
+ gap: getVar('--pc-spacing-sm'),
617
1013
  width: '100%',
618
1014
  }} className="pc-filters-row-responsive">
619
1015
  <PCSelect
620
1016
  className="pc-filter-select"
1017
+ size="sm"
621
1018
  placeholder={t('status')}
622
- options={statusOptions}
623
- value={statusFilter}
1019
+ prefix={<Filter size={12} strokeWidth={1.8} />}
1020
+ options={statusFilterOptions}
1021
+ value={statusSelectValue}
624
1022
  onChange={handleStatusFilterChange}
625
- onClear={() => setStatusFilter(null)}
626
- clearable={statusFilter !== null}
627
- clearAriaLabel={t('clearSelection')}
628
- dropdownMatchTriggerWidth
1023
+ showSelectedCheck
629
1024
  style={{
630
1025
  width: '100%',
631
1026
  }}
632
1027
  />
633
1028
  <PCSelect
634
1029
  className="pc-filter-select"
1030
+ size="sm"
635
1031
  placeholder={t('product')}
636
- searchable
637
- searchPlaceholder={t('searchPlaceholder')}
638
- noOptionsMessage={t('noOptionsFound')}
639
- options={productOptions}
640
- value={productFilter}
1032
+ prefix={<Circle size={10} strokeWidth={2} />}
1033
+ options={productFilterOptions}
1034
+ value={productSelectValue}
641
1035
  onChange={handleProductFilterChange}
642
- onClear={() => setProductFilter(null)}
643
- clearable={productFilter !== null}
644
- clearAriaLabel={t('clearSelection')}
645
- dropdownMatchTriggerWidth
1036
+ showSelectedCheck
646
1037
  style={{
647
1038
  width: '100%',
648
1039
  }}
649
1040
  />
1041
+ <PCText
1042
+ size="sm"
1043
+ color="secondary"
1044
+ className="pc-filters-summary"
1045
+ style={{
1046
+ margin: 0,
1047
+ whiteSpace: 'nowrap',
1048
+ alignSelf: 'center',
1049
+ }}
1050
+ >
1051
+ {summaryText}
1052
+ </PCText>
650
1053
  <div className="pc-export-button-responsive">
651
1054
  <PCButton
1055
+ size="sm"
652
1056
  variant="outline"
653
1057
  onClick={() => setExportModal(true)}
654
1058
  style={{
655
1059
  whiteSpace: 'nowrap',
656
1060
  width: '100%',
657
- minHeight: '2.25rem',
658
- padding: '0.4375rem 0.875rem',
659
1061
  }}
660
1062
  >
661
- <ArrowUpToLine size={16} />
1063
+ <ArrowUpToLine size={13} />
662
1064
  <span>{t('export')}</span>
663
1065
  </PCButton>
664
1066
  </div>
665
1067
  </div>
666
1068
  </div>
1069
+ {isLoading ? (
1070
+ <ConsentManagerTableSkeleton />
1071
+ ) : (
1072
+ <>
1073
+ <div className="pc-consent-desktop-view">
1074
+ {viewMode === 'product' ? (
1075
+ <AdvancedTable
1076
+ table={productTable}
1077
+ variant={variant}
1078
+ classNames={groupedTableClassNames}
1079
+ isLoading={isLoading}
1080
+ expandedComponent={(row) => {
1081
+ const cardKey = getCardKey(row.original);
1082
+ return (
1083
+ <ConsentManagerExpandedRow
1084
+ group={row.original}
1085
+ onModify={handleOpenModal}
1086
+ extraPurposes={extraPurposesByKey[cardKey]}
1087
+ hasMore={hasMoreByKey[cardKey] ?? row.original.has_more_purposes}
1088
+ isLoadingMore={loadingMoreByKey[cardKey]}
1089
+ onLoadMore={handleLoadMorePurposes}
1090
+ />
1091
+ );
1092
+ }}
1093
+ />
1094
+ ) : (
1095
+ <AdvancedTable
1096
+ table={purposeTable}
1097
+ variant={variant}
1098
+ classNames={purposeTableClassNames}
1099
+ isLoading={isLoading}
1100
+ />
1101
+ )}
1102
+ </div>
1103
+
1104
+ <div className="pc-consent-mobile-view">
1105
+ {viewMode === 'product' ? (
1106
+ <ConsentManagerProductCards
1107
+ groups={consentGroups}
1108
+ onModify={handleOpenModal}
1109
+ extraPurposesByKey={extraPurposesByKey}
1110
+ hasMoreByKey={hasMoreByKey}
1111
+ loadingMoreByKey={loadingMoreByKey}
1112
+ onLoadMore={handleLoadMorePurposes}
1113
+ cardKeyFor={getCardKey}
1114
+ />
1115
+ ) : (
1116
+ <ConsentManagerPurposeCards
1117
+ rows={consentRows}
1118
+ onModify={handleOpenModal}
1119
+ />
1120
+ )}
1121
+ </div>
1122
+ </>
1123
+ )}
1124
+
1125
+ {/* Always show pagination, even during loading */}
1126
+ <div className="pc-consent-pagination-wrap">
1127
+ {viewMode === 'product' ? (
1128
+ <AdvancedTablePagination table={productTable} />
1129
+ ) : (
1130
+ <AdvancedTablePagination table={purposeTable} />
1131
+ )}
1132
+ </div>
667
1133
  </div>
668
1134
 
669
1135
  <PCModal
@@ -703,23 +1169,6 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
703
1169
  </div>
704
1170
  </PCModal>
705
1171
 
706
- {isLoading ? (
707
- <ConsentManagerTableSkeleton />
708
- ) : (
709
- <AdvancedTable table={table} variant={variant} isLoading={isLoading} />
710
- )}
711
-
712
- {/* Always show pagination, even during loading */}
713
- <div style={{
714
- ...pcStyles.flex,
715
- ...pcStyles.flexCol,
716
- ...pcStyles.itemsCenter,
717
- ...pcStyles.justifyBetween,
718
- ...pcStyles.gapSm,
719
- }} className="pc-flex-responsive">
720
- <AdvancedTablePagination table={table} />
721
- </div>
722
-
723
1172
  <ModifyConsentModal
724
1173
  isOpen={isModalOpen}
725
1174
  onClose={handleCloseModal}