Files
mimic/frontend/src/pages/EngagementFormPage.tsx
2026-06-21 23:26:22 +02:00

240 lines
8.2 KiB
TypeScript

import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client';
import type { EngagementInput, EngagementStatus } from '@/api/types';
import {
useCreateEngagement,
useEngagement,
usePatchEngagement,
} from '@/hooks/useEngagements';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/useToast';
import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { C2ConfigCard } from '@/components/C2ConfigCard';
import { engagementStatusLabel } from '@/i18n/status';
interface FormState {
name: string;
description: string;
start_date: string;
end_date: string;
status: EngagementStatus;
}
const EMPTY: FormState = {
name: '',
description: '',
start_date: '',
end_date: '',
status: 'planned',
};
export function EngagementFormPage(): JSX.Element {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const editing = Boolean(id);
const numericId = id ? Number(id) : undefined;
const navigate = useNavigate();
const { push } = useToast();
const { canEditEngagements } = useAuth();
const detail = useEngagement(editing ? numericId : undefined);
const createMutation = useCreateEngagement();
const patchMutation = usePatchEngagement(numericId ?? 0);
const [form, setForm] = useState<FormState>(EMPTY);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
{ value: 'planned', label: engagementStatusLabel('planned') },
{ value: 'active', label: engagementStatusLabel('active') },
{ value: 'closed', label: engagementStatusLabel('closed') },
];
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
const errs: Partial<Record<keyof FormState, string>> = {};
if (!state.name.trim()) errs.name = t('engagement.form.validation.nameRequired');
if (!state.start_date) errs.start_date = t('engagement.form.validation.startDateRequired');
if (state.end_date && state.start_date && state.end_date < state.start_date) {
errs.end_date = t('engagement.form.validation.endDateAfterStart');
}
return errs;
}
// Hydrate edit form when data arrives.
useEffect(() => {
if (editing && detail.data) {
setForm({
name: detail.data.name,
description: detail.data.description ?? '',
start_date: detail.data.start_date,
end_date: detail.data.end_date ?? '',
status: detail.data.status,
});
}
}, [editing, detail.data]);
if (editing && detail.isLoading) return <LoadingState label={t('engagement.form.loading')} />;
if (editing && detail.isError) {
return (
<ErrorState
message={extractApiError(detail.error, t('engagement.form.error.load'))}
onRetry={() => detail.refetch()}
/>
);
}
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setSubmitError(null);
const v = validate(form);
setErrors(v);
if (Object.keys(v).length > 0) return;
const payload: EngagementInput = {
name: form.name.trim(),
start_date: form.start_date,
status: form.status,
};
if (form.description.trim()) payload.description = form.description.trim();
// PATCH with null clears end_date; POST with omitted leaves it null
if (editing) {
// Always include end_date for edit: '' → null to clear, otherwise value
payload.end_date = form.end_date === '' ? null : form.end_date;
} else if (form.end_date) {
payload.end_date = form.end_date;
}
try {
if (editing && numericId) {
await patchMutation.mutateAsync(payload);
push(t('engagement.form.toast.updated'), 'success');
navigate(`/engagements/${numericId}`);
} else {
const created = await createMutation.mutateAsync(payload);
push(t('engagement.form.toast.created'), 'success');
navigate(`/engagements/${created.id}`);
}
} catch (err) {
setSubmitError(extractApiError(err, t('engagement.form.error.save')));
}
};
const submitting = createMutation.isPending || patchMutation.isPending;
return (
<div className="flex flex-col gap-xl">
<header>
<h1 className="text-[32px] font-medium leading-none">
{editing ? t('engagement.form.title.edit') : t('engagement.form.title.new')}
</h1>
<p className="text-charcoal text-[16px] mt-sm">
{editing ? t('engagement.form.subtitle.edit') : t('engagement.form.subtitle.new')}
</p>
</header>
<div
className={
editing && canEditEngagements
? 'grid grid-cols-1 lg:grid-cols-2 gap-xl items-start'
: 'max-w-2xl'
}
>
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
<FormField label={t('engagement.form.field.name')} htmlFor="eng-name" required error={errors.name}>
<TextInput
id="eng-name"
name="name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
</FormField>
<FormField label={t('engagement.form.field.description')} htmlFor="eng-description">
<TextArea
id="eng-description"
name="description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</FormField>
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<FormField
label={t('engagement.form.field.startDate')}
htmlFor="eng-start"
required
error={errors.start_date}
>
<TextInput
id="eng-start"
type="date"
name="start_date"
value={form.start_date}
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
required
/>
</FormField>
<FormField
label={t('engagement.form.field.endDate')}
htmlFor="eng-end"
hint={t('engagement.form.field.endDateHint')}
error={errors.end_date}
>
<TextInput
id="eng-end"
type="date"
name="end_date"
value={form.end_date}
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
/>
</FormField>
</div>
<FormField label={t('engagement.form.field.status')} htmlFor="eng-status" required>
<Select
id="eng-status"
name="status"
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })}
options={STATUS_OPTIONS}
/>
</FormField>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</div>
) : null}
<div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting} data-testid="btn-submit">
{submitting
? t('engagement.form.btn.saving')
: editing
? t('engagement.form.btn.save')
: t('engagement.form.btn.create')}
</button>
<Link
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
className="btn-outline-ink"
>
{t('engagement.form.btn.cancel')}
</Link>
</div>
</form>
{editing && numericId && canEditEngagements && (
<C2ConfigCard engagementId={numericId} />
)}
</div>
</div>
);
}