265 lines
9.8 KiB
TypeScript
265 lines
9.8 KiB
TypeScript
/**
|
||
* 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>
|
||
)
|
||
}
|