feat(simulations): finaliser flux /sujets — SimulationForm + SujetDisplay + TaskSelector type prop
- SimulationForm : bouton "Changer de sujet" → /sujets (étape 3 refonte) - SujetDisplay : redevient présentationnel (plus de dropdown) - TaskSelector : prop type 'EE' | 'EO' (EO_CARDS réservé usage futur — non routé) - SimulationPage : type='EE' hardcodé (EO restera ComingSoon jusqu'au Sprint EO) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
43f3ce2c6c
commit
6bfdf15db9
4 changed files with 69 additions and 116 deletions
|
|
@ -11,7 +11,7 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { Clock, Loader2 } from 'lucide-react'
|
||||
import { Clock, Loader2, Shuffle } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/shared/components/ui/button'
|
||||
import { formatTache } from '@/entities/production/lib'
|
||||
|
|
@ -49,20 +49,16 @@ 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
|
||||
onChangeSujet: () => void
|
||||
}
|
||||
|
||||
export function SimulationForm({
|
||||
tache,
|
||||
sujet,
|
||||
sujets,
|
||||
isLoadingSujets,
|
||||
isSubmitting,
|
||||
error,
|
||||
onSubmit,
|
||||
|
|
@ -150,13 +146,22 @@ export function SimulationForm({
|
|||
<h2 className="flex-1 text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
|
||||
</div>
|
||||
|
||||
<SujetDisplay
|
||||
sujet={sujet}
|
||||
sujets={sujets}
|
||||
isLoadingSujets={isLoadingSujets}
|
||||
onChangeSujet={onChangeSujet}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<SujetDisplay sujet={sujet} />
|
||||
|
||||
{sujet && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeSujet}
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex items-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="Changer de sujet"
|
||||
>
|
||||
<Shuffle className="size-4" aria-hidden="true" />
|
||||
Changer de sujet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiError && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,33 +1,22 @@
|
|||
/**
|
||||
* Affichage du sujet d'examen (consigne + documents) avec sélecteur intégré.
|
||||
* Affichage du sujet d'examen (consigne + documents) — purement présentationnel.
|
||||
*
|
||||
* - `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)
|
||||
* Depuis la refonte /sujets (2026-04-21), le choix du sujet se fait sur une
|
||||
* page dédiée (SujetsPage). Ce composant n'affiche que le sujet sélectionné.
|
||||
*
|
||||
* 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).
|
||||
* Le contenu est admin-curé (pas du texte IA) → plain-text avec whitespace-pre-wrap,
|
||||
* 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.
|
||||
* Règle H : purement présentationnel — le sujet vient du parent.
|
||||
* Règle L : tokens Direction H exclusivement.
|
||||
*/
|
||||
|
||||
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 }) {
|
||||
|
|
@ -42,67 +31,12 @@ function DocumentBlock({ titre, texte }: { titre: string | null; texte: string |
|
|||
)
|
||||
}
|
||||
|
||||
export function SujetDisplay({
|
||||
sujet,
|
||||
sujets,
|
||||
isLoadingSujets,
|
||||
onChangeSujet,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
export function SujetDisplay({ sujet }: 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>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
/**
|
||||
* Sélecteur de tâche pour lancer une simulation Expression Écrite.
|
||||
* Sélecteur de tâche pour lancer une simulation.
|
||||
*
|
||||
* Affiche les 3 tâches EE : T1, T2, T3 (sélectionnables si quota OK).
|
||||
* Les tâches Expression Orale seront sur /simulation/eo (Sprint EO).
|
||||
* Filtre les cartes selon `type` :
|
||||
* - 'EE' → 3 tâches EE (T1/T2/T3) sélectionnables si quota OK
|
||||
* - 'EO' → EO_T1 (Entretien) + EO_T3 (Point de vue) + EO_T2 Live verrouillé (Premium)
|
||||
*
|
||||
* Règle D : le quota est vérifié via canSimulate(), jamais if (plan === 'free').
|
||||
* Règle H : aucune logique métier — uniquement appel de canSimulate() et affichage.
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Lock, Loader2 } from 'lucide-react'
|
||||
import { canSimulate } from '@/entities/user/lib'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
|
|
@ -16,7 +17,10 @@ import { Badge } from '@/shared/ui/Badge'
|
|||
import type { Plan } from '@/entities/user/lib'
|
||||
import type { CreateSimulationPayload, Tache } from '@/entities/production/types'
|
||||
|
||||
export type TaskKind = 'EE' | 'EO'
|
||||
|
||||
interface Props {
|
||||
type: TaskKind
|
||||
plan: Plan
|
||||
simulationsUsed: number
|
||||
isLoading: boolean
|
||||
|
|
@ -24,20 +28,29 @@ interface Props {
|
|||
}
|
||||
|
||||
interface TaskCard {
|
||||
tache: Tache
|
||||
key: string
|
||||
tache: Tache | null // null = carte verrouillée (EO_T2 Live)
|
||||
label: string
|
||||
sublabel: string
|
||||
lockLabel?: string
|
||||
}
|
||||
|
||||
const TASK_CARDS: readonly TaskCard[] = [
|
||||
{ tache: 'EE_T1', label: 'Expression Écrite', sublabel: 'Tâche 1' },
|
||||
{ tache: 'EE_T2', label: 'Expression Écrite', sublabel: 'Tâche 2' },
|
||||
{ tache: 'EE_T3', label: 'Expression Écrite', sublabel: 'Tâche 3' },
|
||||
const EE_CARDS: readonly TaskCard[] = [
|
||||
{ key: 'EE_T1', tache: 'EE_T1', label: 'Expression Écrite', sublabel: 'Tâche 1' },
|
||||
{ key: 'EE_T2', tache: 'EE_T2', label: 'Expression Écrite', sublabel: 'Tâche 2' },
|
||||
{ key: 'EE_T3', tache: 'EE_T3', label: 'Expression Écrite', sublabel: 'Tâche 3' },
|
||||
]
|
||||
|
||||
export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Props) {
|
||||
const EO_CARDS: readonly TaskCard[] = [
|
||||
{ key: 'EO_T1', tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Entretien' },
|
||||
{ key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' },
|
||||
{ key: 'EO_T2_LIVE', tache: null, label: 'Expression Orale', sublabel: 'Tâche 2 — Live', lockLabel: 'Exclusivité Premium' },
|
||||
]
|
||||
|
||||
export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect }: Props) {
|
||||
const simulationCheck = canSimulate(plan, simulationsUsed)
|
||||
const quotaBlocked = !simulationCheck.allowed
|
||||
const cards = type === 'EE' ? EE_CARDS : EO_CARDS
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -62,31 +75,40 @@ export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Pro
|
|||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{TASK_CARDS.map((card) => {
|
||||
const abbrev = card.tache.split('_')[0]
|
||||
{cards.map((card) => {
|
||||
const locked = card.tache === null || quotaBlocked
|
||||
const abbrev = card.tache ? card.tache.split('_')[0] : 'EO'
|
||||
|
||||
if (quotaBlocked) {
|
||||
if (locked) {
|
||||
return (
|
||||
<Card
|
||||
key={card.tache}
|
||||
key={card.key}
|
||||
variant="default"
|
||||
className="flex flex-col p-4 opacity-60"
|
||||
>
|
||||
{card.tache === null && (
|
||||
<Lock className="mb-2 size-4 text-ink-4" aria-hidden="true" />
|
||||
)}
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||
{card.label}
|
||||
</span>
|
||||
<span className="mt-1 text-sm font-semibold text-ink-1">{card.sublabel}</span>
|
||||
{card.lockLabel && (
|
||||
<span className="mt-1.5 text-xs text-ink-4">{card.lockLabel}</span>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={card.tache}
|
||||
key={card.key}
|
||||
variant="interactive"
|
||||
className={cn('relative flex flex-col p-4', isLoading && 'cursor-wait')}
|
||||
onClick={() => {
|
||||
if (!isLoading) onSelect({ tache: card.tache, mode: 'entrainement' })
|
||||
if (!isLoading && card.tache) {
|
||||
onSelect({ tache: card.tache, mode: 'entrainement' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Page de simulation Expression Écrite.
|
||||
*
|
||||
* Orchestre les 3 étapes du flux : sélection de tâche → saisie du texte → rapport.
|
||||
* Le choix du sujet est désormais délégué à la page /sujets (refonte UX 2026-04-21).
|
||||
* Orchestre les 3 étapes du flux : sélection de tâche → saisie → rapport.
|
||||
* Le choix du sujet est délégué à la page /sujets (refonte UX 2026-04-21).
|
||||
*
|
||||
* Règle D : quotas et permissions passent par canSimulate() — jamais de plan === '...'
|
||||
* Règle H : aucune logique métier — tout est dans useSimulation() et les entités.
|
||||
|
|
@ -17,7 +17,6 @@ import { useQuery } from '@tanstack/react-query'
|
|||
import { getPlanStatus } from '@/entities/user/api'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { useSimulation } from '../hooks/useSimulation'
|
||||
import { useSujets } from '../hooks/useSujets'
|
||||
import { TaskSelector } from '../components/TaskSelector'
|
||||
import { SimulationForm } from '../components/SimulationForm'
|
||||
|
||||
|
|
@ -57,16 +56,10 @@ export function SimulationPage() {
|
|||
correctError,
|
||||
selectTask,
|
||||
submitText,
|
||||
changeSubject,
|
||||
goToSubjectPicker,
|
||||
reset,
|
||||
} = useSimulation()
|
||||
|
||||
// Catalogue passé à SimulationForm (dropdown hérité — refacto étape 3).
|
||||
const { data: sujets, isLoading: isLoadingSujets } = useSujets(
|
||||
production?.tache ?? 'EE_T1',
|
||||
!!production,
|
||||
)
|
||||
|
||||
// Redirige vers /sujets dès que la création aboutit pour une tâche avec catalogue.
|
||||
useEffect(() => {
|
||||
if (step === 'choosing-subject' && production) {
|
||||
|
|
@ -97,6 +90,7 @@ export function SimulationPage() {
|
|||
|
||||
{planData && step === 'idle' && (
|
||||
<TaskSelector
|
||||
type="EE"
|
||||
plan={planData.plan}
|
||||
simulationsUsed={planData.simulations_used}
|
||||
isLoading={isCreating}
|
||||
|
|
@ -110,13 +104,11 @@ export function SimulationPage() {
|
|||
<SimulationForm
|
||||
tache={production.tache}
|
||||
sujet={sujet}
|
||||
sujets={sujets ?? []}
|
||||
isLoadingSujets={isLoadingSujets}
|
||||
isSubmitting={isCorrecting}
|
||||
error={correctError}
|
||||
onSubmit={submitText}
|
||||
onBack={reset}
|
||||
onChangeSujet={changeSubject}
|
||||
onChangeSujet={goToSubjectPicker}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue