482 lines
22 KiB
TypeScript
482 lines
22 KiB
TypeScript
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<DataSubjectRequestSummary[]>([]);
|
|
const [selected, setSelected] = useState<DataSubjectRequestDetail | null>(null);
|
|
const [creating, setCreating] = useState(false);
|
|
const [executing, setExecuting] = useState(false);
|
|
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
|
const [selectedActions, setSelectedActions] = useState<string[]>([]);
|
|
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<DataSubjectRequestDetail>,
|
|
message: string
|
|
): Promise<boolean> {
|
|
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<DataGridColumn<DataSubjectRequestSummary>[]>(() => [
|
|
{
|
|
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) => <div><strong>{row.reference}</strong><span className="muted block">{subjectLabel(row)}</span></div>
|
|
},
|
|
{ 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) => <StatusBadge status={statusTone(row.status)} label={row.status} /> },
|
|
{ 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) => <TableActionGroup actions={[{
|
|
id: "inspect",
|
|
label: I18N.inspect,
|
|
icon: <Eye size={16} />,
|
|
disabled: busy,
|
|
onClick: () => void inspect(row)
|
|
}]} />
|
|
}
|
|
], [busy]);
|
|
|
|
const recordColumns = useMemo<DataGridColumn<DataSubjectRecord>[]>(() => [
|
|
{ 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) => <div><strong>{row.title}</strong><span className="muted block">{row.resource_type}</span></div> },
|
|
{ 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 ? <StatusBadge status="warning" label={row.retention_reason || I18N.retainedEvidence} /> : <StatusBadge status="active" label={I18N.mutable} /> }
|
|
], []);
|
|
|
|
const actionColumns = useMemo<DataGridColumn<DataSubjectErasureAction>[]>(() => [
|
|
{
|
|
id: "select",
|
|
header: I18N.select,
|
|
width: 72,
|
|
render: (row) => <input
|
|
type="checkbox"
|
|
checked={selectedActions.includes(row.action_id)}
|
|
disabled={!row.executable || busy}
|
|
aria-label={`${I18N.select}: ${row.title}`}
|
|
onChange={(event) => 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) => <div><strong>{row.title}</strong><span className="muted block">{row.kind}</span></div> },
|
|
{ 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) => <StatusBadge status={row.executable ? (row.irreversible ? "warning" : "active") : "inactive"} label={row.executable ? (row.irreversible ? I18N.irreversible : I18N.executableState) : I18N.retainedReview} /> }
|
|
], [busy, selectedActions]);
|
|
|
|
const coverageGaps = selected?.request.coverage.modules_without_provider ?? [];
|
|
const actions = selected?.erasure_plan.actions ?? [];
|
|
const records = selected?.search.records ?? [];
|
|
|
|
return (
|
|
<>
|
|
<AdminPageLayout
|
|
title={I18N.title}
|
|
description={I18N.description}
|
|
loading={loading}
|
|
error={error}
|
|
success={success}
|
|
actions={<>
|
|
<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw size={16} /> {I18N.reload}</Button>
|
|
<AdminIconButton label={I18N.create} icon={<Plus />} variant="primary" onClick={() => setCreating(true)} disabled={!canManage || busy} />
|
|
</>}
|
|
>
|
|
<div className="admin-table-surface">
|
|
<DataGrid
|
|
id="admin-data-subject-requests"
|
|
rows={items}
|
|
columns={requestColumns}
|
|
initialFit="container"
|
|
getRowKey={(row) => row.id}
|
|
emptyText={I18N.noRequests}
|
|
/>
|
|
</div>
|
|
|
|
{selected ? <>
|
|
<div className="metric-grid">
|
|
<MetricCard label={I18N.records} value={selected.request.record_count} tone="info" />
|
|
<MetricCard label={I18N.executable} value={selected.request.executable_action_count} tone={selected.request.executable_action_count ? "warning" : "neutral"} />
|
|
<MetricCard label={I18N.uncovered} value={coverageGaps.length} tone={coverageGaps.length ? "warning" : "good"} />
|
|
</div>
|
|
|
|
<Card title={selected.request.reference}>
|
|
<dl className="admin-details-grid">
|
|
<div><dt>{I18N.subject}</dt><dd>{subjectLabel(selected.request)}</dd></div>
|
|
<div><dt>{I18N.kindLabel}</dt><dd>{selected.request.request_kind}</dd></div>
|
|
<div><dt>{I18N.statusLabel}</dt><dd><StatusBadge status={statusTone(selected.request.status)} label={selected.request.status} /></dd></div>
|
|
<div><dt>{I18N.due}</dt><dd>{selected.request.due_at ? formatDateTime(selected.request.due_at) : "-"}</dd></div>
|
|
<div><dt>{I18N.purpose}</dt><dd>{selected.request.purpose}</dd></div>
|
|
<div><dt>{I18N.legalBasis}</dt><dd>{selected.request.legal_basis || "-"}</dd></div>
|
|
</dl>
|
|
<div className="button-row">
|
|
<Button onClick={() => void mutate((request) => searchDataSubjectRequest(settings, request), I18N.searchComplete)} disabled={!canManage || busy}><Search size={16} /> {I18N.runSearch}</Button>
|
|
{selected.request.request_kind !== "access" && <Button onClick={() => void mutate((request) => planDataSubjectErasure(settings, request), I18N.planComplete)} disabled={!canManage || busy || !selected.search.searched_at}><ShieldCheck size={16} /> {I18N.plan}</Button>}
|
|
<Button onClick={() => void download()} disabled={!canExport || busy}><Download size={16} /> {I18N.export}</Button>
|
|
{actions.some((action) => action.executable) && <Button variant="danger" onClick={() => setExecuting(true)} disabled={!canErase || busy || !selectedActions.length}><Play size={16} /> {I18N.execute}</Button>}
|
|
</div>
|
|
</Card>
|
|
|
|
{coverageGaps.length > 0 && <DismissibleAlert tone="warning"><strong>{I18N.providerGap}</strong><br />{I18N.coverage}: {coverageGaps.join(", ")}</DismissibleAlert>}
|
|
|
|
<Card title={I18N.collectedRecords}>
|
|
<DataGrid id={`admin-dsar-records-${selected.request.id}`} rows={records} columns={recordColumns} initialFit="container" getRowKey={(row) => `${row.provider_id}:${row.resource_type}:${row.resource_id}`} emptyText={I18N.noSelection} />
|
|
</Card>
|
|
|
|
{actions.length > 0 && <Card title={I18N.erasurePlan}>
|
|
<DataGrid id={`admin-dsar-actions-${selected.request.id}`} rows={actions} columns={actionColumns} initialFit="container" getRowKey={(row) => row.action_id} emptyText={I18N.noSelection} />
|
|
</Card>}
|
|
</> : <DismissibleAlert tone="info">{I18N.noSelection}</DismissibleAlert>}
|
|
</AdminPageLayout>
|
|
|
|
<Dialog
|
|
open={creating}
|
|
title={I18N.createTitle}
|
|
className="admin-dialog admin-dialog-wide"
|
|
portal
|
|
closeDisabled={busy}
|
|
onClose={() => setCreating(false)}
|
|
footer={<><Button onClick={() => setCreating(false)} disabled={busy}>{I18N.cancel}</Button><Button variant="primary" onClick={() => void create()} disabled={busy || !draft.reference.trim() || !draft.purpose.trim()}>{I18N.save}</Button></>}
|
|
helpContextId="admin.privacy.data-subject-requests"
|
|
>
|
|
<div className="admin-form-grid two-columns">
|
|
<FormField label={I18N.reference}><input value={draft.reference} onChange={(event) => setDraft({ ...draft, reference: event.target.value })} /></FormField>
|
|
<FormField label={I18N.kindLabel}><select value={draft.requestKind} onChange={(event) => setDraft({ ...draft, requestKind: event.target.value as Draft["requestKind"] })}><option value="access">{I18N.accessRequest}</option><option value="erasure">{I18N.erasureRequest}</option><option value="access_and_erasure">{I18N.combinedRequest}</option></select></FormField>
|
|
<FormField label={I18N.email}><input type="email" value={draft.email} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
|
<FormField label={I18N.accountIdLabel}><input value={draft.accountId} onChange={(event) => setDraft({ ...draft, accountId: event.target.value })} /></FormField>
|
|
<FormField label={I18N.membershipIdLabel}><input value={draft.membershipId} onChange={(event) => setDraft({ ...draft, membershipId: event.target.value })} /></FormField>
|
|
<FormField label={I18N.identityIdLabel}><input value={draft.identityId} onChange={(event) => setDraft({ ...draft, identityId: event.target.value })} /></FormField>
|
|
<FormField label={I18N.externalReferences} help={I18N.externalReferencesHelp}><textarea rows={3} value={draft.externalReferences} onChange={(event) => setDraft({ ...draft, externalReferences: event.target.value })} placeholder="source.namespace=reference" /></FormField>
|
|
<FormField label={I18N.due}><input type="date" value={draft.dueDate} onChange={(event) => setDraft({ ...draft, dueDate: event.target.value })} /></FormField>
|
|
<FormField label={I18N.legalBasis}><input value={draft.legalBasis} onChange={(event) => setDraft({ ...draft, legalBasis: event.target.value })} /></FormField>
|
|
<FormField label={I18N.purpose}><textarea rows={3} value={draft.purpose} onChange={(event) => setDraft({ ...draft, purpose: event.target.value })} /></FormField>
|
|
<FormField label={I18N.notes}><textarea rows={3} value={draft.notes} onChange={(event) => setDraft({ ...draft, notes: event.target.value })} /></FormField>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={executing && Boolean(selected)}
|
|
title={I18N.executeTitle}
|
|
className="admin-dialog"
|
|
portal
|
|
role="alertdialog"
|
|
closeDisabled={busy}
|
|
onClose={() => setExecuting(false)}
|
|
footer={<><Button onClick={() => setExecuting(false)} disabled={busy}>{I18N.cancel}</Button><Button variant="danger" onClick={() => void execute()} disabled={busy || !selected || confirmation !== `ERASE ${selected.request.id}`}>{I18N.execute}</Button></>}
|
|
>
|
|
<DismissibleAlert tone="warning">{I18N.executeWarning}</DismissibleAlert>
|
|
<p>{selectedActions.length} / {actions.filter((action) => action.executable).length}</p>
|
|
<FormField label={I18N.confirmation} help={selected ? `${I18N.confirmationHelp}: ERASE ${selected.request.id}` : I18N.confirmationHelp}>
|
|
<input value={confirmation} onChange={(event) => setConfirmation(event.target.value)} autoComplete="off" />
|
|
</FormField>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function createPayload(draft: Draft): DataSubjectRequestCreatePayload {
|
|
return {
|
|
reference: draft.reference.trim(),
|
|
request_kind: draft.requestKind,
|
|
subject: {
|
|
email: optional(draft.email),
|
|
account_id: optional(draft.accountId),
|
|
membership_id: optional(draft.membershipId),
|
|
identity_id: optional(draft.identityId),
|
|
external_references: externalReferenceLines(draft.externalReferences)
|
|
},
|
|
purpose: draft.purpose.trim(),
|
|
legal_basis: optional(draft.legalBasis),
|
|
due_at: draft.dueDate ? new Date(`${draft.dueDate}T23:59:59`).toISOString() : null,
|
|
notes: optional(draft.notes)
|
|
};
|
|
}
|
|
|
|
function defaultActionSelection(detail: DataSubjectRequestDetail): string[] {
|
|
return (detail.erasure_plan.actions ?? []).filter((action) => action.executable).map((action) => action.action_id);
|
|
}
|
|
|
|
function subjectLabel(request: DataSubjectRequestSummary): string {
|
|
const subject = request.subject;
|
|
return subject.email || subject.membership_id || subject.identity_id || subject.account_id || Object.values(subject.external_references ?? {})[0] || "-";
|
|
}
|
|
|
|
function statusTone(value: string): string {
|
|
if (value === "completed") return "success";
|
|
if (value.includes("partial")) return "warning";
|
|
if (value === "draft") return "inactive";
|
|
return "active";
|
|
}
|
|
|
|
function optional(value: string): string | null {
|
|
const normalized = value.trim();
|
|
return normalized || null;
|
|
}
|
|
|
|
function validExternalReferenceLines(value: string): boolean {
|
|
return value.split(/\r?\n/).every((line) => {
|
|
if (!line.trim()) return true;
|
|
const separator = line.indexOf("=");
|
|
return separator > 0 && Boolean(line.slice(separator + 1).trim());
|
|
});
|
|
}
|
|
|
|
function externalReferenceLines(value: string): Record<string, string> {
|
|
return Object.fromEntries(value.split(/\r?\n/).flatMap((line) => {
|
|
const separator = line.indexOf("=");
|
|
if (separator <= 0) return [];
|
|
const key = line.slice(0, separator).trim();
|
|
const reference = line.slice(separator + 1).trim();
|
|
return key && reference ? [[key, reference]] : [];
|
|
}));
|
|
}
|