diff --git a/frontend/src/screens/engagements/EngagementCreateDialog.tsx b/frontend/src/screens/engagements/EngagementCreateDialog.tsx new file mode 100644 index 0000000..3a0581b --- /dev/null +++ b/frontend/src/screens/engagements/EngagementCreateDialog.tsx @@ -0,0 +1,447 @@ +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" */} +