Compare commits
2 Commits
72a5863871
...
5cd94c05ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cd94c05ad | |||
| f1f5223076 |
@@ -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. Список тестов
|
||||
|
||||
Тут — все доступные вам тесты. **Название** (или область слева) ведёт в **редактирование**, кнопка **«Пройти»** — **сдать** тест, если вы в роли сотрудника, а не автора. Не путайте: редактировать могут не все.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 2. О тесте
|
||||
|
||||
Название, **описание** для коллег, **порог зачёта** (процент). Это «паспорт» теста: заполните так, чтобы было понятно, **что проверяете** и с какой планкой «зачёт/незачёт».
|
||||
|
||||
---
|
||||
|
||||
### 3. Вопросы
|
||||
|
||||
Здесь вы **пишете сами** вопросы и **варианты ответов**. Кружок/квадратик слева от варианта — **отметьте верные**. Один верный = как контрольный; несколько = можно включить **«Несколько верных ответов»**.
|
||||
|
||||
- **+ вопрос** / **+ вариант** — добавить.
|
||||
- **Крестик** у варианта — убрать лишний ответ.
|
||||
- **Документ в вопросы** — подгрузить **файл** (PDF, Word, текст) и по подсказкам вставить в черновик; **не** обязателен, если вы всё ввели руками.
|
||||
- **ИИ** (если есть) — **помощь**, а не готовый клинический протокол: всё равно **прочитайте и исправьте** перед публикацией.
|
||||
|
||||
---
|
||||
|
||||
### 4. Сохранить
|
||||
|
||||
Кнопка **«Сохранить черновик»** (часто закреплена **внизу экрана** на телефоне) — **обязательно**, если не хотите потерять правки. **«К списку»** — выйти без паники: если **уже нажимали «Сохранить»** до этого, черновик сохранён.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 5. История
|
||||
|
||||
- **Версии** — когда менялся тест. Актуальная помечена. Редко нужно **«Сделать активной»** другую версию — обычно по согласованию с IT/методистом.
|
||||
- **Прохождения** — **кто уже сдавал**; «Разбор» — посмотреть **ответы** (если вам открыт доступ). Для **обычного заведения теста** смотрите **редко**; для **разбора ситуации** — полезно.
|
||||
|
||||
---
|
||||
|
||||
### 6. Показ в каталоге
|
||||
|
||||
- **Видимость** — **показать** тест **всех** в списке или **скрыть** (тест **не** удаляется, просто **не мешается** в ленте). Скрытие **не** отменяет уже выданные ссылки, если кто-то ими **поделился**.
|
||||
- **Кому выдать** (если раздел есть) — **кому** из сотрудников **назначить** тест. Сначала **поиск/фильтр**, **галочки**, при необходимости **«Выбрать всех»** по **текущему** списку, затем **«Назначить выбранных»**. Это **про людей**, не про текст вопросов.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### В одном предложении
|
||||
|
||||
**Написали вопросы → нажали «Сохранить» → при необходимости показали в списке / кому-то выдали.** Всё остальное — по ситуации.
|
||||
|
||||
+202
-132
@@ -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"
|
||||
|
||||
@@ -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