expria-frontend/src/features/simulations/pages/RapportPage.tsx
Hermann_Kitio 99617f117c style: prettier format
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 03:17:16 +03:00

265 lines
9.8 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 — Sprint 3.6b.
*
* Sections toujours visibles : score + jauge, revelation, diagnostic, conseil_nclc.
* Sections conditionnelles via isSectionVisible(plan, section) :
* 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').
* Règle H : logique de floutage dans entities/report/lib.ts.
*/
import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Lock } from 'lucide-react'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
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 { Button } from '@/shared/ui/Button'
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 (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code: unknown }).code === 'REPORT_NOT_READY'
)
}
// ── Floutage section ────────────────────────────────────────────────────
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-[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}`} />
))}
</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>
)
}
// ── Squelette ───────────────────────────────────────────────────────────
function RapportSkeleton() {
return (
<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>
)
}
// ── Sections thématiques ─────────────────────────────────────────────────
function CriteresSection({ rapport }: { rapport: Report }) {
const grouped = groupErreursByCritere(rapport.erreurs_codes)
return (
<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>
</section>
)
}
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 }>()
const navigate = useNavigate()
const { rapport, isLoading, isError, error } = useRapport(id)
const isInProgress = isError && isReportNotReady(error)
const { reset } = useSimulation()
const { data: planData, isLoading: isPlanLoading, 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')
// 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">
<button
type="button"
onClick={goToSimulations}
className="transition-colors duration-150 hover:text-ink-2"
>
Simulations
</button>
<span aria-hidden="true"></span>
<span aria-current="page" className="text-ink-2">
Rapport
</span>
</nav>
{(isLoading || isPlanLoading) && <RapportSkeleton />}
{isInProgress && (
<p className="text-center text-sm text-ink-4" aria-live="polite">
Votre simulation est en cours.
</p>
)}
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
<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 && (
<>
<ScoreHero score={rapport.score} nclc={rapport.nclc} nclcCible={rapport.nclc_cible} />
<RevelationCards revelation={rapport.revelation} />
<DiagnosticCallout diagnostic={rapport.diagnostic} />
<BlurredSection
visible={isSectionVisible(planData.plan, 'criteres')}
onUpgrade={onUpgrade}
>
<CriteresSection rapport={rapport} />
</BlurredSection>
<ConseilNclcCallout conseil={rapport.conseil_nclc} />
<BlurredSection
visible={isSectionVisible(planData.plan, 'exercices')}
onUpgrade={onUpgrade}
>
<ExercicesSection rapport={rapport} />
</BlurredSection>
<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>
)
}