feat(rapport): Sprint 3.6b — RapportPage enrichie, exercices dynamiques, production modèle, sélecteur NCLC

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-22 20:14:38 +03:00
parent 8390e8b873
commit f51caa1b75
22 changed files with 1357 additions and 297 deletions

View file

@ -1,10 +1,15 @@
/**
* Page de rapport de correction.
* Page de rapport de correction Sprint 3.6b.
*
* Sections toujours visibles : score /20, NCLC, feedback_court.
* Sections toujours visibles : score + jauge, revelation, diagnostic, conseil_nclc.
* Sections conditionnelles via isSectionVisible(plan, section) :
* detailed_report criteres, erreurs
* tips modele, idees, exercices
* detailed_report criteres (avec exemple/suggestion/astuce/codes)
* tips exercices, modele
*
* Les exercices et la production modèle peuvent être dans l'état `pending`
* (jobs fire-and-forget côté backend cf. correctionController 3.6a) ou
* `error` : JobStatusFallback affiche le message approprié (refresh manuel
* uniquement ; polling traqué en FTD-24).
*
* SEC-05 : textes IA rendus via react-markdown, jamais dangerouslySetInnerHTML.
* Règle D : isSectionVisible() obligatoire jamais if (plan === 'xxx').
@ -12,16 +17,27 @@
*/
import { useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { Lock } from 'lucide-react'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { isSectionVisible } from '@/entities/report/lib'
import {
isSectionVisible,
groupErreursByCritere,
critereCodeFromNom,
} from '@/entities/report/lib'
import type { Report } from '@/entities/report/types'
import { useRapport } from '../hooks/useRapport'
import { useSimulation } from '../hooks/useSimulation'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import { Button } from '@/shared/ui/Button'
import type { Critere } from '@/entities/report/types'
import { ScoreHero } from '../components/rapport/ScoreHero'
import { RevelationCards } from '../components/rapport/RevelationCards'
import { DiagnosticCallout } from '../components/rapport/DiagnosticCallout'
import { CritereCard } from '../components/rapport/CritereCard'
import { ConseilNclcCallout } from '../components/rapport/ConseilNclcCallout'
import { ExerciceInteractive } from '../components/rapport/ExerciceInteractive'
import { ProductionModeleSection } from '../components/rapport/ProductionModeleSection'
import { JobStatusFallback } from '../components/rapport/JobStatusFallback'
function isReportNotReady(err: unknown): boolean {
return (
@ -32,19 +48,7 @@ function isReportNotReady(err: unknown): boolean {
)
}
// ── Composants internes ──────────────────────────────────────────────────────
function RapportSkeleton() {
return (
<div className="space-y-4" aria-busy="true" aria-label="Chargement du rapport…">
<div className="h-32 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-20 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-40 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-40 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-36 animate-pulse rounded-lg bg-canvas-2" />
</div>
)
}
// ── Floutage section ────────────────────────────────────────────────────
const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
@ -58,9 +62,8 @@ function BlurredSection({
children: React.ReactNode
}) {
if (visible) return <>{children}</>
return (
<div className="relative min-h-[88px] overflow-hidden rounded-lg border border-line bg-canvas-2">
<div className="relative min-h-[120px] overflow-hidden rounded-lg border border-line bg-canvas-2">
<div className="space-y-2 p-4 opacity-25 blur-sm" aria-hidden="true">
{PLACEHOLDER_WIDTHS.map((w, i) => (
<div key={i} className={`h-3 rounded bg-ink-4 ${w}`} />
@ -77,38 +80,87 @@ function BlurredSection({
)
}
function CritereRow({ critere }: { critere: Critere }) {
// ── Squelette ───────────────────────────────────────────────────────────
function RapportSkeleton() {
return (
<li className="flex flex-col gap-1.5 rounded-lg border border-line bg-canvas-2 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-ink-1">{critere.nom}</span>
<Badge variant="neutral">{critere.score}</Badge>
</div>
<div className="text-sm text-ink-3">
<ReactMarkdown
disallowedElements={['script', 'iframe']}
components={{ p: ({ children: c }) => <p className="leading-relaxed">{c}</p> }}
>
{critere.commentaire}
</ReactMarkdown>
</div>
</li>
<div className="space-y-4" aria-busy="true" aria-label="Chargement du rapport…">
<div className="h-40 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-28 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-32 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-48 animate-pulse rounded-lg bg-canvas-2" />
</div>
)
}
function ExerciceCard({ exercice }: { exercice: string }) {
// ── Sections thématiques ─────────────────────────────────────────────────
function CriteresSection({ rapport }: { rapport: Report }) {
const grouped = groupErreursByCritere(rapport.erreurs_codes)
return (
<li className="rounded-lg border border-line bg-canvas-2 p-4">
<div className="text-sm leading-relaxed text-ink-3">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{exercice}
</ReactMarkdown>
<section aria-label="Détail par critère">
<h2 className="mb-3 text-base font-semibold text-ink-1">Détail par critère</h2>
<div className="space-y-3">
{rapport.criteres.map((c) => {
const code = critereCodeFromNom(c.nom)
const codes = code ? grouped[code] : []
return <CritereCard key={c.nom} critere={c} erreursCodes={codes} />
})}
</div>
</li>
</section>
)
}
// ── Page principale ──────────────────────────────────────────────────────────
function ExercicesSection({ rapport }: { rapport: Report }) {
if (rapport.exercices_status !== 'ready' || !rapport.exercices) {
return (
<section aria-label="Exercices personnalisés">
<h2 className="mb-3 text-base font-semibold text-ink-1">Mes exercices personnalisés</h2>
<JobStatusFallback
status={rapport.exercices_status}
pendingLabel="Génération des exercices en cours…"
errorLabel="Exercices indisponibles. Réessayez plus tard."
/>
</section>
)
}
return (
<section aria-label="Exercices personnalisés">
<h2 className="mb-3 text-base font-semibold text-ink-1">Mes exercices personnalisés</h2>
<div className="space-y-3">
{rapport.exercices.map((ex, i) => (
<ExerciceInteractive key={`${ex.theme}-${i}`} exercice={ex} />
))}
</div>
</section>
)
}
function ModeleSection({ rapport }: { rapport: Report }) {
if (rapport.modele_status !== 'ready' || !rapport.modele) {
return (
<section aria-label="Production modèle">
<h2 className="mb-3 text-base font-semibold text-ink-1">Version restructurée NCLC 9+</h2>
<JobStatusFallback
status={rapport.modele_status}
pendingLabel="Production modèle en cours de génération…"
errorLabel="Production modèle indisponible. Réessayez plus tard."
/>
</section>
)
}
return (
<section aria-label="Production modèle">
<h2 className="mb-3 text-base font-semibold text-ink-1">Version restructurée NCLC 9+</h2>
<ProductionModeleSection modele={rapport.modele} />
</section>
)
}
// ── Page principale ──────────────────────────────────────────────────────
export function RapportPage() {
const { id = '' } = useParams<{ id: string }>()
@ -117,13 +169,7 @@ export function RapportPage() {
const { rapport, isLoading, isError, error } = useRapport(id)
const isInProgress = isError && isReportNotReady(error)
// FTD-21 — si la simulation n'est pas encore corrigée, rediriger vers /simulation/ee.
// Le SimulationFlowProvider restaurera la session via localStorage si présent.
useEffect(() => {
if (isInProgress) {
navigate('/simulation/ee', { replace: true })
}
}, [isInProgress, navigate])
const { reset } = useSimulation()
const {
data: planData,
@ -131,179 +177,100 @@ export function RapportPage() {
isError: isPlanError,
} = usePlan()
// FTD-21 — si la simulation n'est pas encore corrigée, rediriger vers /simulation/ee.
useEffect(() => {
if (isInProgress) {
navigate('/simulation/ee', { replace: true })
}
}, [isInProgress, navigate])
const onUpgrade = () => navigate('/plan')
return (
<main className="mx-auto max-w-2xl space-y-6 px-4 py-6">
// Quitter le rapport proprement : reset du flow (step, production, mutations)
// avant de naviguer — sinon step='done' resterait sticky et empêcherait le
// retour au TaskSelector ou à /sujets.
function goToSimulations() {
reset()
navigate('/simulation/ee')
}
return (
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
{/* Breadcrumb */}
<nav aria-label="Fil d'Ariane" className="flex items-center gap-1.5 text-sm text-ink-4">
<Link
to="/simulation/ee"
<button
type="button"
onClick={goToSimulations}
className="transition-colors duration-150 hover:text-ink-2"
>
Simulations
</Link>
</button>
<span aria-hidden="true"></span>
<span aria-current="page" className="text-ink-2">Rapport</span>
</nav>
{/* Loading */}
{(isLoading || isPlanLoading) && <RapportSkeleton />}
{/* FTD-21 — simulation en cours : message discret avant redirection via useEffect */}
{isInProgress && (
<p className="text-center text-sm text-ink-4" aria-live="polite">
Votre simulation est en cours.
</p>
)}
{/* Erreur (hors "en cours" déjà géré au-dessus) */}
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
<div
role="alert"
className="space-y-3 rounded-lg border border-danger/30 bg-danger-bg px-4 py-6 text-center"
>
<p className="text-sm text-danger">
Impossible de charger ce rapport. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
Retour aux simulations
</Button>
</div>
<Card variant="default" className="border-l-4 border-l-danger p-4">
<div role="alert" className="space-y-3">
<p className="text-sm text-danger">
Impossible de charger ce rapport. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
Retour aux simulations
</Button>
</div>
</Card>
)}
{rapport && planData && (
<>
{/* ── Hero : Score + NCLC — toujours visibles ───────────── */}
<Card variant="raised" className="p-6">
<div className="flex flex-wrap items-end gap-8">
<div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Score
</p>
<p className="mt-1 tabular-nums text-ink-1">
<span className="text-5xl font-bold">{rapport.score}</span>
<span className="text-2xl font-medium text-ink-4">/20</span>
</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Niveau estimé
</p>
<Badge variant="nclc" className="mt-2">
NCLC {rapport.nclc.toFixed(1).replace('.', ',')}
</Badge>
</div>
</div>
</Card>
<ScoreHero
score={rapport.score}
nclc={rapport.nclc}
nclcCible={rapport.nclc_cible}
/>
{/* ── Feedback court — toujours visible ─────────────────── */}
<section aria-label="Retour général">
<h2 className="mb-3 text-base font-semibold text-ink-1">Retour général</h2>
<Card variant="default" className="p-4">
<div className="text-sm leading-relaxed text-ink-2">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{rapport.feedback_court}
</ReactMarkdown>
</div>
</Card>
</section>
<RevelationCards revelation={rapport.revelation} />
{/* ── Critères — detailed_report ────────────────────────── */}
<section aria-label="Détail par critère">
<h2 className="mb-3 text-base font-semibold text-ink-1">Détail par critère</h2>
<BlurredSection
visible={isSectionVisible(planData.plan, 'criteres')}
onUpgrade={onUpgrade}
>
<ul className="space-y-2">
{rapport.criteres.map((c) => (
<CritereRow key={c.nom} critere={c} />
))}
</ul>
</BlurredSection>
</section>
<DiagnosticCallout diagnostic={rapport.diagnostic} />
{/* ── Erreurs — detailed_report ─────────────────────────── */}
<section aria-label="Erreurs détectées">
<h2 className="mb-3 text-base font-semibold text-ink-1">Erreurs détectées</h2>
<BlurredSection
visible={isSectionVisible(planData.plan, 'erreurs')}
onUpgrade={onUpgrade}
>
<ul className="space-y-2 rounded-lg border border-line bg-canvas-2 p-4">
{rapport.erreurs.map((erreur, i) => (
<li key={i} className="flex gap-2 text-sm text-ink-2">
<span className="mt-0.5 shrink-0 text-danger" aria-hidden="true"></span>
<ReactMarkdown
disallowedElements={['script', 'iframe']}
components={{ p: ({ children: c }) => <span>{c}</span> }}
>
{erreur}
</ReactMarkdown>
</li>
))}
</ul>
</BlurredSection>
</section>
<BlurredSection
visible={isSectionVisible(planData.plan, 'criteres')}
onUpgrade={onUpgrade}
>
<CriteresSection rapport={rapport} />
</BlurredSection>
{/* ── Modèle — tips ────────────────────────────────────── */}
<section aria-label="Production modèle">
<h2 className="mb-3 text-base font-semibold text-ink-1">Production modèle</h2>
<BlurredSection
visible={isSectionVisible(planData.plan, 'modele')}
onUpgrade={onUpgrade}
>
<Card variant="default" className="p-4">
<div className="text-sm leading-relaxed text-ink-2">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{rapport.modele}
</ReactMarkdown>
</div>
</Card>
</BlurredSection>
</section>
<ConseilNclcCallout conseil={rapport.conseil_nclc} />
{/* ── Idées — tips ─────────────────────────────────────── */}
<section aria-label="Suggestions d'idées">
<h2 className="mb-3 text-base font-semibold text-ink-1">Suggestions d'idées</h2>
<BlurredSection
visible={isSectionVisible(planData.plan, 'idees')}
onUpgrade={onUpgrade}
>
<ol className="space-y-2 rounded-lg border border-line bg-canvas-2 p-4">
{rapport.idees.map((idee, i) => (
<li key={i} className="flex gap-2 text-sm text-ink-2">
<span className="shrink-0 font-semibold tabular-nums text-expria">
{i + 1}.
</span>
<ReactMarkdown
disallowedElements={['script', 'iframe']}
components={{ p: ({ children: c }) => <span>{c}</span> }}
>
{idee}
</ReactMarkdown>
</li>
))}
</ol>
</BlurredSection>
</section>
<BlurredSection
visible={isSectionVisible(planData.plan, 'exercices')}
onUpgrade={onUpgrade}
>
<ExercicesSection rapport={rapport} />
</BlurredSection>
{/* ── Exercices — tips ─────────────────────────────────── */}
<section aria-label="Exercices personnalisés">
<h2 className="mb-3 text-base font-semibold text-ink-1">Exercices personnalisés</h2>
<BlurredSection
visible={isSectionVisible(planData.plan, 'exercices')}
onUpgrade={onUpgrade}
>
<ul className="space-y-3">
{rapport.exercices.map((ex, i) => (
<ExerciceCard key={i} exercice={ex} />
))}
</ul>
</BlurredSection>
</section>
<BlurredSection
visible={isSectionVisible(planData.plan, 'modele')}
onUpgrade={onUpgrade}
>
<ModeleSection rapport={rapport} />
</BlurredSection>
{/* Action de sortie — reset + nouvelle simulation */}
<div className="flex justify-center pt-4">
<Button variant="primary" onClick={goToSimulations}>
Nouvelle simulation
</Button>
</div>
</>
)}
</main>