import { useEffect, useId, useMemo, useRef, useState, type FormEvent, type KeyboardEvent, type ReactNode, type Ref, } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/Button'; import { ApiClientError } from '@/lib/api'; import type { ApiValidationError } from '@/types/api'; import { createEngagement, ENGAGEMENTS_QUERY_KEY } from './engagementsApi'; interface EngagementCreateDialogProps { onClose: () => void; } type FieldErrors = Partial>; /** * "Arm engagement" dialog. * * Visual grammar: * - Backdrop: graphite dim + faint scanline texture, no blur. Reads as * "the cockpit is paused while you issue a command", not a sleek * SaaS overlay. * - Surface: --surface-3 (one level above panels), corner-mark utility * at the four corners, hairline divider beneath the masthead. * - Inputs: label-system uppercase + underline that lights amber on * focus. No rounded boxes; the form should read as a console. * - Submit: primary amber Button — the same accent used for RT-only * actions throughout the app, so the action lineage is obvious. * * Behavior: * - Esc and outside click close (unless the mutation is in flight). * - Backend 422 Pydantic errors are mapped to per-field inline messages. * - Other 4xx/5xx surface as a generic top-of-form alert. */ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) { const titleId = useId(); const surfaceRef = useRef(null); const firstFieldRef = useRef(null); const [name, setName] = useState(''); const [clientName, setClientName] = useState(''); const [description, setDescription] = useState(''); const [fieldErrors, setFieldErrors] = useState({}); const [topError, setTopError] = useState(null); const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: createEngagement, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ENGAGEMENTS_QUERY_KEY }); onClose(); }, onError: (err) => { if (err instanceof ApiClientError && err.status === 422 && err.body) { setFieldErrors(mapValidationErrors(err.body as ApiValidationError)); setTopError(null); return; } if (err instanceof ApiClientError && err.status === 401) { setTopError('Session expirée. Reconnectez-vous.'); return; } setTopError('Création impossible. Réessayez dans un instant.'); }, }); const isPending = mutation.isPending; // Focus the first field on open. ESC closes unless a request is in flight. useEffect(() => { firstFieldRef.current?.focus(); const onKeyDown = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape' && !isPending) { e.preventDefault(); onClose(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [isPending, onClose]); // Rudimentary focus trap: cycle Tab/Shift+Tab within the dialog. const handleSurfaceKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Tab' || !surfaceRef.current) return; const focusables = surfaceRef.current.querySelectorAll( 'input, textarea, button, [tabindex]:not([tabindex="-1"])', ); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (!first || !last) return; const active = document.activeElement; if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } }; const handleSubmit = (e: FormEvent) => { e.preventDefault(); setFieldErrors({}); setTopError(null); if (!name.trim()) { setFieldErrors({ name: 'Nom requis.' }); return; } mutation.mutate({ name: name.trim(), client_name: clientName.trim() || undefined, description: description.trim() || undefined, }); }; const draftId = useMemo(() => generateDraftId(), []); return (
{ if (e.target === e.currentTarget && !isPending) onClose(); }} style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'radial-gradient(circle at 50% 35%, oklch(7.4% 0.012 247 / 0.55), oklch(5.8% 0.012 247 / 0.82) 60%)', backgroundColor: 'oklch(5.8% 0.012 247 / 0.78)', }} > {/* Faint scanline texture overlay — reads as "instrument feed paused" */}