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 { ApiError, C2Type, PydanticErrorItem } from '@/types/api'; import { createEngagement, ENGAGEMENTS_QUERY_KEY } from './engagementsApi'; interface EngagementCreateDialogProps { onClose: () => void; } type FieldKey = 'client_name' | 'description' | 'c2_type'; type FieldErrors = Partial>; const C2_OPTIONS: ReadonlyArray<{ value: C2Type; label: string }> = [ { value: 'mythic', label: 'Mythic' }, { value: 'home', label: 'Home (RT-internal)' }, ]; /** * "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. * * Contract (api.md): * POST /api/v1/engagements/ with { client_name (required), description?, * c2_type? (default mythic), start_date?, end_date? }. Backend returns * the created Engagement on 201, the uniform { error, message, details? } * envelope on 422 / 4xx. Per-field details on 422 are matched via the * last segment of `loc`. */ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) { const titleId = useId(); const surfaceRef = useRef(null); const firstFieldRef = useRef(null); const [clientName, setClientName] = useState(''); const [description, setDescription] = useState(''); const [c2Type, setC2Type] = useState('mythic'); 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) { if (err.status === 422 && err.body?.details) { setFieldErrors(mapValidationErrors(err.body.details)); setTopError(null); return; } if (err.status === 401) { setTopError('Session expirée. Reconnectez-vous.'); return; } if (err.status === 403) { setTopError('Action interdite pour ce rôle.'); return; } if (err.body?.message) { setTopError(genericMessage(err.body)); return; } } setTopError('Création impossible. Réessayez dans un instant.'); }, }); const isPending = mutation.isPending; 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]); const handleSurfaceKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Tab' || !surfaceRef.current) return; const focusables = surfaceRef.current.querySelectorAll( 'input, textarea, select, 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 (!clientName.trim()) { setFieldErrors({ client_name: 'Client requis.' }); return; } mutation.mutate({ client_name: clientName.trim(), description: description.trim() || null, c2_type: c2Type, }); }; 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)', }} >