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 {
|
interface Props {
|
||||||
tache: Tache
|
tache: Tache
|
||||||
sujet: SujetData | null
|
sujet: SujetData | null
|
||||||
|
sujets: SujetData[]
|
||||||
|
isLoadingSujets: boolean
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
error: ApiError | null
|
error: ApiError | null
|
||||||
onSubmit: (texte: string) => void
|
onSubmit: (texte: string) => void
|
||||||
onBack: () => 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 textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const hasAutoSubmittedRef = useRef(false)
|
const hasAutoSubmittedRef = useRef(false)
|
||||||
const [texte, setTexte] = useState('')
|
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>
|
<h2 className="flex-1 text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SujetDisplay sujet={sujet} />
|
<SujetDisplay
|
||||||
|
sujet={sujet}
|
||||||
|
sujets={sujets}
|
||||||
|
isLoadingSujets={isLoadingSujets}
|
||||||
|
onChangeSujet={onChangeSujet}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
<div
|
<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`).
|
* - `sujet` : le sujet actuellement affiché (null = rien à rendre)
|
||||||
* Si `sujet === null` (aucun sujet actif pour la tâche, ou EO_T2_LIVE) → rien n'est rendu.
|
* - `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).
|
* 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.
|
* Le contenu des sujets est admin-curé (pas du texte IA) → plain-text avec
|
||||||
* Les sujets étant du contenu admin-curé (pas du texte IA), pas de react-markdown.
|
* `whitespace-pre-wrap`, pas de react-markdown.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Shuffle } from 'lucide-react'
|
||||||
import { Badge } from '@/shared/ui/Badge'
|
import { Badge } from '@/shared/ui/Badge'
|
||||||
import { Card } from '@/shared/ui/Card'
|
import { Card } from '@/shared/ui/Card'
|
||||||
import type { SujetData } from '@/entities/production/types'
|
import type { SujetData } from '@/entities/production/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sujet: SujetData | null
|
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 }) {
|
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
|
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 (
|
return (
|
||||||
<Card variant="default" className="p-5">
|
<Card variant="default" className="p-5">
|
||||||
<div className="space-y-4">
|
<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 && (
|
{sujet.role && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="neutral">Rôle</Badge>
|
<Badge variant="neutral">Rôle</Badge>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
* Tests de la state machine useSimulation.
|
* Tests de la state machine useSimulation.
|
||||||
*
|
*
|
||||||
* Transitions couvertes :
|
* Transitions couvertes :
|
||||||
* idle → task-selected (selectTask success)
|
* idle → choosing-subject (selectTask success, tâche avec catalogue)
|
||||||
|
* choosing-subject → task-selected (selectRandom / selectSujet)
|
||||||
* task-selected → correcting (submitText déclenché)
|
* task-selected → correcting (submitText déclenché)
|
||||||
* correcting → done (correctEe success)
|
* correcting → done (correctEe success)
|
||||||
* correcting → task-selected (correctEe error)
|
* correcting → task-selected (correctEe error)
|
||||||
|
|
@ -69,7 +70,7 @@ describe('useSimulation — état initial', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useSimulation — selectTask', () => {
|
describe('useSimulation — selectTask', () => {
|
||||||
it('step passe à task-selected et production est hydratée après succès', async () => {
|
it('step passe à choosing-subject et production est hydratée pour une tâche avec catalogue', async () => {
|
||||||
mockCreateSimulation.mockResolvedValue(mockProduction)
|
mockCreateSimulation.mockResolvedValue(mockProduction)
|
||||||
|
|
||||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||||
|
|
@ -78,10 +79,24 @@ describe('useSimulation — selectTask', () => {
|
||||||
result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })
|
result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })
|
||||||
})
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
||||||
expect(result.current.production).toEqual(mockProduction)
|
expect(result.current.production).toEqual(mockProduction)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('step passe directement à task-selected pour EO_T1 (sans catalogue)', async () => {
|
||||||
|
const eoProduction: Production = { ...mockProduction, tache: 'EO_T1' }
|
||||||
|
mockCreateSimulation.mockResolvedValue(eoProduction)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.selectTask({ tache: 'EO_T1', mode: 'entrainement' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||||
|
expect(result.current.production).toEqual(eoProduction)
|
||||||
|
})
|
||||||
|
|
||||||
it('isCreating = true pendant la mutation createSimulation', async () => {
|
it('isCreating = true pendant la mutation createSimulation', async () => {
|
||||||
let resolveCreate!: (p: Production) => void
|
let resolveCreate!: (p: Production) => void
|
||||||
mockCreateSimulation.mockImplementation(() => new Promise(r => { resolveCreate = r }))
|
mockCreateSimulation.mockImplementation(() => new Promise(r => { resolveCreate = r }))
|
||||||
|
|
@ -109,6 +124,8 @@ describe('useSimulation — submitText', () => {
|
||||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||||
|
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
||||||
|
act(() => result.current.selectRandom([]))
|
||||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||||
|
|
||||||
act(() => result.current.submitText('Mon texte de production.'))
|
act(() => result.current.submitText('Mon texte de production.'))
|
||||||
|
|
@ -126,6 +143,8 @@ describe('useSimulation — submitText', () => {
|
||||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||||
|
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
||||||
|
act(() => result.current.selectRandom([]))
|
||||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||||
|
|
||||||
act(() => result.current.submitText('Mon texte.')
|
act(() => result.current.submitText('Mon texte.')
|
||||||
|
|
@ -151,6 +170,8 @@ describe('useSimulation — reset', () => {
|
||||||
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
|
||||||
|
await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
|
||||||
|
act(() => result.current.selectRandom([]))
|
||||||
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
await waitFor(() => expect(result.current.step).toBe('task-selected'))
|
||||||
|
|
||||||
act(() => result.current.reset())
|
act(() => result.current.reset())
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
* Hook d'orchestration du flux simulation EE.
|
* Hook d'orchestration du flux simulation EE.
|
||||||
*
|
*
|
||||||
* Séquence : createSimulation (POST /simulations)
|
* Séquence : createSimulation (POST /simulations)
|
||||||
|
* → [choosing-subject] (sauf EO_T1 — sujet fixe)
|
||||||
* → correctEe (POST /corrections/ee, timeout 30 s)
|
* → correctEe (POST /corrections/ee, timeout 30 s)
|
||||||
*
|
*
|
||||||
* State machine :
|
* State machine :
|
||||||
* 'idle' → sélection de tâche disponible
|
* 'idle' → sélection de tâche disponible
|
||||||
|
* 'choosing-subject' → écran SujetSelector (hors EO_T1)
|
||||||
* 'task-selected' → formulaire de saisie visible
|
* 'task-selected' → formulaire de saisie visible
|
||||||
* 'correcting' → correction en cours (30 s max)
|
* 'correcting' → correction en cours (30 s max)
|
||||||
* 'done' → rapport disponible dans `report`
|
* 'done' → rapport disponible dans `report`
|
||||||
|
|
@ -18,11 +20,24 @@ import { useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { createSimulation } from '@/entities/production/api'
|
import { createSimulation } from '@/entities/production/api'
|
||||||
import { correctEe } from '@/entities/report/api'
|
import { correctEe } from '@/entities/report/api'
|
||||||
import type { CreateSimulationPayload, Production } from '@/entities/production/types'
|
import type {
|
||||||
|
CreateSimulationPayload,
|
||||||
|
Production,
|
||||||
|
SujetData,
|
||||||
|
Tache,
|
||||||
|
} from '@/entities/production/types'
|
||||||
import type { Report } from '@/entities/report/types'
|
import type { Report } from '@/entities/report/types'
|
||||||
import type { ApiError } from '@/shared/types/api'
|
import type { ApiError } from '@/shared/types/api'
|
||||||
|
|
||||||
export type SimulationStep = 'idle' | 'task-selected' | 'correcting' | 'done'
|
export type SimulationStep =
|
||||||
|
| 'idle'
|
||||||
|
| 'choosing-subject'
|
||||||
|
| 'task-selected'
|
||||||
|
| 'correcting'
|
||||||
|
| 'done'
|
||||||
|
|
||||||
|
/** Tâches qui ne passent pas par l'écran de choix de sujet. */
|
||||||
|
const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
|
||||||
|
|
||||||
export function useSimulation() {
|
export function useSimulation() {
|
||||||
const [step, setStep] = useState<SimulationStep>('idle')
|
const [step, setStep] = useState<SimulationStep>('idle')
|
||||||
|
|
@ -32,7 +47,7 @@ export function useSimulation() {
|
||||||
mutationFn: createSimulation,
|
mutationFn: createSimulation,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setProduction(data)
|
setProduction(data)
|
||||||
setStep('task-selected')
|
setStep(TACHES_SANS_CATALOGUE.includes(data.tache) ? 'task-selected' : 'choosing-subject')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -52,6 +67,31 @@ export function useSimulation() {
|
||||||
correctMutation.mutate({ simulationId: production.id, contenu: texte, tache: production.tache })
|
correctMutation.mutate({ simulationId: production.id, contenu: texte, tache: production.tache })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remplace le sujet courant sans toucher à l'étape. */
|
||||||
|
function changeSubject(sujet: SujetData): void {
|
||||||
|
setProduction((p) => (p ? { ...p, sujet } : p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choix manuel : remplace le sujet et passe à la saisie. */
|
||||||
|
function selectSujet(sujet: SujetData): void {
|
||||||
|
changeSubject(sujet)
|
||||||
|
setStep('task-selected')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choix aléatoire côté client à partir d'une liste pré-chargée. */
|
||||||
|
function selectRandom(sujets: SujetData[]): void {
|
||||||
|
if (sujets.length > 0) {
|
||||||
|
const pick = sujets[Math.floor(Math.random() * sujets.length)]
|
||||||
|
changeSubject(pick)
|
||||||
|
}
|
||||||
|
setStep('task-selected')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retour à l'écran SujetSelector depuis SimulationForm. */
|
||||||
|
function backToSubject(): void {
|
||||||
|
setStep('choosing-subject')
|
||||||
|
}
|
||||||
|
|
||||||
function reset(): void {
|
function reset(): void {
|
||||||
setStep('idle')
|
setStep('idle')
|
||||||
setProduction(null)
|
setProduction(null)
|
||||||
|
|
@ -70,6 +110,10 @@ export function useSimulation() {
|
||||||
correctError: correctMutation.error as ApiError | null,
|
correctError: correctMutation.error as ApiError | null,
|
||||||
selectTask,
|
selectTask,
|
||||||
submitText,
|
submitText,
|
||||||
|
changeSubject,
|
||||||
|
selectSujet,
|
||||||
|
selectRandom,
|
||||||
|
backToSubject,
|
||||||
reset,
|
reset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||||
import { getPlanStatus } from '@/entities/user/api'
|
import { getPlanStatus } from '@/entities/user/api'
|
||||||
import { Button } from '@/shared/ui/Button'
|
import { Button } from '@/shared/ui/Button'
|
||||||
import { useSimulation } from '../hooks/useSimulation'
|
import { useSimulation } from '../hooks/useSimulation'
|
||||||
|
import { useSujets } from '../hooks/useSujets'
|
||||||
import { TaskSelector } from '../components/TaskSelector'
|
import { TaskSelector } from '../components/TaskSelector'
|
||||||
import { SimulationForm } from '../components/SimulationForm'
|
import { SimulationForm } from '../components/SimulationForm'
|
||||||
|
|
||||||
|
|
@ -55,9 +56,17 @@ export function SimulationPage() {
|
||||||
correctError,
|
correctError,
|
||||||
selectTask,
|
selectTask,
|
||||||
submitText,
|
submitText,
|
||||||
|
changeSubject,
|
||||||
reset,
|
reset,
|
||||||
} = useSimulation()
|
} = useSimulation()
|
||||||
|
|
||||||
|
// Catalogue des sujets pour le dropdown dans SujetDisplay.
|
||||||
|
// EO_T1 n'a pas de catalogue (getSujets retourne [] — requête court-circuitée côté API).
|
||||||
|
const {
|
||||||
|
data: sujets,
|
||||||
|
isLoading: isLoadingSujets,
|
||||||
|
} = useSujets(production?.tache ?? 'EE_T1', !!production)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === 'done' && production) {
|
if (step === 'done' && production) {
|
||||||
navigate(`/rapport/${production.id}`)
|
navigate(`/rapport/${production.id}`)
|
||||||
|
|
@ -88,14 +97,19 @@ export function SimulationPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{planData && (step === 'task-selected' || step === 'correcting') && production && (
|
{planData &&
|
||||||
|
(step === 'choosing-subject' || step === 'task-selected' || step === 'correcting') &&
|
||||||
|
production && (
|
||||||
<SimulationForm
|
<SimulationForm
|
||||||
tache={production.tache}
|
tache={production.tache}
|
||||||
sujet={sujet}
|
sujet={sujet}
|
||||||
|
sujets={sujets ?? []}
|
||||||
|
isLoadingSujets={isLoadingSujets}
|
||||||
isSubmitting={isCorrecting}
|
isSubmitting={isCorrecting}
|
||||||
error={correctError}
|
error={correctError}
|
||||||
onSubmit={submitText}
|
onSubmit={submitText}
|
||||||
onBack={reset}
|
onBack={reset}
|
||||||
|
onChangeSujet={changeSubject}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue