333 lines
18 KiB
JavaScript
333 lines
18 KiB
JavaScript
import React, { useMemo, useState } from "react";
|
||
import { motion } from "framer-motion";
|
||
import {
|
||
Bar,
|
||
BarChart,
|
||
CartesianGrid,
|
||
PolarAngleAxis,
|
||
PolarGrid,
|
||
PolarRadiusAxis,
|
||
Radar,
|
||
RadarChart,
|
||
ResponsiveContainer,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
} from "recharts";
|
||
import {
|
||
CalendarDays,
|
||
ClipboardCheck,
|
||
Clock3,
|
||
Eye,
|
||
FileText,
|
||
Gauge,
|
||
KanbanSquare,
|
||
Layers3,
|
||
Plus,
|
||
ShieldAlert,
|
||
Sparkles,
|
||
Target,
|
||
} from "lucide-react";
|
||
import { useSoc2Registry } from "./features/hooks/useSoc2Registry.js";
|
||
import { OFFICIAL_SCHEMA } from "./model/schema.js";
|
||
import { INITIAL_DB } from "./model/seed.js";
|
||
import { buildRegistryManifests, validateRegistry } from "./lib/validation.js";
|
||
import {
|
||
buildBarData,
|
||
buildBlockers,
|
||
buildDataHealth,
|
||
buildEvidenceMap,
|
||
buildPackReadiness,
|
||
buildRadarData,
|
||
buildScoredPillars,
|
||
computeGlobalScore,
|
||
filterEvidence,
|
||
getOverallStatus,
|
||
getPackTone,
|
||
getOwners,
|
||
} from "./lib/selectors.js";
|
||
import { daysUntil } from "./lib/utils.js";
|
||
import { PILLAR_ICONS } from "./lib/view.js";
|
||
import PillarOverviewCard from "./components/PillarOverviewCard.jsx";
|
||
import MilestoneCard from "./components/MilestoneCard.jsx";
|
||
import EvidenceLibrary from "./components/EvidenceLibrary.jsx";
|
||
import EvidenceEditor from "./components/EvidenceEditor.jsx";
|
||
import DataTab from "./components/DataTab.jsx";
|
||
import { ProgressBar, ScorePill, SectionTitle, Tag } from "./components/ui/primitives.jsx";
|
||
|
||
function DashboardPanel({ scoredPillars, targetReadiness, overall, selectedPillar, setSelectedPillar }) {
|
||
const radarData = buildRadarData(scoredPillars, targetReadiness);
|
||
|
||
return (
|
||
<>
|
||
<div className="grid dashboard-top gap-20">
|
||
<div className="panel">
|
||
<SectionTitle icon={Gauge} title="Radar de conformité" subtitle="Vue synthèse des 5 piliers. La cible interne est fixée à 85%." />
|
||
<div className="chart-area">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<RadarChart data={radarData} outerRadius="72%">
|
||
<PolarGrid stroke="rgba(255,255,255,0.12)" />
|
||
<PolarAngleAxis dataKey="subject" tick={{ fill: "#cbd5e1", fontSize: 13 }} />
|
||
<PolarRadiusAxis domain={[0, 100]} tick={{ fill: "#64748b", fontSize: 11 }} />
|
||
<Radar name="Cible" dataKey="target" stroke="#cbd5e1" fill="#cbd5e1" fillOpacity={0.07} />
|
||
<Radar name="Actuel" dataKey="score" stroke="#60a5fa" fill="#60a5fa" fillOpacity={0.45} />
|
||
<Tooltip />
|
||
</RadarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
<div className="panel">
|
||
<SectionTitle icon={Sparkles} title="Lecture utile du radar" subtitle="Le radar sert au pilotage. Il n’est pas une preuve à lui seul." />
|
||
<div className="stack mt-16">
|
||
<div className="subpanel"><div className="card-title-xs">Logique de score</div><div className="muted mt-8">Absent = 0, Conçu = 0,35, En opération = 0,7, Prouvé = 1. Les preuves bonifient la crédibilité du score.</div></div>
|
||
<div className="subpanel"><div className="card-title-xs">Règle de plancher</div><div className="muted mt-8">Tant que Security reste sous 60%, l’état global doit être considéré fragile.</div></div>
|
||
<div className={`subpanel ${overall.tone}`}><div className="card-title-xs">État actuel</div><div className="mt-8">{overall.label}</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="grid pillars-grid gap-20 mt-20">
|
||
{scoredPillars.map((pillar) => (
|
||
<PillarOverviewCard key={pillar.id} pillar={pillar} selected={selectedPillar === pillar.id} onSelect={setSelectedPillar} />
|
||
))}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function App() {
|
||
const registry = useSoc2Registry();
|
||
const { db, lastSavedAt, importError, syncMode, syncStatus, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry;
|
||
|
||
const [activeTab, setActiveTab] = useState("dashboard");
|
||
const [selectedPillar, setSelectedPillar] = useState("security");
|
||
const [selectedEvidenceId, setSelectedEvidenceId] = useState(db.evidence[0]?.id || "");
|
||
const [search, setSearch] = useState("");
|
||
const [evidenceFilter, setEvidenceFilter] = useState("all");
|
||
const [ownerFilter, setOwnerFilter] = useState("all");
|
||
const [auditorMode, setAuditorMode] = useState(false);
|
||
const [importText, setImportText] = useState("");
|
||
|
||
const evidenceMap = useMemo(() => buildEvidenceMap(db), [db]);
|
||
const scoredPillars = useMemo(() => buildScoredPillars(db, evidenceMap), [db, evidenceMap]);
|
||
const selected = scoredPillars.find((pillar) => pillar.id === selectedPillar) || scoredPillars[0];
|
||
const selectedEvidence = db.evidence.find((item) => item.id === selectedEvidenceId) || db.evidence[0] || null;
|
||
const owners = useMemo(() => getOwners(db), [db]);
|
||
const globalScore = useMemo(() => computeGlobalScore(db.pillars, evidenceMap), [db, evidenceMap]);
|
||
const overall = getOverallStatus(globalScore);
|
||
const auditWindowDays = daysUntil(db.appContext.observationWindow.end);
|
||
const blockers = useMemo(() => buildBlockers(db, evidenceMap), [db, evidenceMap]);
|
||
const packReadiness = useMemo(() => buildPackReadiness(db), [db]);
|
||
const dataHealth = useMemo(() => buildDataHealth(db), [db]);
|
||
const validation = useMemo(() => validateRegistry(db), [db]);
|
||
const manifests = useMemo(() => buildRegistryManifests(db), [db]);
|
||
|
||
const filteredEvidence = useMemo(
|
||
() => filterEvidence(db, { query: search, pillarId: evidenceFilter, owner: ownerFilter, auditorMode }),
|
||
[db, search, evidenceFilter, ownerFilter, auditorMode]
|
||
);
|
||
|
||
const barData = useMemo(() => buildBarData(selected, evidenceMap), [selected, evidenceMap]);
|
||
const readyEvidenceCount = db.evidence.filter((item) => item.auditorReady).length;
|
||
const readinessRate = db.evidence.length ? Math.round((readyEvidenceCount / db.evidence.length) * 1000) / 10 : 0;
|
||
const blocker = scoredPillars.find((pillar) => pillar.id === "security" && pillar.score < 60);
|
||
|
||
const handleCreateEvidence = () => {
|
||
const id = createEvidence(selectedPillar);
|
||
setSelectedEvidenceId(id);
|
||
setActiveTab("evidence");
|
||
};
|
||
|
||
const handleDeleteEvidence = (evidenceId) => {
|
||
deleteEvidence(evidenceId);
|
||
const fallback = db.evidence.find((item) => item.id !== evidenceId);
|
||
setSelectedEvidenceId(fallback?.id || "");
|
||
};
|
||
|
||
const handleImport = () => {
|
||
const result = importDb(importText);
|
||
if (result.ok) {
|
||
setSelectedPillar(result.data.pillars[0]?.id || "security");
|
||
setSelectedEvidenceId(result.data.evidence[0]?.id || "");
|
||
setImportText("");
|
||
}
|
||
};
|
||
|
||
const handleReset = () => {
|
||
resetDemo();
|
||
setSelectedPillar("security");
|
||
setSelectedEvidenceId(INITIAL_DB.evidence[0]?.id || "");
|
||
};
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<div className="app-container">
|
||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.45 }} className="hero panel">
|
||
<div className="grid hero-grid gap-20">
|
||
<div>
|
||
<div className="row wrap gap-8">
|
||
<Tag tone="info">{db.appContext.orgName} • {db.appContext.programName}</Tag>
|
||
<Tag tone={overall.tone}>{overall.label}</Tag>
|
||
{blocker ? <Tag tone="is-bad">Blocage: Security < 60</Tag> : null}
|
||
</div>
|
||
<h1 className="hero-title">Assistant web de conformité SOC 2 orienté preuve, remédiation et exposition auditeur</h1>
|
||
<p className="hero-subtitle">Architecture modulaire, source de vérité versionnable, backend API optionnel, radar des 5 piliers, bibliothèque de preuves et salle auditeur.</p>
|
||
<div className="row wrap gap-8 mt-16">
|
||
<button className="btn btn-primary"><Eye size={16} /> Ouvrir la salle auditeur</button>
|
||
<button className="btn btn-secondary" onClick={handleCreateEvidence}><Plus size={16} /> Nouvelle preuve</button>
|
||
<button className="btn btn-secondary" onClick={() => setAuditorMode((v) => !v)}><ClipboardCheck size={16} /> {auditorMode ? "Mode interne" : "Mode auditeur"}</button>
|
||
</div>
|
||
</div>
|
||
<div className="grid cols-2 gap-12">
|
||
<ScorePill label="Conformité pondérée" value={`${globalScore}%`} sublabel="Score global des 5 piliers" />
|
||
<ScorePill label="Preuves auditables" value={`${readinessRate}%`} sublabel={`${readyEvidenceCount}/${db.evidence.length} exposables aux auditeurs`} />
|
||
<ScorePill label="Fenêtre Type II" value={`${auditWindowDays} j`} sublabel={`${db.appContext.observationWindow.start} → ${db.appContext.observationWindow.end}`} />
|
||
<ScorePill label="Dernière sauvegarde" value={lastSavedAt || "—"} sublabel={`Mode ${syncMode} • état ${syncStatus}`} />
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
|
||
<div className="tabs panel mt-20">
|
||
{["dashboard", "controls", "execution", "evidence", "auditor", "data"].map((tab) => (
|
||
<button key={tab} className={`tab ${activeTab === tab ? "active" : ""}`} onClick={() => setActiveTab(tab)}>{tab}</button>
|
||
))}
|
||
</div>
|
||
|
||
{activeTab === "dashboard" && (
|
||
<DashboardPanel scoredPillars={scoredPillars} targetReadiness={db.appContext.observationWindow.targetReadiness} overall={overall} selectedPillar={selectedPillar} setSelectedPillar={setSelectedPillar} />
|
||
)}
|
||
|
||
{activeTab === "controls" && (
|
||
<div className="grid controls-grid gap-20">
|
||
<div className="panel">
|
||
<SectionTitle icon={PILLAR_ICONS[selected.id] || Layers3} title={`Pilier sélectionné : ${selected.name}`} subtitle={selected.objective} />
|
||
<div className="grid cols-3 gap-12 mt-16">
|
||
<ScorePill label="Score" value={`${selected.score}%`} sublabel="Maturité pondérée du pilier" />
|
||
<ScorePill label="Poids global" value={`${selected.globalWeight}%`} sublabel="Charge relative dans l’ensemble" />
|
||
<ScorePill label="Preuves" value={`${selected.evidenceCoverage}%`} sublabel="Couverture actuelle des éléments probants" />
|
||
</div>
|
||
<div className="chart-area chart-area-sm mt-16">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<BarChart data={barData} margin={{ top: 10, right: 10, left: -10, bottom: 40 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||
<XAxis dataKey="name" angle={-20} textAnchor="end" interval={0} height={80} tick={{ fill: "#cbd5e1", fontSize: 11 }} />
|
||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
||
<Tooltip />
|
||
<Bar dataKey="score" radius={[8, 8, 0, 0]} fill="#60a5fa" />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
<div className="panel panel-tall">
|
||
<SectionTitle icon={KanbanSquare} title="Jalons détaillés" subtitle="Chaque jalon combine état d’implantation, cadence, propriétaire, échéance et chaîne de preuve." />
|
||
<div className="scroll-area mt-16">
|
||
<div className="stack">
|
||
{selected.milestones.map((milestone) => (
|
||
<MilestoneCard key={milestone.id} pillarId={selected.id} milestone={milestone} evidenceMap={evidenceMap} linkedEvidence={db.evidence.filter((item) => milestone.evidenceRefs.includes(item.id))} onStageChange={updateMilestoneStage} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "execution" && (
|
||
<div className="grid execution-grid gap-20">
|
||
<div className="panel">
|
||
<SectionTitle icon={Target} title="Écarts et priorités" subtitle="Ce qui freine réellement la préparation Type II, trié par urgence et maturité." />
|
||
<div className="stack mt-16">
|
||
{blockers.map((item) => (
|
||
<div key={`${item.pillarId}-${item.id}`} className="subpanel">
|
||
<div className="row space-between align-start gap-12">
|
||
<div>
|
||
<div className="tiny muted">{item.pillarName}</div>
|
||
<div className="card-title-sm mt-6">{item.title}</div>
|
||
</div>
|
||
<Tag tone={item.dueInDays < 0 ? "is-bad" : item.dueInDays <= 7 ? "is-warn" : "is-muted"}>{item.dueInDays < 0 ? `Retard ${Math.abs(item.dueInDays)} j` : `${item.dueInDays} j`}</Tag>
|
||
</div>
|
||
<div className="muted mt-8">{item.nextAction}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="panel">
|
||
<SectionTitle icon={CalendarDays} title="Cadence opérationnelle" subtitle="Vue synthèse du travail récurrent nécessaire pour faire vivre les contrôles et garder la preuve à jour." />
|
||
<div className="grid cols-3 gap-12 mt-16">
|
||
{db.pillars.flatMap((pillar) =>
|
||
pillar.milestones.map((milestone) => (
|
||
<div key={milestone.id} className="subpanel">
|
||
<div className="row space-between align-start gap-8"><Tag>{pillar.name}</Tag><Tag>{milestone.stage}</Tag></div>
|
||
<div className="card-title-sm mt-8">{milestone.title}</div>
|
||
<div className="muted mt-8">{milestone.owner} • {milestone.cadence}</div>
|
||
<div className="muted mt-8">Échéance: {milestone.dueAt}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "evidence" && (
|
||
<div className="grid evidence-grid gap-20">
|
||
<EvidenceLibrary
|
||
evidence={filteredEvidence}
|
||
selectedEvidenceId={selectedEvidenceId}
|
||
onSelect={setSelectedEvidenceId}
|
||
filters={{ search, setSearch, evidenceFilter, setEvidenceFilter, ownerFilter, setOwnerFilter }}
|
||
owners={owners}
|
||
pillars={db.pillars}
|
||
onCreate={handleCreateEvidence}
|
||
/>
|
||
<EvidenceEditor evidence={selectedEvidence} owners={owners} pillars={db.pillars} onUpdate={updateEvidence} onDelete={handleDeleteEvidence} onToggleLink={toggleEvidenceMilestoneLink} />
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "auditor" && (
|
||
<div className="grid auditor-grid gap-20">
|
||
<div className="panel">
|
||
<SectionTitle icon={FileText} title="Dossier auditeur" subtitle="Paquets structurés, lisibles, avec degré de préparation calculé." />
|
||
<div className="stack mt-16">
|
||
{packReadiness.map((pack) => (
|
||
<div key={pack.id} className="subpanel">
|
||
<div className="row space-between align-start gap-12"><div><div className="card-title-sm">{pack.title}</div><div className="muted mt-8">{pack.description}</div></div><Tag tone={getPackTone(pack.status)}>{pack.status}</Tag></div>
|
||
<div className="row space-between tiny muted mt-12"><span>Degré de préparation</span><span>{pack.readiness}%</span></div>
|
||
<ProgressBar value={pack.readiness} />
|
||
<div className="row wrap gap-8 mt-12">{pack.linked.map((item) => <Tag key={item.id}>{item.title}</Tag>)}</div>
|
||
</div>
|
||
))}
|
||
<div className="subpanel is-info"><div className="row gap-8 align-center"><Clock3 size={16} /><strong>Mode {auditorMode ? "auditeur" : "interne"}</strong></div><div className="mt-8">{auditorMode ? "Affichage réduit aux preuves auditables." : "Affichage complet incluant brouillons et remédiation."}</div></div>
|
||
</div>
|
||
</div>
|
||
<div className="panel">
|
||
<SectionTitle icon={Layers3} title="Matériel exposé à l’auditeur" subtitle="Vue condensée du narratif d’audit : description du système, preuves, écarts connus et actions correctives." />
|
||
<div className="stack mt-16">
|
||
<div className="subpanel"><div className="card-title-xs">Description du système</div><div className="muted mt-8">Service in-scope, architecture, dépendances, responsabilités, flux de données, outils de surveillance, changements et posture de reprise.</div></div>
|
||
<div className="subpanel"><div className="card-title-xs">Chaîne de preuve</div><div className="muted mt-8">Chaque jalon significatif doit être relié à des éléments probants datés, intelligibles et contextualisés, avec propriétaire et fraîcheur connue.</div></div>
|
||
<div className="subpanel"><div className="card-title-xs">Écarts et remédiation</div><div className="muted mt-8">Les trous de conformité ne sont pas cachés. Ils sont nommés, qualifiés, suivis et accompagnés d’une action corrective explicite.</div></div>
|
||
<div className="subpanel good"><div className="row gap-8 align-center"><ShieldAlert size={16} /><strong>Narratif recommandé</strong></div><div className="mt-8">Présenter d’abord le périmètre, puis les contrôles communs, ensuite les preuves les plus parlantes, et finir par les exceptions connues avec leurs remédiations.</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "data" && (
|
||
<DataTab
|
||
db={db}
|
||
owners={owners}
|
||
dataHealth={dataHealth}
|
||
importText={importText}
|
||
setImportText={setImportText}
|
||
importError={importError}
|
||
onImport={handleImport}
|
||
onExport={exportDb}
|
||
onCopy={copyDb}
|
||
onReset={handleReset}
|
||
validation={validation}
|
||
manifests={manifests}
|
||
schema={OFFICIAL_SCHEMA}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|