(9)
const config = getSimulationConfig(tache)
const wordCount = countWords(texte)
@@ -150,8 +153,8 @@ export function SimulationForm({
if (wordCount < config.motsMin) return
hasAutoSubmittedRef.current = true
- onSubmit(texte)
- }, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, onSubmit])
+ onSubmit(texte, nclcCible)
+ }, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, nclcCible, onSubmit])
function handleInsert(char: string) {
const el = textareaRef.current
@@ -185,7 +188,7 @@ export function SimulationForm({
return
}
- onSubmit(parsed.data.texte)
+ onSubmit(parsed.data.texte, nclcCible)
}
const apiError = mapCorrectError(error)
@@ -308,6 +311,12 @@ export function SimulationForm({
className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50"
/>
+
+
{autosave.savedAt && !fieldError && (
Sauvegardé à{' '}
diff --git a/src/features/simulations/components/rapport/ConseilNclcCallout.tsx b/src/features/simulations/components/rapport/ConseilNclcCallout.tsx
new file mode 100644
index 0000000..d0028a8
--- /dev/null
+++ b/src/features/simulations/components/rapport/ConseilNclcCallout.tsx
@@ -0,0 +1,46 @@
+/**
+ * ConseilNclcCallout — Sprint 3.6b.
+ *
+ * Section "Plan d'action NCLC" : écart au NCLC cible + action prioritaire.
+ * Visible pour tous les plans.
+ *
+ * Règle L : tokens Direction H exclusivement.
+ */
+
+import ReactMarkdown from 'react-markdown'
+import { Card } from '@/shared/ui/Card'
+import type { ConseilNclc } from '@/entities/report/types'
+
+interface Props {
+ conseil: ConseilNclc
+}
+
+export function ConseilNclcCallout({ conseil }: Props) {
+ return (
+
+ Plan d'action NCLC
+
+
+
+ Objectif
+
+
{conseil.nclc_cible}
+
+ Écart
+
+
{conseil.ecart}
+
+
+
+ Action prioritaire
+
+
+
+ {conseil.action_prioritaire}
+
+
+
+
+
+ )
+}
diff --git a/src/features/simulations/components/rapport/CritereCard.tsx b/src/features/simulations/components/rapport/CritereCard.tsx
new file mode 100644
index 0000000..4ec443c
--- /dev/null
+++ b/src/features/simulations/components/rapport/CritereCard.tsx
@@ -0,0 +1,80 @@
+/**
+ * CritereCard — Sprint 3.6b.
+ *
+ * Carte critère enrichie : nom, score /5, commentaire, exemple, suggestion,
+ * astuce + badges des codes d'erreurs taxonomie correspondants.
+ *
+ * Visible pour Standard et Premium (gate `detailed_report`). Le floutage est
+ * géré par le parent via BlurredSection — CritereCard ne connaît pas le plan.
+ *
+ * Règle L : tokens Direction H exclusivement.
+ * Règle H : purement présentationnel — aucune logique plan ici.
+ */
+
+import ReactMarkdown from 'react-markdown'
+import { Card } from '@/shared/ui/Card'
+import { Badge } from '@/shared/ui/Badge'
+import type { Critere, ErreurCode } from '@/entities/report/types'
+
+interface Props {
+ critere: Critere
+ erreursCodes: ErreurCode[]
+}
+
+export function CritereCard({ critere, erreursCodes }: Props) {
+ return (
+
+
+
{critere.nom}
+
+ {critere.score}/5
+
+
+
+ {critere.commentaire && (
+
+
+ {critere.commentaire}
+
+
+ )}
+
+ {critere.exemple && (
+
+
+ Exemple tiré de votre texte
+
+
+ « {critere.exemple} »
+
+
+ )}
+
+ {critere.suggestion && (
+
+
+ Reformulation suggérée
+
+
{critere.suggestion}
+
+ )}
+
+ {critere.astuce && (
+
+ 💡
+ {critere.astuce}
+
+ )}
+
+ {erreursCodes.length > 0 && (
+
+ {erreursCodes.map((e) => (
+
+ {e.description ?? e.code.replace(/_/g, ' ')}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/features/simulations/components/rapport/DiagnosticCallout.tsx b/src/features/simulations/components/rapport/DiagnosticCallout.tsx
new file mode 100644
index 0000000..97bf160
--- /dev/null
+++ b/src/features/simulations/components/rapport/DiagnosticCallout.tsx
@@ -0,0 +1,32 @@
+/**
+ * DiagnosticCallout — Sprint 3.6b.
+ *
+ * Section "Ce qui freine votre progression" — phrase courte identifiant
+ * le frein principal. Visible pour tous les plans.
+ *
+ * Règle L : tokens Direction H exclusivement.
+ */
+
+import ReactMarkdown from 'react-markdown'
+import { Card } from '@/shared/ui/Card'
+
+interface Props {
+ diagnostic: string
+}
+
+export function DiagnosticCallout({ diagnostic }: Props) {
+ return (
+
+
+ Ce qui freine votre progression
+
+
+
+
+ {diagnostic}
+
+
+
+
+ )
+}
diff --git a/src/features/simulations/components/rapport/ExerciceInteractive.tsx b/src/features/simulations/components/rapport/ExerciceInteractive.tsx
new file mode 100644
index 0000000..4b20fe0
--- /dev/null
+++ b/src/features/simulations/components/rapport/ExerciceInteractive.tsx
@@ -0,0 +1,143 @@
+/**
+ * ExerciceInteractive — Sprint 3.6b.
+ *
+ * Carte d'exercice avec interactions :
+ * - Badge de difficulté + thème + diagnostic
+ * - Consigne + extrait candidat
+ * - Zone de texte libre (tentative du candidat)
+ * - Bouton "Indice" → révèle une piste (fond jaune), une seule fois
+ * - Bouton "Voir la correction" → activé dès qu'une saisie est présente →
+ * révèle correction (fond vert) + explication
+ * - Message "Comparez avec votre réponse" une fois la correction révélée
+ *
+ * Règle H : aucune logique métier — la correction ne calcule rien, elle
+ * révèle seulement ce que DeepSeek a produit.
+ * Règle L : tokens Direction H exclusivement.
+ */
+
+import { useState } from 'react'
+import ReactMarkdown from 'react-markdown'
+import { Card } from '@/shared/ui/Card'
+import { Badge } from '@/shared/ui/Badge'
+import { Button } from '@/shared/ui/Button'
+import { DIFFICULTE_LABEL, type Exercice } from '@/entities/report/types'
+
+interface Props {
+ exercice: Exercice
+}
+
+export function ExerciceInteractive({ exercice }: Props) {
+ const [tentative, setTentative] = useState('')
+ const [indiceRevealed, setIndiceRevealed] = useState(false)
+ const [correctionRevealed, setCorrectionRevealed] = useState(false)
+
+ const canRevealCorrection = tentative.trim().length > 0
+
+ return (
+
+
+
+ {DIFFICULTE_LABEL[exercice.difficulte]}
+ {exercice.theme && (
+
+ {exercice.theme.replace(/_/g, ' ')}
+
+ )}
+
+
+
+ {exercice.diagnostic && (
+ {exercice.diagnostic}
+ )}
+
+ {exercice.consigne && (
+
+
+ Consigne
+
+
{exercice.consigne}
+
+ )}
+
+ {exercice.extrait && (
+
+
+ Extrait à retravailler
+
+
« {exercice.extrait} »
+
+ )}
+
+
+ Votre réponse
+
+
+
+ setIndiceRevealed(true)}
+ >
+ {indiceRevealed ? 'Indice révélé' : 'Indice'}
+
+ setCorrectionRevealed(true)}
+ title={!canRevealCorrection ? 'Écrivez d\'abord votre tentative' : undefined}
+ >
+ Voir la correction
+
+
+
+ {indiceRevealed && exercice.indice && (
+
+
+ Indice
+
+
{exercice.indice}
+
+ )}
+
+ {correctionRevealed && (
+
+
+
+ Correction attendue
+
+
{exercice.correction}
+
+ {exercice.explication && (
+
+
+ Explication
+
+
+
+ {exercice.explication}
+
+
+
+ )}
+
+ Comparez avec votre réponse ci-dessus pour repérer les différences.
+
+
+ )}
+
+ )
+}
diff --git a/src/features/simulations/components/rapport/JobStatusFallback.tsx b/src/features/simulations/components/rapport/JobStatusFallback.tsx
new file mode 100644
index 0000000..c1bd7c4
--- /dev/null
+++ b/src/features/simulations/components/rapport/JobStatusFallback.tsx
@@ -0,0 +1,48 @@
+/**
+ * JobStatusFallback — Sprint 3.6b.
+ *
+ * Affiche un fallback visuel pour les sections générées en asynchrone par le
+ * backend (exercices, production modèle) :
+ * - 'pending' → "Génération en cours…" avec spinner (refresh manuel côté user)
+ * - 'error' → "Indisponible pour le moment"
+ *
+ * FTD-24 tracera le polling automatique (laissé pour une session ultérieure).
+ *
+ * Règle L : tokens Direction H exclusivement.
+ */
+
+import { Loader2 } from 'lucide-react'
+import { Card } from '@/shared/ui/Card'
+import type { JobStatus } from '@/entities/report/types'
+
+interface Props {
+ status: JobStatus
+ pendingLabel?: string
+ errorLabel?: string
+}
+
+export function JobStatusFallback({
+ status,
+ pendingLabel = 'Génération en cours…',
+ errorLabel = 'Indisponible pour le moment.',
+}: Props) {
+ if (status === 'pending') {
+ return (
+
+
+
+ {pendingLabel}{' '}
+ Rafraîchissez la page dans quelques instants.
+
+
+ )
+ }
+
+ return (
+
+
+ {errorLabel}
+
+
+ )
+}
diff --git a/src/features/simulations/components/rapport/ProductionModeleSection.tsx b/src/features/simulations/components/rapport/ProductionModeleSection.tsx
new file mode 100644
index 0000000..fdf15d7
--- /dev/null
+++ b/src/features/simulations/components/rapport/ProductionModeleSection.tsx
@@ -0,0 +1,103 @@
+/**
+ * ProductionModeleSection — Sprint 3.6b.
+ *
+ * Affiche la production modèle NCLC 9 générée par DeepSeek :
+ * - Texte final prêt pour l'examen
+ * - 3 passages commentés (notes_pedagogiques)
+ * - Transformations : original → amélioré → explication
+ * - Bandeau message encourageant
+ *
+ * Gate `tips` (Standard+). Le floutage est géré par le parent via BlurredSection.
+ *
+ * Règle L : tokens Direction H exclusivement.
+ * Règle H : purement présentationnel.
+ */
+
+import ReactMarkdown from 'react-markdown'
+import { Card } from '@/shared/ui/Card'
+import { Badge } from '@/shared/ui/Badge'
+import type { ProductionModele } from '@/entities/report/types'
+
+interface Props {
+ modele: ProductionModele
+}
+
+export function ProductionModeleSection({ modele }: Props) {
+ return (
+
+
+
+
+ Version restructurée NCLC 9+
+
+
+ {modele.tcf_word_count ?? ''} mots
+
+
+
+ {modele.production_modele_propre}
+
+ {modele.tcf_truncated && (
+
+ Texte tronqué au maximum autorisé pour la tâche ({modele.tcf_word_max} mots).
+
+ )}
+
+
+ {modele.notes_pedagogiques.length > 0 && (
+
+
+ Passages clés
+
+
+ {modele.notes_pedagogiques.map((n, i) => (
+
+ « {n.passage} »
+ {n.explication}
+
+ ))}
+
+
+ )}
+
+ {modele.transformations.length > 0 && (
+
+
+ Transformations appliquées
+
+
+ {modele.transformations.map((t, i) => (
+
+
+
+ Original
+
+
+ {t.original}
+
+
+
+
+ Amélioré
+
+
{t.ameliore}
+
+ {t.explication}
+
+ ))}
+
+
+ )}
+
+ {modele.message && (
+
+
+
+ {modele.message}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/simulations/components/rapport/RevelationCards.tsx b/src/features/simulations/components/rapport/RevelationCards.tsx
new file mode 100644
index 0000000..607f04d
--- /dev/null
+++ b/src/features/simulations/components/rapport/RevelationCards.tsx
@@ -0,0 +1,51 @@
+/**
+ * RevelationCards — Sprint 3.6b.
+ *
+ * Section "Lecture du correcteur" — 3 colonnes : ce que le candidat croit faire,
+ * ce que le correcteur observe, et l'impact sur la note.
+ *
+ * Visible pour tous les plans (cf. PLANS_TARIFAIRES.md).
+ * Règle L : tokens Direction H exclusivement.
+ */
+
+import ReactMarkdown from 'react-markdown'
+import { Card } from '@/shared/ui/Card'
+import type { Revelation } from '@/entities/report/types'
+
+interface Props {
+ revelation: Revelation
+}
+
+const SECTIONS: { key: keyof Revelation; titre: string; ton: 'ink' | 'warning' | 'danger' }[] = [
+ { key: 'croyance', titre: 'Ce que vous croyez', ton: 'ink' },
+ { key: 'realite', titre: 'Ce qu\'observe le correcteur', ton: 'warning' },
+ { key: 'consequence', titre: 'Conséquence sur la note', ton: 'danger' },
+]
+
+const TON_CLASS: Record<'ink' | 'warning' | 'danger', string> = {
+ ink: 'text-ink-2',
+ warning: 'text-warning',
+ danger: 'text-danger',
+}
+
+export function RevelationCards({ revelation }: Props) {
+ return (
+
+ Lecture du correcteur
+
+ {SECTIONS.map(({ key, titre, ton }) => (
+
+
+ {titre}
+
+
+
+ {revelation[key]}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/features/simulations/components/rapport/ScoreHero.tsx b/src/features/simulations/components/rapport/ScoreHero.tsx
new file mode 100644
index 0000000..c604222
--- /dev/null
+++ b/src/features/simulations/components/rapport/ScoreHero.tsx
@@ -0,0 +1,105 @@
+/**
+ * ScoreHero — Sprint 3.6b.
+ *
+ * Affiche score /20, jauge avec seuil NCLC cible marqué, badge NCLC atteint,
+ * et un encart d'écart "X points avant NCLC 9+" si objectif non atteint.
+ *
+ * Règle L : tokens Direction H exclusivement.
+ */
+
+import { Card } from '@/shared/ui/Card'
+import { Badge } from '@/shared/ui/Badge'
+import { ecartVsCible } from '@/entities/report/lib'
+import type { NclcCible } from '@/entities/report/types'
+
+interface Props {
+ score: number // /20
+ nclc: number // NCLC atteint
+ nclcCible: NclcCible
+}
+
+const NCLC_MIN_SCORE: Record = { 9: 14, 10: 16 }
+
+export function ScoreHero({ score, nclc, nclcCible }: Props) {
+ const { points, atteint } = ecartVsCible(score, nclcCible)
+ const seuilCible = NCLC_MIN_SCORE[nclcCible] ?? 14
+ const percent = Math.max(0, Math.min(100, (score / 20) * 100))
+ const seuilPercent = (seuilCible / 20) * 100
+
+ return (
+
+
+
+
+ Score
+
+
+ {score}
+ /20
+
+
+
+
+ Niveau atteint
+
+
+ NCLC {nclc}
+
+
+
+
+ Objectif
+
+
+ NCLC {nclcCible}
+
+
+
+
+ {/* Jauge avec marqueur NCLC cible */}
+
+
+
+ {/* Marqueur du seuil NCLC cible */}
+
+
+
+ 0
+ Seuil NCLC {nclcCible} : {seuilCible}/20
+ 20
+
+
+
+ {/* Encart d'écart */}
+ {atteint ? (
+
+ Objectif NCLC {nclcCible} atteint.
+
+ ) : (
+
+ {points === 1
+ ? '1 point avant NCLC '
+ : `${points} points avant NCLC `}
+ {nclcCible}+
+
+ )}
+
+ )
+}
diff --git a/src/features/simulations/components/rapport/__tests__/ExerciceInteractive.test.tsx b/src/features/simulations/components/rapport/__tests__/ExerciceInteractive.test.tsx
new file mode 100644
index 0000000..46d4a81
--- /dev/null
+++ b/src/features/simulations/components/rapport/__tests__/ExerciceInteractive.test.tsx
@@ -0,0 +1,79 @@
+/**
+ * Tests — ExerciceInteractive (Sprint 3.6b).
+ *
+ * Couvre l'état interne : indice révélé une seule fois, bouton correction
+ * désactivé tant qu'aucune saisie, activé dès qu'une tentative existe.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest'
+import { render, screen, cleanup } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+afterEach(cleanup)
+import { ExerciceInteractive } from '../ExerciceInteractive'
+import type { Exercice } from '@/entities/report/types'
+
+const EXERCICE: Exercice = {
+ difficulte: 'facile',
+ theme: 'accord_sujet_verbe',
+ diagnostic: 'Les accords sujet-verbe sont fragiles.',
+ consigne: 'Corrigez les accords.',
+ extrait: 'les enfants joue',
+ indice: 'Pluriel du sujet ?',
+ correction: 'les enfants jouent',
+ explication: 'Le verbe s\'accorde en nombre avec le sujet.',
+}
+
+describe('ExerciceInteractive', () => {
+ it('affiche le badge de difficulté avec le libellé mappé', () => {
+ render( )
+ expect(screen.getByText('Facile')).toBeInTheDocument()
+ })
+
+ it('bouton "Voir la correction" désactivé tant que la zone de saisie est vide', () => {
+ render( )
+ const btn = screen.getByRole('button', { name: /voir la correction/i })
+ expect(btn).toBeDisabled()
+ })
+
+ it('bouton "Voir la correction" activé dès qu\'une saisie est présente', async () => {
+ const user = userEvent.setup()
+ render( )
+ await user.type(screen.getByPlaceholderText(/écrivez votre tentative/i), 'ma réponse')
+
+ expect(screen.getByRole('button', { name: /voir la correction/i })).toBeEnabled()
+ })
+
+ it('clic sur "Indice" révèle la piste une seule fois (bouton se désactive)', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ const btn = screen.getByRole('button', { name: /^indice$/i })
+ expect(btn).toBeEnabled()
+ expect(screen.queryByText(EXERCICE.indice)).not.toBeInTheDocument()
+
+ await user.click(btn)
+
+ expect(screen.getByText(EXERCICE.indice)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /indice révélé/i })).toBeDisabled()
+ })
+
+ it('clic sur "Voir la correction" révèle correction + explication + message final', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ await user.type(screen.getByPlaceholderText(/écrivez votre tentative/i), 'les enfants joue')
+ await user.click(screen.getByRole('button', { name: /voir la correction/i }))
+
+ expect(screen.getByText(EXERCICE.correction)).toBeInTheDocument()
+ expect(screen.getByText(EXERCICE.explication)).toBeInTheDocument()
+ expect(screen.getByText(/comparez avec votre réponse/i)).toBeInTheDocument()
+ })
+
+ it('le bouton "Indice" reste disponible si aucun indice fourni par le backend', () => {
+ const sansIndice: Exercice = { ...EXERCICE, indice: '' }
+ render( )
+ // Pas d'indice → bouton désactivé d'office (evite de révéler un vide)
+ expect(screen.getByRole('button', { name: /^indice$/i })).toBeDisabled()
+ })
+})
diff --git a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx
index 05f6de0..84c86b3 100644
--- a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx
+++ b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx
@@ -56,14 +56,18 @@ const mockSujet = {
const mockReport: Report = {
simulation_id: 'sim-1',
- score: 80,
+ score: 14,
nclc: 9,
- feedback_court: 'Bon travail.',
+ nclc_cible: 9,
+ revelation: { croyance: '', realite: '', consequence: '' },
+ diagnostic: 'Diagnostic test.',
criteres: [],
- erreurs: [],
- modele: '',
- idees: [],
- exercices: [],
+ conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
+ erreurs_codes: [],
+ exercices: null,
+ exercices_status: 'pending',
+ modele: null,
+ modele_status: 'pending',
}
function createWrapper() {
@@ -224,6 +228,11 @@ describe('useSimulation — FTD-21 resume depuis localStorage', () => {
contenu: 'Mon brouillon.',
sujet: mockSujet,
rapport: null,
+ nclc_cible: null,
+ exercices: null,
+ exercices_status: 'pending',
+ modele: null,
+ modele_status: 'pending',
})
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
@@ -246,9 +255,20 @@ describe('useSimulation — FTD-21 resume depuis localStorage', () => {
contenu: 'texte',
sujet: null,
rapport: {
- score: 14, nclc: 8, feedback_court: 'OK',
- criteres: [], erreurs: [], modele: '', idees: [], exercices: [],
+ score: 14,
+ nclc: 8,
+ nclc_cible: 9,
+ revelation: { croyance: '', realite: '', consequence: '' },
+ diagnostic: '',
+ criteres: [],
+ conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
+ erreurs_codes: [],
},
+ nclc_cible: 9,
+ exercices: null,
+ exercices_status: 'ready',
+ modele: null,
+ modele_status: 'ready',
})
renderHook(() => useSimulation(), { wrapper: createWrapper() })
diff --git a/src/features/simulations/pages/RapportPage.tsx b/src/features/simulations/pages/RapportPage.tsx
index 635f9dd..e877e38 100644
--- a/src/features/simulations/pages/RapportPage.tsx
+++ b/src/features/simulations/pages/RapportPage.tsx
@@ -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 (
-
- )
-}
+// ── 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 (
-
+
{PLACEHOLDER_WIDTHS.map((w, i) => (
@@ -77,38 +80,87 @@ function BlurredSection({
)
}
-function CritereRow({ critere }: { critere: Critere }) {
+// ── Squelette ───────────────────────────────────────────────────────────
+
+function RapportSkeleton() {
return (
-
-
- {critere.nom}
- {critere.score}
-
-
-
{c}
}}
- >
- {critere.commentaire}
-
-
-
+
)
}
-function ExerciceCard({ exercice }: { exercice: string }) {
+// ── Sections thématiques ─────────────────────────────────────────────────
+
+function CriteresSection({ rapport }: { rapport: Report }) {
+ const grouped = groupErreursByCritere(rapport.erreurs_codes)
+
return (
-
-
-
- {exercice}
-
+
+ Détail par critère
+
+ {rapport.criteres.map((c) => {
+ const code = critereCodeFromNom(c.nom)
+ const codes = code ? grouped[code] : []
+ return
+ })}
-
+
)
}
-// ── Page principale ──────────────────────────────────────────────────────────
+function ExercicesSection({ rapport }: { rapport: Report }) {
+ if (rapport.exercices_status !== 'ready' || !rapport.exercices) {
+ return (
+
+ Mes exercices personnalisés
+
+
+ )
+ }
+
+ return (
+
+ Mes exercices personnalisés
+
+ {rapport.exercices.map((ex, i) => (
+
+ ))}
+
+
+ )
+}
+
+function ModeleSection({ rapport }: { rapport: Report }) {
+ if (rapport.modele_status !== 'ready' || !rapport.modele) {
+ return (
+
+ Version restructurée NCLC 9+
+
+
+ )
+ }
+
+ return (
+
+ Version restructurée NCLC 9+
+
+
+ )
+}
+
+// ── 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 (
-
+ // 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 (
+
{/* Breadcrumb */}
-
Simulations
-
+
›
Rapport
- {/* Loading */}
{(isLoading || isPlanLoading) && }
- {/* FTD-21 — simulation en cours : message discret avant redirection via useEffect */}
{isInProgress && (
Votre simulation est en cours.
)}
- {/* Erreur (hors "en cours" déjà géré au-dessus) */}
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
-
-
- Impossible de charger ce rapport. Réessayez dans quelques instants.
-
-
navigate('/simulation/ee')}>
- Retour aux simulations
-
-
+
+
+
+ Impossible de charger ce rapport. Réessayez dans quelques instants.
+
+
navigate('/simulation/ee')}>
+ Retour aux simulations
+
+
+
)}
{rapport && planData && (
<>
- {/* ── Hero : Score + NCLC — toujours visibles ───────────── */}
-
-
-
-
- Score
-
-
- {rapport.score}
- /20
-
-
-
-
- Niveau estimé
-
-
- NCLC {rapport.nclc.toFixed(1).replace('.', ',')}
-
-
-
-
+
- {/* ── Feedback court — toujours visible ─────────────────── */}
-
- Retour général
-
-
-
- {rapport.feedback_court}
-
-
-
-
+
- {/* ── Critères — detailed_report ────────────────────────── */}
-
- Détail par critère
-
-
- {rapport.criteres.map((c) => (
-
- ))}
-
-
-
+
- {/* ── Erreurs — detailed_report ─────────────────────────── */}
-
- Erreurs détectées
-
-
- {rapport.erreurs.map((erreur, i) => (
-
- •
- {c} }}
- >
- {erreur}
-
-
- ))}
-
-
-
+
+
+
- {/* ── Modèle — tips ────────────────────────────────────── */}
-
- Production modèle
-
-
-
-
- {rapport.modele}
-
-
-
-
-
+
- {/* ── Idées — tips ─────────────────────────────────────── */}
-
- Suggestions d'idées
-
-
- {rapport.idees.map((idee, i) => (
-
-
- {i + 1}.
-
- {c} }}
- >
- {idee}
-
-
- ))}
-
-
-
+
+
+
- {/* ── Exercices — tips ─────────────────────────────────── */}
-
- Exercices personnalisés
-
-
- {rapport.exercices.map((ex, i) => (
-
- ))}
-
-
-
+
+
+
+
+ {/* Action de sortie — reset + nouvelle simulation */}
+
+
+ Nouvelle simulation
+
+
>
)}
diff --git a/src/features/simulations/state/SimulationFlowProvider.tsx b/src/features/simulations/state/SimulationFlowProvider.tsx
index edd3762..b0a7e25 100644
--- a/src/features/simulations/state/SimulationFlowProvider.tsx
+++ b/src/features/simulations/state/SimulationFlowProvider.tsx
@@ -46,7 +46,7 @@ interface FlowValue {
createError: ApiError | null
correctError: ApiError | null
selectTask: (payload: CreateSimulationPayload) => void
- submitText: (texte: string) => void
+ submitText: (texte: string, nclcCible?: 9 | 10) => void
changeSubject: (sujet: SujetData) => void
setStep: (step: SimulationStep) => void
reset: () => void
@@ -101,16 +101,27 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
mutationFn: createSimulation,
onSuccess: (data) => {
setProduction(data)
- setStep(TACHES_SANS_CATALOGUE.includes(data.tache) ? 'task-selected' : 'choosing-subject')
+ const hasCatalogue = !TACHES_SANS_CATALOGUE.includes(data.tache)
+ setStep(hasCatalogue ? 'choosing-subject' : 'task-selected')
+ // Navigation initiale vers /sujets pour les tâches avec catalogue —
+ // gérée ici (et non dans un useEffect sticky côté SimulationPage) pour
+ // éviter la boucle infinie quand l'utilisateur revient depuis /sujets.
+ if (hasCatalogue) {
+ navigate('/sujets')
+ }
},
})
const correctMutation = useMutation({
mutationFn: correctEe,
onMutate: () => setStep('correcting'),
- onSuccess: () => {
+ onSuccess: (_data, variables) => {
setStep('done')
localStorage.removeItem(LS_SIMULATION_ID_KEY)
+ // Navigation vers le rapport déclenchée ici (plutôt que depuis un
+ // useEffect sticky côté SimulationPage) — une seule fois par correction,
+ // pas de redirection en boucle si l'utilisateur revient sur /simulation/ee.
+ navigate(`/rapport/${variables.simulationId}`)
},
onError: () => setStep('task-selected'),
})
@@ -119,9 +130,14 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
createMutation.mutate(payload)
}
- function submitText(texte: string): void {
+ function submitText(texte: string, nclcCible: 9 | 10 = 9): void {
if (!production) return
- correctMutation.mutate({ simulationId: production.id, contenu: texte, tache: production.tache })
+ correctMutation.mutate({
+ simulationId: production.id,
+ contenu: texte,
+ tache: production.tache,
+ nclc_cible: nclcCible,
+ })
}
function changeSubject(sujet: SujetData): void {