@redacto.io/consent-sdk-react 3.4.0 → 3.6.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 (155) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +176 -0
  3. package/dist/index.d.mts +51 -4
  4. package/dist/index.d.ts +51 -4
  5. package/dist/index.js +1912 -345
  6. package/dist/index.mjs +1911 -345
  7. package/dist/privacy-center.d.mts +566 -0
  8. package/dist/privacy-center.d.ts +566 -0
  9. package/dist/privacy-center.js +17174 -0
  10. package/dist/privacy-center.mjs +17136 -0
  11. package/package.json +47 -3
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +201 -118
  13. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +1194 -93
  14. package/src/RedactoNoticeConsent/api/index.ts +337 -246
  15. package/src/RedactoNoticeConsent/api/types.ts +140 -41
  16. package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +602 -0
  17. package/src/RedactoNoticeConsent/components/index.ts +7 -0
  18. package/src/RedactoNoticeConsent/injectStyles.ts +11 -0
  19. package/src/RedactoNoticeConsent/language-codes.ts +61 -0
  20. package/src/RedactoNoticeConsent/services/storage.ts +122 -0
  21. package/src/RedactoNoticeConsent/types.ts +2 -0
  22. package/src/RedactoNoticeConsentInline/api/index.ts +26 -64
  23. package/src/RedactoNoticeConsentInline/api/types.ts +0 -25
  24. package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +19 -0
  25. package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +17 -0
  26. package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +79 -0
  27. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +27 -0
  28. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +142 -0
  29. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +51 -0
  30. package/src/RedactoPrivacyCenter/api/actions.ts +444 -0
  31. package/src/RedactoPrivacyCenter/api/client.ts +413 -0
  32. package/src/RedactoPrivacyCenter/api/config.ts +33 -0
  33. package/src/RedactoPrivacyCenter/api/fetcher.ts +181 -0
  34. package/src/RedactoPrivacyCenter/api/upload.ts +93 -0
  35. package/src/RedactoPrivacyCenter/assets/character.svg +31 -0
  36. package/src/RedactoPrivacyCenter/assets/fileAttach.svg +9 -0
  37. package/src/RedactoPrivacyCenter/assets/greenTick.svg +4 -0
  38. package/src/RedactoPrivacyCenter/assets/mail.svg +3 -0
  39. package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +9 -0
  40. package/src/RedactoPrivacyCenter/assets/sentImg.svg +9 -0
  41. package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +9 -0
  42. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +272 -0
  43. package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +468 -0
  44. package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +433 -0
  45. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +726 -0
  46. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +239 -0
  47. package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +7 -0
  48. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +155 -0
  49. package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +189 -0
  50. package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +118 -0
  51. package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +66 -0
  52. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +305 -0
  53. package/src/RedactoPrivacyCenter/components/Form.tsx +1455 -0
  54. package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +176 -0
  55. package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +330 -0
  56. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +329 -0
  57. package/src/RedactoPrivacyCenter/components/Pagination.tsx +140 -0
  58. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +302 -0
  59. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +218 -0
  60. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +475 -0
  61. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +412 -0
  62. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +42 -0
  63. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +1 -0
  64. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +40 -0
  65. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +38 -0
  66. package/src/RedactoPrivacyCenter/components/ThemeTransitionOverlay.tsx +85 -0
  67. package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +141 -0
  68. package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +179 -0
  69. package/src/RedactoPrivacyCenter/components/constant.ts +20 -0
  70. package/src/RedactoPrivacyCenter/components/types.ts +68 -0
  71. package/src/RedactoPrivacyCenter/components/utils.tsx +10 -0
  72. package/src/RedactoPrivacyCenter/config/routes.ts +9 -0
  73. package/src/RedactoPrivacyCenter/config/table.ts +25 -0
  74. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +123 -0
  75. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +44 -0
  76. package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +11 -0
  77. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +64 -0
  78. package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +30 -0
  79. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +60 -0
  80. package/src/RedactoPrivacyCenter/context/theme.tsx +127 -0
  81. package/src/RedactoPrivacyCenter/lib/api.ts +468 -0
  82. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +63 -0
  83. package/src/RedactoPrivacyCenter/lib/constants.ts +116 -0
  84. package/src/RedactoPrivacyCenter/lib/i18n.ts +81 -0
  85. package/src/RedactoPrivacyCenter/lib/locales.ts +105 -0
  86. package/src/RedactoPrivacyCenter/lib/queries.ts +30 -0
  87. package/src/RedactoPrivacyCenter/lib/types.ts +346 -0
  88. package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +37 -0
  89. package/src/RedactoPrivacyCenter/lib/utils.ts +625 -0
  90. package/src/RedactoPrivacyCenter/locales/as/translation.json +188 -0
  91. package/src/RedactoPrivacyCenter/locales/bho/translation.json +188 -0
  92. package/src/RedactoPrivacyCenter/locales/bn/translation.json +188 -0
  93. package/src/RedactoPrivacyCenter/locales/brx/translation.json +188 -0
  94. package/src/RedactoPrivacyCenter/locales/doi/translation.json +188 -0
  95. package/src/RedactoPrivacyCenter/locales/en/translation.json +188 -0
  96. package/src/RedactoPrivacyCenter/locales/gom/translation.json +188 -0
  97. package/src/RedactoPrivacyCenter/locales/gu/translation.json +188 -0
  98. package/src/RedactoPrivacyCenter/locales/hi/translation.json +188 -0
  99. package/src/RedactoPrivacyCenter/locales/kn/translation.json +188 -0
  100. package/src/RedactoPrivacyCenter/locales/ks/translation.json +188 -0
  101. package/src/RedactoPrivacyCenter/locales/mai/translation.json +188 -0
  102. package/src/RedactoPrivacyCenter/locales/ml/translation.json +188 -0
  103. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +188 -0
  104. package/src/RedactoPrivacyCenter/locales/mr/translation.json +188 -0
  105. package/src/RedactoPrivacyCenter/locales/ne/translation.json +188 -0
  106. package/src/RedactoPrivacyCenter/locales/or/translation.json +188 -0
  107. package/src/RedactoPrivacyCenter/locales/pa/translation.json +188 -0
  108. package/src/RedactoPrivacyCenter/locales/sa/translation.json +188 -0
  109. package/src/RedactoPrivacyCenter/locales/sat/translation.json +188 -0
  110. package/src/RedactoPrivacyCenter/locales/sd/translation.json +188 -0
  111. package/src/RedactoPrivacyCenter/locales/ta/translation.json +188 -0
  112. package/src/RedactoPrivacyCenter/locales/te/translation.json +188 -0
  113. package/src/RedactoPrivacyCenter/locales/ur/translation.json +188 -0
  114. package/src/RedactoPrivacyCenter/styles/base.css +117 -0
  115. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +1000 -0
  116. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +819 -0
  117. package/src/RedactoPrivacyCenter/styles/responsive.ts +138 -0
  118. package/src/RedactoPrivacyCenter/styles/variables.css +133 -0
  119. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +365 -0
  120. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +479 -0
  121. package/src/RedactoPrivacyCenter/table/index.ts +2 -0
  122. package/src/RedactoPrivacyCenter/types/images.d.ts +29 -0
  123. package/src/RedactoPrivacyCenter/ui/Footer.tsx +73 -0
  124. package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +54 -0
  125. package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +87 -0
  126. package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +47 -0
  127. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +69 -0
  128. package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +48 -0
  129. package/src/RedactoPrivacyCenter/ui/PCButton.tsx +77 -0
  130. package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +55 -0
  131. package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +97 -0
  132. package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +35 -0
  133. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +128 -0
  134. package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +30 -0
  135. package/src/RedactoPrivacyCenter/ui/PCModal.tsx +104 -0
  136. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +175 -0
  137. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +381 -0
  138. package/src/RedactoPrivacyCenter/ui/PCTable.tsx +137 -0
  139. package/src/RedactoPrivacyCenter/ui/PCText.tsx +39 -0
  140. package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +43 -0
  141. package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +37 -0
  142. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +119 -0
  143. package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +25 -0
  144. package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +55 -0
  145. package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +70 -0
  146. package/src/RedactoPrivacyCenter/ui/index.ts +18 -0
  147. package/src/RedactoPrivacyCenter/utils/cn.ts +28 -0
  148. package/src/RedactoPrivacyCenter/utils/date.ts +48 -0
  149. package/src/RedactoPrivacyCenter/utils/status.ts +111 -0
  150. package/src/RedactoPrivacyCenter/utils/toast.ts +130 -0
  151. package/src/index.ts +10 -0
  152. package/src/privacy-center.ts +107 -0
  153. package/tsconfig.json +2 -0
  154. package/tsup.config.ts +16 -1
  155. package/src/utils/isLegacyApi.ts +0 -12
