feat(simulations): useSimulation hook + TaskSelector + SimulationForm + SimulationPage + route (Sprint 3 étape 14)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b31e8666a5
commit
997f39bd33
7 changed files with 621 additions and 0 deletions
|
|
@ -161,6 +161,21 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
|
|||
|
||||
---
|
||||
|
||||
### FTD-17 — Clé `['plan']` dupliquée entre features
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert — accepté par design (Sprint 3)
|
||||
**Estimation de session :** 30 min
|
||||
**Description :** La queryKey TanStack Query `['plan']` est écrite littéralement dans deux endroits : `features/dashboard/hooks/usePlan.ts` (via `PLAN_QUERY_KEY`) et `features/simulations/pages/SimulationPage.tsx` (inline). TanStack Query partage bien le cache par clé identique, donc la déduplication fonctionne en pratique. Mais toute faute de frappe dans l'une des occurrences briserait silencieusement la mise en cache partagée.
|
||||
|
||||
**À faire :**
|
||||
- Déplacer `PLAN_QUERY_KEY` vers `src/entities/user/api.ts` (ou un nouveau `src/entities/user/query-keys.ts`)
|
||||
- Importer `PLAN_QUERY_KEY` dans `usePlan.ts` et `SimulationPage.tsx` depuis ce fichier partagé
|
||||
- Supprimer la constante locale dans `usePlan.ts`
|
||||
|
||||
**Condition de résolution :** avant l'ajout d'une troisième feature qui consomme `['plan']` (T2 Live page, rapport page).
|
||||
|
||||
---
|
||||
|
||||
## 3. Fonctionnalités reportées
|
||||
|
||||
### FTD-06 — AudioWorklet au lieu de ScriptProcessorNode (T2 Live)
|
||||
|
|
@ -270,3 +285,4 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
|
|||
| 1.2 | 2026-04-17 | Ajout FTD-13 résolu (incompatibilité Vitest 3 / Vite 8) suite à l'étape 12-bis du Sprint 0 |
|
||||
| 1.3 | 2026-04-18 | FTD-11 résolu (design system Sprint 0.5) ; ajout FTD-14 (anti-FOUC), FTD-15 (option 'system' thème) |
|
||||
| 1.4 | 2026-04-18 | FTD-16 résolu (VITE_MAINTENANCE_MODE implémenté — Sprint 1 étape 6) |
|
||||
| 1.5 | 2026-04-19 | Ajout FTD-17 (clé ['plan'] dupliquée entre features — Sprint 3 étape 14) |
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { LoginPage } from '@/features/auth/pages/LoginPage'
|
|||
import { RegisterPage } from '@/features/auth/pages/RegisterPage'
|
||||
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
|
||||
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
|
||||
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
|
||||
|
||||
const DesignSystemPage = import.meta.env.DEV
|
||||
? React.lazy(() => import('@/features/design-system/DesignSystemPage'))
|
||||
|
|
@ -24,6 +25,14 @@ export function AppRouter() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/simulation"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SimulationPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{import.meta.env.DEV && (
|
||||
<Route
|
||||
path="/design-system"
|
||||
|
|
|
|||
139
src/features/simulations/components/SimulationForm.tsx
Normal file
139
src/features/simulations/components/SimulationForm.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Formulaire de saisie pour une simulation Expression Écrite.
|
||||
*
|
||||
* SEC-04 : validation Zod avant envoi (texte non vide, max 5 000 caractères).
|
||||
* SEC-05 : aucun dangerouslySetInnerHTML — le texte utilisateur est rendu comme texte.
|
||||
* Règle H : aucune logique métier — le composant reçoit tache, handlers et états par props.
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/shared/components/ui/button'
|
||||
import { formatTache } from '@/entities/production/lib'
|
||||
import type { Tache } from '@/entities/production/types'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
|
||||
const textSchema = z.object({
|
||||
texte: z
|
||||
.string()
|
||||
.min(1, 'Le texte ne peut pas être vide.')
|
||||
.max(5000, 'Le texte ne doit pas dépasser 5 000 caractères.'),
|
||||
})
|
||||
|
||||
function mapCorrectError(err: ApiError | null): string | null {
|
||||
if (!err) return null
|
||||
switch (err.code) {
|
||||
case 'SIMULATION_NOT_FOUND':
|
||||
return 'Simulation introuvable. Revenez en arrière et recommencez.'
|
||||
case 'AUTH_REQUIRED':
|
||||
return 'Votre session a expiré. Reconnectez-vous.'
|
||||
case 'VALIDATION_ERROR':
|
||||
case 'INVALID_BODY':
|
||||
return 'Le texte soumis est invalide. Vérifiez votre saisie.'
|
||||
default:
|
||||
return 'Correction impossible. Réessayez dans quelques instants.'
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tache: Tache
|
||||
isSubmitting: boolean
|
||||
error: ApiError | null
|
||||
onSubmit: (texte: string) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function SimulationForm({ tache, isSubmitting, error, onSubmit, onBack }: Props) {
|
||||
const [texte, setTexte] = useState('')
|
||||
const [fieldError, setFieldError] = useState<string | null>(null)
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setFieldError(null)
|
||||
|
||||
const parsed = textSchema.safeParse({ texte })
|
||||
if (!parsed.success) {
|
||||
setFieldError(parsed.error.flatten().fieldErrors.texte?.[0] ?? null)
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(parsed.data.texte)
|
||||
}
|
||||
|
||||
const apiError = mapCorrectError(error)
|
||||
const charCount = texte.length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm text-ink-4 underline-offset-4 hover:text-ink-2 hover:underline disabled:pointer-events-none"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="texte" className="text-sm font-medium text-ink-2">
|
||||
Votre production
|
||||
</label>
|
||||
<textarea
|
||||
id="texte"
|
||||
rows={12}
|
||||
value={texte}
|
||||
onChange={(e) => setTexte(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Rédigez votre texte ici…"
|
||||
aria-invalid={!!fieldError}
|
||||
aria-describedby={fieldError ? 'texte-error' : undefined}
|
||||
className="w-full resize-none 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:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{fieldError ? (
|
||||
<p id="texte-error" className="text-sm text-danger">
|
||||
{fieldError}
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className={`text-xs tabular-nums ${charCount > 5000 ? 'text-danger' : 'text-ink-5'}`}>
|
||||
{charCount.toLocaleString('fr-FR')} / 5 000
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
Correction en cours…
|
||||
</>
|
||||
) : (
|
||||
'Envoyer pour correction'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isSubmitting && (
|
||||
<p className="text-center text-xs text-ink-4">
|
||||
La correction peut prendre jusqu'à 30 secondes.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
src/features/simulations/components/TaskSelector.tsx
Normal file
113
src/features/simulations/components/TaskSelector.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Sélecteur de tâche pour lancer une simulation.
|
||||
*
|
||||
* Affiche les 6 tâches TCF :
|
||||
* - EE T1/T2/T3 → sélectionnables si quota OK
|
||||
* - EO T1/T3 → verrouillées (audio — Sprint 4)
|
||||
* - EO T2 Live → verrouillée (Exclusivité Premium — Sprint 6)
|
||||
*
|
||||
* 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 { Lock, Loader2 } from 'lucide-react'
|
||||
import { canSimulate } from '@/entities/user/lib'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import type { Plan } from '@/entities/user/lib'
|
||||
import type { CreateSimulationPayload, Tache } from '@/entities/production/types'
|
||||
|
||||
interface Props {
|
||||
plan: Plan
|
||||
simulationsUsed: number
|
||||
isLoading: boolean
|
||||
onSelect: (payload: CreateSimulationPayload) => void
|
||||
}
|
||||
|
||||
interface TaskCard {
|
||||
tache: Tache | null // null = EO T2 Live (hors Tache type)
|
||||
label: string
|
||||
sublabel: string
|
||||
sprintLocked?: boolean // audio ou premium non encore implémenté
|
||||
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' },
|
||||
{ tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Tâche 1', sprintLocked: true, lockLabel: 'Bientôt disponible' },
|
||||
{ tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Tâche 3', sprintLocked: true, lockLabel: 'Bientôt disponible' },
|
||||
{ tache: null, label: 'Expression Orale', sublabel: 'Tâche 2 — Live', sprintLocked: true, lockLabel: 'Exclusivité Premium' },
|
||||
]
|
||||
|
||||
|
||||
export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Props) {
|
||||
const simulationCheck = canSimulate(plan, simulationsUsed)
|
||||
const quotaBlocked = !simulationCheck.allowed
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink-1">Choisir une tâche</h2>
|
||||
<p className="mt-1 text-sm text-ink-3">
|
||||
Sélectionnez la tâche que vous souhaitez simuler.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quotaBlocked && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-danger/30 bg-danger-bg px-4 py-3 text-sm text-danger"
|
||||
>
|
||||
Vous avez utilisé vos 5 simulations gratuites.{' '}
|
||||
<a href="/pricing" className="underline underline-offset-4">
|
||||
Passer en Standard
|
||||
</a>{' '}
|
||||
pour continuer votre préparation.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{TASK_CARDS.map((card) => {
|
||||
const locked = card.sprintLocked || quotaBlocked
|
||||
const disabled = locked || isLoading || card.tache === null
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${card.tache ?? 'eo-t2'}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (card.tache && !locked) {
|
||||
onSelect({ tache: card.tache, mode: 'entrainement' })
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex flex-col rounded-lg border p-4 text-left transition-colors',
|
||||
locked || card.tache === null
|
||||
? 'cursor-not-allowed border-line bg-canvas-2 opacity-60'
|
||||
: 'cursor-pointer border-line bg-surface hover:border-expria hover:bg-expria-50',
|
||||
isLoading && !locked && 'cursor-wait',
|
||||
)}
|
||||
>
|
||||
{(card.sprintLocked || card.tache === null) && (
|
||||
<Lock
|
||||
className="mb-2 size-4 text-ink-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs font-medium text-ink-4">{card.label}</span>
|
||||
<span className="mt-0.5 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>
|
||||
)}
|
||||
{isLoading && !locked && card.tache && (
|
||||
<Loader2 className="absolute right-3 top-3 size-3.5 animate-spin text-expria" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
src/features/simulations/hooks/__tests__/useSimulation.test.tsx
Normal file
160
src/features/simulations/hooks/__tests__/useSimulation.test.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Tests de la state machine useSimulation.
|
||||
*
|
||||
* Transitions couvertes :
|
||||
* idle → task-selected (selectTask success)
|
||||
* task-selected → correcting (submitText déclenché)
|
||||
* correcting → done (correctEe success)
|
||||
* correcting → task-selected (correctEe error)
|
||||
* * → idle (reset)
|
||||
* guard submitText sans production (aucune mutation)
|
||||
*/
|
||||
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useSimulation } from '../useSimulation'
|
||||
import { createSimulation } from '@/entities/production/api'
|
||||
import { correctEe } from '@/entities/report/api'
|
||||
import type { Production } from '@/entities/production/types'
|
||||
import type { Report } from '@/entities/report/types'
|
||||
|
||||
vi.mock('@/entities/production/api')
|
||||
vi.mock('@/entities/report/api')
|
||||
|
||||
const mockCreateSimulation = vi.mocked(createSimulation)
|
||||
const mockCorrectEe = vi.mocked(correctEe)
|
||||
|
||||
const mockProduction: Production = {
|
||||
id: 'sim-1',
|
||||
tache: 'EE_T1',
|
||||
mode: 'entrainement',
|
||||
created_at: '2026-04-19T00:00:00Z',
|
||||
}
|
||||
|
||||
const mockReport: Report = {
|
||||
simulation_id: 'sim-1',
|
||||
score: 80,
|
||||
nclc: 9,
|
||||
feedback_court: 'Bon travail.',
|
||||
criteres: [],
|
||||
erreurs: [],
|
||||
modele: '',
|
||||
idees: [],
|
||||
exercices: [],
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
})
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useSimulation — état initial', () => {
|
||||
it('step = idle, production null, report null', () => {
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
expect(result.current.step).toBe('idle')
|
||||
expect(result.current.production).toBeNull()
|
||||
expect(result.current.report).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSimulation — selectTask', () => {
|
||||
it('step passe à task-selected et production est hydratée après succès', async () => {
|
||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
expect(result.current.production).toEqual(mockProduction)
|
||||
})
|
||||
|
||||
it('isCreating = true pendant la mutation createSimulation', async () => {
|
||||
let resolveCreate!: (p: Production) => void
|
||||
mockCreateSimulation.mockImplementation(() => new Promise(r => { resolveCreate = r }))
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => {
|
||||
result.current.selectTask({ tache: 'EE_T2', mode: 'entrainement' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isCreating).toBe(true))
|
||||
|
||||
act(() => resolveCreate(mockProduction))
|
||||
await waitFor(() => expect(result.current.isCreating).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSimulation — submitText', () => {
|
||||
it('step correcting pendant la correction, puis done après succès', async () => {
|
||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||
|
||||
let resolveCorrect!: (r: Report) => void
|
||||
mockCorrectEe.mockImplementation(() => new Promise(r => { resolveCorrect = r }))
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
|
||||
act(() => result.current.submitText('Mon texte de production.'))
|
||||
await waitFor(() => expect(result.current.step).toBe('correcting'))
|
||||
|
||||
act(() => resolveCorrect(mockReport))
|
||||
await waitFor(() => expect(result.current.step).toBe('done'))
|
||||
expect(result.current.report).toEqual(mockReport)
|
||||
})
|
||||
|
||||
it('step revient à task-selected si correctEe échoue', async () => {
|
||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||
mockCorrectEe.mockRejectedValue({ code: 'SIMULATION_NOT_FOUND', message: 'Not found' })
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
|
||||
act(() => result.current.submitText('Mon texte.')
|
||||
)
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
expect(result.current.report).toBeNull()
|
||||
})
|
||||
|
||||
it('submitText sans production ne déclenche aucune mutation', async () => {
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => result.current.submitText('texte quelconque'))
|
||||
|
||||
expect(mockCorrectEe).not.toHaveBeenCalled()
|
||||
expect(result.current.step).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSimulation — reset', () => {
|
||||
it('reset depuis task-selected remet step à idle et production à null', async () => {
|
||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||
|
||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||
|
||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||
|
||||
act(() => result.current.reset())
|
||||
|
||||
expect(result.current.step).toBe('idle')
|
||||
expect(result.current.production).toBeNull()
|
||||
})
|
||||
})
|
||||
74
src/features/simulations/hooks/useSimulation.ts
Normal file
74
src/features/simulations/hooks/useSimulation.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Hook d'orchestration du flux simulation EE.
|
||||
*
|
||||
* Séquence : createSimulation (POST /simulations)
|
||||
* → correctEe (POST /corrections/ee, timeout 30 s)
|
||||
*
|
||||
* State machine :
|
||||
* 'idle' → sélection de tâche disponible
|
||||
* 'task-selected' → formulaire de saisie visible
|
||||
* 'correcting' → correction en cours (30 s max)
|
||||
* 'done' → rapport disponible dans `report`
|
||||
*
|
||||
* Règle H : aucune logique métier ici — les gardes de quota et de plan
|
||||
* sont dans TaskSelector (UX) et dans le backend (autorité).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { createSimulation } from '@/entities/production/api'
|
||||
import { correctEe } from '@/entities/report/api'
|
||||
import type { CreateSimulationPayload, Production } from '@/entities/production/types'
|
||||
import type { Report } from '@/entities/report/types'
|
||||
import type { ApiError } from '@/shared/types/api'
|
||||
|
||||
export type SimulationStep = 'idle' | 'task-selected' | 'correcting' | 'done'
|
||||
|
||||
export function useSimulation() {
|
||||
const [step, setStep] = useState<SimulationStep>('idle')
|
||||
const [production, setProduction] = useState<Production | null>(null)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createSimulation,
|
||||
onSuccess: (data) => {
|
||||
setProduction(data)
|
||||
setStep('task-selected')
|
||||
},
|
||||
})
|
||||
|
||||
const correctMutation = useMutation({
|
||||
mutationFn: correctEe,
|
||||
onMutate: () => setStep('correcting'),
|
||||
onSuccess: () => setStep('done'),
|
||||
onError: () => setStep('task-selected'),
|
||||
})
|
||||
|
||||
function selectTask(payload: CreateSimulationPayload): void {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
|
||||
function submitText(texte: string): void {
|
||||
if (!production) return
|
||||
correctMutation.mutate({ simulation_id: production.id, texte })
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
setStep('idle')
|
||||
setProduction(null)
|
||||
createMutation.reset()
|
||||
correctMutation.reset()
|
||||
}
|
||||
|
||||
return {
|
||||
step,
|
||||
production,
|
||||
report: (correctMutation.data ?? null) as Report | null,
|
||||
isCreating: createMutation.isPending,
|
||||
isCorrecting: correctMutation.isPending,
|
||||
createError: createMutation.error as ApiError | null,
|
||||
correctError: correctMutation.error as ApiError | null,
|
||||
selectTask,
|
||||
submitText,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
110
src/features/simulations/pages/SimulationPage.tsx
Normal file
110
src/features/simulations/pages/SimulationPage.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Page de simulation Expression Écrite.
|
||||
*
|
||||
* Orchestre les 3 étapes du flux : sélection de tâche → saisie du texte → rapport.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Nota : queryKey ['plan'] est dupliqué depuis features/dashboard/hooks/usePlan.
|
||||
* TanStack Query partage le cache par clé — FTD-17.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getPlanStatus } from '@/entities/user/api'
|
||||
import { Logo } from '@/shared/components/Logo'
|
||||
import { ThemeToggle } from '@/shared/components/ThemeToggle'
|
||||
import { Button } from '@/shared/components/ui/button'
|
||||
import { useSimulation } from '../hooks/useSimulation'
|
||||
import { TaskSelector } from '../components/TaskSelector'
|
||||
import { SimulationForm } from '../components/SimulationForm'
|
||||
|
||||
function SimulationSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true" aria-label="Chargement…">
|
||||
<div className="h-6 w-40 animate-pulse rounded bg-canvas-2" />
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-24 animate-pulse rounded-lg bg-canvas-2" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimulationPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const {
|
||||
data: planData,
|
||||
isLoading: isPlanLoading,
|
||||
isError: isPlanError,
|
||||
refetch: refetchPlan,
|
||||
} = useQuery({
|
||||
queryKey: ['plan'],
|
||||
queryFn: getPlanStatus,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const {
|
||||
step,
|
||||
production,
|
||||
isCreating,
|
||||
isCorrecting,
|
||||
correctError,
|
||||
selectTask,
|
||||
submitText,
|
||||
reset,
|
||||
} = useSimulation()
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 'done' && production) {
|
||||
navigate(`/rapport/${production.id}`)
|
||||
}
|
||||
}, [step, production, navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<header className="flex items-center justify-between border-b border-line bg-surface px-4 py-3">
|
||||
<Logo size="sm" />
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
{isPlanLoading && <SimulationSkeleton />}
|
||||
|
||||
{isPlanError && (
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="text-sm text-danger">
|
||||
Impossible de charger vos informations. Réessayez dans quelques instants.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetchPlan()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{planData && step === 'idle' && (
|
||||
<TaskSelector
|
||||
plan={planData.plan}
|
||||
simulationsUsed={planData.simulations_used}
|
||||
isLoading={isCreating}
|
||||
onSelect={selectTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
{planData && (step === 'task-selected' || step === 'correcting') && production && (
|
||||
<SimulationForm
|
||||
tache={production.tache}
|
||||
isSubmitting={isCorrecting}
|
||||
error={correctError}
|
||||
onSubmit={submitText}
|
||||
onBack={reset}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue