Compare commits

...

2 Commits

Author SHA1 Message Date
Константин Лебединский 5cd94c05ad docs: user guide for cabinet, sprint 4, SVG placeholders; drop token sprint
- Replace old sprint 4 (design tokens) with sprint 4: plain-language guide + images
- Add 'Как пользоваться' section: list, about test, questions, save, history, show/assign
- Add docs/images/cabinet-ui SVG placeholders and README for swapping in PNGs

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

Made-with: Cursor
2026-04-27 20:36:09 +05:00
7 changed files with 721 additions and 328 deletions
+10
View File
@@ -0,0 +1,10 @@
# Иллюстрации к разделу «Как пользоваться»
Сейчас здесь **SVG-схемы** — их видно в Git и в просмотрщике Markdown.
Чтобы подставить **скриншоты** с экрана мини-приложения:
1. Сделайте 2–3 скриншота (список, низ с «Сохранить», блок «Показ / назначения»).
2. Сохраните в эту папку, например: `spisok.png`, `chernovik.png`, `pokaz.png`.
3. В файле `docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md` замените в картинках `placeholder-*.svg` на ваши имена `*.png` (или оставьте SVG).
Имена в Markdown сейчас: `placeholder-spisok.svg`, `placeholder-chernovik.svg`, `placeholder-pokaz.svg`.
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200" role="img" aria-label="!E5<0: A>E@0=5=85">
<rect width="100%" height="100%" fill="#f8faf9" stroke="#cfe8e3"/>
<text x="180" y="36" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="#0d5c54">=87C  «!>E@0=8BL G5@=>28:»</text>
<text x="180" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="#5c6b69">A>E@0=O5B 2AQ, GB> =0?8A0;8 2 2>?@>A0E</text>
<rect x="32" y="100" width="296" height="40" rx="8" fill="#0d5c54"/>
<text x="180" y="125" text-anchor="middle" font-family="system-ui,sans-serif" font-size="12" font-weight="600" fill="#fff">!>E@0=8BL G5@=>28:</text>
<rect x="32" y="150" width="296" height="32" rx="8" fill="#fff" stroke="#0d5c54"/>
<text x="180" y="170" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="#0d5c54"> A?8A:C</text>
</svg>
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="180" viewBox="0 0 360 180" role="img" aria-label="!E5<0: 2848<>ABL 8 =07=0G5=8O">
<rect width="100%" height="100%" fill="#f8faf9" stroke="#cfe8e3"/>
<text x="180" y="32" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="#0d5c54">>:07 2 :0B0;>35</text>
<text x="32" y="64" font-family="system-ui,sans-serif" font-size="11" fill="#1a1a1a">· 848<>ABL  2 >1I5< A?8A:5 8;8 A:@KB</text>
<text x="32" y="88" font-family="system-ui,sans-serif" font-size="11" fill="#1a1a1a">· ><C 2K40BL  A>B@C4=8:8 (5A;8 2:;NG5=>)</text>
<text x="32" y="118" font-family="system-ui,sans-serif" font-size="10" fill="#5c6b69">07=0G5=85 = 2K40BL B5AB 2 @01>BC, =5 ?CB09B5 A «A>E@0=8BL»</text>
<text x="32" y="148" font-family="system-ui,sans-serif" font-size="10" fill="#5c6b69">«K1@0BL 2A5E» = B>;L:> 2 B5:CI5< >BD8;LB@>20==>< A?8A:5</text>
</svg>
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200" role="img" aria-label="!E5<0: A?8A>: B5AB>2">
<rect width="100%" height="100%" fill="#f8faf9" stroke="#cfe8e3"/>
<text x="180" y="32" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="#0d5c54">!?8A>: B5AB>2</text>
<text x="180" y="54" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="#5c6b69">:0@B>G:0 =06<8B5 =0 =0720=85 8;8 «@>9B8»</text>
<rect x="24" y="72" width="312" height="44" rx="10" fill="#fff" stroke="#b8d4cf"/>
<rect x="24" y="124" width="312" height="44" rx="10" fill="#fff" stroke="#b8d4cf"/>
</svg>
@@ -31,6 +31,106 @@
---
## Спринт 3 — дизайн-токены (по желанию)
## Спринт 3 — структура «карточки теста», копи, доводка списка и кабинета
- [ ] Единая шкала: `--control-height`, `--control-padding-x`, `--button-gap` рефакторинг всех `inline-actions` и форм
**Цель:** человекочитаемые названия и группировка разделов; согласованные подсказки и тап-зоны; доводка списка тестов, истории, назначения и редактора по замечаниям.
### Страница теста (`TestDetail`)
- [x] **3.1** `AccSection`: подзаголовок под заголовком (`subtitle` + стили `cabinet-disclosure__summary-*`)
- [x] **3.2** У «тела» аккордеона — верхний внутренний отступ: `.cabinet-disclosure__body` (контент не «прилипает» к header)
- [x] **3.3** Переименования: «О тесте», «Вопросы» (вместо мета/содержания); подзаголовки-описания по смыслу
- [x] **3.4** **Импорт** встроен в «Вопросы»: подсекция «Документ в вопросы»; отдельного аккордеона «Импорт из файла» нет
- [x] **3.5** **История**: одна секция, подпункты «Версии» + «Прохождения»; пустое/ошибочное состояние у прогонов
- [x] **3.6** Ошибка загрузки прогонов: не в герое страницы, а внутри «Прохождения»
- [x] **3.7** **Показ в каталоге:** подсекция «Видимость» + (при `assignmentUi`) «Кому выдать»; кнопка видимости **не** на всю ширину (`.publication-visibility__actions`)
- [x] **3.8** Панель ИИ-генерации: `.test-detail-ai-panel` (фон/рамка в духе кабинета, без «лишней» карточки)
- [x] **3.9** Вопрос: блоки `.question-editor-block` + первый вопрос без лишнего верхнего бордера (`.question-editor-block--first`)
- [x] **3.10** Варианты: удаление — **иконка** «закрыть» + `aria-label` (вместо текста «убрать»)
- [x] **3.11** Одна панель `.question-editor__footer`: «+ вариант» и «Удалить вопрос»; «+ вопрос» вынесен в `.test-detail-add-question` над блоком импорта
- [x] **3.12** **Назначение:** кнопка **«Выбрать всех (N)»** по текущему отфильтрованному списку; подсказки в подсекциях
- [x] **3.13** Стили подсекций: `.test-detail-subsection`, `.test-detail-subsection__title`, `.test-detail-hint`
- [x] **3.14** Импорт: на мобилке **полная ширина** кнопки «Выбрать файл» (`.import-file-row--block`); ровнее отступы у превью
- [x] **3.15** Классы списка версий: на узком экране у `.version-card-list__main` `flex-grow: 0` в column — **без пустой «вытянутой» карточки v1**
### Список тестов и шапка
- [x] **3.16** **Список:** на `≤640px` карточка в **колонку** (заголовок на ширину экрана, **«Пройти»** снизу на **всю ширину**; без пустой полосы)
- [x] **3.17** `hover` у строк списка — только при `@media (hover: hover) and (pointer: fine)`; лёгкий **`:active`** на ссылке (тач-фидбек)
- [x] **3.18** **Шапка** `cabinet-header__inner`: учёт **safe-area** сверху и по бокам
- [x] **3.19** **Назначение (кабинет):** у `.assign-list` — выше область прокрутки на мобилке; у строк — линия раздела чуть заметнее; **центр чекбокса** + **line-clamp** у мета; `accent-color` у чекбокса
- [x] **3.20** Ритм **аккордеонов** на `test-detail-page` — чуть больше `margin` между `cabinet-brick`; **нижний отступ** у страницы под **фикс-футер** увеличен
- [x] **3.21** **Поле поиска** в назначении: более «строчный» вид (min/max `height` + `padding` у `.assign-toolbar__search`)
**Файлы:** `frontend/src/styles/cabinet-theme.css`, `frontend/src/pages/TestDetail.jsx`, `frontend/src/pages/TestsList.jsx` (только косвенно через стили, разметка списка ранее).
---
## Спринт 4 — памятка для врачей/заведующих (без IT-сленга)
**Цель:** в этом же файле в конце — краткая инструкция, как пользоваться кабинетом тестов; иллюстрации к разделу; никакой отдельной «саги» про дизайн-токены.
- [x] **4.1** Блок «Как пользоваться» простым языком, по смыслу блоков интерфейса
- [x] **4.2** Папка `docs/images/cabinet-ui/`: схемы-заглушки (SVG); при необходимости **замените** их своими **скриншотами** с тем же именем файла или поправьте пути внизу документа
- [x] **4.3** Отдельный спринт про design tokens **убран** — при желании вынесите в issue/другой документ
**Файлы:** `docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md` (блок внизу), `docs/images/cabinet-ui/*`.
---
## Как пользоваться: коротко и по делу
*Если картинки не открываются — проверьте, что рядом с этим файлом лежат файлы в `images/cabinet-ui/`. Схемы можно заменить на настоящие скриншоты из мини-приложения (PNG) — с теми же названиями.*
### 1. Список тестов
Тут — все доступные вам тесты. **Название** (или область слева) ведёт в **редактирование**, кнопка **«Пройти»** — **сдать** тест, если вы в роли сотрудника, а не автора. Не путайте: редактировать могут не все.
![Схема: список](images/cabinet-ui/placeholder-spisok.svg)
---
### 2. О тесте
Название, **описание** для коллег, **порог зачёта** (процент). Это «паспорт» теста: заполните так, чтобы было понятно, **что проверяете** и с какой планкой «зачёт/незачёт».
---
### 3. Вопросы
Здесь вы **пишете сами** вопросы и **варианты ответов**. Кружок/квадратик слева от варианта — **отметьте верные**. Один верный = как контрольный; несколько = можно включить **«Несколько верных ответов»**.
- **+ вопрос** / **+ вариант** — добавить.
- **Крестик** у варианта — убрать лишний ответ.
- **Документ в вопросы** — подгрузить **файл** (PDF, Word, текст) и по подсказкам вставить в черновик; **не** обязателен, если вы всё ввели руками.
- **ИИ** (если есть) — **помощь**, а не готовый клинический протокол: всё равно **прочитайте и исправьте** перед публикацией.
---
### 4. Сохранить
Кнопка **«Сохранить черновик»** (часто закреплена **внизу экрана** на телефоне) — **обязательно**, если не хотите потерять правки. **«К списку»** — выйти без паники: если **уже нажимали «Сохранить»** до этого, черновик сохранён.
![Схема: кнопки внизу](images/cabinet-ui/placeholder-chernovik.svg)
---
### 5. История
- **Версии** — когда менялся тест. Актуальная помечена. Редко нужно **«Сделать активной»** другую версию — обычно по согласованию с IT/методистом.
- **Прохождения** — **кто уже сдавал**; «Разбор» — посмотреть **ответы** (если вам открыт доступ). Для **обычного заведения теста** смотрите **редко**; для **разбора ситуации** — полезно.
---
### 6. Показ в каталоге
- **Видимость** — **показать** тест **всех** в списке или **скрыть** (тест **не** удаляется, просто **не мешается** в ленте). Скрытие **не** отменяет уже выданные ссылки, если кто-то ими **поделился**.
- **Кому выдать** (если раздел есть) — **кому** из сотрудников **назначить** тест. Сначала **поиск/фильтр**, **галочки**, при необходимости **«Выбрать всех»** по **текущему** списку, затем **«Назначить выбранных»**. Это **про людей**, не про текст вопросов.
![Схема: видимость и назначение](images/cabinet-ui/placeholder-pokaz.svg)
---
### В одном предложении
**Написали вопросы → нажали «Сохранить» → при необходимости показали в списке / кому-то выдали.** Всё остальное — по ситуации.
+202 -132
View File
@@ -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>
<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="Параметры генерации теста с ИИ"
<AccSection
title="Вопросы"
subtitle="Тексты, варианты и при необходимости загрузка из файла"
defaultOpen
>
<div className="draft-block">
<div className="test-detail-ai-panel" aria-label="Параметры генерации теста с ИИ">
<p
className="font-headline"
style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }}
@@ -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,7 +1048,13 @@ export default function TestDetail() {
</p>
)}
</div>
<AccSection title="История версий" defaultOpen={false}>
<AccSection
title="История"
subtitle="Версии теста и кто проходил"
defaultOpen={false}
>
<div className="test-detail-subsection test-detail-subsection--tight">
<h3 className="test-detail-subsection__title">Версии</h3>
<ul className="version-card-list" aria-label="Список версий теста">
{versions.map((r) => (
<li key={r.id} className="surface-card version-card-list__item">
@@ -995,15 +1090,20 @@ export default function TestDetail() {
</li>
))}
</ul>
</AccSection>
{attemptsList != null && attemptsList.length > 0 && (
<AccSection title="Прогоны и разбор" defaultOpen={false}>
{attemptsErr && (
<p className="error-text" role="alert">
{attemptsErr}
</div>
{attemptsList !== undefined && (
<div className="test-detail-subsection">
<h3 className="test-detail-subsection__title">Прохождения</h3>
{attemptsList == null && (
<p
className={attemptsErr ? 'error-text' : 'text-muted'}
style={{ margin: 0 }}
role={attemptsErr ? 'alert' : undefined}
>
{attemptsErr || 'Список прогонов сейчас недоступен.'}
</p>
)}
{attemptsList && attemptsList.length > 0 && (
<ul className="attempts-card-list" aria-label="Список прогонов">
{attemptsList.map((a) => {
const when = a.completedAt
@@ -1024,10 +1124,16 @@ export default function TestDetail() {
<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' }}>
<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 }}>
<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 }}>
@@ -1053,14 +1159,31 @@ export default function TestDetail() {
);
})}
</ul>
</AccSection>
)}
{attemptsList && attemptsList.length === 0 && (
<p className="text-muted" style={{ margin: 0 }}>
Пока нет зарегистрированных прогонов.
</p>
)}
</div>
)}
</AccSection>
<AccSection title="Публикация (видимость в списке)" defaultOpen={false}>
<div
className="inline-actions inline-actions--block-mobile"
style={{ marginTop: '0.5rem' }}
<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"
@@ -1081,82 +1204,15 @@ export default function TestDetail() {
</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}>
<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">
<p className="error-text" role="alert" style={{ marginTop: 0, marginBottom: 8 }}>
{assignErr}
</p>
)}
@@ -1194,6 +1250,21 @@ export default function TestDetail() {
</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) => {
@@ -1211,9 +1282,7 @@ export default function TestDetail() {
/>
<span className="assign-row__text">
<span className="assign-row__fio">{p.fio}</span>
{p.webLogin && (
<span className="assign-row__login">{p.webLogin}</span>
)}
{p.webLogin && <span className="assign-row__login">{p.webLogin}</span>}
<span className="assign-row__meta">
{p.departments || '—'}
{p.clinicUserId
@@ -1232,7 +1301,7 @@ export default function TestDetail() {
</p>
)
)}
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
<div className="publication-visibility__actions" style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="btn btn-ghost"
@@ -1245,9 +1314,10 @@ export default function TestDetail() {
: ''}
</button>
</div>
{assignMsg && <p className="text-muted">{assignMsg}</p>}
</AccSection>
{assignMsg && <p className="text-muted" style={{ marginTop: 8, marginBottom: 0 }}>{assignMsg}</p>}
</div>
)}
</AccSection>
<div
className="test-detail-fixed-actions"
+192 -3
View File
@@ -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;