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
This commit is contained in:
+393
-323
@@ -34,13 +34,18 @@ function createEmptyQuestion() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ title: string, defaultOpen?: boolean, id?: string, children: import('react').ReactNode }} p
|
||||
* @param {{ title: string, subtitle?: string, defaultOpen?: boolean, id?: string, children: import('react').ReactNode }} p
|
||||
*/
|
||||
function AccSection({ title, defaultOpen, id, children }) {
|
||||
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 font-headline">{title}</summary>
|
||||
<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>
|
||||
@@ -235,6 +240,16 @@ export default function TestDetail() {
|
||||
});
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -657,14 +672,13 @@ export default function TestDetail() {
|
||||
При сохранении будет создана новая версия теста.
|
||||
</div>
|
||||
)}
|
||||
{data && attemptsErr && (
|
||||
<p className="error-text" style={{ marginTop: '0.75rem', marginBottom: 0 }} role="alert">
|
||||
{attemptsErr}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccSection title="Метаинформация" defaultOpen>
|
||||
<AccSection
|
||||
title="О тесте"
|
||||
subtitle="Название, описание и порог зачёта"
|
||||
defaultOpen
|
||||
>
|
||||
<div className="draft-block">
|
||||
<label className="form-label" htmlFor="draft-title">
|
||||
Название
|
||||
@@ -702,17 +716,13 @@ export default function TestDetail() {
|
||||
</div>
|
||||
</AccSection>
|
||||
|
||||
<AccSection title="Содержание" defaultOpen>
|
||||
<AccSection
|
||||
title="Вопросы"
|
||||
subtitle="Тексты, варианты и при необходимости загрузка из файла"
|
||||
defaultOpen
|
||||
>
|
||||
<div className="draft-block">
|
||||
<div
|
||||
className="surface-card"
|
||||
style={{
|
||||
padding: '1rem 1.1rem',
|
||||
marginBottom: '1.25rem',
|
||||
borderLeft: '3px solid color-mix(in srgb, var(--primary) 45%, transparent)',
|
||||
}}
|
||||
aria-label="Параметры генерации теста с ИИ"
|
||||
>
|
||||
<div className="test-detail-ai-panel" aria-label="Параметры генерации теста с ИИ">
|
||||
<p
|
||||
className="font-headline"
|
||||
style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }}
|
||||
@@ -792,12 +802,7 @@ export default function TestDetail() {
|
||||
{draftQuestions.map((q, qi) => (
|
||||
<div
|
||||
key={q.key}
|
||||
className="question-editor-block"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-subtle, #2a2a2a)',
|
||||
marginTop: '1rem',
|
||||
paddingTop: '1rem',
|
||||
}}
|
||||
className={`question-editor-block${qi === 0 ? ' question-editor-block--first' : ''}`}
|
||||
>
|
||||
<div className="question-editor-block__header">
|
||||
<label
|
||||
@@ -848,11 +853,7 @@ export default function TestDetail() {
|
||||
</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"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}
|
||||
>
|
||||
<li key={o.key} className="question-option-row">
|
||||
<input
|
||||
key={`${o.key}-${q.hasMultipleAnswers ? 'm' : 's'}`}
|
||||
className="question-option-row__mark"
|
||||
@@ -886,8 +887,7 @@ export default function TestDetail() {
|
||||
aria-label="Пометить как верный"
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ flex: '1 1 200px' }}
|
||||
className="form-input question-option-row__text"
|
||||
value={o.text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
@@ -908,17 +908,25 @@ export default function TestDetail() {
|
||||
{q.options.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn--sm"
|
||||
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="inline-actions" style={{ marginTop: 4 }}>
|
||||
<button type="button" className="btn btn-ghost btn--sm" onClick={() => addOption(qi)}>
|
||||
<div className="question-editor__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn--sm"
|
||||
onClick={() => addOption(qi)}
|
||||
>
|
||||
+ вариант
|
||||
</button>
|
||||
{draftQuestions.length > 1 && (
|
||||
@@ -933,11 +941,92 @@ export default function TestDetail() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={addQuestion}>
|
||||
<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>
|
||||
|
||||
@@ -959,295 +1048,276 @@ export default function TestDetail() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<AccSection title="История версий" defaultOpen={false}>
|
||||
<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>
|
||||
</AccSection>
|
||||
|
||||
{attemptsList != null && attemptsList.length > 0 && (
|
||||
<AccSection title="Прогоны и разбор" defaultOpen={false}>
|
||||
{attemptsErr && (
|
||||
<p className="error-text" role="alert">
|
||||
{attemptsErr}
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
</AccSection>
|
||||
)}
|
||||
|
||||
<AccSection title="Публикация (видимость в списке)" defaultOpen={false}>
|
||||
<div
|
||||
className="inline-actions inline-actions--block-mobile"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
<AccSection
|
||||
title="История"
|
||||
subtitle="Версии теста и кто проходил"
|
||||
defaultOpen={false}
|
||||
>
|
||||
{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>
|
||||
</AccSection>
|
||||
|
||||
<AccSection title="Импорт из файла" defaultOpen={false}>
|
||||
<div className="import-file-row" style={{ marginBottom: '0.5rem' }}>
|
||||
<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={{ marginBottom: '1rem' }}>
|
||||
<p className="muted" style={{ marginTop: 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 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={applyGeneratedDraft}
|
||||
>
|
||||
Применить сгенерированный черновик
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{importPreview.extractedText && (
|
||||
<div className="inline-actions" style={{ marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={applyExtractedToQuestion}
|
||||
>
|
||||
Вставить в первый вопрос (до 2000 симв.)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AccSection>
|
||||
|
||||
{assignmentUi && data && (
|
||||
<AccSection title="Назначение сотрудникам" defaultOpen={false}>
|
||||
{assignErr && (
|
||||
<p className="error-text" role="alert">
|
||||
{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="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
|
||||
? ' · есть учётка в модуле'
|
||||
: ' · нет учётки (создадим при назначении)'}
|
||||
<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>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
!assignLoadBusy && (
|
||||
<p className="text-muted" style={{ margin: '0.5rem 0' }}>
|
||||
Нет подходящих записей. Смените фильтры или поиск.
|
||||
{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>
|
||||
)
|
||||
)}
|
||||
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={assignSelectedInList.length === 0}
|
||||
onClick={postAssign}
|
||||
>
|
||||
Назначить выбранных
|
||||
{assignSelectedInList.length > 0
|
||||
? ` (${assignSelectedInList.length})`
|
||||
: ''}
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
{assignMsg && <p className="text-muted">{assignMsg}</p>}
|
||||
</AccSection>
|
||||
)}
|
||||
)}
|
||||
</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"
|
||||
|
||||
@@ -605,6 +605,29 @@ code,
|
||||
min-height: 2.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cabinet-disclosure__summary-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cabinet-disclosure__summary-title {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.cabinet-disclosure__summary-sub {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.cabinet-disclosure__summary::-webkit-details-marker {
|
||||
@@ -626,7 +649,7 @@ code,
|
||||
}
|
||||
|
||||
.cabinet-disclosure__body {
|
||||
padding: 0 1rem 1.05rem;
|
||||
padding: 0.7rem 1rem 1.05rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
|
||||
}
|
||||
|
||||
@@ -657,19 +680,160 @@ code,
|
||||
|
||||
.assign-toolbar__search {
|
||||
flex: 1 1 200px;
|
||||
min-height: 2.5rem;
|
||||
max-height: 2.75rem;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
line-height: 1.25;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Назначение: компактнее поля на мобилке; 16px — без авто-зума iOS в поле */
|
||||
/* Назначение: селекты — как раньше; поиск — одна «строка», не «плитка» */
|
||||
@media (max-width: 640px) {
|
||||
.assign-toolbar {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.assign-toolbar .form-input {
|
||||
padding: 8px 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.assign-toolbar__search {
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.assign-toolbar .form-input:not(.assign-toolbar__search) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Подсекции и подсказки (карточка теста) */
|
||||
.test-detail-subsection {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.15rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||
}
|
||||
|
||||
.test-detail-subsection:first-of-type,
|
||||
.test-detail-subsection--tight {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.test-detail-subsection--import {
|
||||
margin-top: 1.35rem;
|
||||
padding-top: 1.2rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||
}
|
||||
|
||||
.test-detail-subsection__title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: var(--on-surface);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.test-detail-hint {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Панель «ИИ» — в тон остальным disclosure, без лишнего «модальности» */
|
||||
.test-detail-ai-panel {
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 1.15rem;
|
||||
background: var(--surface-container-low);
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||
border-radius: 0.85rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Кнопка публикации — не тянем на 100% ширины, если одна */
|
||||
.publication-visibility__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.publication-visibility__actions .btn {
|
||||
width: auto;
|
||||
min-width: min(100%, 11rem);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Убрать вариант: иконка */
|
||||
.question-option-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.65rem;
|
||||
background: transparent;
|
||||
color: var(--secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.question-option-remove:hover {
|
||||
background: color-mix(in srgb, var(--error) 8%, var(--surface));
|
||||
color: var(--error, #b91c1c);
|
||||
}
|
||||
|
||||
.question-option-remove .material-symbols-outlined {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Футер вопроса: + вариант, удалить вопрос */
|
||||
.question-editor__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem 0.65rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.question-editor__footer .btn--sm,
|
||||
.question-editor__add-question {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Добавить вопрос — в конец блока «Вопросы» */
|
||||
.test-detail-add-question {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 1.1rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 28%, transparent);
|
||||
}
|
||||
|
||||
/* Импорт: кнопка на всю ширину в колонку; ритм отступов */
|
||||
@media (max-width: 640px) {
|
||||
.import-file-row--block {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.import-file-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.assign-list {
|
||||
@@ -986,6 +1150,31 @@ code,
|
||||
|
||||
/* --- Спринт 2: редактор вопроса, прогоны, импорт, фикс. футер --- */
|
||||
|
||||
.question-editor-block {
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||
margin-top: 1.05rem;
|
||||
padding-top: 1.05rem;
|
||||
}
|
||||
|
||||
.question-editor-block--first {
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.question-option-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.question-option-row__text {
|
||||
flex: 1 1 10rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.question-editor-block__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user