feat(CC-068): add Exports page (JSON full + CSV per entity, GDPR Art.20 compliant)
This commit is contained in:
parent
afc73c8dc4
commit
f259585d57
1 changed files with 188 additions and 0 deletions
188
src/app/dashboard/reports/exports/page.tsx
Normal file
188
src/app/dashboard/reports/exports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue