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
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