f1f5223076
- 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
1346 lines
47 KiB
React
1346 lines
47 KiB
React
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>
|
||
);
|
||
}
|