expria-frontend/src/features/simulations/pages/RapportPage.tsx

291 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Page de rapport de correction.
*
* Sections toujours visibles : score /20, NCLC, feedback_court.
* Sections conditionnelles via isSectionVisible(plan, section) :
* detailed_report → criteres, erreurs
* tips → modele, idees, exercices
*
* SEC-05 : textes IA rendus via react-markdown, jamais dangerouslySetInnerHTML.
* Règle D : isSectionVisible() obligatoire — jamais if (plan === 'xxx').
* Règle H : logique de floutage dans entities/report/lib.ts.
*/
import ReactMarkdown from 'react-markdown'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Lock } from 'lucide-react'
import { getPlanStatus } from '@/entities/user/api'
import { isSectionVisible } from '@/entities/report/lib'
import { useRapport } from '../hooks/useRapport'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import { Button } from '@/shared/ui/Button'
import type { Critere, Exercice } from '@/entities/report/types'
// ── 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>
)
}
const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
function BlurredSection({
visible,
onUpgrade,
children,
}: {
visible: boolean
onUpgrade: () => void
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="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}`} />
))}
</div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
<Lock className="size-5 text-ink-4" aria-hidden="true" />
<p className="text-sm font-medium text-ink-2">Disponible en Standard</p>
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
Passer en Standard
</Button>
</div>
</div>
)
}
function CritereRow({ critere }: { critere: Critere }) {
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.note}</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>
)
}
function ExerciceCard({ exercice }: { exercice: Exercice }) {
return (
<li className="rounded-lg border border-line bg-canvas-2 p-4">
<p className="mb-2 text-sm font-semibold text-ink-1">{exercice.titre}</p>
<div className="text-sm leading-relaxed text-ink-3">
<ReactMarkdown disallowedElements={['script', 'iframe']}>
{exercice.contenu}
</ReactMarkdown>
</div>
</li>
)
}
// ── Page principale ──────────────────────────────────────────────────────────
export function RapportPage() {
const { id = '' } = useParams<{ id: string }>()
const navigate = useNavigate()
const { rapport, isLoading, isError } = useRapport(id)
const {
data: planData,
isLoading: isPlanLoading,
isError: isPlanError,
} = useQuery({
queryKey: ['plan'],
queryFn: getPlanStatus,
staleTime: 5 * 60 * 1000,
})
const onUpgrade = () => navigate('/plan')
return (
<main className="mx-auto max-w-2xl 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"
className="transition-colors duration-150 hover:text-ink-2"
>
Simulations
</Link>
<span aria-hidden="true"></span>
<span aria-current="page" className="text-ink-2">Rapport</span>
</nav>
{/* Loading */}
{(isLoading || isPlanLoading) && <RapportSkeleton />}
{/* Erreur */}
{(isError || isPlanError) && !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>
)}
{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>
{/* ── 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>
{/* ── 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>
{/* ── 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>
{/* ── 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>
{/* ── 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>
{/* ── 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) => (
<ExerciceCard key={ex.titre} exercice={ex} />
))}
</ul>
</BlurredSection>
</section>
</>
)}
</main>
)
}