feat(simulations): RapportPage avec floutage conditionnel — Sprint 3 étape 15
This commit is contained in:
parent
1dbca24c35
commit
47d5ec9524
5 changed files with 1484 additions and 7 deletions
1172
package-lock.json
generated
1172
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -23,6 +23,7 @@
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { RegisterPage } from '@/features/auth/pages/RegisterPage'
|
||||||
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
|
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
|
||||||
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
|
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
|
||||||
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
|
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
|
||||||
|
import { RapportPage } from '@/features/simulations/pages/RapportPage'
|
||||||
import { AppLayout } from './AppLayout'
|
import { AppLayout } from './AppLayout'
|
||||||
|
|
||||||
const DesignSystemPage = import.meta.env.DEV
|
const DesignSystemPage = import.meta.env.DEV
|
||||||
|
|
@ -49,7 +50,7 @@ export function AppRouter() {
|
||||||
<Route path="/simulation/eo" element={<ComingSoon />} />
|
<Route path="/simulation/eo" element={<ComingSoon />} />
|
||||||
|
|
||||||
{/* Rapport */}
|
{/* Rapport */}
|
||||||
<Route path="/rapport/:id" element={<ComingSoon />} />
|
<Route path="/rapport/:id" element={<RapportPage />} />
|
||||||
|
|
||||||
{/* Autres sections — Sprint 4+ */}
|
{/* Autres sections — Sprint 4+ */}
|
||||||
<Route path="/examen" element={<ComingSoon />} />
|
<Route path="/examen" element={<ComingSoon />} />
|
||||||
|
|
|
||||||
24
src/features/simulations/hooks/useRapport.ts
Normal file
24
src/features/simulations/hooks/useRapport.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Hook de récupération d'un rapport de correction.
|
||||||
|
*
|
||||||
|
* Appelle GET /simulations/:id (cache TanStack Query).
|
||||||
|
* staleTime Infinity : un rapport ne change jamais après correction.
|
||||||
|
*
|
||||||
|
* Règle H : aucune logique métier — expose les données brutes.
|
||||||
|
* FTD-17 : queryKey ['plan'] déjà utilisé dans SimulationPage — ['rapport', id] est distinct.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getReport } from '@/entities/report/api'
|
||||||
|
import type { Report } from '@/entities/report/types'
|
||||||
|
|
||||||
|
export function useRapport(id: string) {
|
||||||
|
const { data, isLoading, isError, error, refetch } = useQuery<Report, Error>({
|
||||||
|
queryKey: ['rapport', id],
|
||||||
|
queryFn: () => getReport(id),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { rapport: data, isLoading, isError, error, refetch }
|
||||||
|
}
|
||||||
291
src/features/simulations/pages/RapportPage.tsx
Normal file
291
src/features/simulations/pages/RapportPage.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue