You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1345 lines
47 KiB
1345 lines
47 KiB
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 ( |
|
<div className="cabinet-brick"> |
|
<details className="cabinet-disclosure" defaultOpen={defaultOpen} id={id || undefined}> |
|
<summary className="cabinet-disclosure__summary"> |
|
<span className="cabinet-disclosure__summary-text"> |
|
<span className="cabinet-disclosure__summary-title font-headline">{title}</span> |
|
{subtitle ? <span className="cabinet-disclosure__summary-sub">{subtitle}</span> : null} |
|
</span> |
|
</summary> |
|
<div className="cabinet-disclosure__body">{children}</div> |
|
</details> |
|
</div> |
|
); |
|
} |
|
|
|
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 <p className="error-text">{err}</p>; |
|
} |
|
if (!data && !taker) { |
|
return <p className="text-muted">Загрузка…</p>; |
|
} |
|
|
|
if (taker) { |
|
const { test: t, hasActiveVersion } = taker.summary; |
|
const title = t?.title || 'Тест'; |
|
return ( |
|
<div> |
|
<p className="link-back"> |
|
<Link to="/tests">← к списку</Link> |
|
</p> |
|
<h1 |
|
className="font-headline" |
|
style={{ fontSize: '1.35rem', marginTop: 0, marginBottom: '0.35rem' }} |
|
> |
|
{title} |
|
</h1> |
|
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.4rem', fontSize: 14 }}> |
|
{formatTestAuthorLabel(user, t?.createdBy, t?.authorFullName)} |
|
</p> |
|
{t?.description && ( |
|
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}> |
|
{t.description} |
|
</p> |
|
)} |
|
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}> |
|
Порог для зачёта: <strong>{t?.passingThreshold ?? '—'}%</strong> |
|
</p> |
|
{!hasActiveVersion && ( |
|
<div className="callout callout--warning" role="status"> |
|
Активная версия недоступна. Обратитесь к автору теста. |
|
</div> |
|
)} |
|
</div> |
|
); |
|
} |
|
|
|
const { test, versions, hasAttempts } = data; |
|
const title = test?.title || 'Тест'; |
|
const assignSelectedInList = assignPeople.filter((p) => |
|
assignSelected.has(assignPersonKey(p)) |
|
); |
|
|
|
return ( |
|
<div className="test-detail-page test-detail-page--with-fixed-actions"> |
|
<div className="cabinet-brick cabinet-brick--hero"> |
|
<p className="link-back" style={{ marginTop: 0 }}> |
|
<Link to="/tests">← к списку</Link> |
|
</p> |
|
|
|
<h1 |
|
className="font-headline" |
|
style={{ fontSize: '1.35rem', marginTop: 0, marginBottom: '0.35rem' }} |
|
> |
|
{title} |
|
</h1> |
|
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.5rem', fontSize: 14 }}> |
|
{formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)} |
|
</p> |
|
<p className="muted" style={{ marginBottom: 0 }}> |
|
Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)} |
|
{test?.chainActive === false && ( |
|
<span className="error-text" style={{ marginLeft: '0.75rem' }}> |
|
· скрыт из списка |
|
</span> |
|
)} |
|
</p> |
|
|
|
{test?.chainActive === false && ( |
|
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}> |
|
Скрыт из общего списка. |
|
</div> |
|
)} |
|
|
|
{chain?.hasAnyAttempt && ( |
|
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}> |
|
При сохранении будет создана новая версия теста. |
|
</div> |
|
)} |
|
</div> |
|
|
|
<AccSection |
|
title="О тесте" |
|
subtitle="Название, описание и порог зачёта" |
|
defaultOpen |
|
> |
|
<div className="draft-block"> |
|
<label className="form-label" htmlFor="draft-title"> |
|
Название |
|
</label> |
|
<input |
|
id="draft-title" |
|
className="form-input" |
|
value={draftTitle} |
|
onChange={(e) => setDraftTitle(e.target.value)} |
|
/> |
|
<label className="form-label" htmlFor="draft-desc" style={{ marginTop: '0.75rem' }}> |
|
Описание |
|
</label> |
|
<textarea |
|
id="draft-desc" |
|
className="form-input" |
|
rows={2} |
|
value={draftDescription} |
|
onChange={(e) => setDraftDescription(e.target.value)} |
|
/> |
|
<label className="form-label" htmlFor="draft-pass" style={{ marginTop: '0.75rem' }}> |
|
Порог зачёта, % |
|
</label> |
|
<input |
|
id="draft-pass" |
|
type="number" |
|
className="form-input" |
|
min={0} |
|
max={100} |
|
inputMode="numeric" |
|
value={draftPassing} |
|
onChange={(e) => setDraftPassing(e.target.value)} |
|
style={{ maxWidth: 120 }} |
|
/> |
|
</div> |
|
</AccSection> |
|
|
|
<AccSection |
|
title="Вопросы" |
|
subtitle="Тексты, варианты и при необходимости загрузка из файла" |
|
defaultOpen |
|
> |
|
<div className="draft-block"> |
|
<div className="test-detail-ai-panel" aria-label="Параметры генерации теста с ИИ"> |
|
<p |
|
className="font-headline" |
|
style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }} |
|
> |
|
Генерация сетки вопросов (ИИ) |
|
</p> |
|
<label className="form-label" htmlFor="ai-gen-topic"> |
|
Тема |
|
</label> |
|
<input |
|
id="ai-gen-topic" |
|
className="form-input" |
|
value={aiGenTopic} |
|
onChange={(e) => setAiGenTopic(e.target.value)} |
|
placeholder="По умолчанию — как в «Название»; при необходимости уточните" |
|
/> |
|
<div |
|
className="inline-actions" |
|
style={{ marginTop: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end', gap: '0.9rem' }} |
|
> |
|
<div> |
|
<label className="form-label" htmlFor="ai-n-questions" style={{ display: 'block' }}> |
|
Вопросов |
|
</label> |
|
<input |
|
id="ai-n-questions" |
|
type="number" |
|
className="form-input" |
|
min={1} |
|
max={30} |
|
inputMode="numeric" |
|
value={aiQuestionsCount} |
|
onChange={(e) => { |
|
const n = parseInt(e.target.value, 10); |
|
if (!Number.isFinite(n) || n < 1) { |
|
return; |
|
} |
|
setAiQuestionsCount(Math.min(30, Math.max(1, n))); |
|
}} |
|
style={{ maxWidth: 90 }} |
|
/> |
|
</div> |
|
<div> |
|
<label className="form-label" htmlFor="ai-n-answers" style={{ display: 'block' }}> |
|
Вариантов |
|
</label> |
|
<input |
|
id="ai-n-answers" |
|
type="number" |
|
className="form-input" |
|
min={2} |
|
max={8} |
|
inputMode="numeric" |
|
value={aiAnswersCount} |
|
onChange={(e) => { |
|
const n = parseInt(e.target.value, 10); |
|
if (!Number.isFinite(n) || n < 2) { |
|
return; |
|
} |
|
setAiAnswersCount(Math.min(8, Math.max(2, n))); |
|
}} |
|
style={{ maxWidth: 90 }} |
|
/> |
|
</div> |
|
<button |
|
type="button" |
|
className="btn btn-ghost" |
|
style={{ marginTop: '0.1rem' }} |
|
disabled={aiTestBusy} |
|
onClick={runAiGenerateTest} |
|
> |
|
{aiTestBusy ? 'Генерация…' : 'Сгенерировать тест (ИИ)'} |
|
</button> |
|
</div> |
|
</div> |
|
|
|
{draftQuestions.map((q, qi) => ( |
|
<div |
|
key={q.key} |
|
className={`question-editor-block${qi === 0 ? ' question-editor-block--first' : ''}`} |
|
> |
|
<div className="question-editor-block__header"> |
|
<label |
|
className="form-label question-editor-block__title" |
|
htmlFor={`qtext-${q.key}`} |
|
> |
|
Вопрос {qi + 1} |
|
</label> |
|
<button |
|
type="button" |
|
className="btn btn-ghost btn--sm question-editor-block__ai-btn" |
|
disabled={aiQBusy != null} |
|
onClick={() => runAiGenerateQuestion(qi)} |
|
> |
|
{aiQBusy === qi ? '…' : 'Сгенерировать вопрос (ИИ)'} |
|
</button> |
|
</div> |
|
<textarea |
|
id={`qtext-${q.key}`} |
|
className="form-input" |
|
rows={2} |
|
value={q.text} |
|
onChange={(e) => { |
|
const v = e.target.value; |
|
setDraftQuestions((prev) => |
|
prev.map((row, i) => (i === qi ? { ...row, text: v } : row)) |
|
); |
|
}} |
|
/> |
|
<label |
|
className="form-label" |
|
style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }} |
|
> |
|
<input |
|
type="checkbox" |
|
checked={q.hasMultipleAnswers} |
|
onChange={(e) => { |
|
const v = e.target.checked; |
|
setDraftQuestions((prev) => |
|
prev.map((row, i) => (i === qi ? { ...row, hasMultipleAnswers: v } : row)) |
|
); |
|
}} |
|
/> |
|
Несколько верных ответов |
|
</label> |
|
<p className="muted" style={{ marginBottom: 6, fontSize: 13 }}> |
|
Варианты |
|
</p> |
|
<ul className="question-options-list" style={{ listStyle: 'none', padding: 0, margin: 0 }}> |
|
{q.options.map((o, oi) => ( |
|
<li key={o.key} className="question-option-row"> |
|
<input |
|
key={`${o.key}-${q.hasMultipleAnswers ? 'm' : 's'}`} |
|
className="question-option-row__mark" |
|
type={q.hasMultipleAnswers ? 'checkbox' : 'radio'} |
|
name={q.hasMultipleAnswers ? undefined : `q-${q.key}-correct`} |
|
checked={o.isCorrect} |
|
onChange={(e) => { |
|
const v = e.target.checked; |
|
setDraftQuestions((prev) => |
|
prev.map((row, i) => { |
|
if (i !== qi) { |
|
return row; |
|
} |
|
if (!row.hasMultipleAnswers && v) { |
|
return { |
|
...row, |
|
options: row.options.map((op, j) => ({ |
|
...op, |
|
isCorrect: j === oi, |
|
})), |
|
}; |
|
} |
|
const options = row.options.map((op, j) => |
|
j === oi ? { ...op, isCorrect: v } : op |
|
); |
|
return { ...row, options }; |
|
}) |
|
); |
|
}} |
|
title="Верный ответ" |
|
aria-label="Пометить как верный" |
|
/> |
|
<input |
|
className="form-input question-option-row__text" |
|
value={o.text} |
|
onChange={(e) => { |
|
const v = e.target.value; |
|
setDraftQuestions((prev) => |
|
prev.map((row, i) => { |
|
if (i !== qi) { |
|
return row; |
|
} |
|
return { |
|
...row, |
|
options: row.options.map((op, j) => (j === oi ? { ...op, text: v } : op)), |
|
}; |
|
}) |
|
); |
|
}} |
|
placeholder="Текст варианта" |
|
/> |
|
{q.options.length > 1 && ( |
|
<button |
|
type="button" |
|
className="question-option-remove" |
|
onClick={() => removeOption(qi, oi)} |
|
title="Удалить вариант" |
|
aria-label="Удалить вариант" |
|
> |
|
<span className="material-symbols-outlined" aria-hidden> |
|
close |
|
</span> |
|
</button> |
|
)} |
|
</li> |
|
))} |
|
</ul> |
|
<div className="question-editor__footer"> |
|
<button |
|
type="button" |
|
className="btn btn-ghost btn--sm" |
|
onClick={() => addOption(qi)} |
|
> |
|
+ вариант |
|
</button> |
|
{draftQuestions.length > 1 && ( |
|
<button |
|
type="button" |
|
className="btn btn-ghost btn--sm" |
|
onClick={() => removeQuestion(qi)} |
|
> |
|
Удалить вопрос |
|
</button> |
|
)} |
|
</div> |
|
</div> |
|
))} |
|
<div className="test-detail-add-question"> |
|
<button |
|
type="button" |
|
className="btn btn-ghost question-editor__add-question" |
|
onClick={addQuestion} |
|
> |
|
+ вопрос |
|
</button> |
|
</div> |
|
|
|
<div className="test-detail-subsection test-detail-subsection--import"> |
|
<h3 className="test-detail-subsection__title">Документ в вопросы</h3> |
|
<p className="muted test-detail-hint" style={{ marginTop: 0 }}> |
|
PDF, Word или текст — вставьте в черновик вопросов. |
|
</p> |
|
<div |
|
className="import-file-row import-file-row--block" |
|
style={{ marginBottom: importPreview || importErr ? '0.5rem' : 0, marginTop: 0 }} |
|
> |
|
<input |
|
id="import-test-file" |
|
className="import-file-input" |
|
type="file" |
|
accept=".pdf,.docx,.txt,.md,application/pdf" |
|
onChange={onImportFile} |
|
disabled={importBusy} |
|
aria-label="Выбрать файл для импорта" |
|
/> |
|
<label |
|
htmlFor="import-test-file" |
|
className={`btn btn-ghost import-file-label${importBusy ? ' is-busy' : ''}`} |
|
style={importBusy ? { pointerEvents: 'none' } : undefined} |
|
> |
|
{importBusy ? 'Обработка…' : 'Выбрать файл'} |
|
</label> |
|
</div> |
|
{importErr && ( |
|
<p className="error-text" role="alert"> |
|
{importErr} |
|
</p> |
|
)} |
|
{importPreview && ( |
|
<div className="draft-block" style={{ marginTop: '0.65rem', marginBottom: 0 }}> |
|
<p className="muted" style={{ marginTop: 0, marginBottom: 0 }}> |
|
{importPreview.generation?.message} |
|
</p> |
|
{importPreview.generation?.textPreview && !importPreview.generation?.available && ( |
|
<pre |
|
className="form-input" |
|
style={{ |
|
maxHeight: 180, |
|
overflow: 'auto', |
|
fontSize: 13, |
|
marginTop: 8, |
|
whiteSpace: 'pre-wrap', |
|
}} |
|
> |
|
{importPreview.generation.textPreview} |
|
{importPreview.textLength > 4000 ? '…' : ''} |
|
</pre> |
|
)} |
|
{importPreview.generation?.draft && ( |
|
<div className="inline-actions" style={{ marginTop: 8, marginBottom: 0 }}> |
|
<button |
|
type="button" |
|
className="btn btn-ghost" |
|
onClick={applyGeneratedDraft} |
|
> |
|
Применить сгенерированный черновик |
|
</button> |
|
</div> |
|
)} |
|
{importPreview.extractedText && ( |
|
<div className="inline-actions" style={{ marginTop: 8, marginBottom: 0 }}> |
|
<button |
|
type="button" |
|
className="btn btn-ghost" |
|
onClick={applyExtractedToQuestion} |
|
> |
|
Вставить в первый вопрос (до 2000 симв.) |
|
</button> |
|
</div> |
|
)} |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
</AccSection> |
|
|
|
<div className="cabinet-brick editor-actions-flow" style={{ marginTop: 0 }}> |
|
<div |
|
className="actions-bar" |
|
style={{ marginBottom: draftStatus ? '0.35rem' : 0 }} |
|
> |
|
<button type="button" className="btn btn-primary" onClick={saveDraft}> |
|
Сохранить черновик |
|
</button> |
|
<Link to="/tests" className="btn btn-ghost"> |
|
К списку |
|
</Link> |
|
</div> |
|
{draftStatus && ( |
|
<p className="muted editor-actions-status--flow" style={{ marginTop: 0, marginBottom: 0 }}> |
|
{draftStatus} |
|
</p> |
|
)} |
|
</div> |
|
<AccSection |
|
title="История" |
|
subtitle="Версии теста и кто проходил" |
|
defaultOpen={false} |
|
> |
|
<div className="test-detail-subsection test-detail-subsection--tight"> |
|
<h3 className="test-detail-subsection__title">Версии</h3> |
|
<ul className="version-card-list" aria-label="Список версий теста"> |
|
{versions.map((r) => ( |
|
<li key={r.id} className="surface-card version-card-list__item"> |
|
<div className="version-card-list__row"> |
|
<div className="version-card-list__main"> |
|
<div className="version-card-list__title-line"> |
|
<span className="font-headline" style={{ fontSize: '1rem' }}> |
|
v{r.version} |
|
</span> |
|
{r.is_active && ( |
|
<span className="code-inline" style={{ fontSize: '0.7rem' }}> |
|
текущая |
|
</span> |
|
)} |
|
</div> |
|
<p className="muted mono" style={{ margin: '0.4rem 0 0', fontSize: '0.8rem' }}> |
|
{fmtDt(r.created_at)} |
|
</p> |
|
<p className="muted" style={{ margin: '0.2rem 0 0', fontSize: '0.8rem' }}> |
|
Активна: {r.is_active ? 'да' : 'нет'} |
|
</p> |
|
</div> |
|
{!r.is_active && ( |
|
<button |
|
type="button" |
|
className="btn btn-ghost btn--sm version-card-list__action" |
|
onClick={() => activateVersion(r.id)} |
|
> |
|
Сделать активной |
|
</button> |
|
)} |
|
</div> |
|
</li> |
|
))} |
|
</ul> |
|
</div> |
|
{attemptsList !== undefined && ( |
|
<div className="test-detail-subsection"> |
|
<h3 className="test-detail-subsection__title">Прохождения</h3> |
|
{attemptsList == null && ( |
|
<p |
|
className={attemptsErr ? 'error-text' : 'text-muted'} |
|
style={{ margin: 0 }} |
|
role={attemptsErr ? 'alert' : undefined} |
|
> |
|
{attemptsErr || 'Список прогонов сейчас недоступен.'} |
|
</p> |
|
)} |
|
{attemptsList && attemptsList.length > 0 && ( |
|
<ul className="attempts-card-list" aria-label="Список прогонов"> |
|
{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 ( |
|
<li key={a.id} className="surface-card attempts-card-list__item"> |
|
<div className="attempts-card-list__row"> |
|
<div className="attempts-card-list__main"> |
|
<p |
|
className="muted attempts-card-list__when mono" |
|
style={{ margin: 0, fontSize: '0.8rem' }} |
|
> |
|
{when} |
|
</p> |
|
<p |
|
className="attempts-card-list__who" |
|
style={{ margin: '0.35rem 0 0', fontWeight: 600 }} |
|
> |
|
{a.attempterName || '—'} |
|
{a.attempterLogin && ( |
|
<span className="code-inline" style={{ fontSize: 11, marginLeft: 6 }}> |
|
{a.attempterLogin} |
|
</span> |
|
)} |
|
</p> |
|
<p className="muted" style={{ margin: '0.25rem 0 0', fontSize: '0.85rem' }}> |
|
v{a.testVersion} ·{' '} |
|
<span className="attempts-card-list__result">{result}</span> |
|
</p> |
|
</div> |
|
{a.status === 'completed' && ( |
|
<Link |
|
to={`/tests/${id}/attempts/${a.id}/review`} |
|
className="btn btn-ghost btn--sm attempts-card-list__action" |
|
> |
|
Разбор |
|
</Link> |
|
)} |
|
</div> |
|
</li> |
|
); |
|
})} |
|
</ul> |
|
)} |
|
{attemptsList && attemptsList.length === 0 && ( |
|
<p className="text-muted" style={{ margin: 0 }}> |
|
Пока нет зарегистрированных прогонов. |
|
</p> |
|
)} |
|
</div> |
|
)} |
|
</AccSection> |
|
|
|
<AccSection |
|
title="Показ в каталоге" |
|
subtitle={ |
|
assignmentUi && data |
|
? 'Видимость в списке и выдача сотрудникам' |
|
: 'Показать или скрыть в общем списке' |
|
} |
|
defaultOpen={false} |
|
> |
|
<div className="test-detail-subsection test-detail-subsection--tight"> |
|
<h3 className="test-detail-subsection__title">Видимость</h3> |
|
<p className="test-detail-hint" style={{ marginTop: 0 }}> |
|
Скрытые тесты в общем списке не показываются; ссылку на тест по-прежнему можно открыть. |
|
</p> |
|
<div className="publication-visibility__actions" style={{ marginTop: 4 }}> |
|
{test?.chainActive !== false ? ( |
|
<button |
|
type="button" |
|
className="btn btn-ghost" |
|
disabled={deactivateBusy} |
|
onClick={() => setChainVisible(false)} |
|
> |
|
Скрыть из списка |
|
</button> |
|
) : ( |
|
<button |
|
type="button" |
|
className="btn btn-ghost" |
|
disabled={deactivateBusy} |
|
onClick={() => setChainVisible(true)} |
|
> |
|
Снова показать в списке |
|
</button> |
|
)} |
|
</div> |
|
</div> |
|
{assignmentUi && data && ( |
|
<div className="test-detail-subsection"> |
|
<h3 className="test-detail-subsection__title">Кому выдать</h3> |
|
<p className="test-detail-hint" style={{ marginTop: 0 }}> |
|
Список с учётом поиска и фильтров; можно отметить всех на экране. |
|
</p> |
|
{assignErr && ( |
|
<p className="error-text" role="alert" style={{ marginTop: 0, marginBottom: 8 }}> |
|
{assignErr} |
|
</p> |
|
)} |
|
<div className="assign-toolbar"> |
|
<input |
|
type="search" |
|
className="form-input assign-toolbar__search" |
|
placeholder="Поиск: ФИО, логин" |
|
value={assignSearch} |
|
onChange={(e) => setAssignSearch(e.target.value)} |
|
aria-label="Поиск сотрудника" |
|
/> |
|
<select |
|
className="form-input" |
|
value={assignDept} |
|
onChange={(e) => setAssignDept(e.target.value)} |
|
aria-label="Фильтр по отделу" |
|
> |
|
<option value="__all__">Все отделы</option> |
|
{assignDepts.map((d) => ( |
|
<option key={d} value={d}> |
|
{d} |
|
</option> |
|
))} |
|
</select> |
|
<select |
|
className="form-input" |
|
value={assignClinic} |
|
onChange={(e) => setAssignClinic(e.target.value)} |
|
aria-label="Кто в модуле" |
|
> |
|
<option value="all">Все</option> |
|
<option value="with">С учёткой в модуле</option> |
|
<option value="without">Без учётки (создать)</option> |
|
</select> |
|
{assignLoadBusy && <span className="text-muted">Загрузка…</span>} |
|
</div> |
|
{assignPeople.length > 0 && ( |
|
<div |
|
className="publication-visibility__actions" |
|
style={{ marginTop: '0.45rem', marginBottom: '0.25rem' }} |
|
> |
|
<button |
|
type="button" |
|
className="btn btn-ghost btn--sm" |
|
onClick={selectAllVisible} |
|
disabled={assignLoadBusy} |
|
> |
|
Выбрать всех ({assignPeople.length}) |
|
</button> |
|
</div> |
|
)} |
|
{assignPeople.length > 0 ? ( |
|
<div className="assign-list" role="group" aria-label="Список сотрудников"> |
|
{assignPeople.map((p) => { |
|
const k = assignPersonKey(p); |
|
const picked = assignSelected.has(k); |
|
return ( |
|
<label |
|
key={k} |
|
className={`assign-row${picked ? ' assign-row--selected' : ''}`} |
|
> |
|
<input |
|
type="checkbox" |
|
checked={picked} |
|
onChange={() => toggleAssignPerson(p)} |
|
/> |
|
<span className="assign-row__text"> |
|
<span className="assign-row__fio">{p.fio}</span> |
|
{p.webLogin && <span className="assign-row__login">{p.webLogin}</span>} |
|
<span className="assign-row__meta"> |
|
{p.departments || '—'} |
|
{p.clinicUserId |
|
? ' · есть учётка в модуле' |
|
: ' · нет учётки (создадим при назначении)'} |
|
</span> |
|
</span> |
|
</label> |
|
); |
|
})} |
|
</div> |
|
) : ( |
|
!assignLoadBusy && ( |
|
<p className="text-muted" style={{ margin: '0.5rem 0' }}> |
|
Нет подходящих записей. Смените фильтры или поиск. |
|
</p> |
|
) |
|
)} |
|
<div className="publication-visibility__actions" style={{ marginTop: '0.75rem' }}> |
|
<button |
|
type="button" |
|
className="btn btn-ghost" |
|
disabled={assignSelectedInList.length === 0} |
|
onClick={postAssign} |
|
> |
|
Назначить выбранных |
|
{assignSelectedInList.length > 0 |
|
? ` (${assignSelectedInList.length})` |
|
: ''} |
|
</button> |
|
</div> |
|
{assignMsg && <p className="text-muted" style={{ marginTop: 8, marginBottom: 0 }}>{assignMsg}</p>} |
|
</div> |
|
)} |
|
</AccSection> |
|
|
|
<div |
|
className="test-detail-fixed-actions" |
|
role="region" |
|
aria-label="Быстрые действия с черновиком" |
|
> |
|
<div className="test-detail-fixed-actions__inner"> |
|
{draftStatus && ( |
|
<p className="muted test-detail-fixed-actions__status" role="status"> |
|
{draftStatus} |
|
</p> |
|
)} |
|
<div className="actions-bar"> |
|
<button type="button" className="btn btn-primary" onClick={saveDraft}> |
|
Сохранить черновик |
|
</button> |
|
<Link to="/tests" className="btn btn-ghost"> |
|
К списку |
|
</Link> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
); |
|
}
|
|
|