feat(rapport): Sprint 3.6b — RapportPage enrichie, exercices dynamiques, production modèle, sélecteur NCLC
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8390e8b873
commit
f51caa1b75
22 changed files with 1357 additions and 297 deletions
|
|
@ -51,6 +51,53 @@ Chaque entrée suit ce format :
|
||||||
- B8 : comportement actuel diffère du spec `PARCOURS_UTILISATEURS.md §2 "Quota atteint"` — affichage d'une bannière inline au lieu du modal de blocage attendu. À corriger dans un sprint dédié (non inclus dans ce clean, qui n'introduit aucune nouvelle fonctionnalité).
|
- B8 : comportement actuel diffère du spec `PARCOURS_UTILISATEURS.md §2 "Quota atteint"` — affichage d'une bannière inline au lieu du modal de blocage attendu. À corriger dans un sprint dédié (non inclus dans ce clean, qui n'introduit aucune nouvelle fonctionnalité).
|
||||||
|
|
||||||
|
|
||||||
|
## [Unreleased] — 2026-04-22 — Sprint 3.6b — Qualité correction — Frontend
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `NclcCibleSelector` (segmented control NCLC 9 / NCLC 10) dans `SimulationForm` — valeur propagée au payload `POST /corrections/ee` via `SimulationFlowProvider.submitText(texte, nclcCible)`.
|
||||||
|
- Composants `rapport/` dans `features/simulations/components/` :
|
||||||
|
- `ScoreHero` — score /20, jauge avec marqueur du seuil NCLC cible, écart vs objectif (« X points avant NCLC 9 »), badges NCLC atteint / cible.
|
||||||
|
- `RevelationCards` — 3 colonnes : ce que le candidat croit / ce que le correcteur observe / conséquence.
|
||||||
|
- `DiagnosticCallout` — callout « Ce qui freine votre progression ».
|
||||||
|
- `CritereCard` — carte enrichie par critère (exemple / suggestion / astuce + badges codes taxonomie).
|
||||||
|
- `ConseilNclcCallout` — plan d'action NCLC (objectif, écart, action prioritaire).
|
||||||
|
- `ExerciceInteractive` — carte exercice avec zone texte, bouton Indice (révélé une fois), bouton « Voir la correction » (activé après saisie), explication.
|
||||||
|
- `ProductionModeleSection` — texte final + notes pédagogiques + transformations original/amélioré + message encourageant.
|
||||||
|
- `JobStatusFallback` — fallback pour `exercices_status` / `modele_status` en `'pending'` ou `'error'`.
|
||||||
|
- Helpers dans `entities/report/lib.ts` : `groupErreursByCritere`, `ecartVsCible`, `critereCodeFromNom`.
|
||||||
|
- Tests `ExerciceInteractive.test.tsx` (6 tests) — couvre état interne : révélation unique indice, activation bouton correction, affichage correction + explication.
|
||||||
|
- FTD-24 🟡 dans `TECH_DEBT.md` — polling automatique pour exercices/modèle `pending` (refresh manuel en MVP).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `entities/report/types.ts` — refonte complète alignée sur le backend Sprint 3.6a : `Report` remplace l'ancien (revelation, diagnostic, criteres enrichis, conseil_nclc, erreurs_codes top-level, exercices dynamiques, modele structuré, statuts pending/ready/error). Suppression de `feedback_court`, `erreurs[]`, `modele:string`, `idees[]` (obsolètes).
|
||||||
|
- `entities/report/lib.ts` — `BlurableSection` réduite à `'criteres' | 'exercices' | 'modele'` : `revelation`, `diagnostic`, `conseil_nclc` deviennent visibles pour tous les plans conformément à PLANS_TARIFAIRES.md §2.
|
||||||
|
- `entities/production/types.ts` — `SimulationState` étendu avec `nclc_cible`, `exercices`, `exercices_status`, `modele`, `modele_status` ; `SimulationRapport` aligné sur `CorrectionRapport` backend.
|
||||||
|
- `entities/report/api.ts` — `getReport` recombine `SimulationState.rapport` + `exercices` + `modele` + statuts en un `Report` unifié pour `useRapport`.
|
||||||
|
- `RapportPage.tsx` — réécriture complète : câble tous les nouveaux composants, branche le gating plan via `isSectionVisible`, affiche `JobStatusFallback` pour les jobs asynchrones. Résout l'écran blanc post-Sprint 3.6a.
|
||||||
|
- `floutage.test.ts` réécrit (17 tests — matrice de visibilité + helpers lib).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Race condition `modele_status`** (backend) : l'update principal de correction écrasait `modele_status='ready'` déjà posé par `runModeleJob` (lancé en parallèle option b). `correctionController.correctEE` ne touche plus aux colonnes `*_status` — pilotées exclusivement par les jobs asynchrones.
|
||||||
|
- **Boucle infinie retour rapport → SimulationPage** : le useEffect sticky `step === 'done' → navigate('/rapport/:id')` renvoyait l'utilisateur sur le rapport à chaque tentative de retour vers `/simulation/ee`. Supprimé ; la navigation initiale vers `/rapport/:id` est déclenchée une seule fois dans `correctMutation.onSuccess` du provider.
|
||||||
|
- **Boucle retour /sujets → SimulationPage** : même pattern sticky pour `step === 'choosing-subject' → navigate('/sujets')`. Supprimé ; navigation initiale vers `/sujets` déplacée dans `createMutation.onSuccess`.
|
||||||
|
- **RapportPage hors SimulationFlowProvider** : la route `/rapport/:id` n'était pas sous `SimulationFlowLayout` — l'appel à `useSimulation()` depuis RapportPage throw. Route déplacée sous le layout, l'instance du provider est partagée avec `/simulation/ee` et `/sujets`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Bouton « Nouvelle simulation » en bas de `RapportPage` qui `reset()` + `navigate('/simulation/ee')`.
|
||||||
|
- `reset()` explicite dans le bouton « ← Retour » de `SujetsPage` avant la navigation, pour empêcher tout re-déclenchement de la garde sticky.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Navigations post-mutation déplacées dans `onSuccess` du provider (pattern cohérent pour `createMutation` → `/sujets` et `correctMutation` → `/rapport/:id`). Plus de useEffect réactif aux changements de `step` côté SimulationPage.
|
||||||
|
- `SujetsPage` : garde étendue de `!production` à `!production \|\| step === 'idle' \|\| step === 'done'` pour couvrir le cas post-rapport (évite le 400 VALIDATION_ERROR sur `PATCH /simulations/:id/sujet` d'une simulation déjà corrigée).
|
||||||
|
- `RapportPage` breadcrumb : `<Link>` remplacé par `<button>` qui `reset()` avant navigate.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- **Option β retenue** : frontend aligné sur la structure backend réelle du Sprint 3.6a. Aucun aller-retour backend.
|
||||||
|
- `feedback_court` supprimé de l'UI ; `diagnostic` remplace la section « Retour général ».
|
||||||
|
- Polling automatique non implémenté (FTD-24) : refresh manuel de la page si `exercices_status` / `modele_status` = `'pending'`.
|
||||||
|
- Tests : **84/84 verts** (+8 vs baseline 76).
|
||||||
|
|
||||||
|
|
||||||
## [Unreleased] — 2026-04-22 — Sprint 3.6a — Qualité correction — Backend
|
## [Unreleased] — 2026-04-22 — Sprint 3.6a — Qualité correction — Backend
|
||||||
|
|
||||||
### Added (backend)
|
### Added (backend)
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,14 @@
|
||||||
- Migration SQL : `supabase/migrations/004_sprint_3_6a_qualite_correction.sql` (à exécuter manuellement)
|
- Migration SQL : `supabase/migrations/004_sprint_3_6a_qualite_correction.sql` (à exécuter manuellement)
|
||||||
- Tests : 173 tests verts (+18 vs baseline)
|
- Tests : 173 tests verts (+18 vs baseline)
|
||||||
|
|
||||||
## Sprint 3.6b — Qualité correction — Frontend
|
## Sprint 3.6b — Qualité correction — Frontend ✅
|
||||||
- Sélecteur NCLC cible dans SimulationForm (9 ou 10, défaut 9)
|
- Sélecteur NCLC cible dans SimulationForm (9 ou 10, défaut 9) — NclcCibleSelector
|
||||||
- RapportPage enrichie : revelation, diagnostic, conseil_nclc, jauge NCLC
|
- RapportPage réécrite : ScoreHero (jauge + seuil NCLC cible + écart), RevelationCards, DiagnosticCallout, CritereCard enrichie (exemple/suggestion/astuce + codes taxonomie), ConseilNclcCallout
|
||||||
- Exercices dynamiques : indice, correction, explication
|
- ExerciceInteractive : badge difficulté, zone texte, bouton Indice (une fois), bouton Voir la correction (activé après saisie), explication
|
||||||
- Production modèle : notes_pedagogiques, transformations, message
|
- ProductionModeleSection : texte final + notes pédagogiques + transformations original/amélioré + message
|
||||||
- Gating plan : exercices + modèle floutés Free
|
- JobStatusFallback : gère exercices_status / modele_status (pending / error) — refresh manuel, polling tracé en FTD-24
|
||||||
|
- Gating plan conforme PLANS_TARIFAIRES.md : revelation/diagnostic/conseil_nclc tous plans ; criteres/exercices/modele Standard+
|
||||||
|
- Tests : 84 verts (+8 vs baseline — floutage + helpers lib + ExerciceInteractive)
|
||||||
|
|
||||||
## Sprint 3.6c — Analyse patterns (Premium)
|
## Sprint 3.6c — Analyse patterns (Premium)
|
||||||
- Backend : GET /users/patterns — agrégation SQL erreurs_codes sur 5 dernières productions
|
- Backend : GET /users/patterns — agrégation SQL erreurs_codes sur 5 dernières productions
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,26 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### FTD-24 — Pas de polling automatique pour exercices / modèle `pending`
|
||||||
|
**Priorité :** 🟡 Important
|
||||||
|
**Statut :** Ouvert — accepté au Sprint 3.6b, refresh manuel requis entre-temps
|
||||||
|
**Estimation de session :** 2h
|
||||||
|
**Description :** Après soumission d'une correction EE, le backend génère la correction en bloquant (jusqu'à 45 s), puis retourne 200 dès que la correction est prête. Les jobs `modele` et `exercices` (fire-and-forget côté backend) peuvent mettre 10-30 s supplémentaires après la réponse HTTP. Pendant ce temps, `exercices_status` et `modele_status` valent `'pending'` côté `GET /simulations/:id`. Côté frontend, `RapportPage` affiche un `JobStatusFallback` invitant l'utilisateur à **rafraîchir manuellement** la page pour voir les résultats.
|
||||||
|
|
||||||
|
**Impact UX :** l'utilisateur voit le rapport principal immédiatement, mais doit recharger pour voir ses exercices + production modèle. Expérience acceptable en MVP mais sous-optimale.
|
||||||
|
|
||||||
|
**À faire :**
|
||||||
|
- Hook `useRapport` : déclencher un polling automatique via TanStack Query `refetchInterval: 3000` si `exercices_status === 'pending' || modele_status === 'pending'`.
|
||||||
|
- Arrêt du polling dès que les deux statuts sortent de `'pending'` (ready ou error).
|
||||||
|
- Afficher un indicateur visuel discret pendant le polling actif (petit spinner dans JobStatusFallback).
|
||||||
|
- Timeout de polling : max 2 minutes → message "La génération prend plus de temps que prévu" + bouton Réessayer.
|
||||||
|
|
||||||
|
**Lien avec TD-15 backend :** si le process backend redémarre pendant un job, le statut reste indéfiniment `'pending'`. Le timeout frontend atténue ce problème côté UX (on arrête de poller après 2 min).
|
||||||
|
|
||||||
|
**Condition de résolution :** après Sprint 3.6c (patterns) si la patience utilisateur devient un frein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### FTD-23 — `useAutosave` continue après correction → 400 VALIDATION_ERROR
|
### FTD-23 — `useAutosave` continue après correction → 400 VALIDATION_ERROR
|
||||||
**Priorité :** 🟡 Important
|
**Priorité :** 🟡 Important
|
||||||
**Statut :** Ouvert — pré-existant au Sprint 3.6a, détecté lors des tests manuels 3.6a
|
**Statut :** Ouvert — pré-existant au Sprint 3.6a, détecté lors des tests manuels 3.6a
|
||||||
|
|
@ -374,3 +394,4 @@ Frontend :
|
||||||
| 1.10 | 2026-04-21 | FTD-21 résolu partiellement pour `/simulation/ee` (autosave 30 s + `beforeunload` + reprise via `localStorage` + `PATCH /:id/contenu` + `PATCH /:id/sujet` + `getById` tolère `rapport=null`) ; EO + examen restent ouverts |
|
| 1.10 | 2026-04-21 | FTD-21 résolu partiellement pour `/simulation/ee` (autosave 30 s + `beforeunload` + reprise via `localStorage` + `PATCH /:id/contenu` + `PATCH /:id/sujet` + `getById` tolère `rapport=null`) ; EO + examen restent ouverts |
|
||||||
| 1.11 | 2026-04-22 | Sprint 3.5 Clean — FTD-17, FTD-18, FTD-19 résolus. 15 FTD actives restantes (cap de 15 respecté) |
|
| 1.11 | 2026-04-22 | Sprint 3.5 Clean — FTD-17, FTD-18, FTD-19 résolus. 15 FTD actives restantes (cap de 15 respecté) |
|
||||||
| 1.12 | 2026-04-22 | Sprint 3.6a — Ajout FTD-23 🟡 (useAutosave fire après correction). 16 FTD actives → cap de 15 dépassé temporairement, à revoir au prochain clean. |
|
| 1.12 | 2026-04-22 | Sprint 3.6a — Ajout FTD-23 🟡 (useAutosave fire après correction). 16 FTD actives → cap de 15 dépassé temporairement, à revoir au prochain clean. |
|
||||||
|
| 1.13 | 2026-04-22 | Sprint 3.6b — Ajout FTD-24 🟡 (polling auto exercices/modèle pending). 17 FTD actives → cap dépassé, un clean 3.6.5 devra résoudre FTD-23/24 ensemble. |
|
||||||
|
|
|
||||||
|
|
@ -79,20 +79,61 @@ export interface SimulationState {
|
||||||
contenu: string | null
|
contenu: string | null
|
||||||
sujet: SujetData | null
|
sujet: SujetData | null
|
||||||
rapport: SimulationRapport | null
|
rapport: SimulationRapport | null
|
||||||
|
// Sprint 3.6a — nouveaux champs backend (null/pending si rapport non encore corrigé)
|
||||||
|
nclc_cible: 9 | 10 | null
|
||||||
|
exercices: SimulationExercice[] | null
|
||||||
|
exercices_status: SimulationJobStatus
|
||||||
|
modele: SimulationProductionModele | null
|
||||||
|
modele_status: SimulationJobStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SimulationJobStatus = 'pending' | 'ready' | 'error'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rapport tel que stocké par le backend (sans `simulation_id` — porté par SimulationState).
|
* Rapport tel que stocké par le backend (sans `simulation_id`, `exercices`, `modele`
|
||||||
* Miroir de `EERapport` côté backend ; ré-exposé ici pour éviter l'import circulaire
|
* qui sont portés par SimulationState). Miroir de `CorrectionRapport` côté backend
|
||||||
* avec `entities/report/types.ts`.
|
* (Sprint 3.6a). Ré-exposé ici pour éviter l'import circulaire avec
|
||||||
|
* `entities/report/types.ts`.
|
||||||
*/
|
*/
|
||||||
export interface SimulationRapport {
|
export interface SimulationRapport {
|
||||||
score: number
|
score: number
|
||||||
nclc: number
|
nclc: number
|
||||||
feedback_court: string
|
nclc_cible: 9 | 10
|
||||||
criteres: { nom: string; score: number; commentaire: string }[]
|
revelation: { croyance: string; realite: string; consequence: string }
|
||||||
erreurs: string[]
|
diagnostic: string
|
||||||
modele: string
|
criteres: {
|
||||||
idees: string[]
|
nom: string
|
||||||
exercices: string[]
|
score: number
|
||||||
|
commentaire: string
|
||||||
|
exemple: string
|
||||||
|
suggestion: string
|
||||||
|
astuce: string
|
||||||
|
}[]
|
||||||
|
conseil_nclc: { nclc_cible: string; ecart: string; action_prioritaire: string }
|
||||||
|
erreurs_codes: { code: string; critere: string; description: string | null }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationExercice {
|
||||||
|
difficulte: 'facile' | 'intermediaire' | 'difficile'
|
||||||
|
theme: string
|
||||||
|
diagnostic: string
|
||||||
|
consigne: string
|
||||||
|
extrait: string
|
||||||
|
indice: string
|
||||||
|
correction: string
|
||||||
|
explication: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationProductionModele {
|
||||||
|
production_modele_propre: string
|
||||||
|
notes_pedagogiques: { passage: string; explication: string }[]
|
||||||
|
transformations: { original: string; ameliore: string; explication: string }[]
|
||||||
|
message: string
|
||||||
|
nclc_modele?: number
|
||||||
|
nclc_obtenu?: number
|
||||||
|
score_cible?: number
|
||||||
|
tcf_word_count?: number
|
||||||
|
tcf_word_min?: number
|
||||||
|
tcf_word_max?: number
|
||||||
|
tcf_truncated?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,29 @@
|
||||||
/**
|
/**
|
||||||
* Tests de la logique de floutage — isSectionVisible().
|
* Tests — matrice de floutage Sprint 3.6b.
|
||||||
|
*
|
||||||
|
* Nouvelle matrice :
|
||||||
|
* - Floutables : criteres (detailed_report), exercices + modele (tips)
|
||||||
|
* - Toujours visibles : score, nclc, revelation, diagnostic, conseil_nclc
|
||||||
*
|
*
|
||||||
* Couverture : 5 sections floutables × 3 plans = 15 tests.
|
|
||||||
* Source de vérité : PLANS_TARIFAIRES.md §2.
|
* Source de vérité : PLANS_TARIFAIRES.md §2.
|
||||||
*
|
|
||||||
* detailed_report : criteres, erreurs → false pour Free, true pour Standard+
|
|
||||||
* tips : modele, idees, exercices → false pour Free, true pour Standard+
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { isSectionVisible } from '../lib'
|
import { isSectionVisible, groupErreursByCritere, ecartVsCible, critereCodeFromNom } from '../lib'
|
||||||
|
import type { ErreurCode } from '../types'
|
||||||
|
|
||||||
describe('isSectionVisible — plan free', () => {
|
describe('isSectionVisible — plan free', () => {
|
||||||
it('criteres : non visible (detailed_report = false)', () => {
|
it('criteres : non visible (detailed_report = false)', () => {
|
||||||
expect(isSectionVisible('free', 'criteres')).toBe(false)
|
expect(isSectionVisible('free', 'criteres')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('erreurs : non visible (detailed_report = false)', () => {
|
it('exercices : non visible (tips = false)', () => {
|
||||||
expect(isSectionVisible('free', 'erreurs')).toBe(false)
|
expect(isSectionVisible('free', 'exercices')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('modele : non visible (tips = false)', () => {
|
it('modele : non visible (tips = false)', () => {
|
||||||
expect(isSectionVisible('free', 'modele')).toBe(false)
|
expect(isSectionVisible('free', 'modele')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('idees : non visible (tips = false)', () => {
|
|
||||||
expect(isSectionVisible('free', 'idees')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('exercices : non visible (tips = false)', () => {
|
|
||||||
expect(isSectionVisible('free', 'exercices')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('isSectionVisible — plan standard', () => {
|
describe('isSectionVisible — plan standard', () => {
|
||||||
|
|
@ -38,41 +31,77 @@ describe('isSectionVisible — plan standard', () => {
|
||||||
expect(isSectionVisible('standard', 'criteres')).toBe(true)
|
expect(isSectionVisible('standard', 'criteres')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('erreurs : visible (detailed_report = true)', () => {
|
it('exercices : visible (tips = true)', () => {
|
||||||
expect(isSectionVisible('standard', 'erreurs')).toBe(true)
|
expect(isSectionVisible('standard', 'exercices')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('modele : visible (tips = true)', () => {
|
it('modele : visible (tips = true)', () => {
|
||||||
expect(isSectionVisible('standard', 'modele')).toBe(true)
|
expect(isSectionVisible('standard', 'modele')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('idees : visible (tips = true)', () => {
|
|
||||||
expect(isSectionVisible('standard', 'idees')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('exercices : visible (tips = true)', () => {
|
|
||||||
expect(isSectionVisible('standard', 'exercices')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('isSectionVisible — plan premium', () => {
|
describe('isSectionVisible — plan premium', () => {
|
||||||
it('criteres : visible (detailed_report = true)', () => {
|
it('criteres : visible', () => {
|
||||||
expect(isSectionVisible('premium', 'criteres')).toBe(true)
|
expect(isSectionVisible('premium', 'criteres')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('erreurs : visible (detailed_report = true)', () => {
|
it('exercices : visible', () => {
|
||||||
expect(isSectionVisible('premium', 'erreurs')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('modele : visible (tips = true)', () => {
|
|
||||||
expect(isSectionVisible('premium', 'modele')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('idees : visible (tips = true)', () => {
|
|
||||||
expect(isSectionVisible('premium', 'idees')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('exercices : visible (tips = true)', () => {
|
|
||||||
expect(isSectionVisible('premium', 'exercices')).toBe(true)
|
expect(isSectionVisible('premium', 'exercices')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('modele : visible', () => {
|
||||||
|
expect(isSectionVisible('premium', 'modele')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('groupErreursByCritere', () => {
|
||||||
|
it('regroupe les erreurs par code critère', () => {
|
||||||
|
const erreurs: ErreurCode[] = [
|
||||||
|
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||||
|
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
|
||||||
|
{ code: 'virgule_exces', critere: 'competence_grammaticale', description: null },
|
||||||
|
]
|
||||||
|
const grouped = groupErreursByCritere(erreurs)
|
||||||
|
expect(grouped.competence_grammaticale).toHaveLength(2)
|
||||||
|
expect(grouped.coherence_cohesion).toHaveLength(1)
|
||||||
|
expect(grouped.competence_lexicale).toHaveLength(0)
|
||||||
|
expect(grouped.adequation_tache).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne les 4 critères même sur liste vide', () => {
|
||||||
|
const grouped = groupErreursByCritere([])
|
||||||
|
expect(Object.keys(grouped)).toHaveLength(4)
|
||||||
|
expect(grouped.competence_grammaticale).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ecartVsCible', () => {
|
||||||
|
it('NCLC 9 atteint (score = 14)', () => {
|
||||||
|
expect(ecartVsCible(14, 9)).toEqual({ points: 0, atteint: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('NCLC 9 non atteint (score = 12)', () => {
|
||||||
|
expect(ecartVsCible(12, 9)).toEqual({ points: 2, atteint: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('NCLC 10 atteint (score = 18)', () => {
|
||||||
|
expect(ecartVsCible(18, 10)).toEqual({ points: 0, atteint: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('score supérieur à cible : atteint + points=0', () => {
|
||||||
|
expect(ecartVsCible(20, 9)).toEqual({ points: 0, atteint: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('critereCodeFromNom', () => {
|
||||||
|
it('mappe les 4 libellés officiels', () => {
|
||||||
|
expect(critereCodeFromNom('Adéquation à la tâche et au registre')).toBe('adequation_tache')
|
||||||
|
expect(critereCodeFromNom('Cohérence et cohésion du discours')).toBe('coherence_cohesion')
|
||||||
|
expect(critereCodeFromNom('Compétence lexicale')).toBe('competence_lexicale')
|
||||||
|
expect(critereCodeFromNom('Compétence grammaticale')).toBe('competence_grammaticale')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne null sur un libellé inconnu', () => {
|
||||||
|
expect(critereCodeFromNom('Autre chose')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,17 @@ export function getReport(id: string): Promise<Report> {
|
||||||
message: 'Simulation en cours — rédaction pas encore corrigée.',
|
message: 'Simulation en cours — rédaction pas encore corrigée.',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ...state.rapport, simulation_id: state.simulation_id }
|
// Sprint 3.6b : reconstruit un Report en combinant rapport (correction) +
|
||||||
|
// exercices / modele (jobs fire-and-forget, portés par SimulationState).
|
||||||
|
return {
|
||||||
|
...state.rapport,
|
||||||
|
simulation_id: state.simulation_id,
|
||||||
|
erreurs_codes: state.rapport.erreurs_codes as Report['erreurs_codes'],
|
||||||
|
exercices: state.exercices as Report['exercices'],
|
||||||
|
exercices_status: state.exercices_status,
|
||||||
|
modele: state.modele as Report['modele'],
|
||||||
|
modele_status: state.modele_status,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,24 @@
|
||||||
* Import cross-entity volontaire et documenté :
|
* Import cross-entity volontaire et documenté :
|
||||||
* entities/report → entities/user/lib (hasAccess, Plan)
|
* entities/report → entities/user/lib (hasAccess, Plan)
|
||||||
* Justification : la logique de floutage dépend intrinsèquement des permissions
|
* Justification : la logique de floutage dépend intrinsèquement des permissions
|
||||||
* utilisateur. Exception validée — cf. ARCHITECTURE.md §3 et étape 13c.
|
* utilisateur. Exception validée — cf. ARCHITECTURE.md §3.
|
||||||
*
|
*
|
||||||
* Règle D : aucun `if (plan === 'xxx')` — tout passe par hasAccess().
|
* Règle D : aucun `if (plan === 'xxx')` — tout passe par hasAccess().
|
||||||
* Règle H : cette logique vit ici, jamais dans les composants features/.
|
* Règle H : cette logique vit ici, jamais dans les composants features/.
|
||||||
|
*
|
||||||
|
* Sprint 3.6b : nouvelle matrice — `revelation`, `diagnostic`, `conseil_nclc`
|
||||||
|
* sont visibles tous plans (pas listés ici). Seuls `criteres`, `exercices`,
|
||||||
|
* `modele` sont floutés selon le plan (cf. types.ts BlurableSection).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { hasAccess } from '@/entities/user/lib'
|
import { hasAccess } from '@/entities/user/lib'
|
||||||
import type { Plan } from '@/entities/user/lib'
|
import type { Plan } from '@/entities/user/lib'
|
||||||
import type { BlurableSection } from './types'
|
import type { BlurableSection, Critere, ErreurCode, CritereCode } from './types'
|
||||||
|
|
||||||
const SECTION_FEATURE: Record<BlurableSection, 'detailed_report' | 'tips'> = {
|
const SECTION_FEATURE: Record<BlurableSection, 'detailed_report' | 'tips'> = {
|
||||||
criteres: 'detailed_report',
|
criteres: 'detailed_report',
|
||||||
erreurs: 'detailed_report',
|
|
||||||
modele: 'tips',
|
|
||||||
idees: 'tips',
|
|
||||||
exercices: 'tips',
|
exercices: 'tips',
|
||||||
|
modele: 'tips',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,3 +34,56 @@ const SECTION_FEATURE: Record<BlurableSection, 'detailed_report' | 'tips'> = {
|
||||||
export function isSectionVisible(plan: Plan, section: BlurableSection): boolean {
|
export function isSectionVisible(plan: Plan, section: BlurableSection): boolean {
|
||||||
return hasAccess(plan, SECTION_FEATURE[section])
|
return hasAccess(plan, SECTION_FEATURE[section])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regroupe les codes d'erreurs par critère pour affichage dans les cartes critère.
|
||||||
|
*
|
||||||
|
* Le backend retourne `erreurs_codes` en top-level ; l'UI les affiche
|
||||||
|
* contextualisés dans chaque carte critère correspondante.
|
||||||
|
*/
|
||||||
|
export function groupErreursByCritere(
|
||||||
|
erreursCodes: ErreurCode[],
|
||||||
|
): Record<CritereCode, ErreurCode[]> {
|
||||||
|
const acc: Record<CritereCode, ErreurCode[]> = {
|
||||||
|
adequation_tache: [],
|
||||||
|
coherence_cohesion: [],
|
||||||
|
competence_lexicale: [],
|
||||||
|
competence_grammaticale: [],
|
||||||
|
}
|
||||||
|
for (const err of erreursCodes) {
|
||||||
|
acc[err.critere].push(err)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappe le nom d'un critère (libellé humain backend) vers son code taxonomie,
|
||||||
|
* pour rattacher `erreurs_codes` à la bonne carte critère côté UI.
|
||||||
|
*/
|
||||||
|
const CRITERE_NOM_TO_CODE: Record<string, CritereCode> = {
|
||||||
|
'Adéquation à la tâche et au registre': 'adequation_tache',
|
||||||
|
'Cohérence et cohésion du discours': 'coherence_cohesion',
|
||||||
|
'Compétence lexicale': 'competence_lexicale',
|
||||||
|
'Compétence grammaticale': 'competence_grammaticale',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function critereCodeFromNom(nom: string): CritereCode | null {
|
||||||
|
return CRITERE_NOM_TO_CODE[nom] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'écart en points /20 entre le score obtenu et l'objectif NCLC cible.
|
||||||
|
* Barème TCF Canada (cf. Prompt_maître.md §Barème) : NCLC 9 → 14/20, NCLC 10 → 16/20.
|
||||||
|
*/
|
||||||
|
const NCLC_MIN_SCORE: Record<number, number> = { 7: 10, 8: 12, 9: 14, 10: 16 }
|
||||||
|
|
||||||
|
export function ecartVsCible(score: number, nclcCible: number): {
|
||||||
|
points: number
|
||||||
|
atteint: boolean
|
||||||
|
} {
|
||||||
|
const minScore = NCLC_MIN_SCORE[nclcCible] ?? NCLC_MIN_SCORE[9]!
|
||||||
|
const points = Math.max(0, minScore - score)
|
||||||
|
return { points, atteint: score >= minScore }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Critere }
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,118 @@
|
||||||
/**
|
/**
|
||||||
* Types publics du domaine `report`.
|
* Types publics du domaine `report` — Sprint 3.6b.
|
||||||
*
|
*
|
||||||
* Un `Report` est retourné par le backend après correction d'une production
|
* Structure alignée sur le backend Sprint 3.6a (prompt maître + production
|
||||||
* (DeepSeek pour EE, Gemini pour EO). Le backend renvoie directement le payload
|
* modèle + exercices fire-and-forget). Aucune enveloppe : le payload est
|
||||||
* métier — pas d'enveloppe (cf. ARCHITECTURE.md §5).
|
* retourné directement (cf. ARCHITECTURE.md §5).
|
||||||
*
|
*
|
||||||
* Visibilité par section selon le plan (cf. PLANS_TARIFAIRES.md §2) :
|
* Visibilité par section selon le plan (cf. PLANS_TARIFAIRES.md §2) :
|
||||||
* - score, nclc, feedback_court → toujours visibles (tous plans)
|
* - score, nclc, nclc_cible, revelation, diagnostic, conseil_nclc → tous plans
|
||||||
* - criteres, erreurs → detailed_report (Standard+)
|
* - criteres (exemple/suggestion/astuce) → detailed_report (Standard+)
|
||||||
* - modele, idees, exercices → tips (Standard+)
|
* - exercices, modele → tips (Standard+)
|
||||||
*
|
*
|
||||||
* La logique de floutage vit dans lib.ts via isSectionVisible() — jamais dans
|
* SEC-05 : textes IA rendus via react-markdown, jamais dangerouslySetInnerHTML.
|
||||||
* les composants (Règle H). Voir aussi SEC-05 : ces champs contiennent du texte
|
|
||||||
* IA potentiellement malveillant — les rendre via react-markdown, jamais via
|
|
||||||
* dangerouslySetInnerHTML.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Codes taxonomie d'erreurs — valeurs exhaustives dans `TAXONOMIE_ERREURS.md` v1.0. */
|
||||||
|
export type CritereCode =
|
||||||
|
| 'adequation_tache'
|
||||||
|
| 'coherence_cohesion'
|
||||||
|
| 'competence_lexicale'
|
||||||
|
| 'competence_grammaticale'
|
||||||
|
|
||||||
|
export interface ErreurCode {
|
||||||
|
code: string
|
||||||
|
critere: CritereCode
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface Critere {
|
export interface Critere {
|
||||||
nom: string
|
nom: string
|
||||||
score: number
|
score: number // 0-5
|
||||||
commentaire: string
|
commentaire: string
|
||||||
|
exemple: string
|
||||||
|
suggestion: string
|
||||||
|
astuce: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Revelation {
|
||||||
|
croyance: string
|
||||||
|
realite: string
|
||||||
|
consequence: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConseilNclc {
|
||||||
|
nclc_cible: string // ex. "NCLC 9"
|
||||||
|
ecart: string
|
||||||
|
action_prioritaire: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Difficulte = 'facile' | 'intermediaire' | 'difficile'
|
||||||
|
|
||||||
|
/** Libellés affichés pour chaque niveau de difficulté. */
|
||||||
|
export const DIFFICULTE_LABEL: Record<Difficulte, string> = {
|
||||||
|
facile: 'Facile',
|
||||||
|
intermediaire: 'Moyen',
|
||||||
|
difficile: 'Difficile',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Exercice {
|
||||||
|
difficulte: Difficulte
|
||||||
|
theme: string
|
||||||
|
diagnostic: string
|
||||||
|
consigne: string
|
||||||
|
extrait: string
|
||||||
|
indice: string
|
||||||
|
correction: string
|
||||||
|
explication: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotePedagogique {
|
||||||
|
passage: string
|
||||||
|
explication: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transformation {
|
||||||
|
original: string
|
||||||
|
ameliore: string
|
||||||
|
explication: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionModele {
|
||||||
|
production_modele_propre: string
|
||||||
|
notes_pedagogiques: NotePedagogique[]
|
||||||
|
transformations: Transformation[]
|
||||||
|
message: string
|
||||||
|
// Métadonnées backend — non affichées côté UI mais exposées pour complétude.
|
||||||
|
nclc_modele?: number
|
||||||
|
nclc_obtenu?: number
|
||||||
|
score_cible?: number
|
||||||
|
tcf_word_count?: number
|
||||||
|
tcf_word_min?: number
|
||||||
|
tcf_word_max?: number
|
||||||
|
tcf_truncated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JobStatus = 'pending' | 'ready' | 'error'
|
||||||
|
export type NclcCible = 9 | 10
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rapport de correction renvoyé par `POST /corrections/ee` ou `POST /corrections/eo`.
|
* Rapport de correction renvoyé par `GET /simulations/:id` (et ressources dérivées).
|
||||||
* Timeout 30 s (DeepSeek/Gemini — cf. api.ts).
|
|
||||||
*/
|
*/
|
||||||
export interface Report {
|
export interface Report {
|
||||||
simulation_id: string
|
simulation_id: string
|
||||||
score: number // /20
|
score: number // /20
|
||||||
nclc: number // estimation NCLC, ex. 7.5
|
nclc: number // NCLC atteint — ex. 8
|
||||||
feedback_court: string // 2-3 lignes — toujours visible
|
nclc_cible: NclcCible
|
||||||
criteres: Critere[] // feature : detailed_report
|
revelation: Revelation
|
||||||
erreurs: string[] // feature : detailed_report
|
diagnostic: string
|
||||||
modele: string // feature : tips — 1re phrase visible pour Free
|
criteres: Critere[]
|
||||||
idees: string[] // feature : tips — 1er item visible pour Free
|
conseil_nclc: ConseilNclc
|
||||||
exercices: string[] // feature : tips — 1er item visible pour Free
|
erreurs_codes: ErreurCode[] // top-level — regroupés par critère côté UI
|
||||||
|
exercices: Exercice[] | null
|
||||||
|
exercices_status: JobStatus
|
||||||
|
modele: ProductionModele | null
|
||||||
|
modele_status: JobStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Corps de `POST /corrections/ee`. */
|
/** Corps de `POST /corrections/ee`. */
|
||||||
|
|
@ -43,6 +120,7 @@ export interface CorrectEePayload {
|
||||||
simulationId: string
|
simulationId: string
|
||||||
contenu: string
|
contenu: string
|
||||||
tache: string
|
tache: string
|
||||||
|
nclc_cible?: NclcCible // défaut backend : 9
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,5 +133,14 @@ export interface CorrectEoPayload {
|
||||||
tache: string
|
tache: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sections du rapport dont la visibilité dépend du plan. */
|
/**
|
||||||
export type BlurableSection = 'criteres' | 'erreurs' | 'modele' | 'idees' | 'exercices'
|
* Sections du rapport dont la visibilité dépend du plan (Sprint 3.6b).
|
||||||
|
*
|
||||||
|
* - `criteres` → gate `detailed_report` : floute les champs exemple/suggestion/astuce
|
||||||
|
* + codes d'erreurs pour les utilisateurs Free.
|
||||||
|
* - `exercices` / `modele` → gate `tips`.
|
||||||
|
*
|
||||||
|
* `revelation`, `diagnostic`, `conseil_nclc`, `score`, `nclc` ne sont **pas**
|
||||||
|
* des `BlurableSection` : elles sont visibles pour tous les plans (cf. PLANS_TARIFAIRES.md §2).
|
||||||
|
*/
|
||||||
|
export type BlurableSection = 'criteres' | 'exercices' | 'modele'
|
||||||
|
|
|
||||||
69
src/features/simulations/components/NclcCibleSelector.tsx
Normal file
69
src/features/simulations/components/NclcCibleSelector.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* Sélecteur de niveau NCLC cible — Sprint 3.6b.
|
||||||
|
*
|
||||||
|
* Segmented control à 2 valeurs : NCLC 9 ou NCLC 10.
|
||||||
|
* La valeur est envoyée dans le payload `POST /corrections/ee` (champ `nclc_cible`).
|
||||||
|
* Backend par défaut : 9 (cf. expria-backend `corrections.ts`).
|
||||||
|
*
|
||||||
|
* Règle L : tokens Direction H exclusivement.
|
||||||
|
* Règle H : purement présentationnel — l'état vit chez le parent (SimulationForm).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from '@/shared/lib/utils'
|
||||||
|
import type { NclcCible } from '@/entities/report/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: NclcCible
|
||||||
|
onChange: (next: NclcCible) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTIONS: { value: NclcCible; label: string; hint: string }[] = [
|
||||||
|
{ value: 9, label: 'NCLC 9', hint: 'Visa — 14/20 minimum' },
|
||||||
|
{ value: 10, label: 'NCLC 10', hint: 'Excellence — 16/20 minimum' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function NclcCibleSelector({ value, onChange, disabled = false }: Props) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
className="space-y-2"
|
||||||
|
aria-label="Niveau NCLC cible pour la correction"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<legend className="text-sm font-medium text-ink-2">Objectif de correction</legend>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Niveau NCLC cible"
|
||||||
|
className="inline-flex overflow-hidden rounded-md border border-line bg-surface"
|
||||||
|
>
|
||||||
|
{OPTIONS.map((opt) => {
|
||||||
|
const active = opt.value === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
||||||
|
'focus-visible:outline-none focus-visible:shadow-focus',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
active
|
||||||
|
? 'bg-expria text-white'
|
||||||
|
: 'bg-surface text-ink-2 hover:bg-canvas-2 hover:text-ink-1',
|
||||||
|
)}
|
||||||
|
title={opt.hint}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-4">
|
||||||
|
{OPTIONS.find((o) => o.value === value)?.hint}
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import { Button } from '@/shared/ui/Button'
|
||||||
import { formatTache } from '@/entities/production/lib'
|
import { formatTache } from '@/entities/production/lib'
|
||||||
import { hasAccess, type Plan } from '@/entities/user/lib'
|
import { hasAccess, type Plan } from '@/entities/user/lib'
|
||||||
import type { SujetData, Tache } from '@/entities/production/types'
|
import type { SujetData, Tache } from '@/entities/production/types'
|
||||||
|
import type { NclcCible } from '@/entities/report/types'
|
||||||
import type { ApiError } from '@/shared/types/api'
|
import type { ApiError } from '@/shared/types/api'
|
||||||
import { countWords, getSimulationConfig } from '../lib/simulationConfig'
|
import { countWords, getSimulationConfig } from '../lib/simulationConfig'
|
||||||
import { useTimer } from '../hooks/useTimer'
|
import { useTimer } from '../hooks/useTimer'
|
||||||
|
|
@ -28,6 +29,7 @@ import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
|
||||||
import { TimerDisplay } from './TimerDisplay'
|
import { TimerDisplay } from './TimerDisplay'
|
||||||
import { WordCountBar } from './WordCountBar'
|
import { WordCountBar } from './WordCountBar'
|
||||||
import { IdeesSuggestions } from './IdeesSuggestions'
|
import { IdeesSuggestions } from './IdeesSuggestions'
|
||||||
|
import { NclcCibleSelector } from './NclcCibleSelector'
|
||||||
|
|
||||||
const MIN_WORDS_IDEES = 30
|
const MIN_WORDS_IDEES = 30
|
||||||
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
|
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
|
||||||
|
|
@ -66,7 +68,7 @@ interface Props {
|
||||||
step: SimulationStep
|
step: SimulationStep
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
error: ApiError | null
|
error: ApiError | null
|
||||||
onSubmit: (texte: string) => void
|
onSubmit: (texte: string, nclcCible: NclcCible) => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onChangeSujet: () => void
|
onChangeSujet: () => void
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +91,7 @@ export function SimulationForm({
|
||||||
const [texte, setTexte] = useState(() => initialContenu ?? '')
|
const [texte, setTexte] = useState(() => initialContenu ?? '')
|
||||||
const [fieldError, setFieldError] = useState<string | null>(null)
|
const [fieldError, setFieldError] = useState<string | null>(null)
|
||||||
const [isIdeesOpen, setIsIdeesOpen] = useState(false)
|
const [isIdeesOpen, setIsIdeesOpen] = useState(false)
|
||||||
|
const [nclcCible, setNclcCible] = useState<NclcCible>(9)
|
||||||
|
|
||||||
const config = getSimulationConfig(tache)
|
const config = getSimulationConfig(tache)
|
||||||
const wordCount = countWords(texte)
|
const wordCount = countWords(texte)
|
||||||
|
|
@ -150,8 +153,8 @@ export function SimulationForm({
|
||||||
if (wordCount < config.motsMin) return
|
if (wordCount < config.motsMin) return
|
||||||
|
|
||||||
hasAutoSubmittedRef.current = true
|
hasAutoSubmittedRef.current = true
|
||||||
onSubmit(texte)
|
onSubmit(texte, nclcCible)
|
||||||
}, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, onSubmit])
|
}, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, nclcCible, onSubmit])
|
||||||
|
|
||||||
function handleInsert(char: string) {
|
function handleInsert(char: string) {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
|
|
@ -185,7 +188,7 @@ export function SimulationForm({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(parsed.data.texte)
|
onSubmit(parsed.data.texte, nclcCible)
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiError = mapCorrectError(error)
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<WordCountBar count={wordCount} config={config} />
|
<WordCountBar count={wordCount} config={config} />
|
||||||
|
<NclcCibleSelector
|
||||||
|
value={nclcCible}
|
||||||
|
onChange={setNclcCible}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
{autosave.savedAt && !fieldError && (
|
{autosave.savedAt && !fieldError && (
|
||||||
<p className="text-xs text-ink-4" aria-live="polite">
|
<p className="text-xs text-ink-4" aria-live="polite">
|
||||||
Sauvegardé à{' '}
|
Sauvegardé à{' '}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<section aria-label="Plan d'action NCLC">
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-ink-1">Plan d'action NCLC</h2>
|
||||||
|
<Card variant="raised" className="space-y-3 p-4">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Objectif
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-ink-1">{conseil.nclc_cible}</p>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Écart
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-ink-2">{conseil.ecart}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 rounded-md border border-expria/30 bg-expria-50 p-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-expria">
|
||||||
|
Action prioritaire
|
||||||
|
</p>
|
||||||
|
<div className="text-sm leading-relaxed text-ink-1">
|
||||||
|
<ReactMarkdown disallowedElements={['script', 'iframe']}>
|
||||||
|
{conseil.action_prioritaire}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/features/simulations/components/rapport/CritereCard.tsx
Normal file
80
src/features/simulations/components/rapport/CritereCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Card variant="default" className="space-y-3 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-1">{critere.nom}</h3>
|
||||||
|
<Badge variant="nclc" className="shrink-0 tabular-nums">
|
||||||
|
{critere.score}/5
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{critere.commentaire && (
|
||||||
|
<div className="text-sm leading-relaxed text-ink-2">
|
||||||
|
<ReactMarkdown disallowedElements={['script', 'iframe']}>
|
||||||
|
{critere.commentaire}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{critere.exemple && (
|
||||||
|
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Exemple tiré de votre texte
|
||||||
|
</p>
|
||||||
|
<p className="italic text-sm leading-relaxed text-ink-2">
|
||||||
|
« {critere.exemple} »
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{critere.suggestion && (
|
||||||
|
<div className="space-y-1.5 rounded-md border border-expria/30 bg-expria-50 p-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-expria">
|
||||||
|
Reformulation suggérée
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed text-ink-1">{critere.suggestion}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{critere.astuce && (
|
||||||
|
<div className="flex gap-2 text-sm text-ink-3">
|
||||||
|
<span className="shrink-0 text-expria" aria-hidden="true">💡</span>
|
||||||
|
<span>{critere.astuce}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{erreursCodes.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 border-t border-line pt-3">
|
||||||
|
{erreursCodes.map((e) => (
|
||||||
|
<Badge key={`${e.code}-${e.description ?? ''}`} variant="neutral">
|
||||||
|
{e.description ?? e.code.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<section aria-label="Frein principal">
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-ink-1">
|
||||||
|
Ce qui freine votre progression
|
||||||
|
</h2>
|
||||||
|
<Card variant="default" className="border-l-4 border-l-expria p-4">
|
||||||
|
<div className="text-sm leading-relaxed text-ink-1">
|
||||||
|
<ReactMarkdown disallowedElements={['script', 'iframe']}>
|
||||||
|
{diagnostic}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<Card variant="default" className="space-y-3 p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="nclc">{DIFFICULTE_LABEL[exercice.difficulte]}</Badge>
|
||||||
|
{exercice.theme && (
|
||||||
|
<span className="text-xs font-medium text-ink-4">
|
||||||
|
{exercice.theme.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exercice.diagnostic && (
|
||||||
|
<p className="text-sm leading-relaxed text-ink-3">{exercice.diagnostic}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exercice.consigne && (
|
||||||
|
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Consigne
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed text-ink-1">{exercice.consigne}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exercice.extrait && (
|
||||||
|
<div className="space-y-1.5 rounded-md border border-line bg-surface p-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Extrait à retravailler
|
||||||
|
</p>
|
||||||
|
<p className="italic text-sm leading-relaxed text-ink-2">« {exercice.extrait} »</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium text-ink-2">Votre réponse</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={tentative}
|
||||||
|
onChange={(e) => setTentative(e.target.value)}
|
||||||
|
placeholder="Écrivez votre tentative ici…"
|
||||||
|
className="w-full resize-none rounded-md border border-line bg-surface p-2 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={indiceRevealed || !exercice.indice}
|
||||||
|
onClick={() => setIndiceRevealed(true)}
|
||||||
|
>
|
||||||
|
{indiceRevealed ? 'Indice révélé' : 'Indice'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canRevealCorrection || correctionRevealed}
|
||||||
|
onClick={() => setCorrectionRevealed(true)}
|
||||||
|
title={!canRevealCorrection ? 'Écrivez d\'abord votre tentative' : undefined}
|
||||||
|
>
|
||||||
|
Voir la correction
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{indiceRevealed && exercice.indice && (
|
||||||
|
<div
|
||||||
|
className="space-y-1 rounded-md border border-warning/30 bg-warning-bg p-3"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning">
|
||||||
|
Indice
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed text-ink-1">{exercice.indice}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{correctionRevealed && (
|
||||||
|
<div className="space-y-3" aria-live="polite">
|
||||||
|
<div className="space-y-1 rounded-md border border-success/30 bg-success-bg p-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-success">
|
||||||
|
Correction attendue
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed text-ink-1">{exercice.correction}</p>
|
||||||
|
</div>
|
||||||
|
{exercice.explication && (
|
||||||
|
<div className="space-y-1 rounded-md border border-line bg-canvas-2 p-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Explication
|
||||||
|
</p>
|
||||||
|
<div className="text-sm leading-relaxed text-ink-2">
|
||||||
|
<ReactMarkdown disallowedElements={['script', 'iframe']}>
|
||||||
|
{exercice.explication}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-ink-4">
|
||||||
|
Comparez avec votre réponse ci-dessus pour repérer les différences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<Card variant="default" className="flex items-center gap-3 p-4">
|
||||||
|
<Loader2 className="size-4 animate-spin text-ink-4" aria-hidden="true" />
|
||||||
|
<p className="text-sm text-ink-3" aria-live="polite">
|
||||||
|
{pendingLabel}{' '}
|
||||||
|
<span className="text-ink-4">Rafraîchissez la page dans quelques instants.</span>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="default" className="border-l-4 border-l-warning p-4">
|
||||||
|
<p className="text-sm text-warning" role="alert">
|
||||||
|
{errorLabel}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card variant="raised" className="space-y-3 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Version restructurée NCLC 9+
|
||||||
|
</p>
|
||||||
|
<Badge variant="nclc">
|
||||||
|
{modele.tcf_word_count ?? ''} mots
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-1">
|
||||||
|
{modele.production_modele_propre}
|
||||||
|
</p>
|
||||||
|
{modele.tcf_truncated && (
|
||||||
|
<p className="text-xs text-warning">
|
||||||
|
Texte tronqué au maximum autorisé pour la tâche ({modele.tcf_word_max} mots).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{modele.notes_pedagogiques.length > 0 && (
|
||||||
|
<Card variant="default" className="space-y-3 p-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Passages clés
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{modele.notes_pedagogiques.map((n, i) => (
|
||||||
|
<li key={i} className="space-y-1.5 border-l-2 border-expria pl-3">
|
||||||
|
<p className="italic text-sm leading-relaxed text-ink-2">« {n.passage} »</p>
|
||||||
|
<p className="text-xs text-ink-3">{n.explication}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modele.transformations.length > 0 && (
|
||||||
|
<Card variant="default" className="space-y-3 p-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Transformations appliquées
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{modele.transformations.map((t, i) => (
|
||||||
|
<li key={i} className="space-y-2">
|
||||||
|
<div className="rounded-md border border-line bg-canvas-2 p-2">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Original
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-ink-3 line-through decoration-danger decoration-1">
|
||||||
|
{t.original}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-success/30 bg-success-bg p-2">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-success">
|
||||||
|
Amélioré
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-ink-1">{t.ameliore}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-4">{t.explication}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modele.message && (
|
||||||
|
<Card variant="default" className="border-l-4 border-l-expria p-4">
|
||||||
|
<div className="text-sm leading-relaxed text-ink-1">
|
||||||
|
<ReactMarkdown disallowedElements={['script', 'iframe']}>
|
||||||
|
{modele.message}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<section aria-label="Lecture du correcteur">
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-ink-1">Lecture du correcteur</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{SECTIONS.map(({ key, titre, ton }) => (
|
||||||
|
<Card key={key} variant="default" className="p-4">
|
||||||
|
<p className={`mb-2 text-[11px] font-semibold uppercase tracking-widest ${TON_CLASS[ton]}`}>
|
||||||
|
{titre}
|
||||||
|
</p>
|
||||||
|
<div className="text-sm leading-relaxed text-ink-2">
|
||||||
|
<ReactMarkdown disallowedElements={['script', 'iframe']}>
|
||||||
|
{revelation[key]}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
src/features/simulations/components/rapport/ScoreHero.tsx
Normal file
105
src/features/simulations/components/rapport/ScoreHero.tsx
Normal file
|
|
@ -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<number, number> = { 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 (
|
||||||
|
<Card variant="raised" className="space-y-4 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">{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 atteint
|
||||||
|
</p>
|
||||||
|
<Badge variant="nclc" className="mt-2">
|
||||||
|
NCLC {nclc}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
Objectif
|
||||||
|
</p>
|
||||||
|
<Badge variant="neutral" className="mt-2">
|
||||||
|
NCLC {nclcCible}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Jauge avec marqueur NCLC cible */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div
|
||||||
|
className="relative h-2 overflow-hidden rounded-full bg-canvas-2"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={score}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={20}
|
||||||
|
aria-label={`Score ${score} sur 20`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${
|
||||||
|
atteint ? 'bg-success' : 'bg-expria'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
{/* Marqueur du seuil NCLC cible */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full w-0.5 bg-ink-2"
|
||||||
|
style={{ left: `${seuilPercent}%` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
title={`Seuil NCLC ${nclcCible} : ${seuilCible}/20`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-ink-4 tabular-nums">
|
||||||
|
<span>0</span>
|
||||||
|
<span className="font-medium">Seuil NCLC {nclcCible} : {seuilCible}/20</span>
|
||||||
|
<span>20</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encart d'écart */}
|
||||||
|
{atteint ? (
|
||||||
|
<p className="rounded-md border border-success/30 bg-success-bg px-3 py-2 text-sm text-success">
|
||||||
|
Objectif NCLC {nclcCible} atteint.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-md border border-warning/30 bg-warning-bg px-3 py-2 text-sm text-warning">
|
||||||
|
{points === 1
|
||||||
|
? '1 point avant NCLC '
|
||||||
|
: `${points} points avant NCLC `}
|
||||||
|
{nclcCible}+
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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(<ExerciceInteractive exercice={EXERCICE} />)
|
||||||
|
expect(screen.getByText('Facile')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bouton "Voir la correction" désactivé tant que la zone de saisie est vide', () => {
|
||||||
|
render(<ExerciceInteractive exercice={EXERCICE} />)
|
||||||
|
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(<ExerciceInteractive exercice={EXERCICE} />)
|
||||||
|
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(<ExerciceInteractive exercice={EXERCICE} />)
|
||||||
|
|
||||||
|
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(<ExerciceInteractive exercice={EXERCICE} />)
|
||||||
|
|
||||||
|
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(<ExerciceInteractive exercice={sansIndice} />)
|
||||||
|
// Pas d'indice → bouton désactivé d'office (evite de révéler un vide)
|
||||||
|
expect(screen.getByRole('button', { name: /^indice$/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -56,14 +56,18 @@ const mockSujet = {
|
||||||
|
|
||||||
const mockReport: Report = {
|
const mockReport: Report = {
|
||||||
simulation_id: 'sim-1',
|
simulation_id: 'sim-1',
|
||||||
score: 80,
|
score: 14,
|
||||||
nclc: 9,
|
nclc: 9,
|
||||||
feedback_court: 'Bon travail.',
|
nclc_cible: 9,
|
||||||
|
revelation: { croyance: '', realite: '', consequence: '' },
|
||||||
|
diagnostic: 'Diagnostic test.',
|
||||||
criteres: [],
|
criteres: [],
|
||||||
erreurs: [],
|
conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
|
||||||
modele: '',
|
erreurs_codes: [],
|
||||||
idees: [],
|
exercices: null,
|
||||||
exercices: [],
|
exercices_status: 'pending',
|
||||||
|
modele: null,
|
||||||
|
modele_status: 'pending',
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
|
|
@ -224,6 +228,11 @@ describe('useSimulation — FTD-21 resume depuis localStorage', () => {
|
||||||
contenu: 'Mon brouillon.',
|
contenu: 'Mon brouillon.',
|
||||||
sujet: mockSujet,
|
sujet: mockSujet,
|
||||||
rapport: null,
|
rapport: null,
|
||||||
|
nclc_cible: null,
|
||||||
|
exercices: null,
|
||||||
|
exercices_status: 'pending',
|
||||||
|
modele: null,
|
||||||
|
modele_status: 'pending',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||||
|
|
@ -246,9 +255,20 @@ describe('useSimulation — FTD-21 resume depuis localStorage', () => {
|
||||||
contenu: 'texte',
|
contenu: 'texte',
|
||||||
sujet: null,
|
sujet: null,
|
||||||
rapport: {
|
rapport: {
|
||||||
score: 14, nclc: 8, feedback_court: 'OK',
|
score: 14,
|
||||||
criteres: [], erreurs: [], modele: '', idees: [], exercices: [],
|
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() })
|
renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||||
|
|
|
||||||
|
|
@ -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) :
|
* Sections conditionnelles via isSectionVisible(plan, section) :
|
||||||
* detailed_report → criteres, erreurs
|
* detailed_report → criteres (avec exemple/suggestion/astuce/codes)
|
||||||
* tips → modele, idees, exercices
|
* 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.
|
* SEC-05 : textes IA rendus via react-markdown, jamais dangerouslySetInnerHTML.
|
||||||
* Règle D : isSectionVisible() obligatoire — jamais if (plan === 'xxx').
|
* Règle D : isSectionVisible() obligatoire — jamais if (plan === 'xxx').
|
||||||
|
|
@ -12,16 +17,27 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
|
||||||
import { Lock } from 'lucide-react'
|
import { Lock } from 'lucide-react'
|
||||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
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 { useRapport } from '../hooks/useRapport'
|
||||||
|
import { useSimulation } from '../hooks/useSimulation'
|
||||||
import { Card } from '@/shared/ui/Card'
|
import { Card } from '@/shared/ui/Card'
|
||||||
import { Badge } from '@/shared/ui/Badge'
|
|
||||||
import { Button } from '@/shared/ui/Button'
|
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 {
|
function isReportNotReady(err: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,19 +48,7 @@ function isReportNotReady(err: unknown): boolean {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Composants internes ──────────────────────────────────────────────────────
|
// ── Floutage section ────────────────────────────────────────────────────
|
||||||
|
|
||||||
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
|
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
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
if (visible) return <>{children}</>
|
if (visible) return <>{children}</>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-[88px] overflow-hidden rounded-lg border border-line bg-canvas-2">
|
<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">
|
<div className="space-y-2 p-4 opacity-25 blur-sm" aria-hidden="true">
|
||||||
{PLACEHOLDER_WIDTHS.map((w, i) => (
|
{PLACEHOLDER_WIDTHS.map((w, i) => (
|
||||||
<div key={i} className={`h-3 rounded bg-ink-4 ${w}`} />
|
<div key={i} className={`h-3 rounded bg-ink-4 ${w}`} />
|
||||||
|
|
@ -77,38 +80,87 @@ function BlurredSection({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CritereRow({ critere }: { critere: Critere }) {
|
// ── Squelette ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RapportSkeleton() {
|
||||||
return (
|
return (
|
||||||
<li className="flex flex-col gap-1.5 rounded-lg border border-line bg-canvas-2 p-3">
|
<div className="space-y-4" aria-busy="true" aria-label="Chargement du rapport…">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="h-40 animate-pulse rounded-lg bg-canvas-2" />
|
||||||
<span className="text-sm font-semibold text-ink-1">{critere.nom}</span>
|
<div className="h-28 animate-pulse rounded-lg bg-canvas-2" />
|
||||||
<Badge variant="neutral">{critere.score}</Badge>
|
<div className="h-32 animate-pulse rounded-lg bg-canvas-2" />
|
||||||
</div>
|
<div className="h-48 animate-pulse rounded-lg bg-canvas-2" />
|
||||||
<div className="text-sm text-ink-3">
|
</div>
|
||||||
<ReactMarkdown
|
|
||||||
disallowedElements={['script', 'iframe']}
|
|
||||||
components={{ p: ({ children: c }) => <p className="leading-relaxed">{c}</p> }}
|
|
||||||
>
|
|
||||||
{critere.commentaire}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExerciceCard({ exercice }: { exercice: string }) {
|
// ── Sections thématiques ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CriteresSection({ rapport }: { rapport: Report }) {
|
||||||
|
const grouped = groupErreursByCritere(rapport.erreurs_codes)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="rounded-lg border border-line bg-canvas-2 p-4">
|
<section aria-label="Détail par critère">
|
||||||
<div className="text-sm leading-relaxed text-ink-3">
|
<h2 className="mb-3 text-base font-semibold text-ink-1">Détail par critère</h2>
|
||||||
<ReactMarkdown disallowedElements={['script', 'iframe']}>
|
<div className="space-y-3">
|
||||||
{exercice}
|
{rapport.criteres.map((c) => {
|
||||||
</ReactMarkdown>
|
const code = critereCodeFromNom(c.nom)
|
||||||
|
const codes = code ? grouped[code] : []
|
||||||
|
return <CritereCard key={c.nom} critere={c} erreursCodes={codes} />
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page principale ──────────────────────────────────────────────────────────
|
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() {
|
export function RapportPage() {
|
||||||
const { id = '' } = useParams<{ id: string }>()
|
const { id = '' } = useParams<{ id: string }>()
|
||||||
|
|
@ -117,13 +169,7 @@ export function RapportPage() {
|
||||||
const { rapport, isLoading, isError, error } = useRapport(id)
|
const { rapport, isLoading, isError, error } = useRapport(id)
|
||||||
const isInProgress = isError && isReportNotReady(error)
|
const isInProgress = isError && isReportNotReady(error)
|
||||||
|
|
||||||
// FTD-21 — si la simulation n'est pas encore corrigée, rediriger vers /simulation/ee.
|
const { reset } = useSimulation()
|
||||||
// Le SimulationFlowProvider restaurera la session via localStorage si présent.
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInProgress) {
|
|
||||||
navigate('/simulation/ee', { replace: true })
|
|
||||||
}
|
|
||||||
}, [isInProgress, navigate])
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: planData,
|
data: planData,
|
||||||
|
|
@ -131,179 +177,100 @@ export function RapportPage() {
|
||||||
isError: isPlanError,
|
isError: isPlanError,
|
||||||
} = usePlan()
|
} = 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')
|
const onUpgrade = () => navigate('/plan')
|
||||||
|
|
||||||
return (
|
// Quitter le rapport proprement : reset du flow (step, production, mutations)
|
||||||
<main className="mx-auto max-w-2xl space-y-6 px-4 py-6">
|
// 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 */}
|
{/* Breadcrumb */}
|
||||||
<nav aria-label="Fil d'Ariane" className="flex items-center gap-1.5 text-sm text-ink-4">
|
<nav aria-label="Fil d'Ariane" className="flex items-center gap-1.5 text-sm text-ink-4">
|
||||||
<Link
|
<button
|
||||||
to="/simulation/ee"
|
type="button"
|
||||||
|
onClick={goToSimulations}
|
||||||
className="transition-colors duration-150 hover:text-ink-2"
|
className="transition-colors duration-150 hover:text-ink-2"
|
||||||
>
|
>
|
||||||
Simulations
|
Simulations
|
||||||
</Link>
|
</button>
|
||||||
<span aria-hidden="true">›</span>
|
<span aria-hidden="true">›</span>
|
||||||
<span aria-current="page" className="text-ink-2">Rapport</span>
|
<span aria-current="page" className="text-ink-2">Rapport</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{(isLoading || isPlanLoading) && <RapportSkeleton />}
|
{(isLoading || isPlanLoading) && <RapportSkeleton />}
|
||||||
|
|
||||||
{/* FTD-21 — simulation en cours : message discret avant redirection via useEffect */}
|
|
||||||
{isInProgress && (
|
{isInProgress && (
|
||||||
<p className="text-center text-sm text-ink-4" aria-live="polite">
|
<p className="text-center text-sm text-ink-4" aria-live="polite">
|
||||||
Votre simulation est en cours.
|
Votre simulation est en cours.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Erreur (hors "en cours" déjà géré au-dessus) */}
|
|
||||||
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
|
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
|
||||||
<div
|
<Card variant="default" className="border-l-4 border-l-danger p-4">
|
||||||
role="alert"
|
<div role="alert" className="space-y-3">
|
||||||
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 className="text-sm text-danger">
|
</p>
|
||||||
Impossible de charger ce rapport. Réessayez dans quelques instants.
|
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
|
||||||
</p>
|
Retour aux simulations
|
||||||
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
|
</Button>
|
||||||
Retour aux simulations
|
</div>
|
||||||
</Button>
|
</Card>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rapport && planData && (
|
{rapport && planData && (
|
||||||
<>
|
<>
|
||||||
{/* ── Hero : Score + NCLC — toujours visibles ───────────── */}
|
<ScoreHero
|
||||||
<Card variant="raised" className="p-6">
|
score={rapport.score}
|
||||||
<div className="flex flex-wrap items-end gap-8">
|
nclc={rapport.nclc}
|
||||||
<div>
|
nclcCible={rapport.nclc_cible}
|
||||||
<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 ─────────────────── */}
|
<RevelationCards revelation={rapport.revelation} />
|
||||||
<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 ────────────────────────── */}
|
<DiagnosticCallout diagnostic={rapport.diagnostic} />
|
||||||
<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 ─────────────────────────── */}
|
<BlurredSection
|
||||||
<section aria-label="Erreurs détectées">
|
visible={isSectionVisible(planData.plan, 'criteres')}
|
||||||
<h2 className="mb-3 text-base font-semibold text-ink-1">Erreurs détectées</h2>
|
onUpgrade={onUpgrade}
|
||||||
<BlurredSection
|
>
|
||||||
visible={isSectionVisible(planData.plan, 'erreurs')}
|
<CriteresSection rapport={rapport} />
|
||||||
onUpgrade={onUpgrade}
|
</BlurredSection>
|
||||||
>
|
|
||||||
<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 ────────────────────────────────────── */}
|
<ConseilNclcCallout conseil={rapport.conseil_nclc} />
|
||||||
<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 ─────────────────────────────────────── */}
|
<BlurredSection
|
||||||
<section aria-label="Suggestions d'idées">
|
visible={isSectionVisible(planData.plan, 'exercices')}
|
||||||
<h2 className="mb-3 text-base font-semibold text-ink-1">Suggestions d'idées</h2>
|
onUpgrade={onUpgrade}
|
||||||
<BlurredSection
|
>
|
||||||
visible={isSectionVisible(planData.plan, 'idees')}
|
<ExercicesSection rapport={rapport} />
|
||||||
onUpgrade={onUpgrade}
|
</BlurredSection>
|
||||||
>
|
|
||||||
<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 ─────────────────────────────────── */}
|
<BlurredSection
|
||||||
<section aria-label="Exercices personnalisés">
|
visible={isSectionVisible(planData.plan, 'modele')}
|
||||||
<h2 className="mb-3 text-base font-semibold text-ink-1">Exercices personnalisés</h2>
|
onUpgrade={onUpgrade}
|
||||||
<BlurredSection
|
>
|
||||||
visible={isSectionVisible(planData.plan, 'exercices')}
|
<ModeleSection rapport={rapport} />
|
||||||
onUpgrade={onUpgrade}
|
</BlurredSection>
|
||||||
>
|
|
||||||
<ul className="space-y-3">
|
{/* Action de sortie — reset + nouvelle simulation */}
|
||||||
{rapport.exercices.map((ex, i) => (
|
<div className="flex justify-center pt-4">
|
||||||
<ExerciceCard key={i} exercice={ex} />
|
<Button variant="primary" onClick={goToSimulations}>
|
||||||
))}
|
Nouvelle simulation
|
||||||
</ul>
|
</Button>
|
||||||
</BlurredSection>
|
</div>
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ interface FlowValue {
|
||||||
createError: ApiError | null
|
createError: ApiError | null
|
||||||
correctError: ApiError | null
|
correctError: ApiError | null
|
||||||
selectTask: (payload: CreateSimulationPayload) => void
|
selectTask: (payload: CreateSimulationPayload) => void
|
||||||
submitText: (texte: string) => void
|
submitText: (texte: string, nclcCible?: 9 | 10) => void
|
||||||
changeSubject: (sujet: SujetData) => void
|
changeSubject: (sujet: SujetData) => void
|
||||||
setStep: (step: SimulationStep) => void
|
setStep: (step: SimulationStep) => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
|
|
@ -101,16 +101,27 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
||||||
mutationFn: createSimulation,
|
mutationFn: createSimulation,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setProduction(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({
|
const correctMutation = useMutation({
|
||||||
mutationFn: correctEe,
|
mutationFn: correctEe,
|
||||||
onMutate: () => setStep('correcting'),
|
onMutate: () => setStep('correcting'),
|
||||||
onSuccess: () => {
|
onSuccess: (_data, variables) => {
|
||||||
setStep('done')
|
setStep('done')
|
||||||
localStorage.removeItem(LS_SIMULATION_ID_KEY)
|
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'),
|
onError: () => setStep('task-selected'),
|
||||||
})
|
})
|
||||||
|
|
@ -119,9 +130,14 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
||||||
createMutation.mutate(payload)
|
createMutation.mutate(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitText(texte: string): void {
|
function submitText(texte: string, nclcCible: 9 | 10 = 9): void {
|
||||||
if (!production) return
|
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 {
|
function changeSubject(sujet: SujetData): void {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue