diff --git a/backend/src/routes/tests.js b/backend/src/routes/tests.js index dd2f53e..dcf785b 100644 --- a/backend/src/routes/tests.js +++ b/backend/src/routes/tests.js @@ -9,6 +9,28 @@ import pool, { query } from '../db/db.js'; import { authenticate } from '../middleware/auth.js'; import { hasAnyAttemptForTest } from '../services/testChainService.js'; import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js'; +import { + getEditorContent, + getPlayContent, + submitAttempt, + getAttemptReviewForUser, + listTestAttemptsForAuthor, +} from '../services/testAttemptService.js'; +import { extractTextFromFile } from '../services/documentExtractService.js'; +import { generationForImportDocument } from '../services/documentGenService.js'; +import { RU } from '../messages/ru.js'; +import { isTestAuthor } from '../config/devAuthor.js'; +import { ensureClinicUserIdForStaff } from '../services/assignmentUserService.js'; +import { + queryTestsVisibleToUser, + userHasTestAccess, +} from '../services/testAccessService.js'; +import { isAssignmentFeatureEnabled } from '../config/featureFlags.js'; +import { + generateFullTestByShape, + generateOrRephraseQuestion, + parseAndValidateShape, +} from '../services/aiEditorService.js'; const router = express.Router(); const upload = multer({ @@ -28,35 +50,59 @@ function asyncHandler(fn) { }; } -/** D.1 — раньше /:id, иначе id = "import" */ +/** D.1 + D.2 + D.3 (заглушка) — `POST` до маршрутов `/:id` */ router.post( '/import/document', authenticate, - upload.single('file'), + (req, res, next) => { + upload.single('file')(req, res, (err) => { + if (err) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: RU.fileTooLarge }); + } + return next(err); + } + next(); + }); + }, asyncHandler(async (req, res) => { if (!req.file) { - return res.status(400).json({ error: 'file field required' }); + return res.status(400).json({ error: RU.fileFieldRequired }); } const p = req.file.path; - let size = 0; + const { mimetype, originalname } = req.file; + let size; + let extractedText; try { const st = await fs.stat(p); size = st.size; - await fs.unlink(p); - } catch { + extractedText = await extractTextFromFile(mimetype, p, originalname); + } catch (e) { try { await fs.unlink(p); } catch { // ignore } - return res.status(500).json({ error: 'upload failed' }); + if (e.status) { + return res.status(e.status).json({ error: e.message }); + } + console.error('import document:', e); + return res.status(500).json({ error: RU.uploadFailed }); } + try { + await fs.unlink(p); + } catch { + // D.5: временный файл удалён; при ошибке extract уже удалили выше + } + const generation = await generationForImportDocument(extractedText); res.json({ received: true, - originalName: req.file.originalname, + originalName: originalname, + mime: mimetype, size, - message: - 'Файл принят; извлечение текста и генерация (D.2–D.3) — в следующем шаге', + extractedText, + textLength: extractedText.length, + generation, }); }) ); @@ -65,15 +111,19 @@ router.get( '/', authenticate, asyncHandler(async (req, res) => { - const { rows } = await query( + const { rows } = await queryTestsVisibleToUser(req.user.id); + const { rows: hidden } = await query( `SELECT t.id, t.title, t.description, t.is_active AS chain_active, - t.created_at, t.updated_at, tv.id AS active_version_id, tv.version + t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, + t.created_by, u.full_name AS author_full_name FROM tests t INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true - WHERE t.is_active = true - ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC` + INNER JOIN users u ON u.id = t.created_by + WHERE t.is_active = false AND t.created_by = $1 + ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`, + [req.user.id] ); - res.json({ tests: rows }); + res.json({ tests: rows, hiddenByYou: hidden }); }) ); @@ -83,7 +133,7 @@ router.post( asyncHandler(async (req, res) => { const { title, description } = req.body; if (!title || typeof title !== 'string') { - return res.status(400).json({ error: 'title required' }); + return res.status(400).json({ error: RU.titleRequired }); } const out = await createTestWithVersion(pool, req.user.id, { title, @@ -93,27 +143,172 @@ router.post( }) ); +/** + * V.8: краткая карточка цепочки — одна строка (активная версия), без дублей. + * Не-автор не видит скрытую с общего списка цепочку (кроме прямой ссылки автора). + */ +router.get( + '/:id/summary', + authenticate, + asyncHandler(async (req, res) => { + const testId = req.params.id; + const { rows } = await query( + `SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active, + t.created_by, t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, + u.full_name AS author_full_name + FROM tests t + LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true + LEFT JOIN users u ON u.id = t.created_by + WHERE t.id = $1`, + [testId] + ); + if (!rows.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + const row = rows[0]; + const isAuthor = isTestAuthor(row.created_by, req.user.id); + if (row.chain_active === false && !isAuthor) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!isAuthor) { + const acc = await userHasTestAccess(req.user.id, testId); + if (!acc.ok) { + return res.status(404).json({ error: RU.testNotFound }); + } + } + res.json({ + test: { + id: row.id, + title: row.title, + description: row.description, + passingThreshold: row.passing_threshold, + chainActive: row.chain_active, + activeVersionId: row.active_version_id, + version: row.version, + createdAt: row.created_at, + updatedAt: row.updated_at, + createdBy: row.created_by, + authorFullName: row.author_full_name, + }, + isAuthor, + hasActiveVersion: row.active_version_id != null, + }); + }) +); + router.get( '/:id/versions', authenticate, asyncHandler(async (req, res) => { const testId = req.params.id; - const { rows: t } = await query(`SELECT id, created_by FROM tests WHERE id = $1`, [ - testId, - ]); + const { rows: t } = await query( + `SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at, t.description, + u.full_name AS author_full_name + FROM tests t + INNER JOIN users u ON u.id = t.created_by + WHERE t.id = $1`, + [testId] + ); if (!t.length) { - return res.status(404).json({ error: 'Test not found' }); + return res.status(404).json({ error: RU.testNotFound }); } - if (t[0].created_by !== req.user.id) { - return res.status(403).json({ error: 'Forbidden' }); + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); } + const testRow = t[0]; const { rows } = await query( `SELECT id, version, is_active, parent_id, created_at FROM test_versions WHERE test_id = $1 ORDER BY version`, [testId] ); const hasAttempts = await hasAnyAttemptForTest(pool, testId); - res.json({ versions: rows, hasAttempts }); + res.json({ + test: { + id: testRow.id, + title: testRow.title, + description: testRow.description, + chainActive: testRow.is_active, + createdAt: testRow.created_at, + updatedAt: testRow.updated_at, + createdBy: testRow.created_by, + authorFullName: testRow.author_full_name, + }, + versions: rows, + hasAttempts, + }); + }) +); + +router.get( + '/:id/editor', + authenticate, + asyncHandler(async (req, res) => { + const out = await getEditorContent(pool, req.user.id, req.params.id); + res.json(out); + }) +); + +/** + * ИИ: заполнить тест по текущей сетке (число вопросов = len(shape), варианты = optionsCount). + * Только автор. Тело: { testTitle?, testDescription?, shape: [{ optionsCount, hasMultipleAnswers }] } + */ +router.post( + '/:id/ai/generate-test', + authenticate, + asyncHandler(async (req, res) => { + const testId = req.params.id; + const { rows: t } = await query( + `SELECT id, created_by FROM tests WHERE id = $1`, + [testId] + ); + if (!t.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); + } + const b = req.body && typeof req.body === 'object' ? req.body : {}; + const shape = parseAndValidateShape(b.shape); + const testTitle = typeof b.testTitle === 'string' ? b.testTitle : ''; + const testDescription = typeof b.testDescription === 'string' ? b.testDescription : ''; + const draft = await generateFullTestByShape(testTitle, testDescription, shape); + res.json({ ok: true, draft }); + }) +); + +/** + * ИИ: один вопрос — пустой текст → сгенерировать вопрос и варианты; иначе переформулировать только текст. + * Только автор. Тело: { testTitle?, testDescription?, questionText, optionsCount, hasMultipleAnswers } + */ +router.post( + '/:id/ai/generate-question', + authenticate, + asyncHandler(async (req, res) => { + const testId = req.params.id; + const { rows: tr } = await query( + `SELECT id, created_by FROM tests WHERE id = $1`, + [testId] + ); + if (!tr.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!isTestAuthor(tr[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); + } + const b = req.body && typeof req.body === 'object' ? req.body : {}; + const testTitle = typeof b.testTitle === 'string' ? b.testTitle : ''; + const testDescription = typeof b.testDescription === 'string' ? b.testDescription : ''; + const questionText = typeof b.questionText === 'string' ? b.questionText : ''; + const optionsCount = b.optionsCount; + const hasMultipleAnswers = Boolean(b.hasMultipleAnswers); + const out = await generateOrRephraseQuestion( + testTitle, + testDescription, + questionText, + optionsCount, + hasMultipleAnswers + ); + res.json({ ok: true, ...out }); }) ); @@ -128,17 +323,17 @@ router.post( [testId] ); if (!t.length) { - return res.status(404).json({ error: 'Test not found' }); + return res.status(404).json({ error: RU.testNotFound }); } - if (t[0].created_by !== req.user.id) { - return res.status(403).json({ error: 'Forbidden' }); + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); } const { rows: v } = await query( `SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`, [testId, versionId] ); if (!v.length) { - return res.status(404).json({ error: 'Version not found' }); + return res.status(404).json({ error: RU.versionNotFound }); } const client = await pool.connect(); try { @@ -170,17 +365,17 @@ router.patch( const { isActive, chainActive } = req.body; const chain = chainActive ?? isActive; if (typeof chain !== 'boolean') { - return res.status(400).json({ error: 'chainActive (boolean) required' }); + return res.status(400).json({ error: RU.chainActiveRequired }); } const { rows: t } = await query( `SELECT id, created_by FROM tests WHERE id = $1`, [testId] ); if (!t.length) { - return res.status(404).json({ error: 'Test not found' }); + return res.status(404).json({ error: RU.testNotFound }); } - if (t[0].created_by !== req.user.id) { - return res.status(403).json({ error: 'Forbidden' }); + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); } await query( `UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, @@ -190,6 +385,111 @@ router.patch( }) ); +/** + * Пакетное назначение — `test_assignments` + `test_assignment_targets` (user). + * Включение: `development` или `CLINIC_ASSIGNMENT_ENABLED=1`. Только автор. Тело: `userIds`, `staffIds` и/или одиночные `userId` / `staffId`. + */ +router.post( + '/:id/assign', + authenticate, + asyncHandler(async (req, res) => { + if (!isAssignmentFeatureEnabled()) { + return res.status(404).json({ error: RU.notFound }); + } + const testId = req.params.id; + const b = req.body && typeof req.body === 'object' ? req.body : {}; + const userIds = new Set(); + + if (Array.isArray(b.userIds)) { + for (const u of b.userIds) { + if (typeof u === 'string' && u.trim()) { + userIds.add(u.trim()); + } + } + } + if (Array.isArray(b.staffIds)) { + for (const s of b.staffIds) { + const n = Number(s); + if (Number.isFinite(n) && n >= 1) { + userIds.add(await ensureClinicUserIdForStaff(pool, n)); + } + } + } + if (userIds.size === 0) { + if (b.staffId != null && b.userId) { + return res.status(400).json({ error: RU.assignmentUserOrStaff }); + } + if (b.staffId != null) { + const sid = Number(b.staffId); + if (Number.isNaN(sid)) { + return res.status(400).json({ error: RU.assignmentUserRequired }); + } + userIds.add(await ensureClinicUserIdForStaff(pool, sid)); + } else if (typeof b.userId === 'string' && b.userId.trim()) { + userIds.add(b.userId.trim()); + } + } + if (userIds.size === 0) { + return res.status(400).json({ error: RU.assignmentUserRequired }); + } + + const { rows: t } = await query( + `SELECT id, created_by FROM tests WHERE id = $1`, + [testId] + ); + if (!t.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); + } + const { rows: tv } = await query( + `SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, + [testId] + ); + if (!tv.length) { + return res.status(400).json({ error: RU.noActiveVersion }); + } + for (const uid of userIds) { + const { rows: u } = await query( + `SELECT id FROM users WHERE id = $1 AND is_active = true`, + [uid] + ); + if (!u.length) { + return res.status(400).json({ error: RU.userNotFound }); + } + } + const versionId = tv[0].id; + const client = await pool.connect(); + let assignmentId; + try { + await client.query('BEGIN'); + const { rows: ins } = await client.query( + `INSERT INTO test_assignments (test_version_id, assigned_by, max_attempts) + VALUES ($1, $2, 5) RETURNING id`, + [versionId, req.user.id] + ); + assignmentId = ins[0].id; + for (const uid of userIds) { + await client.query( + `INSERT INTO test_assignment_targets (assignment_id, target_type, target_id) + VALUES ($1, 'user', $2)`, + [assignmentId, uid] + ); + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + res + .status(201) + .json({ ok: true, assignmentId, count: userIds.size }); + }) +); + router.post( '/:id/draft', authenticate, @@ -204,6 +504,10 @@ router.post( authenticate, asyncHandler(async (req, res) => { const testId = req.params.id; + const acc = await userHasTestAccess(req.user.id, testId); + if (!acc.ok) { + return res.status(404).json({ error: RU.testNotFound }); + } const { rows: tv } = await query( `SELECT tv.id AS test_version_id FROM test_versions tv @@ -211,7 +515,7 @@ router.post( [testId] ); if (!tv.length) { - return res.status(404).json({ error: 'No active version' }); + return res.status(404).json({ error: RU.noActiveVersion }); } const testVersionId = tv[0].test_version_id; const { rows: mx } = await query( @@ -230,11 +534,98 @@ router.post( }) ); +/** Только автор: список попыток по цепочке (все версии) */ +router.get( + '/:id/attempts', + authenticate, + asyncHandler(async (req, res) => { + const rows = await listTestAttemptsForAuthor(pool, req.user.id, req.params.id); + res.json({ + attempts: rows.map((r) => ({ + id: r.id, + userId: r.user_id, + status: r.status, + attemptNumber: r.attempt_number, + startedAt: r.started_at, + completedAt: r.completed_at, + correctCount: r.correct_count, + totalQuestions: r.total_questions, + passed: r.passed, + testVersion: r.test_version, + attempterName: r.attempter_name, + attempterLogin: r.attempter_login, + })), + }); + }) +); + +/** Разбор завершённой попытки: владелец или автор */ +router.get( + '/:id/attempts/:aid/review', + authenticate, + asyncHandler(async (req, res) => { + const review = await getAttemptReviewForUser( + pool, + req.user.id, + req.params.id, + req.params.aid + ); + res.json(review); + }) +); + +router.get( + '/:id/attempts/:aid/play', + authenticate, + asyncHandler(async (req, res) => { + const out = await getPlayContent(pool, req.user.id, req.params.id, req.params.aid); + res.json(out); + }) +); + +router.post( + '/:id/attempts/:aid/submit', + authenticate, + asyncHandler(async (req, res) => { + const out = await submitAttempt( + pool, + req.user.id, + req.params.id, + req.params.aid, + req.body?.answers + ); + res.json(out); + }) +); + router.get( '/:id/chain-info', authenticate, asyncHandler(async (req, res) => { const testId = req.params.id; + const acc = await userHasTestAccess(req.user.id, testId); + if (acc.notFound) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!acc.ok) { + return res.status(404).json({ error: RU.testNotFound }); + } + const { rows: tr } = await query( + `SELECT t.is_active AS chain_active FROM tests t WHERE t.id = $1`, + [testId] + ); + if (!tr.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (tr[0].chain_active === false) { + const { rows: auth } = await query( + `SELECT created_by FROM tests WHERE id = $1`, + [testId] + ); + if (!isTestAuthor(auth[0].created_by, req.user.id)) { + return res.status(404).json({ error: RU.testNotFound }); + } + } const has = await hasAnyAttemptForTest(pool, testId); res.json({ testId, hasAnyAttempt: has }); }) diff --git a/backend/src/services/testAccessService.js b/backend/src/services/testAccessService.js new file mode 100644 index 0000000..f809110 --- /dev/null +++ b/backend/src/services/testAccessService.js @@ -0,0 +1,64 @@ +/** + * Кто видит тест: автор цепочки и пользователи с назначением (target user = clinic user id). + */ +import { isTestAuthor } from '../config/devAuthor.js'; +import { query } from '../db/db.js'; + +/** + * @param {string} userId + * @param {string} testId + * @returns {Promise<{ ok: boolean, isAuthor: boolean, notFound: boolean }>} + */ +export async function userHasTestAccess(userId, testId) { + const { rows } = await query( + `SELECT t.created_by FROM tests t WHERE t.id = $1`, + [testId] + ); + if (!rows.length) { + return { ok: false, isAuthor: false, notFound: true }; + } + if (isTestAuthor(rows[0].created_by, userId)) { + return { ok: true, isAuthor: true, notFound: false }; + } + const { rows: ar } = await query( + `SELECT 1 + FROM test_assignments ta + INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id + INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id + WHERE tv_a.test_id = $1 + AND tat.target_type = 'user' + AND tat.target_id = $2 + LIMIT 1`, + [testId, userId] + ); + return { ok: ar.length > 0, isAuthor: false, notFound: false }; +} + +/** + * Список тестов в каталоге: только `is_active` цепочка + (автор OR назначен). + */ +export async function queryTestsVisibleToUser(userId) { + return query( + `SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active, + t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, + t.created_by, u.full_name AS author_full_name + FROM tests t + INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true + INNER JOIN users u ON u.id = t.created_by + WHERE t.is_active = true + AND ( + t.created_by = $1 + OR EXISTS ( + SELECT 1 + FROM test_assignments ta + INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id + INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id + WHERE tv2.test_id = t.id + AND tat.target_type = 'user' + AND tat.target_id = $1 + ) + ) + ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`, + [userId] + ); +} diff --git a/frontend/src/components/CabinetLayout.jsx b/frontend/src/components/CabinetLayout.jsx new file mode 100644 index 0000000..9f5920c --- /dev/null +++ b/frontend/src/components/CabinetLayout.jsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link, Outlet, useNavigate } from 'react-router-dom'; +import { api } from '../api'; +import { formatSurnameWithInitials } from '../utils/formatUserName'; + +export default function CabinetLayout() { + const nav = useNavigate(); + const [user, setUser] = useState(null); + /** Backend: `devUi` при NODE_ENV=development */ + const [devUi, setDevUi] = useState(false); + /** Каталог HR + блок назначения (env CLINIC_ASSIGNMENT_ENABLED в prod) */ + const [assignmentUi, setAssignmentUi] = useState(false); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + const loadMe = useCallback(async () => { + setErr(null); + try { + const me = await api('/api/auth/me'); + setUser(me.user); + setDevUi(!!me.devUi); + setAssignmentUi(!!me.assignmentUi); + } catch (e) { + if (e.status === 401) { + nav('/login', { replace: true }); + return; + } + setErr(e.message); + } finally { + setLoading(false); + } + }, [nav]); + + useEffect(() => { + loadMe(); + }, [loadMe]); + + async function logout() { + try { + await api('/api/auth/logout', { method: 'POST' }); + } catch { + // ignore; still go to login + } + nav('/login'); + } + + if (loading) { + return ( +
+

Загрузка…

+
+ ); + } + + if (err) { + return ( +
+

+ {err} +

+
+ ); + } + + return ( +
+
+
+ + + school + +
+
Система тестрования
+
Портал
+
+ +
+ {user && ( + + {formatSurnameWithInitials(user.fullName) || user.login || '—'} + · {user.role} + + )} + +
+
+
+
+ +
+
+ ); +} diff --git a/frontend/src/pages/TestDetail.jsx b/frontend/src/pages/TestDetail.jsx index 1bbb49f..66b2460 100644 --- a/frontend/src/pages/TestDetail.jsx +++ b/frontend/src/pages/TestDetail.jsx @@ -1,23 +1,132 @@ import { useEffect, useState } from 'react'; -import { useNavigate, useParams, Link } from 'react-router-dom'; +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, defaultOpen?: boolean, id?: string, children: import('react').ReactNode }} p + */ +function AccSection({ title, defaultOpen, id, children }) { + return ( +
+
+ {title} +
{children}
+
+
+ ); +} + +function mapEditorToDraftQuestions(ed) { + if (!ed?.questions?.length) { + return [createEmptyQuestion()]; + } + return ed.questions.map((q) => ({ + key: String(q.id), + id: q.id, + text: q.text ?? '', + hasMultipleAnswers: !!q.hasMultipleAnswers, + options: (q.options || []).map((o) => ({ + key: String(o.id), + id: o.id, + text: o.text ?? '', + isCorrect: !!o.isCorrect, + })), + })); +} export default function TestDetail() { const { id } = useParams(); const nav = useNavigate(); + const { user, assignmentUi } = useOutletContext() || {}; const [data, setData] = useState(null); + /** V.8: сотрудник / не-автор — краткая карточка без редактора */ + const [taker, setTaker] = useState(null); const [chain, setChain] = useState(null); const [err, setErr] = useState(null); - const [qText, setQText] = useState('Пример вопроса?'); + 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 [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 [assignSource, setAssignSource] = 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 v = await api(`/api/tests/${id}/versions`); - const c = await api(`/api/tests/${id}/chain-info`); + 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 || ''); + 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'); @@ -31,25 +140,159 @@ export default function TestDetail() { 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 || []); + setAssignSource(r.source || ''); + 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; + }); + } + + 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} сотр. (dev).` + : 'Назначение в БД создано (dev).' + ); + 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: 'Обновлённый заголовок (через черновик)', - questions: [ - { - text: qText, - question_order: 1, - hasMultipleAnswers: false, - options: [ - { text: 'Верно', isCorrect: true, option_order: 1 }, - { text: 'Неверно 1', isCorrect: false, option_order: 2 }, - { text: 'Неверно 2', isCorrect: false, option_order: 3 }, - ], - }, - ], + title: (draftTitle || '').trim() || 'Без названия', + description: (draftDescription || '').trim() || null, + passingThreshold: (() => { + const n = Number(draftPassing); + return Number.isFinite(n) ? n : undefined; + })(), + questions, }), }); setDraftStatus( @@ -63,21 +306,225 @@ export default function TestDetail() { } } - async function startAttempt() { + async function runAiGenerateTest() { + if (aiTestBusy || !id) { + return; + } + setDraftStatus(''); + setAiTestBusy(true); try { - const o = await api(`/api/tests/${id}/attempts/start`, { + const shape = draftQuestions.map((q) => ({ + optionsCount: Math.max(2, Math.min(12, q.options?.length || 2)), + hasMultipleAnswers: q.hasMultipleAnswers, + })); + const out = await api(`/api/tests/${id}/ai/generate-test`, { method: 'POST', - body: JSON.stringify({}), + body: JSON.stringify({ + testTitle: draftTitle, + testDescription: draftDescription, + shape, + }), }); - setDraftStatus(`Попытка стартовала: ${o.attempt.id}`); - load(); + 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: (q.options || []).map((o) => ({ + key: newKey(), + text: (o.text || '').trim() || 'Вариант', + isCorrect: !!o.isCorrect, + })), + })); + if (qs.length) { + setDraftQuestions(qs); + } + setDraftStatus('Тексты и верные варианты сгенерированы по сетке. Проверьте и сохраните.'); + } } catch (e) { - setDraftStatus(e.message); + 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() || 'Без названия'); + 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('Сделать эту версию активной?')) { + if (!window.confirm('Сделать эту версию активной? Новые попытки пойдут по выбранной версии.')) { return; } try { @@ -91,87 +538,676 @@ export default function TestDetail() { } } + async function setChainVisible(chainActive) { + const act = chainActive ? 'снова показывать' : 'скрывать'; + if (!window.confirm(`Цепочка будет ${act} в общем списке тестов. Продолжить?`)) { + return; + } + setDeactivateBusy(true); + setErr(null); + try { + await api(`/api/tests/${id}`, { + method: 'PATCH', + body: JSON.stringify({ chainActive }), + }); + await load(); + } catch (e) { + setErr(e.message); + } finally { + setDeactivateBusy(false); + } + } + if (err) { - return

{err}

; + return

{err}

; } - if (!data) { - return

Загрузка…

; + if (!data && !taker) { + return

Загрузка…

; } + if (taker) { + const { test: t, hasActiveVersion } = taker.summary; + const title = t?.title || 'Тест'; + return ( +
+

+ ← к списку +

+

+ {title} +

+

+ {formatTestAuthorLabel(user, t?.createdBy, t?.authorFullName)} +

+

+ Режим сотрудника: одна цепочка — одна активная версия (v{t?.version ?? '—'} + {t?.activeVersionId && ( + + · {String(t.activeVersionId).slice(0, 8)}… + + )} + ) +

+ {t?.description && ( +

+ {t.description} +

+ )} +

+ Порог для зачёта: {t?.passingThreshold ?? '—'}% +

+ {!hasActiveVersion && ( +
+ Активная версия недоступна. Обратитесь к автору теста. +
+ )} +

+ Пройдите тест в{' '} + каталоге — в строке с названием слева откроется карточка, справа + кнопка «Пройти». +

+
+ ); + } + + const { test, versions, hasAttempts } = data; + const title = test?.title || 'Тест'; + const assignSelectedInList = assignPeople.filter((p) => + assignSelected.has(assignPersonKey(p)) + ); + return ( -
-

- ← к списку -

- {chain?.hasAnyAttempt && ( -
+
+

+ ← к списку +

+ +

- По этой цепочке уже есть попытка. Сохранение черновика с вопросами - создаст новую версию (V.1–V.3). + {title} +

+

+ {formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)} +

+
+
- )} +

+ Заполняет все вопросы и варианты по текущей структуре (число карточек + вопросов и вариантов в каждой — задайте кнопками «+ вопрос» / «+ вариант»). Верные ответы + отмечает модель. Нужен ключ в backend:{' '} + DEEPSEEK_API_KEY или{' '} + OPENAI_API_KEY. +

+ {test?.description && ( +

+ {test.description} +

+ )} +

+ Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)} + {test?.chainActive === false && ( + + · скрыт из списка + + )} +

-

Версии

- - - - - - - - - - {data.versions.map((r) => ( - - - - - + {test?.chainActive === false && ( +
+ Эта цепочка не показывается в верхнем списке на «Тесты». + Карточку всегда можно открыть из раздела «Скрытые вами из списка»{' '} + на той же странице или по закладке с адресом карточки. Снова включить + отображение — в блоке «Публикация»; данные не удаляются. +
+ )} + + {chain?.hasAnyAttempt && ( +
+ Уже были попытки по этой цепочке. Сохранение черновика с вопросами создаст{' '} + новую версию (см. V.1–V.3); старая останется в истории. +
+ )} + {data && attemptsErr && ( +

+ {attemptsErr} +

+ )} + + + +

+ Активная — та, по которой сейчас идут новые попытки. Можно вручную сделать + активной другую строку. +

+
+
veridactive -
{r.version}{r.id}{r.is_active ? 'да' : 'нет'} - {!r.is_active && ( - - )} -
+ + + + + + - ))} - -
ВерсияАктивнаСоздана
- {data.hasAttempts && ( -

hasAttempts: да

+ + + {versions.map((r) => ( + + + v{r.version} + {r.is_active && ( + + текущая + + )} + + {r.is_active ? 'да' : 'нет'} + {fmtDt(r.created_at)} + + {!r.is_active && ( + + )} + + + ))} + + +
+ {hasAttempts && ( +

+ По цепочке есть зафиксированные прогоны — разбор идёт по той версии, с + которой проходили. +

+ )} + + + {attemptsList != null && attemptsList.length > 0 && ( + +

+ Попытки по всем версиям цепочки. Подробный разбор вариантов — для завершённых + прогонов. +

+ {attemptsErr && ( +

+ {attemptsErr} +

+ )} +
+ + + + + + + + + + + {attemptsList.map((a) => ( + + + + + + + + ))} + +
КогдаУчастникvРезультат +
+ {a.completedAt + ? fmtDt(a.completedAt) + : a.startedAt + ? fmtDt(a.startedAt) + : '—'} + + {a.attempterName || '—'} + {a.attempterLogin && ( + + {a.attempterLogin} + + )} + v{a.testVersion} + {a.status === 'completed' && a.totalQuestions != null ? ( + <> + {a.correctCount}/{a.totalQuestions} + {a.passed ? ' · зачёт' : ' · незачёт'} + + ) : ( + a.status + )} + + {a.status === 'completed' && ( + + Разбор + + )} +
+
+
+ )} + + +

+ Деактивация убирает тест из общего списка; карточка открывается по прямой + ссылке. +

+
+ {test?.chainActive !== false ? ( + + ) : ( + + )} +
+
+ + +

+ PDF, DOCX, TXT/MD, до 10 МБ. Текст извлекается на сервере; при{' '} + DEEPSEEK_API_KEY или{' '} + OPENAI_API_KEY в backend строится черновик теста + (см. generation.draft). +

+
+ + {importBusy && Обработка…} +
+ {importErr && ( +

+ {importErr} +

+ )} + {importPreview && ( +
+

+ {importPreview.generation?.message} +

+ {importPreview.generation?.textPreview && !importPreview.generation?.available && ( +
+              {importPreview.generation.textPreview}
+              {importPreview.textLength > 4000 ? '…' : ''}
+            
+ )} + {importPreview.generation?.draft && ( +
+ +
+ )} + {importPreview.extractedText && ( +
+ +
+ )} +
)} +
-

Черновик (V.3)

-