feat(CC-068): add Exports page (JSON full + CSV per entity, GDPR Art.20 compliant)

This commit is contained in:
admin-valentin 2026-08-02 12:18:10 +00:00
parent afc73c8dc4
commit f259585d57

View file

@ -0,0 +1,188 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
interface ExportData {
exportedAt: string; schema: string;
data: {
organizations?: unknown[];
tasks?: unknown[];
goals?: unknown[];
decisions?: unknown[];
transactions?: unknown[];
contacts?: unknown[];
risks?: unknown[];
projects?: unknown[];
contracts?: unknown[];
obligations?: unknown[];
};
counts: Record<string, number>;
}
function downloadJson(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function downloadCsv(rows: string[][], filename: string) {
const bom = '';
const csv = bom + rows.map((r) => r.map((c) => `"${String(c ?? '').replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
const TODAY = new Date().toISOString().slice(0, 10);
const EXPORT_SETS: Array<{
key: string; label: string; icon: string; desc: string;
csvExtract?: (d: ExportData) => { headers: string[]; rows: string[][] };
}> = [
{
key: 'full', label: 'Export complet (JSON)', icon: '📦', desc: 'Toate datele platformei în format JSON — Art. 20 GDPR',
csvExtract: undefined,
},
{
key: 'transactions', label: 'Tranzacții (CSV)', icon: '💳', desc: 'Toate tranzacțiile cu status dovezi — pentru contabil',
csvExtract: (d) => ({
headers: ['Data', 'Descriere', 'Sumă', 'Monedă', 'Partener', 'Categorie', 'Status dovezi', 'URL dovadă'],
rows: (d.data.transactions ?? []).map((t: any) => [
t.transactionDate ?? '', t.description ?? '', (parseFloat(t.amountMinorUnits ?? '0') / 100).toFixed(2),
t.currency ?? '', t.counterparty ?? '', t.category ?? '',
t.evidenceStatus ?? '', t.evidenceUrl ?? '',
]),
}),
},
{
key: 'contacts', label: 'Contacte (CSV)', icon: '👥', desc: 'Registru CRM — pentru campanii sau mail merge',
csvExtract: (d) => ({
headers: ['Nume', 'Email', 'Telefon', 'Rol', 'Consimțământ', 'Note'],
rows: (d.data.contacts ?? []).map((c: any) => [
c.fullName ?? '', c.email ?? '', c.phone ?? '', c.role ?? '',
c.consentStatus ?? '', c.notes ?? '',
]),
}),
},
{
key: 'risks', label: 'Riscuri (CSV)', icon: '⚠', desc: 'Registru riscuri — pentru audit sau raport board',
csvExtract: (d) => ({
headers: ['Titlu', 'Categorie', 'Probabilitate', 'Impact', 'Status', 'Responsabil', 'Data revizuire'],
rows: (d.data.risks ?? []).map((r: any) => [
r.title ?? '', r.category ?? '', r.probability ?? '', r.impact ?? '',
r.status ?? '', r.owner ?? '', r.reviewDueAt ?? '',
]),
}),
},
{
key: 'decisions', label: 'Decizii (CSV)', icon: '🎯', desc: 'Registru decizii strategice — jurnal executiv',
csvExtract: (d) => ({
headers: ['Context', 'Opțiune selectată', 'Status', 'Data'],
rows: (d.data.decisions ?? []).map((dec: any) => [
(dec.context ?? '').slice(0, 100), dec.selectedOption ?? '', dec.status ?? '',
dec.createdAt ? new Date(dec.createdAt).toLocaleDateString('ro-RO') : '',
]),
}),
},
];
export default function ExportsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [loading, setLoading] = useState<string | null>(null);
const [lastExport, setLastExport] = useState<string | null>(null);
const { data: exportData, isLoading: fetching, refetch } = useQuery({
queryKey: ['export-preview', tenantId],
queryFn: () => apiFetch<ExportData>('/v1/export/data', { tenantId }),
enabled: Boolean(tenantId), staleTime: 300_000,
});
const handleExport = async (key: string) => {
setLoading(key);
try {
const data = exportData ?? await refetch().then((r) => r.data!);
if (!data) return;
if (key === 'full') {
downloadJson(data, `ceo-os-export-${TODAY}.json`);
} else {
const set = EXPORT_SETS.find((s) => s.key === key);
if (!set?.csvExtract) return;
const { headers, rows } = set.csvExtract(data);
downloadCsv([headers, ...rows], `ceo-os-${key}-${TODAY}.csv`);
}
setLastExport(key);
} finally {
setLoading(null);
}
};
const counts = exportData?.counts ?? {};
return (
<div className="max-w-3xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Exporturi</h1>
<p className="text-sm text-ink-faint mt-1">
Descarcă datele platformei CEO OS. Exporturile sunt generate în timp real.
</p>
</div>
{/* Entity counts */}
{exportData && (
<div className="card p-4">
<p className="text-xs font-semibold text-ink-faint mb-3 uppercase tracking-wider">Rezumat date</p>
<div className="grid grid-cols-3 gap-x-6 gap-y-1.5 sm:grid-cols-4">
{Object.entries(counts).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span className="text-xs text-ink-faint capitalize">{k}</span>
<span className="text-xs font-mono font-medium text-ink">{v}</span>
</div>
))}
</div>
</div>
)}
{/* Export cards */}
<div className="space-y-3">
{EXPORT_SETS.map(({ key, label, icon, desc }) => {
const isLoading = loading === key;
const isDone = lastExport === key;
return (
<div key={key} className="card p-5 flex items-center gap-4">
<span className="text-2xl shrink-0">{icon}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink">{label}</p>
<p className="text-xs text-ink-faint mt-0.5">{desc}</p>
</div>
<button
onClick={() => handleExport(key)}
disabled={isLoading || fetching}
className={`shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
isDone ? 'bg-signal-ok/10 text-signal-ok border border-signal-ok/20'
: 'btn btn-primary'
} disabled:opacity-50`}
>
{isLoading ? '⏳ Generare…' : isDone ? '✓ Descărcat' : '↓ Descarcă'}
</button>
</div>
);
})}
</div>
<div className="rounded-lg border border-border/50 bg-muted/30 p-4">
<p className="text-xs text-ink-faint leading-relaxed">
<strong className="text-ink"> Conformitate GDPR:</strong> Exportul complet JSON respectă dreptul la portabilitate (Art. 20 GDPR).
Datele sunt generate în timp real și nu sunt stocate pe server.
Exporturile CSV sunt compatibile cu Excel (UTF-8 BOM inclus).
</p>
</div>
</div>
);
}