2026-06-10 20:11:12 +02:00
|
|
|
import { useState } from 'react';
|
2026-06-21 23:51:12 +02:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-06-10 20:11:12 +02:00
|
|
|
import { extractApiError } from '@/api/client';
|
|
|
|
|
import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2';
|
|
|
|
|
import { C2CallbackPicker } from './C2CallbackPicker';
|
|
|
|
|
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
|
|
|
|
|
import { useToast } from '@/hooks/useToast';
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 25;
|
|
|
|
|
|
|
|
|
|
interface ImportC2HistoryModalProps {
|
|
|
|
|
simulationId: number;
|
|
|
|
|
engagementId: number;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ImportC2HistoryModal({
|
|
|
|
|
simulationId,
|
|
|
|
|
engagementId,
|
|
|
|
|
onClose,
|
|
|
|
|
}: ImportC2HistoryModalProps): JSX.Element {
|
2026-06-21 23:51:12 +02:00
|
|
|
const { t } = useTranslation();
|
2026-06-10 20:11:12 +02:00
|
|
|
const { push } = useToast();
|
|
|
|
|
|
|
|
|
|
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
|
|
|
|
|
const importMutation = useImportC2(simulationId);
|
|
|
|
|
|
|
|
|
|
const [selectedCallbackId, setSelectedCallbackId] = useState<number | null>(null);
|
|
|
|
|
const [page, setPage] = useState(1);
|
|
|
|
|
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
|
|
|
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const historyQuery = useC2CallbackHistory(engagementId, selectedCallbackId, {
|
|
|
|
|
page,
|
|
|
|
|
pageSize: PAGE_SIZE,
|
|
|
|
|
enabled: selectedCallbackId !== null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const historyTasks = historyQuery.data?.tasks ?? [];
|
|
|
|
|
const total = historyQuery.data?.total ?? 0;
|
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
|
|
|
|
|
|
|
|
const callbacks = callbacksQuery.data?.callbacks ?? [];
|
|
|
|
|
|
|
|
|
|
function handleCallbackSelect(id: number) {
|
|
|
|
|
setSelectedCallbackId(id);
|
|
|
|
|
setPage(1);
|
|
|
|
|
setCheckedIds(new Set());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleCheck(displayId: number) {
|
|
|
|
|
setCheckedIds((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
if (next.has(displayId)) {
|
|
|
|
|
next.delete(displayId);
|
|
|
|
|
} else {
|
|
|
|
|
next.add(displayId);
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const canImport = checkedIds.size > 0 && selectedCallbackId !== null;
|
|
|
|
|
|
|
|
|
|
const onImport = async () => {
|
|
|
|
|
if (!canImport) return;
|
|
|
|
|
setSubmitError(null);
|
|
|
|
|
try {
|
|
|
|
|
const result = await importMutation.mutateAsync({
|
|
|
|
|
callback_display_id: selectedCallbackId,
|
|
|
|
|
task_display_ids: Array.from(checkedIds),
|
|
|
|
|
});
|
|
|
|
|
const msg =
|
|
|
|
|
result.skipped > 0
|
2026-06-21 23:51:12 +02:00
|
|
|
? t('c2.modal.import.toast.partial', { imported: result.imported, skipped: result.skipped })
|
|
|
|
|
: t('c2.modal.import.toast.imported', { count: result.imported });
|
2026-06-10 20:11:12 +02:00
|
|
|
push(msg, 'success');
|
|
|
|
|
onClose();
|
|
|
|
|
} catch (err) {
|
2026-06-21 23:51:12 +02:00
|
|
|
setSubmitError(extractApiError(err, t('c2.modal.import.error.import')));
|
2026-06-10 20:11:12 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby="c2-import-modal-title"
|
|
|
|
|
data-testid="c2-import-modal"
|
|
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
|
|
|
>
|
|
|
|
|
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
|
|
|
|
|
|
|
|
|
|
<div className="relative card-product w-full max-w-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
|
|
|
|
|
<h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink">
|
2026-06-21 23:51:12 +02:00
|
|
|
{t('c2.modal.import.title')}
|
2026-06-10 20:11:12 +02:00
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
{/* Step 1: callback picker */}
|
|
|
|
|
<div className="flex flex-col gap-xs">
|
2026-06-21 23:51:12 +02:00
|
|
|
<span className="text-[14px] font-medium text-ink">{t('c2.modal.picker.title')}</span>
|
2026-06-10 20:11:12 +02:00
|
|
|
<C2CallbackPicker
|
|
|
|
|
callbacks={callbacks}
|
|
|
|
|
isLoading={callbacksQuery.isLoading}
|
|
|
|
|
isError={callbacksQuery.isError}
|
|
|
|
|
error={callbacksQuery.error}
|
|
|
|
|
selectedId={selectedCallbackId}
|
|
|
|
|
onSelect={handleCallbackSelect}
|
|
|
|
|
rowTestId="c2-import-callback-row"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Step 2: history table (shown once a callback is selected) */}
|
|
|
|
|
{selectedCallbackId !== null && (
|
|
|
|
|
<div className="flex flex-col gap-xs">
|
|
|
|
|
<span className="text-[14px] font-medium text-ink">
|
2026-06-21 23:51:12 +02:00
|
|
|
{t('c2.tasks.title')}{' '}
|
2026-06-10 20:11:12 +02:00
|
|
|
{total > 0 && (
|
2026-06-21 23:51:12 +02:00
|
|
|
<span className="text-graphite font-normal">({total} {t('c2.modal.import.total').toLowerCase()})</span>
|
2026-06-10 20:11:12 +02:00
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
{historyQuery.isLoading && (
|
2026-06-21 23:51:12 +02:00
|
|
|
<p className="text-[14px] text-graphite">{t('state.loading')}</p>
|
2026-06-10 20:11:12 +02:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{historyQuery.isError && (
|
|
|
|
|
<p className="text-[14px] text-bloom-deep">
|
2026-06-21 23:51:12 +02:00
|
|
|
{extractApiError(historyQuery.error, t('c2.modal.import.error.import'))}
|
2026-06-10 20:11:12 +02:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
|
2026-06-21 23:51:12 +02:00
|
|
|
<p className="text-[14px] text-graphite">{t('c2.modal.import.empty')}</p>
|
2026-06-10 20:11:12 +02:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{historyTasks.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="border border-hairline overflow-x-auto">
|
|
|
|
|
<table className="w-full text-[14px]">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="bg-cloud border-b border-hairline">
|
2026-06-22 00:23:03 +02:00
|
|
|
<th className="px-md py-sm w-8" aria-label={t('common.select')} />
|
2026-06-10 20:11:12 +02:00
|
|
|
<th className="px-md py-sm text-left font-medium text-ink">#</th>
|
2026-06-21 23:51:12 +02:00
|
|
|
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.command')}</th>
|
|
|
|
|
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.status')}</th>
|
|
|
|
|
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.import.col.completed')}</th>
|
|
|
|
|
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.import.col.timestamp')}</th>
|
2026-06-10 20:11:12 +02:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{historyTasks.map((task) => (
|
|
|
|
|
<tr
|
|
|
|
|
key={task.display_id}
|
|
|
|
|
data-testid="c2-history-row"
|
|
|
|
|
onClick={() => toggleCheck(task.display_id)}
|
|
|
|
|
className="cursor-pointer border-b border-hairline hover:bg-cloud"
|
|
|
|
|
>
|
|
|
|
|
<td className="px-md py-sm">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
data-testid="c2-history-row-checkbox"
|
|
|
|
|
checked={checkedIds.has(task.display_id)}
|
|
|
|
|
onChange={() => toggleCheck(task.display_id)}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="h-4 w-4 accent-primary"
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-md py-sm font-mono">{task.display_id}</td>
|
|
|
|
|
<td
|
|
|
|
|
className="px-md py-sm font-mono max-w-[200px] truncate"
|
|
|
|
|
title={task.command}
|
|
|
|
|
>
|
|
|
|
|
{task.command}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-md py-sm">
|
|
|
|
|
<C2TaskStatusBadge status={task.status} />
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-md py-sm text-[14px]">
|
2026-06-21 23:51:12 +02:00
|
|
|
{task.completed ? t('common.yes') : t('common.no')}
|
2026-06-10 20:11:12 +02:00
|
|
|
</td>
|
|
|
|
|
<td className="px-md py-sm font-mono text-graphite">
|
|
|
|
|
{task.created_at}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Pagination */}
|
|
|
|
|
<div className="flex items-center gap-md text-[14px] text-graphite">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="c2-history-prev"
|
|
|
|
|
className="btn-outline-ink"
|
|
|
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
|
|
|
disabled={page <= 1}
|
|
|
|
|
>
|
2026-06-21 23:51:12 +02:00
|
|
|
{t('c2.modal.import.prev')}
|
2026-06-10 20:11:12 +02:00
|
|
|
</button>
|
|
|
|
|
<span>
|
2026-06-21 23:51:12 +02:00
|
|
|
{t('c2.modal.import.page')} {page} {t('c2.modal.import.of')} {totalPages}
|
2026-06-10 20:11:12 +02:00
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="c2-history-next"
|
|
|
|
|
className="btn-outline-ink"
|
|
|
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
|
|
|
disabled={page >= totalPages}
|
|
|
|
|
>
|
2026-06-21 23:51:12 +02:00
|
|
|
{t('c2.modal.import.next')}
|
2026-06-10 20:11:12 +02:00
|
|
|
</button>
|
|
|
|
|
{checkedIds.size > 0 && (
|
|
|
|
|
<span className="ml-auto text-ink">
|
2026-06-21 23:51:12 +02:00
|
|
|
{checkedIds.size} {t('c2.modal.import.selected')}
|
2026-06-10 20:11:12 +02:00
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{submitError && (
|
|
|
|
|
<p role="alert" className="text-[14px] text-bloom-deep">
|
|
|
|
|
{submitError}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Footer */}
|
|
|
|
|
<div className="flex items-center gap-md pt-xs">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="c2-import-submit-btn"
|
|
|
|
|
className="btn-primary"
|
|
|
|
|
onClick={onImport}
|
|
|
|
|
disabled={!canImport || importMutation.isPending}
|
|
|
|
|
>
|
2026-06-21 23:51:12 +02:00
|
|
|
{importMutation.isPending ? t('c2.modal.import.btn.importing') : t('c2.modal.import.btn.import')}
|
2026-06-10 20:11:12 +02:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn-outline-ink"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
disabled={importMutation.isPending}
|
|
|
|
|
>
|
2026-06-21 23:51:12 +02:00
|
|
|
{t('c2.modal.import.btn.cancel')}
|
2026-06-10 20:11:12 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|