import { useEffect, useMemo, useState } from "react"; import { Download, Eye, Play, Plus, RefreshCw, Search, ShieldCheck } from "lucide-react"; import type { ApiSettings, DataGridColumn } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, Button, Card, DataGrid, Dialog, DismissibleAlert, FormField, MetricCard, StatusBadge, TableActionGroup, adminErrorMessage, formatDateTime } from "@govoplan/core-webui"; import { createDataSubjectRequest, executeDataSubjectErasure, exportDataSubjectRequest, fetchDataSubjectRequest, fetchDataSubjectRequests, planDataSubjectErasure, searchDataSubjectRequest, type DataSubjectErasureAction, type DataSubjectRecord, type DataSubjectRequestCreatePayload, type DataSubjectRequestDetail, type DataSubjectRequestSummary } from "../../api/admin"; const I18N = { title: "i18n:govoplan-admin.data_subject_requests.ds001", description: "i18n:govoplan-admin.data_subject_requests.ds002", create: "i18n:govoplan-admin.data_subject_requests.ds003", reload: "i18n:govoplan-admin.data_subject_requests.ds004", reference: "i18n:govoplan-admin.data_subject_requests.ds005", kindLabel: "i18n:govoplan-admin.data_subject_requests.ds006", statusLabel: "i18n:govoplan-admin.data_subject_requests.ds007", subject: "i18n:govoplan-admin.data_subject_requests.ds008", due: "i18n:govoplan-admin.data_subject_requests.ds009", actions: "i18n:govoplan-admin.data_subject_requests.ds010", inspect: "i18n:govoplan-admin.data_subject_requests.ds011", noRequests: "i18n:govoplan-admin.data_subject_requests.ds012", records: "i18n:govoplan-admin.data_subject_requests.ds013", executable: "i18n:govoplan-admin.data_subject_requests.ds014", uncovered: "i18n:govoplan-admin.data_subject_requests.ds015", runSearch: "i18n:govoplan-admin.data_subject_requests.ds016", plan: "i18n:govoplan-admin.data_subject_requests.ds017", export: "i18n:govoplan-admin.data_subject_requests.ds018", execute: "i18n:govoplan-admin.data_subject_requests.ds019", collectedRecords: "i18n:govoplan-admin.data_subject_requests.ds020", erasurePlan: "i18n:govoplan-admin.data_subject_requests.ds021", coverage: "i18n:govoplan-admin.data_subject_requests.ds022", noSelection: "i18n:govoplan-admin.data_subject_requests.ds023", provider: "i18n:govoplan-admin.data_subject_requests.ds024", category: "i18n:govoplan-admin.data_subject_requests.ds025", evidence: "i18n:govoplan-admin.data_subject_requests.ds026", decision: "i18n:govoplan-admin.data_subject_requests.ds027", rationale: "i18n:govoplan-admin.data_subject_requests.ds028", select: "i18n:govoplan-admin.data_subject_requests.ds029", createTitle: "i18n:govoplan-admin.data_subject_requests.ds030", purpose: "i18n:govoplan-admin.data_subject_requests.ds031", legalBasis: "i18n:govoplan-admin.data_subject_requests.ds032", email: "i18n:govoplan-admin.data_subject_requests.ds033", accountIdLabel: "i18n:govoplan-admin.data_subject_requests.ds034", membershipIdLabel: "i18n:govoplan-admin.data_subject_requests.ds035", identityIdLabel: "i18n:govoplan-admin.data_subject_requests.ds036", notes: "i18n:govoplan-admin.data_subject_requests.ds037", save: "i18n:govoplan-admin.data_subject_requests.ds038", cancel: "i18n:govoplan-admin.data_subject_requests.ds039", executeTitle: "i18n:govoplan-admin.data_subject_requests.ds040", executeWarning: "i18n:govoplan-admin.data_subject_requests.ds041", confirmation: "i18n:govoplan-admin.data_subject_requests.ds042", confirmationHelp: "i18n:govoplan-admin.data_subject_requests.ds043", missingSelector: "i18n:govoplan-admin.data_subject_requests.ds044", providerGap: "i18n:govoplan-admin.data_subject_requests.ds045", created: "i18n:govoplan-admin.data_subject_requests.ds046", searchComplete: "i18n:govoplan-admin.data_subject_requests.ds047", planComplete: "i18n:govoplan-admin.data_subject_requests.ds048", executionComplete: "i18n:govoplan-admin.data_subject_requests.ds049", exportComplete: "i18n:govoplan-admin.data_subject_requests.ds050", accessRequest: "i18n:govoplan-admin.data_subject_requests.ds051", erasureRequest: "i18n:govoplan-admin.data_subject_requests.ds052", combinedRequest: "i18n:govoplan-admin.data_subject_requests.ds053", mutable: "i18n:govoplan-admin.data_subject_requests.ds054", retainedEvidence: "i18n:govoplan-admin.data_subject_requests.ds055", irreversible: "i18n:govoplan-admin.data_subject_requests.ds056", executableState: "i18n:govoplan-admin.data_subject_requests.ds057", retainedReview: "i18n:govoplan-admin.data_subject_requests.ds058", externalReferences: "i18n:govoplan-admin.data_subject_requests.ds059", externalReferencesHelp: "i18n:govoplan-admin.data_subject_requests.ds060", invalidExternalReferences: "i18n:govoplan-admin.data_subject_requests.ds061" } as const; type Draft = { reference: string; requestKind: DataSubjectRequestSummary["request_kind"]; email: string; accountId: string; membershipId: string; identityId: string; externalReferences: string; purpose: string; legalBasis: string; dueDate: string; notes: string; }; const emptyDraft: Draft = { reference: "", requestKind: "access_and_erasure", email: "", accountId: "", membershipId: "", identityId: "", externalReferences: "", purpose: "", legalBasis: "", dueDate: "", notes: "" }; export default function DataSubjectRequestsPanel({ settings, canManage, canExport, canErase }: { settings: ApiSettings; canManage: boolean; canExport: boolean; canErase: boolean; }) { const [items, setItems] = useState([]); const [selected, setSelected] = useState(null); const [creating, setCreating] = useState(false); const [executing, setExecuting] = useState(false); const [draft, setDraft] = useState(emptyDraft); const [selectedActions, setSelectedActions] = useState([]); const [confirmation, setConfirmation] = useState(""); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); async function load(preferredId?: string) { setLoading(true); setError(""); try { const next = await fetchDataSubjectRequests(settings); setItems(next); const requestId = preferredId ?? selected?.request.id; if (requestId && next.some((item) => item.id === requestId)) { setSelected(await fetchDataSubjectRequest(settings, requestId)); } else if (selected && !next.some((item) => item.id === selected.request.id)) { setSelected(null); } } catch (err) { setError(adminErrorMessage(err)); } finally { setLoading(false); } } useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]); async function inspect(item: DataSubjectRequestSummary) { setBusy(true); setError(""); try { const detail = await fetchDataSubjectRequest(settings, item.id); setSelected(detail); setSelectedActions(defaultActionSelection(detail)); } catch (err) { setError(adminErrorMessage(err)); } finally { setBusy(false); } } async function create() { if (![draft.email, draft.accountId, draft.membershipId, draft.identityId, draft.externalReferences].some((value) => value.trim())) { setError(I18N.missingSelector); return; } if (!validExternalReferenceLines(draft.externalReferences)) { setError(I18N.invalidExternalReferences); return; } setBusy(true); setError(""); try { const detail = await createDataSubjectRequest(settings, createPayload(draft)); setCreating(false); setDraft(emptyDraft); setSelected(detail); setSuccess(I18N.created); await load(detail.request.id); } catch (err) { setError(adminErrorMessage(err)); } finally { setBusy(false); } } async function mutate( operation: (request: DataSubjectRequestSummary) => Promise, message: string ): Promise { if (!selected) return false; setBusy(true); setError(""); setSuccess(""); try { const detail = await operation(selected.request); setSelected(detail); setSelectedActions(defaultActionSelection(detail)); setSuccess(message); await load(detail.request.id); return true; } catch (err) { setError(adminErrorMessage(err)); return false; } finally { setBusy(false); } } async function download() { if (!selected) return; setBusy(true); setError(""); try { await exportDataSubjectRequest(settings, selected.request); setSuccess(I18N.exportComplete); } catch (err) { setError(adminErrorMessage(err)); } finally { setBusy(false); } } async function execute() { if (!selected) return; const succeeded = await mutate( (request) => executeDataSubjectErasure(settings, request, selectedActions, confirmation), I18N.executionComplete ); if (succeeded) { setExecuting(false); setConfirmation(""); } } const requestColumns = useMemo[]>(() => [ { id: "reference", header: I18N.reference, width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => `${row.reference} ${subjectLabel(row)}`, render: (row) =>
{row.reference}{subjectLabel(row)}
}, { id: "kind", header: I18N.kindLabel, width: 170, sortable: true, filterable: true, value: (row) => row.request_kind }, { id: "status", header: I18N.statusLabel, width: 150, sortable: true, filterable: true, value: (row) => row.status, render: (row) => }, { id: "due", header: I18N.due, width: 170, sortable: true, value: (row) => row.due_at ?? "", render: (row) => row.due_at ? formatDateTime(row.due_at) : "-" }, { id: "actions", header: I18N.actions, width: 72, sticky: "end", align: "right", render: (row) => , disabled: busy, onClick: () => void inspect(row) }]} /> } ], [busy]); const recordColumns = useMemo[]>(() => [ { id: "title", header: I18N.records, width: "minmax(220px, 1.3fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (row) => `${row.title} ${row.resource_type}`, render: (row) =>
{row.title}{row.resource_type}
}, { id: "provider", header: I18N.provider, width: 140, sortable: true, filterable: true, value: (row) => row.module_id }, { id: "category", header: I18N.category, width: 170, sortable: true, filterable: true, value: (row) => row.category }, { id: "evidence", header: I18N.evidence, width: 190, filterable: true, value: (row) => row.immutable_evidence ? row.retention_reason || "retained" : "mutable", render: (row) => row.immutable_evidence ? : } ], []); const actionColumns = useMemo[]>(() => [ { id: "select", header: I18N.select, width: 72, render: (row) => setSelectedActions((current) => event.target.checked ? [...current, row.action_id] : current.filter((id) => id !== row.action_id))} /> }, { id: "action", header: I18N.decision, width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (row) => `${row.title} ${row.kind}`, render: (row) =>
{row.title}{row.kind}
}, { id: "provider", header: I18N.provider, width: 140, sortable: true, filterable: true, value: (row) => row.module_id }, { id: "rationale", header: I18N.rationale, width: "minmax(260px, 1.4fr)", minWidth: 220, resizable: true, value: (row) => row.rationale }, { id: "state", header: I18N.statusLabel, width: 150, value: (row) => row.executable ? "executable" : row.kind, render: (row) => } ], [busy, selectedActions]); const coverageGaps = selected?.request.coverage.modules_without_provider ?? []; const actions = selected?.erasure_plan.actions ?? []; const records = selected?.search.records ?? []; return ( <> } variant="primary" onClick={() => setCreating(true)} disabled={!canManage || busy} /> } >
row.id} emptyText={I18N.noRequests} />
{selected ? <>
{I18N.subject}
{subjectLabel(selected.request)}
{I18N.kindLabel}
{selected.request.request_kind}
{I18N.statusLabel}
{I18N.due}
{selected.request.due_at ? formatDateTime(selected.request.due_at) : "-"}
{I18N.purpose}
{selected.request.purpose}
{I18N.legalBasis}
{selected.request.legal_basis || "-"}
{selected.request.request_kind !== "access" && } {actions.some((action) => action.executable) && }
{coverageGaps.length > 0 && {I18N.providerGap}
{I18N.coverage}: {coverageGaps.join(", ")}
} `${row.provider_id}:${row.resource_type}:${row.resource_id}`} emptyText={I18N.noSelection} /> {actions.length > 0 && row.action_id} emptyText={I18N.noSelection} /> } : {I18N.noSelection}}
setCreating(false)} footer={<>} helpContextId="admin.privacy.data-subject-requests" >
setDraft({ ...draft, reference: event.target.value })} /> setDraft({ ...draft, email: event.target.value })} /> setDraft({ ...draft, accountId: event.target.value })} /> setDraft({ ...draft, membershipId: event.target.value })} /> setDraft({ ...draft, identityId: event.target.value })} />