US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment US-18: done status fully read-only + Reopen button (done → review_required) for all roles US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=) US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence 92/92 tests passing, typecheck and lint clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
81 lines
2.8 KiB
TypeScript
81 lines
2.8 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
createSimulation,
|
|
deleteSimulation,
|
|
getSimulation,
|
|
listSimulations,
|
|
transitionSimulation,
|
|
updateSimulation,
|
|
} from '@/api/simulations';
|
|
import type { SimulationCreateInput, SimulationPatchInput, SimulationStatus } from '@/api/types';
|
|
|
|
function simulationsKey(engagementId: number) {
|
|
return ['engagements', engagementId, 'simulations'] as const;
|
|
}
|
|
|
|
function simulationKey(id: number) {
|
|
return ['simulations', id] as const;
|
|
}
|
|
|
|
export function useEngagementSimulations(engagementId: number | undefined) {
|
|
return useQuery({
|
|
queryKey: engagementId ? simulationsKey(engagementId) : ['simulations', 'none'],
|
|
queryFn: () => listSimulations(engagementId as number),
|
|
enabled: typeof engagementId === 'number' && !Number.isNaN(engagementId),
|
|
});
|
|
}
|
|
|
|
export function useSimulation(id: number | undefined) {
|
|
return useQuery({
|
|
queryKey: id ? simulationKey(id) : ['simulations', 'none'],
|
|
queryFn: () => getSimulation(id as number),
|
|
enabled: typeof id === 'number' && !Number.isNaN(id),
|
|
});
|
|
}
|
|
|
|
export function useCreateSimulation(engagementId: number) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (input: SimulationCreateInput) => createSimulation(engagementId, input),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: simulationsKey(engagementId) }),
|
|
});
|
|
}
|
|
|
|
export function useUpdateSimulation(id: number, engagementId: number) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (patch: SimulationPatchInput) => updateSimulation(id, patch),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
|
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
|
qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
|
|
qc.invalidateQueries({ queryKey: ['engagements'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteSimulation(engagementId: number) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (id: number) => deleteSimulation(id),
|
|
onSuccess: (_data, id) => {
|
|
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
|
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useTransitionSimulation(id: number, engagementId: number) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (to: Extract<SimulationStatus, 'review_required' | 'done'>) =>
|
|
transitionSimulation(id, to),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
|
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
|
qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
|
|
qc.invalidateQueries({ queryKey: ['engagements'] });
|
|
},
|
|
});
|
|
}
|