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)} />