Files
TestingWebApp/frontend/src/pages/TestDetail.jsx
T
Константин Лебединский f1f5223076 feat(test detail): regroup sections, copy, and editor affordances
- AccSection: optional subtitle; disclosure body top padding
- Renames: О тесте, Вопросы, История (Версии + Прохождения), Показ в каталоге
- Move import into Вопросы as «Документ в вопросы»; remove duplicate import section
- Merge publication + assignment with subheads Видимость / Кому выдать; non-full-width actions
- Assign: «Выбрать всех (N)»; search row height tweaks
- Editor: крестик удаления варианта; единый футер + вариант/удалить вопрос; + вопрос в отдельной полосе
- AI block: test-detail-ai-panel (lighter, aligned with rest)
- History: empty state for runs; attempts error only in section (remove hero duplicate)

Made-with: Cursor
2026-04-27 20:36:09 +05:00

1346 lines
47 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}