feat(simulations): SimulationFlowProvider + SujetsPage + SujetCard
This commit is contained in:
parent
7902eec042
commit
782439b309
3 changed files with 278 additions and 0 deletions
31
src/features/simulations/components/SujetCard.tsx
Normal file
31
src/features/simulations/components/SujetCard.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* Carte cliquable pour un sujet dans la grille /sujets.
|
||||||
|
*
|
||||||
|
* Rendu : consigne tronquée sur 3 lignes (line-clamp-3) + badge rôle si présent.
|
||||||
|
* Règle H : purement présentationnel — l'action vient du parent.
|
||||||
|
* Règle L : tokens Direction H via la primitive Card (variant interactive).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Badge } from '@/shared/ui/Badge'
|
||||||
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
import type { SujetData } from '@/entities/production/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sujet: SujetData
|
||||||
|
onSelect: (sujet: SujetData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SujetCard({ sujet, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<Card variant="interactive" onClick={() => onSelect(sujet)}>
|
||||||
|
<div className="flex h-full flex-col gap-3 p-4 text-left">
|
||||||
|
{sujet.role && (
|
||||||
|
<div>
|
||||||
|
<Badge variant="neutral">{sujet.role}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="line-clamp-3 text-sm leading-relaxed text-ink-1">{sujet.consigne}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/features/simulations/pages/SujetsPage.tsx
Normal file
130
src/features/simulations/pages/SujetsPage.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
/**
|
||||||
|
* Page /sujets — sélection d'un sujet en cartes pour une production en cours.
|
||||||
|
*
|
||||||
|
* Flux :
|
||||||
|
* 1. /simulation/ee → selectTask (POST /simulations) → navigate('/sujets')
|
||||||
|
* 2. Ici : liste les sujets de la tâche en cours, permet choix manuel ou aléatoire
|
||||||
|
* 3. Sélection → changeSubject + navigate('/simulation/ee') (SimulationForm visible)
|
||||||
|
*
|
||||||
|
* MVP : refresh direct sur /sujets → redirect vers /simulation/ee (pas de state).
|
||||||
|
* Règle D : aucun contrôle de plan/quota ici — déjà fait à la création de la simulation.
|
||||||
|
* Règle H : aucune logique métier — délègue au provider + useSujets.
|
||||||
|
* Règle L : tokens Direction H exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Shuffle } from 'lucide-react'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
import { formatTache } from '@/entities/production/lib'
|
||||||
|
import type { SujetData } from '@/entities/production/types'
|
||||||
|
import { useSimulationFlow } from '../state/SimulationFlowProvider'
|
||||||
|
import { useSujets } from '../hooks/useSujets'
|
||||||
|
import { SujetCard } from '../components/SujetCard'
|
||||||
|
|
||||||
|
function SujetsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-32 animate-pulse rounded-lg bg-canvas-2" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SujetsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { production, changeSubject, setStep } = useSimulationFlow()
|
||||||
|
|
||||||
|
// MVP : pas de production en contexte (refresh direct) → retour à /simulation/ee
|
||||||
|
useEffect(() => {
|
||||||
|
if (!production) navigate('/simulation/ee', { replace: true })
|
||||||
|
}, [production, navigate])
|
||||||
|
|
||||||
|
const { data: sujets, isLoading, isError, refetch } = useSujets(
|
||||||
|
production?.tache ?? 'EE_T1',
|
||||||
|
!!production,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!production) return null
|
||||||
|
|
||||||
|
function handleSelect(sujet: SujetData) {
|
||||||
|
changeSubject(sujet)
|
||||||
|
setStep('task-selected')
|
||||||
|
navigate('/simulation/ee')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRandom() {
|
||||||
|
if (!sujets || sujets.length === 0) return
|
||||||
|
const pool = production?.sujet
|
||||||
|
? sujets.filter((s) => s.id !== production.sujet?.id)
|
||||||
|
: sujets
|
||||||
|
const list = pool.length > 0 ? pool : sujets
|
||||||
|
const pick = list[Math.floor(Math.random() * list.length)]
|
||||||
|
if (pick) handleSelect(pick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSujets = (sujets?.length ?? 0) > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/simulation/ee')}
|
||||||
|
className="text-sm text-ink-4 underline-offset-4 hover:text-ink-2 hover:underline"
|
||||||
|
>
|
||||||
|
← Retour
|
||||||
|
</button>
|
||||||
|
<h2 className="flex-1 text-lg font-semibold text-ink-1">
|
||||||
|
Choisir un sujet — {formatTache(production.tache)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-ink-3">
|
||||||
|
{isLoading
|
||||||
|
? 'Chargement des sujets…'
|
||||||
|
: hasSujets
|
||||||
|
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
|
||||||
|
: 'Aucun sujet disponible pour cette tâche.'}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Shuffle className="size-4" aria-hidden="true" />}
|
||||||
|
onClick={handleRandom}
|
||||||
|
disabled={!hasSujets}
|
||||||
|
>
|
||||||
|
Sujet aléatoire
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mb-4 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
Impossible de charger les sujets.{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<SujetsSkeleton />
|
||||||
|
) : hasSujets ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{sujets!.map((sujet) => (
|
||||||
|
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
src/features/simulations/state/SimulationFlowProvider.tsx
Normal file
117
src/features/simulations/state/SimulationFlowProvider.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* Provider de flux simulation — partage l'état entre /simulation/ee et /sujets.
|
||||||
|
*
|
||||||
|
* Hérite de la state machine de useSimulation mais déplace la source de vérité
|
||||||
|
* hors du hook pour qu'elle survive aux navigations React Router (Option A — cf.
|
||||||
|
* plan de refonte UX "page /sujets avec cartes").
|
||||||
|
*
|
||||||
|
* Règle H : aucune logique métier — les mutations s'appuient sur entities/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { createSimulation } from '@/entities/production/api'
|
||||||
|
import { correctEe } from '@/entities/report/api'
|
||||||
|
import type {
|
||||||
|
CreateSimulationPayload,
|
||||||
|
Production,
|
||||||
|
SujetData,
|
||||||
|
Tache,
|
||||||
|
} from '@/entities/production/types'
|
||||||
|
import type { Report } from '@/entities/report/types'
|
||||||
|
import type { ApiError } from '@/shared/types/api'
|
||||||
|
|
||||||
|
export type SimulationStep =
|
||||||
|
| 'idle'
|
||||||
|
| 'choosing-subject'
|
||||||
|
| 'task-selected'
|
||||||
|
| 'correcting'
|
||||||
|
| 'done'
|
||||||
|
|
||||||
|
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
|
||||||
|
|
||||||
|
interface FlowValue {
|
||||||
|
step: SimulationStep
|
||||||
|
production: Production | null
|
||||||
|
sujet: SujetData | null
|
||||||
|
report: Report | null
|
||||||
|
isCreating: boolean
|
||||||
|
isCorrecting: boolean
|
||||||
|
createError: ApiError | null
|
||||||
|
correctError: ApiError | null
|
||||||
|
selectTask: (payload: CreateSimulationPayload) => void
|
||||||
|
submitText: (texte: string) => void
|
||||||
|
changeSubject: (sujet: SujetData) => void
|
||||||
|
setStep: (step: SimulationStep) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SimulationFlowContext = createContext<FlowValue | null>(null)
|
||||||
|
|
||||||
|
export function SimulationFlowProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [step, setStep] = useState<SimulationStep>('idle')
|
||||||
|
const [production, setProduction] = useState<Production | null>(null)
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createSimulation,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setProduction(data)
|
||||||
|
setStep(TACHES_SANS_CATALOGUE.includes(data.tache) ? 'task-selected' : 'choosing-subject')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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({ simulationId: production.id, contenu: texte, tache: production.tache })
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSubject(sujet: SujetData): void {
|
||||||
|
setProduction((p) => (p ? { ...p, sujet } : p))
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
setStep('idle')
|
||||||
|
setProduction(null)
|
||||||
|
createMutation.reset()
|
||||||
|
correctMutation.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: FlowValue = {
|
||||||
|
step,
|
||||||
|
production,
|
||||||
|
sujet: production?.sujet ?? null,
|
||||||
|
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,
|
||||||
|
changeSubject,
|
||||||
|
setStep,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimulationFlowContext.Provider value={value}>{children}</SimulationFlowContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSimulationFlow(): FlowValue {
|
||||||
|
const ctx = useContext(SimulationFlowContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useSimulationFlow doit être utilisé dans un <SimulationFlowProvider>.')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue