soc2-cockpit/src/App.jsx

334 lines
18 KiB
React
Raw Normal View History

2026-04-17 10:17:56 -04:00
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 nest 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;
2026-04-17 10:17:56 -04:00
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 &lt; 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>
2026-04-17 10:17:56 -04:00
<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}`} />
2026-04-17 10:17:56 -04:00
</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 lensemble" />
<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 dimplantation, 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é à lauditeur" subtitle="Vue condensée du narratif daudit : 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 dune 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 dabord 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>
);
}