@@ -0,0 +1,726 @@
1
+ import { useState, useEffect, useCallback, useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { PCInput, PCSelect, PCButton, PCText, PCModal, type SelectOption } from '../ui';
4
+ import { Search, ArrowUpToLine } from 'lucide-react';
5
+ import { pcStyles } from '../styles/pcStyles';
6
+ import { AdvancedTable, AdvancedTablePagination } from '../table';
7
+ import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
8
+ import {
9
+ fetchUserConsents,
10
+ manageConsent,
11
+ } from '../lib/api';
12
+ import { useOrganization } from '../context/OrganizationContext';
13
+ import { useAuth } from '../context/AuthContext';
14
+ import { useBaseUrl } from '../context/BaseUrlContext';
15
+ import type { UserConsent, UserConsentDetail, ProductConsentHistoryGroup, ConsentAction } from '../lib/types';
16
+ import { useCurrentLanguage } from '../lib/useCurrentLanguage';
17
+ import { exportTableToCSV, useStatusOptions } from '../lib/consent-manager/utils';
18
+ import { toast } from '../utils/toast';
19
+ import { ConsentManagerTableSkeleton } from './ConsentManagerTableSkeleton';
20
+ import { ModifyConsentModal } from './ModifyConsentModal';
21
+ import { useConsentManagerColumns } from './ConsentManagerColumns';
22
+ import type {
23
+ ConsentManagerProps,
24
+ StatusFilterOption,
25
+ ProductFilterOption,
26
+ } from './types';
27
+ import {
28
+ SEARCH_DEBOUNCE_DELAY,
29
+ DEFAULT_PAGE,
30
+ DEFAULT_PAGE_SIZE,
31
+ } from './ConsentManagerConstants';
32
+
33
+ export default function ConsentManager({ variant }: ConsentManagerProps) {
34
+ const { t } = useTranslation();
35
+ const statusOptions = useStatusOptions();
36
+ const { organization } = useOrganization();
37
+ const { accessToken, refreshToken, onError, updateTokens } = useAuth();
38
+ const { consentClient } = useBaseUrl();
39
+ const organizationId = organization?.uuid;
40
+ const workspaceId = organization?.workspaces[0]?.uuid;
41
+ const contact = sessionStorage.getItem('email') || '';
42
+
43
+ // Create auth context object for API calls - memoized to prevent infinite loops
44
+ const authContext = useMemo(() => ({
45
+ accessToken,
46
+ refreshToken,
47
+ onError,
48
+ updateTokens,
49
+ }), [accessToken, refreshToken, onError, updateTokens]);
50
+
51
+ // Track language changes for localized API calls
52
+ const { langParam } = useCurrentLanguage();
53
+
54
+ // State management
55
+ const [activeTab, setActiveTab] = useState<'direct' | 'nominated'>('direct');
56
+ const [consentDetail, setConsentDetail] = useState<UserConsentDetail | null>(null);
57
+ const [consentData, setConsentData] = useState<UserConsent[]>([]);
58
+ const [totalCount, setTotalCount] = useState(0);
59
+ const [isLoading, setIsLoading] = useState(true);
60
+ const [searchQuery, setSearchQuery] = useState('');
61
+ const [debouncedSearch, setDebouncedSearch] = useState('');
62
+ const [statusFilter, setStatusFilter] = useState<StatusFilterOption | null>(
63
+ null
64
+ );
65
+ const [productFilter, setProductFilter] =
66
+ useState<ProductFilterOption | null>(null);
67
+ const [productOptions, setProductOptions] = useState<ProductFilterOption[]>(
68
+ []
69
+ );
70
+ const [isModalOpen, setIsModalOpen] = useState(false);
71
+ const [selectedRecord, setSelectedRecord] = useState<UserConsent | null>(
72
+ null
73
+ );
74
+ const [exportModal, setExportModal] = useState(false);
75
+ const [isManageLoading, setIsManageLoading] = useState(false);
76
+ const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
77
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
78
+
79
+ // Sync selected status filter label when language changes
80
+ useEffect(() => {
81
+ if (statusFilter) {
82
+ const updated = statusOptions.find(opt => opt.value === statusFilter.value);
83
+ if (updated && updated.label !== statusFilter.label) {
84
+ setStatusFilter(updated);
85
+ }
86
+ }
87
+ }, [statusOptions, statusFilter]);
88
+
89
+ // Sync selected product filter label when language changes
90
+ useEffect(() => {
91
+ if (productFilter) {
92
+ const updated = productOptions.find(opt => opt.value === productFilter.value);
93
+ if (updated && updated.label !== productFilter.label) {
94
+ setProductFilter(updated);
95
+ }
96
+ }
97
+ }, [productOptions, productFilter]);
98
+
99
+ // Debounce search query
100
+ useEffect(() => {
101
+ const timer = setTimeout(() => {
102
+ setDebouncedSearch(searchQuery);
103
+ }, SEARCH_DEBOUNCE_DELAY);
104
+ return () => clearTimeout(timer);
105
+ }, [searchQuery]);
106
+
107
+ const handleOpenModal = useCallback((record: UserConsent) => {
108
+ setSelectedRecord(record);
109
+ setIsModalOpen(true);
110
+ }, []);
111
+
112
+ const handleCloseModal = useCallback(() => {
113
+ setIsModalOpen(false);
114
+ setSelectedRecord(null);
115
+ }, []);
116
+
117
+ const flattenProductGroups = useCallback(
118
+ (groups: ProductConsentHistoryGroup[] | undefined): UserConsent[] => {
119
+ if (!groups || groups.length === 0) return [];
120
+ return groups.flatMap((group) =>
121
+ (group.purposes || []).map((purpose) => ({
122
+ ...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,
126
+ }))
127
+ );
128
+ },
129
+ []
130
+ );
131
+
132
+ const filterConsentRows = useCallback(
133
+ (
134
+ rows: UserConsent[],
135
+ search: string,
136
+ statusValue?: string,
137
+ productUuid?: string
138
+ ) => {
139
+ const normalizedSearch = search.trim().toLowerCase();
140
+
141
+ return rows.filter((row) => {
142
+ const matchesSearch =
143
+ !normalizedSearch ||
144
+ [
145
+ row.purpose,
146
+ row.purpose_description,
147
+ row.product_name,
148
+ row.product_description,
149
+ row.method,
150
+ row.status,
151
+ ]
152
+ .filter(Boolean)
153
+ .some((value) =>
154
+ String(value).toLowerCase().includes(normalizedSearch)
155
+ );
156
+
157
+ const matchesStatus =
158
+ !statusValue || row.status?.toLowerCase() === statusValue.toLowerCase();
159
+
160
+ const matchesProduct =
161
+ !productUuid || row.product_uuid === productUuid;
162
+
163
+ return matchesSearch && matchesStatus && matchesProduct;
164
+ });
165
+ },
166
+ []
167
+ );
168
+
169
+ const buildProductOptionsFromGroups = useCallback(
170
+ (groups: ProductConsentHistoryGroup[] | undefined) => {
171
+ if (!groups || groups.length === 0) return [];
172
+ const productMap = new Map<string, { label: string; value: string }>();
173
+ groups.forEach((group) => {
174
+ if (!productMap.has(group.product_uuid)) {
175
+ productMap.set(group.product_uuid, {
176
+ label: group.product_name,
177
+ value: group.product_uuid,
178
+ });
179
+ }
180
+ });
181
+ return Array.from(productMap.values());
182
+ },
183
+ []
184
+ );
185
+
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
+ },
201
+ []
202
+ );
203
+
204
+ const columns = useConsentManagerColumns({
205
+ onOpenModal: handleOpenModal,
206
+ });
207
+
208
+ // Single data fetch function
209
+ const fetchData = useCallback(async () => {
210
+ if (!organizationId || !workspaceId || !contact) {
211
+ return;
212
+ }
213
+
214
+ setIsLoading(true);
215
+ try {
216
+ const offset = (currentPage - 1) * pageSize;
217
+ const searchParam = debouncedSearch.trim() || undefined;
218
+ const statusParam = statusFilter?.value;
219
+ const productUuidParam = productFilter?.value;
220
+
221
+ const response = await fetchUserConsents(
222
+ organizationId,
223
+ workspaceId,
224
+ contact,
225
+ offset,
226
+ pageSize,
227
+ searchParam,
228
+ statusParam,
229
+ productUuidParam,
230
+ authContext,
231
+ consentClient,
232
+ langParam
233
+ );
234
+
235
+ if (!response) {
236
+ throw new Error('Failed to fetch consent data');
237
+ }
238
+
239
+ setConsentDetail(response);
240
+
241
+ const directGroups = response?.direct_product_groups;
242
+ const nominatedGroups = getNominatedProductGroups(response);
243
+
244
+ const selectedGroups =
245
+ activeTab === 'direct' ? directGroups : nominatedGroups;
246
+ const flattenedRows = flattenProductGroups(selectedGroups);
247
+ const filteredRows = filterConsentRows(
248
+ flattenedRows,
249
+ debouncedSearch,
250
+ statusParam,
251
+ productUuidParam
252
+ );
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
+ );
266
+
267
+ // Extract unique products from all groups
268
+ const combinedGroups = [
269
+ ...(response?.direct_product_groups || []),
270
+ ...nominatedGroups,
271
+ ];
272
+ setProductOptions(buildProductOptionsFromGroups(combinedGroups));
273
+ } catch (error) {
274
+ console.error(error);
275
+ setConsentDetail(null);
276
+ setConsentData([]);
277
+ setTotalCount(0);
278
+ setProductOptions([]);
279
+ toast.error(t('failedToFetchConsentData'));
280
+ } finally {
281
+ setIsLoading(false);
282
+ }
283
+ }, [
284
+ organizationId,
285
+ workspaceId,
286
+ contact,
287
+ currentPage,
288
+ pageSize,
289
+ debouncedSearch,
290
+ statusFilter?.value,
291
+ productFilter?.value,
292
+ activeTab,
293
+ authContext,
294
+ consentClient,
295
+ langParam,
296
+ flattenProductGroups,
297
+ filterConsentRows,
298
+ buildProductOptionsFromGroups,
299
+ getNominatedProductGroups,
300
+ t,
301
+ ]);
302
+
303
+ // Single effect for data fetching
304
+ useEffect(() => {
305
+ fetchData();
306
+ }, [fetchData]);
307
+
308
+ const table = useReactTable<UserConsent>({
309
+ data: consentData,
310
+ columns: columns,
311
+ getCoreRowModel: getCoreRowModel(),
312
+ manualPagination: true,
313
+ pageCount: Math.ceil(totalCount / pageSize) || 0,
314
+ state: {
315
+ pagination: {
316
+ pageIndex: currentPage - 1,
317
+ pageSize: pageSize,
318
+ },
319
+ },
320
+ 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);
330
+ },
331
+ });
332
+
333
+ const handleStatusFilterChange = (data: SelectOption | null) => {
334
+ setStatusFilter(data as StatusFilterOption | null);
335
+ setCurrentPage(DEFAULT_PAGE);
336
+ };
337
+
338
+ const handleProductFilterChange = (data: SelectOption | null) => {
339
+ setProductFilter(data as ProductFilterOption | null);
340
+ setCurrentPage(DEFAULT_PAGE);
341
+ };
342
+
343
+ const handleTabChange = (tab: 'direct' | 'nominated') => {
344
+ setActiveTab(tab);
345
+ setCurrentPage(DEFAULT_PAGE);
346
+ };
347
+
348
+ 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
+ });
354
+ exportTableToCSV(value);
355
+ setExportModal(false);
356
+ }, [table]);
357
+
358
+ const handleManageConsent = useCallback(
359
+ async (
360
+ purposeId: string,
361
+ action: ConsentAction,
362
+ nominatorContact?: string,
363
+ dataElementUuids?: string[]
364
+ ) => {
365
+ if (!organizationId || !workspaceId || !contact) {
366
+ toast.error(t('missingInfoToManage'));
367
+ return;
368
+ }
369
+
370
+ setIsManageLoading(true);
371
+
372
+ const successMessages: Record<ConsentAction, string> = {
373
+ revoke: t('consentRevokedSuccess'),
374
+ regrant: t('consentRegrantedSuccess'),
375
+ renew: t('consentRenewedSuccess'),
376
+ };
377
+
378
+ const errorMessages: Record<ConsentAction, string> = {
379
+ revoke: t('unableToRevokeConsent'),
380
+ regrant: t('unableToRegrantConsent'),
381
+ renew: t('unableToRenewConsent'),
382
+ };
383
+
384
+ try {
385
+ const response = await manageConsent({
386
+ organizationId,
387
+ workspaceId,
388
+ purposeId,
389
+ contact,
390
+ action,
391
+ authContext,
392
+ consentClient,
393
+ nominatorContact,
394
+ dataElementUuids,
395
+ });
396
+
397
+ if (response) {
398
+ toast.success(successMessages[action]);
399
+ await fetchData();
400
+ } else {
401
+ toast.error(errorMessages[action]);
402
+ }
403
+ } catch (error: any) {
404
+ console.error(`Error ${action} consent:`, error);
405
+ if (error?.code === 'NOMINATOR_NOT_FOUND') {
406
+ toast.error(t('nominatorNotFound'));
407
+ } else if (error?.code === 'NOMINEE_MUST_SPECIFY_NOMINATOR') {
408
+ toast.error(t('nomineeMustSpecifyNominator'));
409
+ } else {
410
+ toast.error(errorMessages[action]);
411
+ }
412
+ } finally {
413
+ setIsManageLoading(false);
414
+ handleCloseModal();
415
+ }
416
+ },
417
+ [organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient]
418
+ );
419
+
420
+ const getVar = (varName: string): string => `var(${varName})`;
421
+
422
+ const directBadge =
423
+ consentDetail?.status_summary?.direct_purposes ??
424
+ (activeTab === 'direct' ? totalCount : '');
425
+
426
+ 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
+ }
446
+
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
+ };
501
+
502
+ return (
503
+ <div style={{ width: '100%' }}>
504
+ {/* User Relationship Strip */}
505
+ {renderUserRelationshipStrip()}
506
+
507
+ <div
508
+ 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'),
515
+ }}
516
+ >
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')}
527
+ style={{
528
+ display: 'flex',
529
+ alignItems: 'center',
530
+ 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',
540
+ }}
541
+ >
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')}
556
+ 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',
569
+ }}
570
+ >
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}
580
+ </span>
581
+ </button>
582
+ </div>
583
+
584
+ {/* Filters Section: Search on left, filters and export on the right */}
585
+ <div style={{
586
+ display: 'flex',
587
+ flexDirection: 'column',
588
+ gap: getVar('--pc-spacing-md'),
589
+ width: '100%',
590
+ }} className="pc-filters-responsive">
591
+ <div style={{ width: '100%' }} className="pc-search-responsive">
592
+ <PCInput
593
+ type="search"
594
+ clearable
595
+ placeholder={t('search')}
596
+ value={searchQuery}
597
+ onClear={() => {
598
+ setSearchQuery('');
599
+ setCurrentPage(DEFAULT_PAGE);
600
+ }}
601
+ prefix={<Search size={18} strokeWidth={1.8} />}
602
+ onChange={(e) => {
603
+ setSearchQuery(e.target.value);
604
+ setCurrentPage(DEFAULT_PAGE);
605
+ }}
606
+ style={{
607
+ width: '100%',
608
+ }}
609
+ />
610
+ </div>
611
+
612
+ <div style={{
613
+ display: 'flex',
614
+ flexDirection: 'column',
615
+ gap: getVar('--pc-spacing-md'),
616
+ width: '100%',
617
+ }} className="pc-filters-row-responsive">
618
+ <PCSelect
619
+ className="pc-filter-select"
620
+ placeholder={t('status')}
621
+ options={statusOptions}
622
+ value={statusFilter}
623
+ onChange={handleStatusFilterChange}
624
+ onClear={() => setStatusFilter(null)}
625
+ clearable={statusFilter !== null}
626
+ dropdownMatchTriggerWidth
627
+ style={{
628
+ width: '100%',
629
+ }}
630
+ />
631
+ <PCSelect
632
+ className="pc-filter-select"
633
+ placeholder={t('product')}
634
+ searchable
635
+ options={productOptions}
636
+ value={productFilter}
637
+ onChange={handleProductFilterChange}
638
+ onClear={() => setProductFilter(null)}
639
+ clearable={productFilter !== null}
640
+ dropdownMatchTriggerWidth
641
+ style={{
642
+ width: '100%',
643
+ }}
644
+ />
645
+ <div className="pc-export-button-responsive">
646
+ <PCButton
647
+ variant="outline"
648
+ onClick={() => setExportModal(true)}
649
+ style={{
650
+ whiteSpace: 'nowrap',
651
+ width: '100%',
652
+ minHeight: '2.25rem',
653
+ padding: '0.4375rem 0.875rem',
654
+ }}
655
+ >
656
+ <ArrowUpToLine size={16} />
657
+ <span>{t('export')}</span>
658
+ </PCButton>
659
+ </div>
660
+ </div>
661
+ </div>
662
+ </div>
663
+
664
+ <PCModal
665
+ isOpen={exportModal}
666
+ onClose={() => setExportModal(false)}
667
+ size="sm"
668
+ title={
669
+ <div style={{ minWidth: 0, flex: 1, paddingRight: getVar('--pc-spacing-sm') }}>
670
+ <PCText size="base" weight="semibold" style={{ margin: 0, color: getVar('--pc-text-primary') }}>
671
+ {t('exportConsentData')}
672
+ </PCText>
673
+ <PCText
674
+ size="xs"
675
+ color="secondary"
676
+ style={{ marginTop: pcStyles.mtXs.marginTop, marginBottom: 0, lineHeight: 1.5 }}
677
+ >
678
+ {t('exportConsentDataSubtitle')}
679
+ </PCText>
680
+ </div>
681
+ }
682
+ footer={
683
+ <div className="pc-modal-footer-actions">
684
+ <PCButton variant="outline" onClick={() => setExportModal(false)}>
685
+ {t('cancel')}
686
+ </PCButton>
687
+ <PCButton variant="primary" onClick={handleExport}>
688
+ {t('export')}
689
+ </PCButton>
690
+ </div>
691
+ }
692
+ >
693
+ <div style={{ ...pcStyles.flex, ...pcStyles.flexCol, gap: pcStyles.gapSm.gap }}>
694
+ <PCText size="sm" color="secondary" style={{ margin: 0, lineHeight: 1.6 }}>
695
+ {t('exportingIncludesFilters')}
696
+ </PCText>
697
+ </div>
698
+ </PCModal>
699
+
700
+ {isLoading ? (
701
+ <ConsentManagerTableSkeleton />
702
+ ) : (
703
+ <AdvancedTable table={table} variant={variant} isLoading={isLoading} />
704
+ )}
705
+
706
+ {/* Always show pagination, even during loading */}
707
+ <div style={{
708
+ ...pcStyles.flex,
709
+ ...pcStyles.flexCol,
710
+ ...pcStyles.itemsCenter,
711
+ ...pcStyles.justifyBetween,
712
+ ...pcStyles.gapSm,
713
+ }} className="pc-flex-responsive">
714
+ <AdvancedTablePagination table={table} />
715
+ </div>
716
+
717
+ <ModifyConsentModal
718
+ isOpen={isModalOpen}
719
+ onClose={handleCloseModal}
720
+ selectedRecord={selectedRecord}
721
+ isLoading={isManageLoading}
722
+ onManageConsent={handleManageConsent}
723
+ />
724
+ </div>
725
+ );
726
+ }