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 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 ( ) ;
2026-04-17 11:08:30 -04:00
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 >
2026-04-17 11:08:30 -04:00
< 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 } ` } / >
2026-04-17 11:08:30 -04:00
< 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 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 >
) ;
}