import { useEffect, useState } from 'react';
import { useNavigate, useParams, Link, useOutletContext } from 'react-router-dom';
import { api } from '../api';
import { formatTestAuthorLabel } from '../utils/formatUserName';
function fmtDt(iso) {
if (!iso) {
return '—';
}
try {
return new Date(iso).toLocaleString('ru-RU', {
dateStyle: 'short',
timeStyle: 'short',
});
} catch {
return '—';
}
}
function newKey() {
return globalThis.crypto?.randomUUID?.() ?? `k-${Date.now()}-${Math.random()}`;
}
function createEmptyQuestion() {
return {
key: newKey(),
text: '',
hasMultipleAnswers: false,
options: [
{ key: newKey(), text: '', isCorrect: true },
{ key: newKey(), text: '', isCorrect: false },
],
};
}
/**
* @param {{ title: string, subtitle?: string, defaultOpen?: boolean, id?: string, children: import('react').ReactNode }} p
*/
function AccSection({ title, subtitle, defaultOpen, id, children }) {
return (
{title}
{subtitle ? {subtitle} : null}
{children}
);
}
function mapEditorToDraftQuestions(ed) {
if (!ed?.questions?.length) {
return [createEmptyQuestion()];
}
return ed.questions.map((q) => ({
key: String(q.id),
id: q.id,
text: q.text ?? '',
hasMultipleAnswers: !!q.hasMultipleAnswers,
options: (q.options || []).map((o) => ({
key: String(o.id),
id: o.id,
text: o.text ?? '',
isCorrect: !!o.isCorrect,
})),
}));
}
export default function TestDetail() {
const { id } = useParams();
const nav = useNavigate();
const { user, assignmentUi } = useOutletContext() || {};
const [data, setData] = useState(null);
/** V.8: сотрудник / не-автор — краткая карточка без редактора */
const [taker, setTaker] = useState(null);
const [chain, setChain] = useState(null);
const [err, setErr] = useState(null);
const [draftTitle, setDraftTitle] = useState('');
const [draftDescription, setDraftDescription] = useState('');
const [draftPassing, setDraftPassing] = useState('70');
const [draftQuestions, setDraftQuestions] = useState(() => [createEmptyQuestion()]);
const [draftStatus, setDraftStatus] = useState('');
const [deactivateBusy, setDeactivateBusy] = useState(false);
const [importPreview, setImportPreview] = useState(null);
const [importErr, setImportErr] = useState(null);
const [importBusy, setImportBusy] = useState(false);
const [aiTestBusy, setAiTestBusy] = useState(false);
const [aiQBusy, setAiQBusy] = useState(null);
/** Параметры блока «Сгенерировать тест (ИИ)» (редизайн формы редактора) */
const [aiGenTopic, setAiGenTopic] = useState('');
const [aiQuestionsCount, setAiQuestionsCount] = useState(7);
const [aiAnswersCount, setAiAnswersCount] = useState(3);
const [assignSearch, setAssignSearch] = useState('');
const [assignSearchApplied, setAssignSearchApplied] = useState('');
const [assignDept, setAssignDept] = useState('__all__');
const [assignClinic, setAssignClinic] = useState('all');
const [assignPeople, setAssignPeople] = useState([]);
const [assignDepts, setAssignDepts] = useState([]);
const [assignSelected, setAssignSelected] = useState(() => new Set());
const [assignMsg, setAssignMsg] = useState('');
const [assignErr, setAssignErr] = useState(null);
const [assignLoadBusy, setAssignLoadBusy] = useState(false);
const [attemptsList, setAttemptsList] = useState(undefined);
const [attemptsErr, setAttemptsErr] = useState(null);
async function load() {
setErr(null);
setData(null);
setTaker(null);
setChain(null);
try {
const sum = await api(`/api/tests/${id}/summary`);
if (!sum.isAuthor) {
const c = await api(`/api/tests/${id}/chain-info`);
setTaker({ summary: sum, chain: c });
return;
}
const [v, c, ed] = await Promise.all([
api(`/api/tests/${id}/versions`),
api(`/api/tests/${id}/chain-info`),
api(`/api/tests/${id}/editor`),
]);
setData(v);
setChain(c);
if (ed?.test) {
setDraftTitle(ed.test.title || '');
setAiGenTopic((ed.test.title || '').trim());
setDraftDescription(ed.test.description || '');
const th = ed.test.passingThreshold;
setDraftPassing(
th !== undefined && th !== null && String(th) !== '' ? String(th) : '70'
);
setDraftQuestions(mapEditorToDraftQuestions(ed));
}
} catch (e) {
if (e.status === 401) {
nav('/login');
return;
}
setErr(e.message);
}
}
useEffect(() => {
load();
}, [id, nav]);
useEffect(() => {
if (!data) {
setAttemptsList(undefined);
setAttemptsErr(null);
return;
}
let cancelled = false;
setAttemptsErr(null);
(async () => {
try {
const r = await api(`/api/tests/${id}/attempts`);
if (!cancelled) {
setAttemptsList(r.attempts || []);
}
} catch (e) {
if (!cancelled) {
if (e.status === 403 || e.status === 404) {
setAttemptsList(null);
} else {
setAttemptsErr(e.message);
setAttemptsList(null);
}
}
}
})();
return () => {
cancelled = true;
};
}, [data, id]);
useEffect(() => {
const t = setTimeout(() => setAssignSearchApplied(assignSearch), 400);
return () => clearTimeout(t);
}, [assignSearch]);
useEffect(() => {
if (!assignmentUi || !data) {
return;
}
let cancelled = false;
(async () => {
setAssignErr(null);
setAssignLoadBusy(true);
try {
const params = new URLSearchParams();
if (assignSearchApplied.trim()) {
params.set('q', assignSearchApplied.trim());
}
if (assignDept && assignDept !== '__all__') {
params.set('department', assignDept);
}
params.set('clinic', assignClinic);
const r = await api(`/api/auth/dev/assignment-directory?${params.toString()}`);
if (cancelled) {
return;
}
setAssignPeople(r.people || []);
setAssignDepts(r.departments || []);
setAssignSelected(new Set());
} catch (e) {
if (!cancelled) {
setAssignErr(e.message);
}
} finally {
if (!cancelled) {
setAssignLoadBusy(false);
}
}
})();
return () => {
cancelled = true;
};
}, [assignmentUi, data, id, assignSearchApplied, assignDept, assignClinic]);
function assignPersonKey(p) {
return `${p.staffId ?? '—'}|${p.clinicUserId ?? '—'}`;
}
function toggleAssignPerson(p) {
const k = assignPersonKey(p);
setAssignSelected((prev) => {
const next = new Set(prev);
if (next.has(k)) {
next.delete(k);
} else {
next.add(k);
}
return next;
});
}
function selectAllVisible() {
setAssignSelected((prev) => {
const next = new Set(prev);
for (const p of assignPeople) {
next.add(assignPersonKey(p));
}
return next;
});
}
async function postAssign() {
const selectedRows = assignPeople.filter((p) =>
assignSelected.has(assignPersonKey(p))
);
if (selectedRows.length === 0) {
return;
}
setAssignMsg('');
setAssignErr(null);
const userIds = [];
const staffIds = [];
for (const p of selectedRows) {
if (p.clinicUserId) {
userIds.push(p.clinicUserId);
} else if (p.staffId != null) {
staffIds.push(p.staffId);
}
}
if (userIds.length === 0 && staffIds.length === 0) {
setAssignErr('Не выбраны сотрудники с валидным staff_id.');
return;
}
try {
const out = await api(`/api/tests/${id}/assign`, {
method: 'POST',
body: JSON.stringify({ userIds, staffIds }),
});
setAssignMsg(
out.count != null
? `Назначено: ${out.count} сотр.`
: 'Назначение сохранено.'
);
setAssignSelected(new Set());
} catch (e) {
setAssignErr(e.message);
}
}
async function saveDraft() {
setDraftStatus('…');
try {
const questions = draftQuestions.map((q, i) => ({
text: (q.text || '').trim() || 'Вопрос',
question_order: i + 1,
hasMultipleAnswers: q.hasMultipleAnswers,
options: q.options.map((o, j) => ({
text: (o.text || '').trim() || 'Вариант',
isCorrect: o.isCorrect,
option_order: j + 1,
})),
}));
const out = await api(`/api/tests/${id}/draft`, {
method: 'POST',
body: JSON.stringify({
title: (draftTitle || '').trim() || 'Без названия',
description: (draftDescription || '').trim() || null,
passingThreshold: (() => {
const n = Number(draftPassing);
return Number.isFinite(n) ? n : undefined;
})(),
questions,
}),
});
setDraftStatus(out.forked ? 'Сохранено как новая версия' : 'Сохранено');
load();
} catch (e) {
setDraftStatus(e.message);
}
}
function normalizeGeneratedQuestionOptions(q, targetCount) {
const n = Math.min(12, Math.max(2, targetCount));
const raw = (q?.options || []).map((o) => ({
key: newKey(),
text: (o.text || '').trim() || 'Вариант',
isCorrect: !!o.isCorrect,
}));
const out = raw.slice(0, n);
while (out.length < n) {
out.push({ key: newKey(), text: '', isCorrect: false });
}
if (!out.some((o) => o.isCorrect)) {
out[0] = { ...out[0], isCorrect: true };
}
return out;
}
async function runAiGenerateTest() {
if (aiTestBusy || !id) {
return;
}
const nQ = Math.min(30, Math.max(1, Math.floor(Number(aiQuestionsCount)) || 7));
const nA = Math.min(8, Math.max(2, Math.floor(Number(aiAnswersCount)) || 3));
const topic = (aiGenTopic || draftTitle || '').trim() || 'Тест';
setDraftStatus('');
setAiTestBusy(true);
try {
const shape = Array.from({ length: nQ }, () => ({
optionsCount: nA,
hasMultipleAnswers: false,
}));
const out = await api(`/api/tests/${id}/ai/generate-test`, {
method: 'POST',
body: JSON.stringify({
testTitle: topic,
testDescription: draftDescription,
shape,
}),
});
if (out.draft) {
setDraftTitle((out.draft.title || '').trim() || 'Без названия');
setDraftDescription(
out.draft.description != null && String(out.draft.description).trim()
? String(out.draft.description).trim()
: ''
);
const qs = (out.draft.questions || []).map((q) => ({
key: newKey(),
text: (q.text || '').trim() || 'Вопрос',
hasMultipleAnswers: !!q.hasMultipleAnswers,
options: normalizeGeneratedQuestionOptions(q, nA),
}));
if (qs.length) {
setDraftQuestions(qs);
}
setDraftStatus('Тексты и верные варианты сгенерированы по сетке. Проверьте и сохраните.');
}
} catch (e) {
setDraftStatus(e.message || 'Ошибка');
} finally {
setAiTestBusy(false);
}
}
async function runAiGenerateQuestion(qi) {
if (aiQBusy != null || !id) {
return;
}
setDraftStatus('');
const q = draftQuestions[qi];
if (!q) {
return;
}
setAiQBusy(qi);
try {
const out = await api(`/api/tests/${id}/ai/generate-question`, {
method: 'POST',
body: JSON.stringify({
testTitle: draftTitle,
testDescription: draftDescription,
questionText: (q.text || '').trim(),
optionsCount: Math.max(2, Math.min(12, q.options?.length || 2)),
hasMultipleAnswers: q.hasMultipleAnswers,
}),
});
if (out.mode === 'rephrase' && out.text) {
setDraftQuestions((prev) =>
prev.map((row, i) => (i === qi ? { ...row, text: out.text } : row))
);
setDraftStatus('Вопрос переформулирован. При необходимости сохраните.');
} else if (out.mode === 'full') {
setDraftQuestions((prev) =>
prev.map((row, i) =>
i === qi
? {
...row,
key: newKey(),
text: (out.text || '').trim() || 'Вопрос',
hasMultipleAnswers: !!out.hasMultipleAnswers,
options: (out.options || []).map((o) => ({
key: newKey(),
text: (o.text || '').trim() || 'Вариант',
isCorrect: !!o.isCorrect,
})),
}
: row
)
);
setDraftStatus('Вопрос и варианты сгенерированы. Проверьте и сохраните.');
}
} catch (e) {
setDraftStatus(e.message || 'Ошибка');
} finally {
setAiQBusy(null);
}
}
async function onImportFile(e) {
const f = e.target.files?.[0];
e.target.value = '';
if (!f) {
return;
}
setImportErr(null);
setImportPreview(null);
setImportBusy(true);
try {
const fd = new FormData();
fd.append('file', f);
const out = await api('/api/tests/import/document', {
method: 'POST',
body: fd,
});
setImportPreview(out);
} catch (er) {
setImportErr(er.message);
} finally {
setImportBusy(false);
}
}
function applyExtractedToQuestion() {
const t = importPreview?.extractedText;
if (!t) {
return;
}
const chunk = t.length > 2000 ? `${t.slice(0, 2000)}…` : t;
setDraftQuestions((prev) => {
if (!prev.length) {
const q = createEmptyQuestion();
q.text = chunk;
return [q];
}
const next = [...prev];
next[0] = { ...next[0], text: chunk };
return next;
});
}
function applyGeneratedDraft() {
const d = importPreview?.generation?.draft;
if (!d) {
return;
}
setDraftTitle((d.title || '').trim() || 'Без названия');
setAiGenTopic((d.title || '').trim());
setDraftDescription(
d.description != null && String(d.description).trim() ? String(d.description).trim() : ''
);
const qs = (d.questions || []).map((q) => {
const opts = (q.options || []).map((o) => ({
key: newKey(),
text: (o.text || '').trim() || 'Вариант',
isCorrect: !!o.isCorrect,
}));
if (opts.length < 2) {
return {
key: newKey(),
text: (q.text || '').trim() || 'Вопрос',
hasMultipleAnswers: !!q.hasMultipleAnswers,
options: [
{ key: newKey(), text: 'Вариант 1', isCorrect: true },
{ key: newKey(), text: 'Вариант 2', isCorrect: false },
],
};
}
return {
key: newKey(),
text: (q.text || '').trim() || 'Вопрос',
hasMultipleAnswers: !!q.hasMultipleAnswers,
options: opts,
};
});
setDraftQuestions(qs.length ? qs : [createEmptyQuestion()]);
setDraftStatus('Черновик из LLM перенесён в редактор. Сохраните при необходимости.');
}
function addQuestion() {
setDraftQuestions((prev) => [...prev, createEmptyQuestion()]);
}
function removeQuestion(index) {
setDraftQuestions((prev) => prev.filter((_, i) => i !== index));
}
function addOption(qIndex) {
setDraftQuestions((prev) => {
const next = prev.map((q, i) =>
i === qIndex
? {
...q,
options: [...q.options, { key: newKey(), text: '', isCorrect: false }],
}
: q
);
return next;
});
}
function removeOption(qIndex, oIndex) {
setDraftQuestions((prev) => {
const next = prev.map((q, i) => {
if (i !== qIndex) {
return q;
}
if (q.options.length <= 1) {
return q;
}
return { ...q, options: q.options.filter((_, j) => j !== oIndex) };
});
return next;
});
}
async function activateVersion(vid) {
if (!window.confirm('Сделать эту версию активной? Новые попытки пойдут по выбранной версии.')) {
return;
}
try {
await api(`/api/tests/${id}/versions/${vid}/activate`, {
method: 'POST',
body: JSON.stringify({}),
});
load();
} catch (e) {
setErr(e.message);
}
}
async function setChainVisible(chainActive) {
const act = chainActive ? 'снова показывать' : 'скрывать';
if (!window.confirm(`Цепочка будет ${act} в общем списке тестов. Продолжить?`)) {
return;
}
setDeactivateBusy(true);
setErr(null);
try {
await api(`/api/tests/${id}`, {
method: 'PATCH',
body: JSON.stringify({ chainActive }),
});
await load();
} catch (e) {
setErr(e.message);
} finally {
setDeactivateBusy(false);
}
}
if (err) {
return {err}
;
}
if (!data && !taker) {
return Загрузка…
;
}
if (taker) {
const { test: t, hasActiveVersion } = taker.summary;
const title = t?.title || 'Тест';
return (
← к списку
{title}
{formatTestAuthorLabel(user, t?.createdBy, t?.authorFullName)}
{t?.description && (
{t.description}
)}
Порог для зачёта: {t?.passingThreshold ?? '—'}%
{!hasActiveVersion && (
Активная версия недоступна. Обратитесь к автору теста.
)}
);
}
const { test, versions, hasAttempts } = data;
const title = test?.title || 'Тест';
const assignSelectedInList = assignPeople.filter((p) =>
assignSelected.has(assignPersonKey(p))
);
return (
← к списку
{title}
{formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)}
Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)}
{test?.chainActive === false && (
· скрыт из списка
)}
{test?.chainActive === false && (
Скрыт из общего списка.
)}
{chain?.hasAnyAttempt && (
При сохранении будет создана новая версия теста.
)}
setDraftTitle(e.target.value)}
/>
{draftQuestions.map((q, qi) => (
))}
Документ в вопросы
PDF, Word или текст — вставьте в черновик вопросов.
{importErr && (
{importErr}
)}
{importPreview && (
{importPreview.generation?.message}
{importPreview.generation?.textPreview && !importPreview.generation?.available && (
{importPreview.generation.textPreview}
{importPreview.textLength > 4000 ? '…' : ''}
)}
{importPreview.generation?.draft && (
)}
{importPreview.extractedText && (
)}
)}
К списку
{draftStatus && (
{draftStatus}
)}
Версии
{versions.map((r) => (
-
v{r.version}
{r.is_active && (
текущая
)}
{fmtDt(r.created_at)}
Активна: {r.is_active ? 'да' : 'нет'}
{!r.is_active && (
)}
))}
{attemptsList !== undefined && (
Прохождения
{attemptsList == null && (
{attemptsErr || 'Список прогонов сейчас недоступен.'}
)}
{attemptsList && attemptsList.length > 0 && (
{attemptsList.map((a) => {
const when = a.completedAt
? fmtDt(a.completedAt)
: a.startedAt
? fmtDt(a.startedAt)
: '—';
const result =
a.status === 'completed' && a.totalQuestions != null ? (
<>
{a.correctCount}/{a.totalQuestions}
{a.passed ? ' · зачёт' : ' · незачёт'}
>
) : (
a.status
);
return (
-
{when}
{a.attempterName || '—'}
{a.attempterLogin && (
{a.attempterLogin}
)}
v{a.testVersion} ·{' '}
{result}
{a.status === 'completed' && (
Разбор
)}
);
})}
)}
{attemptsList && attemptsList.length === 0 && (
Пока нет зарегистрированных прогонов.
)}
)}
Видимость
Скрытые тесты в общем списке не показываются; ссылку на тест по-прежнему можно открыть.
{test?.chainActive !== false ? (
) : (
)}
{assignmentUi && data && (
Кому выдать
Список с учётом поиска и фильтров; можно отметить всех на экране.
{assignErr && (
{assignErr}
)}
setAssignSearch(e.target.value)}
aria-label="Поиск сотрудника"
/>
{assignLoadBusy && Загрузка…}
{assignPeople.length > 0 && (
)}
{assignPeople.length > 0 ? (
{assignPeople.map((p) => {
const k = assignPersonKey(p);
const picked = assignSelected.has(k);
return (
);
})}
) : (
!assignLoadBusy && (
Нет подходящих записей. Смените фильтры или поиск.
)
)}
{assignMsg &&
{assignMsg}
}
)}
{draftStatus && (
{draftStatus}
)}
К списку
);
}