feat(simulations): choix du sujet — dropdown intégré + bouton aléatoire
This commit is contained in:
parent
477477b6a6
commit
7902eec042
5 changed files with 193 additions and 29 deletions
|
|
@ -49,13 +49,26 @@ function mapCorrectError(err: ApiError | null): string | null {
|
|||
interface Props {
|
||||
tache: Tache
|
||||
sujet: SujetData | null
|
||||
sujets: SujetData[]
|
||||
isLoadingSujets: boolean
|
||||
isSubmitting: boolean
|
||||
error: ApiError | null
|
||||
onSubmit: (texte: string) => void
|
||||
onBack: () => void
|
||||
onChangeSujet: (sujet: SujetData) => void
|
||||
}
|
||||
|
||||
export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, onBack }: Props) {
|
||||
export function SimulationForm({
|
||||
tache,
|
||||
sujet,
|
||||
sujets,
|
||||
isLoadingSujets,
|
||||
isSubmitting,
|
||||
error,
|
||||
onSubmit,
|
||||
onBack,
|
||||
onChangeSujet,
|
||||
}: Props) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const hasAutoSubmittedRef = useRef(false)
|
||||
const [texte, setTexte] = useState('')
|
||||
|
|
@ -137,7 +150,13 @@ export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, on
|
|||
<h2 className="flex-1 text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
|
||||
</div>
|
||||
|
||||
<SujetDisplay sujet={sujet} />
|
||||
<SujetDisplay
|
||||
sujet={sujet}
|
||||
sujets={sujets}
|
||||
isLoadingSujets={isLoadingSujets}
|
||||
onChangeSujet={onChangeSujet}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
{apiError && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,22 +1,33 @@
|
|||
/**
|
||||
* Affichage du sujet d'examen (consigne + documents) au-dessus de la zone de saisie.
|
||||
* Affichage du sujet d'examen (consigne + documents) avec sélecteur intégré.
|
||||
*
|
||||
* Prop `sujet` vient du hook `useSimulation` (alimenté par `POST /simulations`).
|
||||
* Si `sujet === null` (aucun sujet actif pour la tâche, ou EO_T2_LIVE) → rien n'est rendu.
|
||||
* - `sujet` : le sujet actuellement affiché (null = rien à rendre)
|
||||
* - `sujets` : catalogue complet pour le dropdown + le tirage aléatoire
|
||||
* - `onChangeSujet` : appelé avec le nouveau sujet choisi (dropdown ou random)
|
||||
*
|
||||
* Règle H : composant purement présentationnel, aucune logique métier.
|
||||
* Règle H : purement présentationnel — la liste et le callback viennent du parent.
|
||||
* Règle L : tokens Direction H exclusivement (canvas, surface, ink-*, line, expria).
|
||||
*
|
||||
* Rendu plain-text avec `whitespace-pre-wrap` pour préserver les sauts de ligne.
|
||||
* Les sujets étant du contenu admin-curé (pas du texte IA), pas de react-markdown.
|
||||
* Le contenu des sujets est admin-curé (pas du texte IA) → plain-text avec
|
||||
* `whitespace-pre-wrap`, pas de react-markdown.
|
||||
*/
|
||||
|
||||
import { Shuffle } from 'lucide-react'
|
||||
import { Badge } from '@/shared/ui/Badge'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import type { SujetData } from '@/entities/production/types'
|
||||
|
||||
interface Props {
|
||||
sujet: SujetData | null
|
||||
sujets: SujetData[]
|
||||
isLoadingSujets: boolean
|
||||
onChangeSujet: (sujet: SujetData) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s
|
||||
return `${s.slice(0, max).trimEnd()}…`
|
||||
}
|
||||
|
||||
function DocumentBlock({ titre, texte }: { titre: string | null; texte: string | null }) {
|
||||
|
|
@ -31,12 +42,67 @@ function DocumentBlock({ titre, texte }: { titre: string | null; texte: string |
|
|||
)
|
||||
}
|
||||
|
||||
export function SujetDisplay({ sujet }: Props) {
|
||||
export function SujetDisplay({
|
||||
sujet,
|
||||
sujets,
|
||||
isLoadingSujets,
|
||||
onChangeSujet,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
if (!sujet) return null
|
||||
|
||||
const hasCatalog = sujets.length > 0
|
||||
const canRandomize = hasCatalog && sujets.length > 1
|
||||
|
||||
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const next = sujets.find((s) => s.id === e.target.value)
|
||||
if (next && next.id !== sujet?.id) onChangeSujet(next)
|
||||
}
|
||||
|
||||
function handleRandom() {
|
||||
if (sujets.length === 0) return
|
||||
const others = sujets.length > 1 ? sujets.filter((s) => s.id !== sujet?.id) : sujets
|
||||
const pick = others[Math.floor(Math.random() * others.length)]
|
||||
if (pick) onChangeSujet(pick)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="default" className="p-5">
|
||||
<div className="space-y-4">
|
||||
{(hasCatalog || isLoadingSujets) && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<label htmlFor="sujet-select" className="text-xs font-semibold uppercase tracking-wide text-ink-4">
|
||||
Sujet
|
||||
</label>
|
||||
<select
|
||||
id="sujet-select"
|
||||
value={sujet.id}
|
||||
onChange={handleSelectChange}
|
||||
disabled={disabled || isLoadingSujets || !hasCatalog}
|
||||
className="min-w-0 flex-1 truncate rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-1 focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoadingSujets && !hasCatalog && (
|
||||
<option value={sujet.id}>Chargement…</option>
|
||||
)}
|
||||
{sujets.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{truncate(s.consigne.replace(/\s+/g, ' '), 80)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRandom}
|
||||
disabled={disabled || isLoadingSujets || !canRandomize}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-2 transition-colors hover:border-expria hover:text-expria focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Tirer un sujet aléatoire"
|
||||
>
|
||||
<Shuffle className="size-4" aria-hidden="true" />
|
||||
Changer de sujet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sujet.role && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="neutral">Rôle</Badge>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue