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
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 { hasAccess, type Plan } from '@/entities/user/lib'
|
||||
import type { SujetData, Tache } from '@/entities/production/types'
|
||||
import type { NclcCible } from '@/entities/report/types'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
import { countWords, getSimulationConfig } from '../lib/simulationConfig'
|
||||
import { useTimer } from '../hooks/useTimer'
|
||||
|
|
@ -28,6 +29,7 @@ import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
|
|||
import { TimerDisplay } from './TimerDisplay'
|
||||
import { WordCountBar } from './WordCountBar'
|
||||
import { IdeesSuggestions } from './IdeesSuggestions'
|
||||
import { NclcCibleSelector } from './NclcCibleSelector'
|
||||
|
||||
const MIN_WORDS_IDEES = 30
|
||||
const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
|
||||
|
|
@ -66,7 +68,7 @@ interface Props {
|
|||
step: SimulationStep
|
||||
isSubmitting: boolean
|
||||
error: ApiError | null
|
||||
onSubmit: (texte: string) => void
|
||||
onSubmit: (texte: string, nclcCible: NclcCible) => void
|
||||
onBack: () => void
|
||||
onChangeSujet: () => void
|
||||
}
|
||||
|
|
@ -89,6 +91,7 @@ export function SimulationForm({
|
|||
const [texte, setTexte] = useState(() => initialContenu ?? '')
|
||||
const [fieldError, setFieldError] = useState<string | null>(null)
|
||||
const [isIdeesOpen, setIsIdeesOpen] = useState(false)
|
||||
const [nclcCible, setNclcCible] = useState<NclcCible>(9)
|
||||
|
||||
const config = getSimulationConfig(tache)
|
||||
const wordCount = countWords(texte)
|
||||
|
|
@ -150,8 +153,8 @@ export function SimulationForm({
|
|||
if (wordCount < config.motsMin) return
|
||||
|
||||
hasAutoSubmittedRef.current = true
|
||||
onSubmit(texte)
|
||||
}, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, onSubmit])
|
||||
onSubmit(texte, nclcCible)
|
||||
}, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, nclcCible, onSubmit])
|
||||
|
||||
function handleInsert(char: string) {
|
||||
const el = textareaRef.current
|
||||
|
|
@ -185,7 +188,7 @@ export function SimulationForm({
|
|||
return
|
||||
}
|
||||
|
||||
onSubmit(parsed.data.texte)
|
||||
onSubmit(parsed.data.texte, nclcCible)
|
||||
}
|
||||
|
||||
const apiError = mapCorrectError(error)
|
||||
|
|
@ -308,6 +311,12 @@ export function SimulationForm({
|
|||
className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<WordCountBar count={wordCount} config={config} />
|
||||
<NclcCibleSelector
|
||||
value={nclcCible}
|
||||
onChange={setNclcCible}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
{autosave.savedAt && !fieldError && (
|
||||
<p className="text-xs text-ink-4" aria-live="polite">
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue