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"
|
||||
|
||||
Reference in New Issue
Block a user