448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
|
|
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<Record<'name' | 'client_name' | 'description', string>>;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* "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<HTMLDivElement>(null);
|
||
|
|
const firstFieldRef = useRef<HTMLInputElement>(null);
|
||
|
|
|
||
|
|
const [name, setName] = useState('');
|
||
|
|
const [clientName, setClientName] = useState('');
|
||
|
|
const [description, setDescription] = useState('');
|
||
|
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||
|
|
const [topError, setTopError] = useState<string | null>(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<HTMLDivElement>) => {
|
||
|
|
if (e.key !== 'Tab' || !surfaceRef.current) return;
|
||
|
|
const focusables = surfaceRef.current.querySelectorAll<HTMLElement>(
|
||
|
|
'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 (
|
||
|
|
<div
|
||
|
|
role="presentation"
|
||
|
|
onMouseDown={(e) => {
|
||
|
|
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" */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
inset: 0,
|
||
|
|
backgroundImage:
|
||
|
|
'repeating-linear-gradient(0deg, transparent 0, transparent 2px, oklch(100% 0 0 / 0.012) 2px, oklch(100% 0 0 / 0.012) 3px)',
|
||
|
|
pointerEvents: 'none',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div
|
||
|
|
role="dialog"
|
||
|
|
aria-modal="true"
|
||
|
|
aria-labelledby={titleId}
|
||
|
|
ref={surfaceRef}
|
||
|
|
onKeyDown={handleSurfaceKeyDown}
|
||
|
|
className="corner-mark"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
top: '14%',
|
||
|
|
left: '50%',
|
||
|
|
transform: 'translateX(-50%)',
|
||
|
|
width: 'min(520px, calc(100vw - 32px))',
|
||
|
|
backgroundColor: 'var(--surface-3)',
|
||
|
|
border: '1px solid var(--line-strong)',
|
||
|
|
borderRadius: 'var(--radius-md)',
|
||
|
|
boxShadow: 'var(--shadow-pop)',
|
||
|
|
animation: 'dialog-in 140ms var(--ease-mech) both',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* Masthead */}
|
||
|
|
<div
|
||
|
|
className="flex items-center justify-between gap-3 px-5 py-3 border-b"
|
||
|
|
style={{ borderColor: 'var(--line-default)' }}
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<span
|
||
|
|
aria-hidden="true"
|
||
|
|
className="status-dot pulsing"
|
||
|
|
style={{ color: 'var(--accent-rt)' }}
|
||
|
|
/>
|
||
|
|
<h2
|
||
|
|
id={titleId}
|
||
|
|
className="label-system"
|
||
|
|
style={{ color: 'var(--accent-rt)', letterSpacing: '0.18em' }}
|
||
|
|
>
|
||
|
|
ARM · NEW ENGAGEMENT
|
||
|
|
</h2>
|
||
|
|
</div>
|
||
|
|
<span
|
||
|
|
className="font-mono tabular text-fg-faint"
|
||
|
|
style={{ fontSize: '10.5px' }}
|
||
|
|
>
|
||
|
|
{draftId}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Amber hairline accent */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
height: 1,
|
||
|
|
background:
|
||
|
|
'linear-gradient(90deg, transparent 0%, var(--accent-rt) 50%, transparent 100%)',
|
||
|
|
opacity: 0.55,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<form onSubmit={handleSubmit} className="px-5 py-4 space-y-4" noValidate>
|
||
|
|
{topError && (
|
||
|
|
<div
|
||
|
|
role="alert"
|
||
|
|
className="label-system"
|
||
|
|
style={{
|
||
|
|
color: 'var(--state-failed)',
|
||
|
|
border: '1px solid var(--state-failed)',
|
||
|
|
padding: '6px 10px',
|
||
|
|
borderRadius: 'var(--radius-sm)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{topError}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<ConsoleField
|
||
|
|
label="Engagement name"
|
||
|
|
required
|
||
|
|
value={name}
|
||
|
|
onChange={setName}
|
||
|
|
error={fieldErrors.name}
|
||
|
|
disabled={isPending}
|
||
|
|
placeholder="OPERATION RUSTED ANCHOR"
|
||
|
|
ref={firstFieldRef}
|
||
|
|
mono
|
||
|
|
/>
|
||
|
|
<ConsoleField
|
||
|
|
label="Client"
|
||
|
|
value={clientName}
|
||
|
|
onChange={setClientName}
|
||
|
|
error={fieldErrors.client_name}
|
||
|
|
disabled={isPending}
|
||
|
|
placeholder="Démo Client X"
|
||
|
|
/>
|
||
|
|
<ConsoleTextarea
|
||
|
|
label="Brief"
|
||
|
|
value={description}
|
||
|
|
onChange={setDescription}
|
||
|
|
error={fieldErrors.description}
|
||
|
|
disabled={isPending}
|
||
|
|
placeholder="Scope notes, ROE pointers, post-mission expectations."
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className="flex items-center justify-between gap-3 pt-3 border-t"
|
||
|
|
style={{ borderColor: 'var(--line-default)' }}
|
||
|
|
>
|
||
|
|
<span className="label-system text-fg-faint">
|
||
|
|
{isPending ? '// transmitting …' : '// awaiting confirmation'}
|
||
|
|
</span>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={onClose}
|
||
|
|
disabled={isPending}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button type="submit" variant="primary" size="sm" disabled={isPending}>
|
||
|
|
{isPending ? 'Arming …' : 'Arm engagement →'}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* keyframes inlined so the component is self-contained */}
|
||
|
|
<style>{`
|
||
|
|
@keyframes dialog-in {
|
||
|
|
0% { opacity: 0; transform: translate(-50%, calc(-50% + 4px)) translateY(8px); }
|
||
|
|
100% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||
|
|
}
|
||
|
|
`}</style>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function mapValidationErrors(body: ApiValidationError): FieldErrors {
|
||
|
|
const out: FieldErrors = {};
|
||
|
|
for (const item of body.detail) {
|
||
|
|
const last = item.loc[item.loc.length - 1];
|
||
|
|
if (last === 'name' || last === 'client_name' || last === 'description') {
|
||
|
|
out[last] = item.msg;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function generateDraftId(): string {
|
||
|
|
const t = new Date();
|
||
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||
|
|
return `DRAFT-${t.getUTCFullYear().toString().slice(-2)}${pad(t.getUTCMonth() + 1)}${pad(t.getUTCDate())}-${pad(t.getUTCHours())}${pad(t.getUTCMinutes())}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ConsoleFieldProps {
|
||
|
|
label: string;
|
||
|
|
value: string;
|
||
|
|
onChange: (next: string) => void;
|
||
|
|
required?: boolean;
|
||
|
|
disabled?: boolean;
|
||
|
|
placeholder?: string;
|
||
|
|
error?: string;
|
||
|
|
mono?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ConsoleField({
|
||
|
|
label,
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
required,
|
||
|
|
disabled,
|
||
|
|
placeholder,
|
||
|
|
error,
|
||
|
|
mono,
|
||
|
|
ref,
|
||
|
|
}: ConsoleFieldProps & { ref?: Ref<HTMLInputElement> }): ReactNode {
|
||
|
|
const id = useId();
|
||
|
|
return (
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<label htmlFor={id} className="block label-system flex items-center justify-between">
|
||
|
|
<span>{label}</span>
|
||
|
|
{required && (
|
||
|
|
<span style={{ color: 'var(--accent-rt)' }} aria-hidden="true">
|
||
|
|
· required
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
id={id}
|
||
|
|
ref={ref}
|
||
|
|
type="text"
|
||
|
|
value={value}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
disabled={disabled}
|
||
|
|
placeholder={placeholder}
|
||
|
|
aria-invalid={error ? 'true' : undefined}
|
||
|
|
aria-describedby={error ? `${id}-err` : undefined}
|
||
|
|
className={mono ? 'font-mono tabular' : 'font-sans'}
|
||
|
|
style={{
|
||
|
|
width: '100%',
|
||
|
|
height: 30,
|
||
|
|
padding: '0 0 4px 0',
|
||
|
|
backgroundColor: 'transparent',
|
||
|
|
color: 'var(--fg-default)',
|
||
|
|
border: 'none',
|
||
|
|
borderBottom: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-strong)'}`,
|
||
|
|
borderRadius: 0,
|
||
|
|
fontSize: 13,
|
||
|
|
outline: 'none',
|
||
|
|
}}
|
||
|
|
onFocus={(e) => {
|
||
|
|
if (!error) {
|
||
|
|
e.currentTarget.style.borderBottomColor = 'var(--accent-rt)';
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
onBlur={(e) => {
|
||
|
|
if (!error) {
|
||
|
|
e.currentTarget.style.borderBottomColor = 'var(--line-strong)';
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
{error && (
|
||
|
|
<p
|
||
|
|
id={`${id}-err`}
|
||
|
|
className="label-system"
|
||
|
|
style={{ color: 'var(--state-failed)' }}
|
||
|
|
>
|
||
|
|
{error}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ConsoleTextareaProps {
|
||
|
|
label: string;
|
||
|
|
value: string;
|
||
|
|
onChange: (next: string) => void;
|
||
|
|
disabled?: boolean;
|
||
|
|
placeholder?: string;
|
||
|
|
error?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ConsoleTextarea({
|
||
|
|
label,
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
disabled,
|
||
|
|
placeholder,
|
||
|
|
error,
|
||
|
|
}: ConsoleTextareaProps): ReactNode {
|
||
|
|
const id = useId();
|
||
|
|
return (
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<label htmlFor={id} className="block label-system">
|
||
|
|
{label}
|
||
|
|
</label>
|
||
|
|
<textarea
|
||
|
|
id={id}
|
||
|
|
value={value}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
disabled={disabled}
|
||
|
|
placeholder={placeholder}
|
||
|
|
rows={4}
|
||
|
|
aria-invalid={error ? 'true' : undefined}
|
||
|
|
aria-describedby={error ? `${id}-err` : undefined}
|
||
|
|
className="font-sans"
|
||
|
|
style={{
|
||
|
|
width: '100%',
|
||
|
|
padding: '8px 10px',
|
||
|
|
backgroundColor: 'var(--surface-inset)',
|
||
|
|
color: 'var(--fg-default)',
|
||
|
|
border: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-default)'}`,
|
||
|
|
borderRadius: 'var(--radius-sm)',
|
||
|
|
fontSize: 12.5,
|
||
|
|
lineHeight: 1.55,
|
||
|
|
resize: 'vertical',
|
||
|
|
outline: 'none',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
{error && (
|
||
|
|
<p
|
||
|
|
id={`${id}-err`}
|
||
|
|
className="label-system"
|
||
|
|
style={{ color: 'var(--state-failed)' }}
|
||
|
|
>
|
||
|
|
{error}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|