diff --git a/docs/modules/auth.md b/docs/modules/auth.md index 158464a07..e7dccd5f7 100644 --- a/docs/modules/auth.md +++ b/docs/modules/auth.md @@ -126,8 +126,11 @@ type RegisterError = // results/password.result.ts type PasswordError = - | { kind: "server_error" } | { kind: "invalid_token" } + | { kind: "validation_error"; messages: string[] } + | { kind: "bad_request"; status: 400 } + | { kind: "server_error"; status: 500 } + | { kind: "network"; status: 0; cause: "unknown" } | { kind: "unknown"; cause?: unknown }; ``` @@ -177,8 +180,8 @@ DI-биндинг (см. `infrastructure/di/auth.providers.ts`): | `LoginUseCase` | `command: LoginCommand` | `Result` | `wrong_credentials` (HTTP 401), `unknown` | | `RegisterUseCase` | `command: RegisterCommand` | `Result` | `server_error` (500), `validation_error` (400, прокидывает `error.error` как `RegisterFieldErrors`), `unknown` | | `ResendEmailUseCase` | `email: string` | `Result` | `unknown` | -| `ResetPasswordUseCase` | `email: string` | `Result` | `unknown` | -| `SetPasswordUseCase` | `password: string, token: string` | `Result` | `unknown` (с `cause`) | +| `ResetPasswordUseCase` | `email: string` | `Result` | `bad_request` (400), `server_error` (500), `network` | +| `SetPasswordUseCase` | `password: string, token: string` | `Result` | `invalid_token` (404/410 или поле `token`), `validation_error` (поле пароля), `bad_request`, `server_error`, `network`, `unknown` | | `DownloadCvUseCase` | — | `Result` | `download_cv_error` | Все use-case'ы покрыты `*.spec.ts` файлами рядом — мокают `AuthRepositoryPort` через `jasmine.createSpyObj`. @@ -276,7 +279,7 @@ Profile-edit хранит достижения в общей форме проф | `EmailVerificationComponent` | `app-email-verification` | `AuthEmailService`, `AuthUIInfoService` | Экран «отправили письмо», counter 60→0 для повторной отправки. Берёт `?adress=...` из query. | | `ConfirmEmailComponent` | `app-confirm-email` | `AuthEmailService` | Принимает `?access_token` + `?refresh_token` из URL (magic-link), сохраняет в cookie, навигирует в `/office`. | | `ResetPasswordComponent` | `app-reset-password` | `AuthPasswordService`, `AuthUIInfoService` | Форма `email`, отправляет ссылку на сброс. | -| `SetPasswordComponent` | `app-set-password` | `AuthPasswordService`, `AuthUIInfoService` | Форма `password + repeat`, требует `?token=` в URL, иначе `LoggerService.error("Token is missing")`. | +| `SetPasswordComponent` | `app-set-password` | `AuthPasswordService`, `AuthUIInfoService` | Форма `password + repeat`, требует `?token=` в URL. Без токена показывает ошибку ссылки и не отправляет запрос. Ошибки ссылки, пароля, сети и сервера показываются отдельно по-русски. | | `ConfirmPasswordResetComponent` | `app-confirm-password-reset` | `AuthPasswordService` | Экран «письмо отправлено», `?email=...` из query. | --- diff --git a/docs/program-v2-ui-audit/ACCEPTANCE.md b/docs/program-v2-ui-audit/ACCEPTANCE.md new file mode 100644 index 000000000..0083891f5 --- /dev/null +++ b/docs/program-v2-ui-audit/ACCEPTANCE.md @@ -0,0 +1,89 @@ + + +# Повторная приемка после 44761cdc + +Этот отчет фиксирует визуальный этап `0a589f4c`. Продолжение с реальным dev API, исправлением R1, матрицей и ручной инструкцией — [интеграционная приемка PR #517](integration-517/README.md). + +Обычный `npm run build:prod` проходит с исходными настройками production. Клиентская регрессия V1/V2 и повторная сверка Figma выполнены. Ветка пригодна для draft PR и предметного ревью. Полная приемка и merge остаются открытыми: сценарии организатора на реальном dev API требуют авторизованной сессии; несколько различий между текущим общим V1/V2 и историческими макетами перечислены ниже для визуального ревью. + +Ветка `fix/program-v2-design-system`, исходный dev `9ef90b5824b857fe53d52c097e37b279b897d9bf`. Сравнение этого этапа — с запрошенным коммитом `44761cdc`. [Новая галерея](acceptance-gallery.html) показывает именно это сравнение. [Первоначальная галерея](gallery.html) сохраняет сравнение реализации до всей задачи с первым исправлением. + +## 1. Сверка Figma и оставшиеся различия + +Figma MCP работает. Повторно прочитаны геометрия и стили 13 основных фреймов, структура диалогов, UI Kit Modal, получены свежие PNG 17 фреймов. Использованы оригинальные страницы файла [PROCOLLAB](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE), а не только исходники или прежние снимки. Полная карта страниц и вложенных поверхностей — [figma-inventory.json](figma-inventory.json); извлеченные метрики и полный набор свежих PNG — в архиве доказательств. + +| Экран | Источник Figma | Результат повторной проверки | +| --- | --- | --- | +| Участники | [825:3337](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=825-3337) | Общий V1/V2: заполненная таблица, чекбоксы, поиск, фильтры, сортировка, действия и мобильные подписи. | +| Проекты / заявки и решения | [179:3341](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3341) | Общая плотность списка и controls. Сохранены восемь колонок V2 и презентация в меню; см. доменные исключения README. | +| Эксперты | [452:3220](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3220) | Общий roster, фильтры и состояния V1/V2. Дополнительно проверен заполненный список из 25 экспертов и выбор между страницами. | +| Распределение | [452:3221](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3221) | Quota-card, таблица, поиск, выбор, меню. Исправлена привязка меню V1 к мобильной строке и ширина пустого состояния обеих версий. | +| Результаты | [526:3628](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=526-3628) | Общая реализация: заполненный рейтинг, фильтры, формула, раскрытие оценки, экспорт. | +| Аналитика | [753:3219](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=753-3219), [629:3220](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=629-3220) | Общие показатели и сводка оценивания. Вложенные таблицы V2 переведены на оформление V1; ноль, «Нет», комментарии и три статуса не теряются. | +| Основные настройки | [179:3343](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3343) | Общая форма, изображения и ссылки; заполнение и клиентский PATCH проверены. | +| Сроки | [179:3497](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3497) | Общая форма и подтверждение изменения завершенного срока. Локализованный вид native datetime зависит от браузера. | +| Материалы | [179:3626](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3626) | Заполненный список, добавление, редактирование и удаление через действующие меню V1. | +| Форма решения / кейсы и поля | [227:3220](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=227-3220) | Поля, редактор и preview используют общие primitives; V2 сохраняет шаблоны и назначение полей Solution. | +| Оценивание | [267:3220](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=267-3220) | Заполненные критерии, блокировки, редактор, preview; обе версии используют одну форму. | +| Организаторы | [582:9039](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=582-9039) | Общая таблица, аватары, статусы, поиск, приглашение и действия. | +| Регистрация | [652:3220](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=652-3220) | Общие способ регистрации, анкета, редактор и preview. | +| Сертификаты | [904:3220–3222](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=904-3220) | Все три шага, заполненный canvas, параметры выдачи и таблица отправки. Сохранен существующий общий редактор V1/V2. | + +Оставшиеся визуальные расхождения относятся к самим источникам или действующим возможностям, а не к новому стилю V2: + +- В ранних макетах четыре–шесть верхних вкладок и select вместо текущей кнопки «Фильтры». Сохранены семь действующих вкладок и фильтры V1, прямо требуемые заданием. +- В доступных технических макетах указан TEMP Inter; в работающей дизайн-системе применяется локальный Mont. Mont сохранен в обеих версиях. Размеры и вес текста сопоставлены, замена шрифта всего продукта не выполнялась. +- Общий редактор сертификатов V1/V2 богаче исторического фрейма: инструменты, список элементов и canvas расположены иначе. В формах материалов и полей действуют дополнительные меню/шаблоны. Это существующая общая реализация; буквальное восстановление старой композиции требует отдельного решения о сохранении этих возможностей. На этом этапе они не перепроектированы. +- V2 имеет Solution, дополнительные статусы, действия и метрики покрытия. Состав строк и длина диалогов зависят от этих данных. Геометрия оболочки и общие controls восстановлены, но разная бизнес-информация не объявляется попиксельно одинаковой. +- Общий office shell отличается от статического макета размещением контента на несколько пикселей, в том числе из-за scrollbar и Mont. Он не переделывался только ради V2. Длинное синтетическое имя в sidebar воспроизводит существующее поведение shell; область управления не перекраивается для его исправления. + +Эти исключения видны на снимках. Утверждения о полном pixel match всех состояний Figma нет. Для состояний, которых в Figma нет, использованы текущие общие компоненты V1. + +## 2. Дополнительные исправления + +- Общая функция [expertDialogBodyClass](../../projects/social_platform/src/app/ui/pages/program/management/experts/expert-presentation.ts) задает размеры оригинальных диалогов: приглашение/квота/отзыв 560 px, кейсы 600 px, назначения 680 px. В V2 приглашение ранее сжималось примерно до 347 px на desktop, V1 раскрывалось до 760 px. Теперь обе версии используют одинаковую оболочку, padding 24 px и ограничение viewport на мобильных ширинах. Сохранены busy, dirty, discard и валидация email. +- [Вложенная аналитика V2](../../projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.html) использует общие таблицы, аватары, статусы, заголовок и close-icon V1. Ссылка эксперта использует userId, ссылка решения — solutionId. Восстановлены именованные размеры 880/980/1100 px и мобильные data-label. Удален лишний внутренний focus trap; общий Modal продолжает управлять фокусом. +- В [общем SCSS распределения](../../projects/social_platform/src/styles/_program-expert-roster.scss) строка стала контейнером позиционирования. До исправления на 320/390/768 px меню V1 находилось под шапкой и его перекрывал аватар. Новая проверка выполняет настоящий click и проверяет границы каждого меню обеих версий. Пустая строка colspan больше не превращается в узкую action-cell. +- Native controls, таблицы/списки управления и аналитики выделены в общие stylesheet-ресурсы с реальными consumers V1/V2. V2 заявки больше не подключают неиспользуемую оболочку и все настройки management. Сохранена инкапсуляция Angular; generic native styles не подключены в компонент с ViewEncapsulation.None. Вне компонентов программ computed styles не изменились. +- При регрессии поймано и исправлено изменение desktop padding текстовых кнопок после переноса foundation. Итоговая геометрия пагинации, toolbar сертификатов и controls соответствует исходной. + +## 3. Стандартная production-сборка + +Команда `NG_BUILD_MAX_WORKERS=2 npm run build:prod` завершилась с exit 0 на окончательном продуктном коде. Переменная ограничивает число workers; настройки production не меняет. Дополнительно проверен непосредственно полученный `dist/social_platform`: 16 заполненных страниц и 30 проверок диалогов/данных на 390/1440 px. API этих браузерных проверок изолирован fixtures. + +Причины блокеров установлены отдельно: + +1. Google Fonts 403: исходный index.html ссылался на внешнюю таблицу Material Icons. Cloud proxy запрещает этот хост, а production inlining пытался ее скачать. Ссылка присутствовала до задачи; пользователей этого icon font в исходниках нет, действующие иконки — SVG. Удалены только неиспользуемые link/preconnect. Локальные Mont и SVG сохранены, font inlining не отключен. +2. CSS budgets: все пять ошибок воспроизведены на исходном dev `9ef90b58` до изменений. Исходные размеры: management 24,91 kB, experts 24,65 kB, certificates 17,33 kB, analytics 15,07 kB, drilldown 19,57 kB при error limit 15 kB. Общие стили выделены по назначению, повторная генерация foundation устранена, мертвые стили аналитики удалены. Лимит Angular относится к отдельному stylesheet-ресурсу; суммарный CSS компонента не выдается за размер одного файла. Бюджеты, angular.json и зависимости не менялись. + +Остались предупреждения существующего toolchain о Sass deprecations, CommonJS, initial warning budget и 2 kB stylesheet warning budget. Ошибок production build нет. Диагностическая сборка из первоначального этапа больше не используется как критерий готовности; ее вспомогательный обходной скрипт удален из итогового checkout. + +## 4. Реальный dev API + +Сеть и DNS работают. `https://dev.procollab.ru/programs/?limit=100` отвечает 200. Детали программы 4 подтверждают `solution_storage_mode=legacy_project`, 49–51 — `solution`. Публичные списки `/programs/4/published-projects/` и `/programs/49/published-solutions/` отвечают 200 и имеют разные схемы данных. + +Управление, настройки, эксперты, результаты и назначения тестовых V1/V2 возвращают 401 с конкретной причиной: `Authentication credentials were not provided.` В среде нет учетных данных и действующей сессии организатора. Это ограничение авторизации, а не недоступность API. Ответы и перечень запросов сохранены в `dev-management-access.json`, `public-program-details.json`, `public-entity-lists.json` полного архива. + +На реальном API выполнены только перечисленные публичные чтения и проверка отказа анонимным управленческим запросам. Никакие данные не менялись; production для тестирования не использовался. + +## 5. Что проверено только локально + +На настоящем Angular с ответами fixtures: поиск/фильтры/пагинация/выбор, таблицы Project и Solution, диалоги, клиентское сохранение настроек, назначение экспертов и preview/apply запросы, сохранение различий ID, просмотр оценки, ноль/boolean/комментарии, preview форм, три шага сертификатов, 403/503/retry/loading/disabled, клавиатура и возврат фокуса. Данные полного roster включают 25 экспертов и несколько статусов. Внешние HTTP и WebSocket из этих браузерных контекстов перехватываются, настоящих серверных записей нет. + +Результат: 14 экранов × 2 версии × 5 ширин = 140 заполненных страниц, 400 проверок взаимодействий, 75 дополнительных проверок диалогов/данных, 40 состояний и 5 сопоставлений controls. Ширины: 320, 390, 768, 1440, 1920 px. Отдельно — 46 проверок стандартного production-артефакта. Все перечисленные финальные проверки успешны; количество не подменяет визуальную и интеграционную приемку. + +Настроенный Vitest-набор: 498 файлов, 3089 passed, 0 failed/pending. Существующие три исключения конфигурации не менялись. Первоначальный повтор дал семь падений старых селекторов кнопки «×» после замены glyph на SVG; селекторы переведены на существующее доступное имя кнопки, проверки focus/lifecycle сохранены. Последний прогон четырех затронутых файлов — 107 passed. Итог объединяет последние исходы каждого файла, а не выдает адресный прогон за повтор всего набора. + +Типовой контракт Solution, адресные ESLint, Stylelint, Prettier и whitespace-проверка проходят. [Команды воспроизведения](VERIFICATION.md), [сводка результатов](verification-summary.json), [review](REVIEW.md). + +## 6. Остаточные риски + +Не проверены на сервере: сохранение и повторное чтение всех настроек, реальные изменения Project/Solution, назначение/снятие/защита экспертных назначений, запись и пересчет оценок, preview/apply при конкурирующих изменениях, реальные organizer/expert/participant права, письма, Excel/PDF и массовая выдача сертификатов. Для них нужны тестовые программы и авторизация в dev. Публичный GET и отказ 401 этого не подтверждают. + +Общий CSS затрагивает V1, поэтому проверены обе версии и снимки относительно `44761cdc`. Основные страницы сохранили размеры; ожидаемое изменение V1 — работающие мобильные меню распределения. Подробности различий и единичных пикселей сглаживания находятся в [acceptance-visual-regression.json](acceptance-visual-regression.json). Проверен Chromium; Safari/Firefox и реальные устройства не проверялись. Серверные контракты, маршруты, guards, миграции и backend не менялись. + +## 7. Git и PR + +Причина отсутствия ветки и `44761cdc` в GitHub: локальная ветка не публиковалась и upstream отсутствовал. Remote корректен, dev совпадает с исходной базой, dry-run создания feature-ветки разрешен. История задачи не содержит посторонних коммитов. Единственное изменение вне области программ — удаление доказанно неиспользуемой внешней ссылки шрифта из index.html для восстановления обычной сборки. + +Описание [PR в dev](PR.md) и доказательства подготовлены. Публикация feature-ветки и draft PR допускает ревью, но не закрывает приемку реального контура и перечисленных визуальных исключений. Merge в dev, deploy и миграции не выполняются. Итоговое состояние удаленной ветки и номер draft PR приведены в сообщении о результате. diff --git a/docs/program-v2-ui-audit/PR.md b/docs/program-v2-ui-audit/PR.md new file mode 100644 index 000000000..4466825a3 --- /dev/null +++ b/docs/program-v2-ui-audit/PR.md @@ -0,0 +1,53 @@ +## Что изменено + +V2 управления программами приведена к общим UI-компонентам PROCOLLAB и композиции V1: таблицы экспертов, фильтры, распределение, заявки, аналитика и вложенные диалоги. Устранены блокеры обычной production-сборки без изменения budgets. Ветка предназначена для draft review; серверная приемка под организатором остается открытой. + +## Зачем + +Устранить два визуально разных интерфейса одного продукта, сохранив самостоятельные Solution в V2 и существующие Project в V1. Аудит всех экранов и исключения: `docs/program-v2-ui-audit/ACCEPTANCE.md`, первоначальная матрица — `README.md` в том же каталоге. + +## Реализация + +Общие ExpertRoster, EvaluationProgressSummary, подписи статусов, native controls, списки management и таблицы/диалоги аналитики. Обработчики и API V1/V2 остаются раздельными. Исправлены scoped-стили, кнопка фильтров/selection, мобильные подписи и положение меню распределения V1, ширина empty state и модальные presets. Стандартная сборка восстановлена удалением неиспользуемой внешней ссылки Material Icons и выделением реально переиспользуемого CSS по ответственности; V2 заявки больше не загружают shell/settings стили. + +## API + +Контракт не менялся: domain, ports, HTTP adapters и backend не затронуты. Solution IDs, snapshots, teams, preview/apply tokens, quota и protected/own-team/eligibility сохранены. Project API не используется вместо Solution API. Типовой C00 контракт проходит. + +## UI + +[Figma PROCOLLAB](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE), [эксперты](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3220), [приглашение](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3222), [назначение](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3230). Повторно получены оригинальные metrics/PNG основных экранов, всех настроек и сертификатов. Восстановлены диалоги 560/600/680 px и общая аналитика 880/980/1100 px. Mont и SVG сохранены. Галереи и компактные PNG находятся в `docs/program-v2-ui-audit/`. + +Остались явные различия исторических макетов и текущего общего V1/V2: семь действующих вкладок, требуемая панель «Фильтры», Mont вместо TEMP Inter, развитый редактор сертификатов/формы и бизнес-колонки Solution. Они перечислены для финального визуального ревью; полный pixel match всех состояний не заявлен. + +## Permissions + +Guards, server-side permissions, object scope и routes не менялись. 403 проверен локально с fixtures. Реальный dev API доступен: публичные GET программ и опубликованных Project/Solution — 200; management/settings/experts/assignments без organizer сессии — 401 `Authentication credentials were not provided.` Production для тестов не использовался, реальные записи не выполнялись. + +## Проверки + +- [x] Обычный `NG_BUILD_MAX_WORKERS=2 npm run build:prod`: exit 0, исходные budgets и font inlining. +- [x] Именно стандартный dist: 16 заполненных страниц и 30 dialog/data проверок на 390/1440 px с изолированным API. +- [x] Настроенный Vitest-набор: 498 файлов, 3089 passed, 0 failed/pending; существующие три exclusions сохранены. После обновления семи glyph-selectors выполнен final replay 107 тестов четырех файлов без ослабления focus/lifecycle assertions. +- [x] `npm run test:solution-contract`, адресные ESLint/Stylelint/Prettier и `git diff --check`. +- [x] Chromium, 320/390/768/1440/1920: 140 заполненных страниц обеих версий, 400 interactions, 75 dialog/data checks, 40 states, 5 control comparisons. +- [x] 140 сравнений с `44761cdc`, размеры всех страниц сохранены; 111 изображений идентичны. Ожидаемое изменение основных V1 — мобильные меню распределения; остальные различия единичные пиксели сглаживания/границ. +- [x] Dev connectivity, публичное чтение обеих моделей и отказ анонимным management-запросам. +- [ ] Реальные сохранение/повторное чтение настроек V1/V2, назначение экспертов, оценивание, серверные роли и конфликты. + +## Acceptance criteria + +- [x] Аудит охватывает все разделы управления и семь подразделов настроек. +- [x] Расхождения устранены либо явно документированы с источниками Figma и V1. +- [x] Общие UI и отдельные Project/Solution контракты сохранены. +- [x] Desktop/mobile, снимки, тесты и обычная production-сборка подтверждены. +- [ ] Полная сохранность реальных organizer flows на dev подтверждена авторизованной интеграционной проверкой. +- [ ] Перечисленные различия исторической Figma/общего V1 приняты на финальном визуальном ревью. + +## Не проверено + +Нет учетных данных/сессии организатора dev. Не проверены реальные записи Project/Solution, сохранение всех настроек и повторное чтение, назначение/снятие/защита экспертов, запись/пересчет оценок, конкурентный preview/apply, реальные роли, email/Excel/PDF и выдача сертификатов. Моки подтверждают только поведение клиента. Safari/Firefox и реальные устройства не проверены. + +## Риски / migration + +Общие стили затрагивают V1: проверены обе версии, заполненные данные, меню, клавиатура и пять ширин. Остаточный риск — реальные серверные данные, права и конфликты вне fixtures. Миграций и изменений данных нет. Сохраняются существующие Sass/CommonJS и warning-budget предупреждения; build ошибок нет. Merge в dev и deploy не выполнялись и требуют разрешения владельца. diff --git a/docs/program-v2-ui-audit/README.md b/docs/program-v2-ui-audit/README.md new file mode 100644 index 000000000..2874d5412 --- /dev/null +++ b/docs/program-v2-ui-audit/README.md @@ -0,0 +1,133 @@ + + +# Аудит управления программами V1/V2 + +Актуальный результат повторной приемки после `44761cdc`: [ACCEPTANCE.md](ACCEPTANCE.md), [новые сравнения](acceptance-gallery.html). Обычная production-сборка проходит; полная серверная приемка остается открытой из-за отсутствия авторизованной сессии организатора dev. Ниже сохранена первоначальная матрица и результаты первого этапа; их исторические счетчики и галерея относятся к `44761cdc`. + +Задача: восстановить согласованный визуальный язык PROCOLLAB, сохранив отдельные модели Project и Solution, существующие HTTP-контракты, права, URL, фильтры и проверки конфликтов. + +Исходная версия Angular: `9ef90b58`, чистая рабочая копия. Ветка: `fix/program-v2-design-system`. Backend и React не изменяются. Merge, deploy и миграции не выполняются. + +## Источники и границы проверки + +Figma MCP доступен через подключение PROCOLLAB PRO. Исследованы страницы UI KIT, 10–14, 17–24, 27 и «Аналитика»: [полная карта фреймов](figma-inventory.json). Прочитаны контракты foundations, components, patterns, templates, legacy и decisions UI Kit. В файле остаются пометки Proposed и TEMP Inter; они не означают разрешения на замену действующего Mont, навигации или бизнес-логики. + +Визуальные источники: [UI Kit](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=0-1), [управление](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3219), [эксперты](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3220), [распределение](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3221), [результаты](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=526-3628), [аналитика](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=753-3219). Для восстановления конкретного поведения панели «Фильтры», чекбоксов и действий используется действующий V1 согласно заданию: ранний фрейм экспертов содержит более старую панель с select и пятью верхними вкладками. + +Скриншоты в задании фактически не переданы. Снимки «до» сняты с исходного Angular-кода. Браузер запускает настоящее приложение на localhost; ответы API заменены изолированными fixtures, включая действующие C00-сценарии Solution. Это проверка UI и запросов клиента, а не приемка реального сервера или действующих программ. Ни один запрос браузерного стенда не отправляется во внешний API. + +## Карта маршрутов и сущностей + +Корень: `/office/program/:programId/management/:section`, определен в [detail.routes.ts](../../projects/social_platform/src/app/ui/routes/program/detail.routes.ts). `solutionStorageMode` выбирает ветку; параметр `section` и вложенный `?tab=` общие. + +- `participants`: общий список регистраций, ответы анкеты, выбор, выгрузка, удаление с разными V1/V2-предпросмотрами. +- `projects`: V1 — связи ProgramProject/Project; V2 — заявки с `application.solutionId`, решения, принятый состав команды, архив, история административных действий. +- `experts`: общий roster приглашений и областей кейсов; назначения V1 по Project, V2 по Solution. Вложенная вкладка распределения и диалоги не являются отдельными route. +- `results`: общий компонент рейтинга; V2 адаптер сохраняет `SolutionRef` и не добавляет Project FK. +- `analytics`: разные источники Project/Solution, общая композиция; вложенные списки, оценки, фильтры, напоминания, XLSX. +- `settings?tab=basic|dates|materials|project-form|criteria|organizers|registration`: семь подразделов. `project-form` в V2 открывает форму решения; остальные редакторы общие. +- `certificates`: общий редактор шаблона, выдача/письмо, журнал, подтверждения, выбор получателей, PDF/архив. +- `projects/:linkId/evaluation`: существующий V1 просмотр оценок. Дополнительные маршруты решений `/solutions/:solutionId`, редактирование и `/evaluation`; создание/подготовка `/launch` и публичные опубликованные решения связаны с управлением, но их бизнес-потоки не переписываются. + +Контракты сверены по [ProgramManagementHttpAdapter](../../projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts), [EvaluationHttpAdapter](../../projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.ts), [SolutionHttpAdapter](../../projects/social_platform/src/app/infrastructure/adapters/solution/solution-http.adapter.ts) и domain-моделям. Счетчики roster Project не используются как счетчики назначений Solution. + +## Матрица расхождений до реализации + +Приоритет высокий: нарушено единство основных рабочих экранов или доступность действий. Средний: состояния, вложенные поверхности и адаптивность. Контроль: общий компонент уже совпадает, нужна регрессия. + +| Экран / компонент | Как должно быть | Как сейчас в V2 | Что исправить | Приоритет | +| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------- | +| Общая оболочка, верхние вкладки | [Figma: управление](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3340), контейнер 863 px, Mont, действующие вкладки V1 | Общий [management](../../projects/social_platform/src/app/ui/pages/program/management/management.component.html), подпись «Заявки» | Сохранить общий каркас и навигацию | Контроль | +| Участники | [Figma: участники](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=822-3220), поиск, фильтры, сортировка, выбор, меню, счетчики | Общая таблица, ветвление только удаления регистраций | Проверить фильтры, анкету, сортировку, массовую панель, удаление, mobile | Контроль | +| Проекты / заявки: заголовок и выгрузка | V1 [management.scss](../../projects/social_platform/src/app/ui/pages/program/management/management.component.scss), компактная icon-кнопка, спокойный счетчик | [SolutionApplications](../../projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-applications.component.html): импорт общего SCSS не переносит правила, зависящие от scoped-предка `.management` | Применить общие контракты заголовка и выгрузки в дочернем компоненте | Высокий | +| Заявки: таблица | [Figma: проекты](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3341), чекбоксы, ссылки, статусы, единая плотность таблицы | Таблица V2 из восьми колонок; локальные шрифты, статусы простым текстом, иконка закрытия символом × | Восстановить типографику и статусы V1, оставить `solutionId` и строки V2; отличие колонки презентации объяснено ниже | Высокий | +| Заявки: фильтры и поиск | V1-панель и модальное окно; текущие server filters и debounce | Общий toolbar, но отдельная геометрия диалога и нет счетчика фильтров | Общий визуальный контракт; не менять query-параметры | Средний | +| Заявка: решение / команда / история | Общий Modal и внутренние underline-вкладки, аватары, меню действий | [SolutionApplications](../../projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-applications.component.scss): отдельные outline-кнопки вместо вкладок, списки и собственный dialog | Вписать новые сущности в существующий язык форм и диалогов | Высокий | +| Заявка: сдача, архив, смена кейса, удаление участника | Modal V1, notice/error, прогноз последствий, безопасное подтверждение | Новые V2-preview/token-потоки работают; символы ×, plain error, разная форма | Единые иконки, alerts, footer; сохранить все preview/token/409-правила | Средний | +| Эксперты: панель | V1 [experts.html](../../projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html): поиск + «Фильтры», invite 32 px, icon export | [solution-experts.html](../../projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.html): select статуса на странице, текстовая выгрузка, кнопки 44 px из SCSS аналитики | Повторно использовать V1 toolbar, фильтры в Modal, единый размер controls | Высокий | +| Эксперты: таблица | V1 roster: select-all, row selection, профиль, avatar, status badge, menu, footer | Отдельная разметка без select-all; длинный email ломается иначе, нет badge/профиля/пагинации | Выделить общий визуальный roster для V1/V2; handlers и данные оставить в родителях | Высокий | +| Эксперты: массовые действия | V1 плавающая панель и состояние выбора | Inline-панель с классом `solution-analytics__actions` | Использовать существующие bulk-классы V1; сохранить actions V2 | Высокий | +| Эксперты: приглашение и кейсы | [Figma: invite](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3222), V1 dialog, checkbox choices, компактный footer | Большой dialog из аналитики; checkbox-label без layout выбора | Перейти на экспертовые dialog/choice/scroll/alerts, сохранить invitation API | Высокий | +| Эксперты: назначения и число оценщиков | [Figma: назначения](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3230), [quota](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3233), protected и disabled | Назначения Solution и quota-preview отдельные, визуально перегружены | Сохранить protected/own-team/eligibility, привести обертки и состояния к V1 | Средний | +| Распределение: композиция | [Figma](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3221), quota отдельно, таблица работ, фильтры и primary action | Кнопка quota в заголовке, generic analytics table, нет мобильных подписей | Отдельная общая quota-card, экспертовая таблица, search/filter dialog, captions | Высокий | +| Распределение: preview и нехватка | [Figma](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3227), choice списка и понятная нагрузка | [SolutionAllocation](../../projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-allocation.component.ts), plain labels и абзацы | Применить общий dialog/choice/status; оставить solution preview/apply и shortage | Средний | +| Результаты: рейтинг, кейсы, фильтры, формула, Excel | [Figma: результаты](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=526-3628), [формула](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=530-4838) | Общий [ResultsComponent](../../projects/social_platform/src/app/ui/pages/program/management/results/results.component.ts) с адаптером V2 | Проверить zero/null, раскрытие оценок, фильтры, menu, mobile; не дублировать | Контроль | +| Аналитика: четыре показателя | [Figma: показатели](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=753-3225), V1 icon + caption + value | [SolutionAnalytics](../../projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.html): нет иконок, h2 наследуют крупный default | Вернуть composition и типографику карточек V1; значения брать только из V2 | Высокий | +| Аналитика: сроки, внимание, воронки | [Figma: аналитика](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=753-3219), общие графики и DS | Большая часть компонентов общая; некоторые заголовки без размеров V1 | Восстановить заголовки, loading/error и actions без изменения метрик | Средний | +| Аналитика: оценивание, кейсы, материалы, регионы, активность | [Figma: ход оценивания](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=629-3220), DS cards и tables | V2 добавляет coverage полей решения; shared charts, отдельные таблицы списков | Общая плотность, заголовки и responsive tables; сохранить дополнительные метрики | Средний | +| Аналитика: вложенные списки, оценки, напоминания | Modal/Table/Input из UI Kit; числовой 0 не пустота | Отдельный `solution-analytics__dialog`, широкие таблицы | Общие Modal/controls, scroll внутри таблицы, alert/empty/focus; сохранить права и запросы | Средний | +| Настройки: Основное, изображения, предпросмотр, возврат в черновик | [Figma: основное](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3343) | Общие basic form, branding-preview/crop и confirmation | Проверить сохранение, disabled, длинные подписи, responsive | Контроль | +| Настройки: Сроки | [Figma: сроки](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3497), единая форма | Общий редактор с датами и validation | Проверить конкурсную и неконкурсную программу, сохранение | Контроль | +| Настройки: Материалы, upload, удаление | [Figma: материалы](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=179-3626) | Общие file/link/dropzone/editor | Проверить dialog, состояния загрузки/ошибки, mobile | Контроль | +| Настройки: Кейсы и поля / Форма решения | [Figma: поля](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=227-3220), общие field rows и editor | [SolutionForm](../../projects/social_platform/src/app/ui/pages/program/management/solution-form/solution-form.component.scss) уже использует V1 SCSS; отличия отражают templates/field purpose/snapshots | Сохранить переиспользование; проверить constraints 320 px и preview/editor | Контроль | +| Настройки: Оценивание и критерии | [Figma: критерии](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=267-3220) | Общий editor, preview, delete/conflict/locked | Проверить обе версии без изменения scoring rules | Контроль | +| Настройки: Организаторы и приглашения | [Figma: организаторы](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=582-9039) | Общий список, меню, self/last manager, invite/remove dialogs | Проверить обе версии; не переносить вычисление прав в UI | Контроль | +| Настройки: Регистрация, поле, импорт, preview, публикация, external | [Figma: регистрация](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=652-3220) | Общий компонент и все поддиалоги | Проверить обе версии и responsive; сохранить published/draft contracts | Контроль | +| Сертификаты: шаблон, canvas, фон/подпись, выдача, письмо, журнал | [Figma: сертификаты](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=904-3220), [выдача](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=904-3221), [журнал](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=904-3222) | Общие [certificates](../../projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.ts) и canvas | Регрессия всех трех вкладок и dialogs; не менять выдачу/подписки/PDF | Контроль | +| Loading / empty / no results / error / disabled | UI Kit [состояния](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=9-330), focus у controls | В V2 экспертах и заявках часть сообщений plain, смешаны alert-стили | Использовать действующие Alert и TransientNotice; различать empty и filtered-empty | Средний | +| 320 / 390 / 768 / 1440 / 1920 px | V1 responsive: desktop table → labeled cards, 44 px hit targets, локальный scroll | V2 expert table не включает нужные классы/подписи; generic table горизонтально прокручивается | Один roster и таблица распределения с mobile data-label; проверить всю карту | Высокий | + +## План до реализации + +1. Выделить представление таблицы roster из V1 в общий компонент (строки, avatar, status, profile, checkbox, menu, mobile, footer). Оставить API, статус программы, отбор строк, protected checks и операции в существующих родителях. Проверить исходные V1 tests и оба экрана в браузере. +2. Перевести V2 экспертов на V1 toolbar/filter Modal, общий roster и bulk-панель. Удалить подключение SCSS аналитики; состояния и диалоги использовать из экспертов. Счетчики остаются вычисленными по SolutionAssignment. +3. Восстановить композицию распределения: quota-card, заголовок, поиск/фильтры и таблица с мобильными подписями. Использовать действующий SolutionAllocation, не переносить Project allocation API. +4. Исправить scoped-стили и диалоги заявок: общие export/title/status/choice, DS-иконки вместо символа ×, вкладки решения/команды. Не менять payload, query и административные preview/apply. +5. Восстановить карточки и типографику аналитики, общие icon/graph/control components, состояния loading/error и вложенные таблицы. Не удалять coverage и остальные метрики V2. +6. Выполнить browser matrix для обеих версий, всех семи настроек и сертификатов на пяти ширинах; отдельно dialogs, filters, row actions, selection, focus и отсутствие доступа. Сохранить снимки до/после и результаты. Проверить локальные состояния с fixture; live/backend evidence указывать отдельно. +7. Запустить tests с ограниченным числом workers, проверку контракта, production build, локальные lint/format и requirement/code review. При общем компоненте приоритетна регрессия V1. + +Новый дизайн не проектируется: визуальное восстановление V1 явно разрешено заданием. Новые DS additions не нужны. Особые риски: scoped CSS Angular в дочерних компонентах, мобильные `nth-child`, выбор скрытых фильтром строк, focus после закрытия диалогов, сохранение Solution ID и snapshot/preview tokens. Изменения server-side permissions и API не входят в план. + +## Подтверждение результата + +Реализация выполнена в отдельной ветке. Основные причины расхождений: повторная разметка экспертов, импорт стилей аналитики в экспертов и зависимость общих Angular-стилей от scoped-предка, которого нет в дочернем компоненте. Данные и обработчики V2 оставлены в его компонентах. + +- Выделены общие [ExpertRosterComponent](../../projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.ts), [подписи состояний экспертов](../../projects/social_platform/src/app/ui/pages/program/management/experts/expert-presentation.ts) и [SCSS таблицы](../../projects/social_platform/src/styles/_program-expert-roster.scss). V1/V2 используют одинаковые аватары, профили, статусы, чекбоксы, меню, пагинацию и мобильные карточки. +- В V2 восстановлены панель поиска, кнопка «Фильтры» с модальным черновиком и сбросом, компактные приглашение/выгрузка, плавающий массовый выбор. Сохранены все статусы и действия V2. Выбор очищается при изменении поиска/фильтра. +- Распределение V2 использует композицию V1: отдельная quota-card, поиск/фильтры, таблица с мобильными подписями. Выбор строк передает именно Solution ID в существующий preview/apply, без Project allocation API. Мобильные меню закреплены внутри своих строк; отдельная браузерная проверка контролирует границы каждого меню. +- Исправлены экспорт, вкладки решения/команды, отступы, alerts и иконки диалогов заявок. Массовая панель позиционируется по существующей геометрии родителя. Сохранены preview tokens, конфликты, архив, история, сдача и исключение участника. +- В аналитике восстановлены иконки и типографика показателей, карточки, подписи/полосы кейсов, loading/error и вложенные таблицы. Общий [EvaluationProgressSummaryComponent](../../projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.ts) получает только счетчики и события; V1/V2 загружают данные через разные API. +- Участники, результаты, семь подразделов настроек и сертификаты уже используют общие компоненты. Они прошли регрессию; новые параллельные реализации не создавались. + +### Снимки и проверки первоначального этапа (44761cdc) + +[Интерактивная галерея до/после с эталоном V1](gallery.html) содержит основные сравнения на 390 и 1440 px. Полный архив результата дополнительно содержит все 14 экранов на пяти ширинах, диалоги и состояния. Снимки являются захватами настоящего Angular-приложения, а не сверстанными отдельно прототипами. + +| Сравнение | До | После | +| ---------------------- | -------------------------------------------------- | ------------------------------------------------- | +| Эксперты, 1440 px | [PNG](screenshots/before/v2-experts-1440.png) | [PNG](screenshots/after/v2-experts-1440.png) | +| Эксперты, 390 px | [PNG](screenshots/before/v2-experts-390.png) | [PNG](screenshots/after/v2-experts-390.png) | +| Распределение, 1440 px | [PNG](screenshots/before/v2-distribution-1440.png) | [PNG](screenshots/after/v2-distribution-1440.png) | +| Заявки, 1440 px | [PNG](screenshots/before/v2-projects-1440.png) | [PNG](screenshots/after/v2-projects-1440.png) | +| Аналитика, 1440 px | [PNG](screenshots/before/v2-analytics-1440.png) | [PNG](screenshots/after/v2-analytics-1440.png) | + +Результаты и команды: [проверка требований и кода](REVIEW.md), [воспроизведение](VERIFICATION.md), [сводка JSON](verification-summary.json), [подготовленное описание PR](PR.md). + +В репозитории оставлены отчет, карта Figma, сводка проверок и основные сравнения. Подробные JSON каждого браузерного сценария, результаты всех spec-файлов, тексты контрактов Figma, все снимки и логи находятся в полном архиве, приложенном к результату задачи; [индекс доказательств](evidence-index.json) содержит их контрольные суммы. + +- **3089 тестов / 498 файлов Vitest — успешно**, без пропусков внутри настроенного набора. Три spec-файла исключены существующим `vitest.config.ts`; конфигурация не менялась. В Cloud набор запускался небольшими fork-процессами из-за памяти, затем повторены 65 тестов четырех затронутых файлов после исправления найденной регрессии tooltip V1. +- **140 основных проверок после изменений**: 14 экранов × V1/V2 × 320/390/768/1440/1920 px. До изменений сохранены те же 140 снимков. +- **395 проверок взаимодействий**: 45 сценариев, обе версии там, где применимо, на пяти ширинах; включая вложенный V1-маршрут просмотра оценки проекта. Дополнительно 40 проверок empty/loading/error/retry/403/disabled/keyboard focus и 5 сравнений визуальных контрактов controls V1/V2. +- **16 проверок оптимизированного артефакта**: заявки/проекты, эксперты, распределение, аналитика, V1/V2, 390/1440 px. Нет ошибок JavaScript и выхода страницы за viewport. +- Из 70 основных снимков V1 **60 попиксельно идентичны**. В остальных 10 отличаются не более **0,00063% пикселей**, без изменения размеров/геометрии; различия ограничены единичными пикселями границ и сглаживания. [Полный результат сравнения](v1-visual-regression.json). +- TypeScript-контракт Solution, ESLint измененного TypeScript, Stylelint измененного SCSS, Prettier TS/HTML и `git diff --check` — успешно. + +### Обоснованные различия V1/V2 + +1. V1 управляет Project/ProgramProject, V2 — заявкой и самостоятельным Solution со снимком формы, составом команды и административной историей. Названия и доступные действия отражают эти сущности. +2. Таблица заявок V2 сохраняет действующий контракт восьми колонок. Презентация остается действием меню и загружается из полей решения по требованию; механическое добавление девятой Project-колонки потребовало бы загрузки каждого решения и предположения о назначении динамического поля. Чекбоксы, плотность, меню и статусы согласованы с V1. +3. Форма решения сохраняет templates, purpose полей, validation и snapshots. Это развитие бизнес-модели, использующее существующие field/editor/preview компоненты, а не новый стиль. +4. Распределение использует актуальные Solution preview/apply, quota, eligibility, own-team и protected-ограничения. Старые Project-счетчики roster не подменяют назначения решений. +5. Аналитика сохраняет дополнительные метрики покрытия полей и данные задержавшихся экспертов V2. Общая сводка оценивания не делает запросы к legacy Project API ради повторения старого подробного списка экспертов. Отсутствие таких запросов проверено браузерным assertion. +6. Удаление регистрации и участника в V2 сохраняет отдельный серверный прогноз последствий. Внешняя оболочка использует общие Modal/Alert/Choice. + +### Актуальные ограничения приемки + +Блокеры стандартной production-сборки устранены без изменения budgets и настроек production. Повторная обычная сборка и проверки ее артефакта проходят. Подробная причина каждого исходного блокера и проверка baseline — в [отчете повторной приемки](ACCEPTANCE.md). + +Реальный dev API доступен: публичные данные программ Project/Solution отвечают 200. Управленческие endpoints отвечают 401 `Authentication credentials were not provided.` Проверки записей под организатором, оценивания и серверных прав остаются открытыми. Клиентские fixtures не выдаются за интеграцию. + +Figma повторно исследована через MCP, включая заполненные страницы, настройки, сертификаты и диалоги. Различия исторических макетов и текущих общих V1/V2 (навигация, TEMP Inter/Mont, развитый редактор сертификатов, поля Solution) перечислены в ACCEPTANCE.md. Полный попиксельный match всех состояний не заявляется. + +Ветка готовится к draft PR в dev с видимыми ограничениями. Итоговая приемка и merge требуют проверки реального контура и визуального ревью перечисленных исключений. Merge, deploy и миграции не выполнялись. diff --git a/docs/program-v2-ui-audit/REVIEW.md b/docs/program-v2-ui-audit/REVIEW.md new file mode 100644 index 000000000..c79c78ca7 --- /dev/null +++ b/docs/program-v2-ui-audit/REVIEW.md @@ -0,0 +1,54 @@ + + +# Повторная проверка требований и кода + +Вердикт: **insufficient evidence для полной приемки/merge; draft PR пригоден для ревью**. Стандартная production-сборка теперь проходит. В исправленном клиентском scope actionable BLOCKER/MAJOR/MINOR не осталось. Непроверенная запись в реальный dev API и различия исторической Figma/общего V1 не скрываются этим выводом. + +## Requirement audit + +| AC | Требование | Реализация и доказательство | Вердикт | +| --- | --- | --- | --- | +| 1 | Аудит всех экранов управления | README: карта всех маршрутов и модальных окон; ACCEPTANCE: 14 экранов, семь настроек, свежие Figma metrics/PNG, обе версии | PASS в границе управления | +| 2 | Устранить или документировать визуальные расхождения | Общие roster, controls, progress, таблицы и dialog presets; новая галерея; явный список исключений Figma/V1 | PASS для аудита и документирования; окончательное визуальное ревью открыто | +| 3 | Переиспользовать UI | ExpertRoster, EvaluationProgressSummary, expert-presentation, native controls, management-list, analytics-table/dialog, общий mixin portal body | PASS | +| 4 | Сохранить Project/Solution | Domain, API adapters, routes и guards не менялись; идентификаторы и preview/apply обработчики сохранены; C00 типовой контракт и tests | PASS на клиенте | +| 5 | Сохранить работу V1/V2 | Tests и настоящие UI с fixtures, сравнение обеих версий с 44761cdc; реальных organizer записей нет | NOT VERIFIED полностью | +| 6 | Проверить desktop/mobile | 320/390/768/1440/1920; заполненные списки, forms, dialogs, states, menus, focus | PASS для зафиксированных сценариев | +| 7 | Доказать результат снимками/тестами | Галереи, JSON, PNG, логи и fingerprints; уровни mock/live явно разделены | PASS | +| 8 | Обычная production-сборка | NG_BUILD_MAX_WORKERS=2 npm run build:prod: exit 0, исходные budgets/font inlining; 46 проверок именно этого артефакта | PASS | +| 9 | Установить причину API blocker | Публичные dev GET 200, management/settings/experts/assignments 401 без сессии; production не использован | PASS для диагностики, NOT VERIFIED для live organizer scenarios | +| 10 | Проверить Git/подготовить PR | dev base совпадает; ветка локальная, dry-run push разрешен; только коммиты задачи, чистота и remote ref проверяются после commit/push | См. итоговый Git-результат | + +## Checked by source + +Повторный diff относительно `44761cdc` и dev: бизнес-обработчики, API payloads, permissions, модели и маршруты не менялись. Новая TS-логика ограничена presentation: выбор CSS-класса диалога, initials и безопасная ссылка аватара. Новый профиль аналитики использует userId, решение — solutionId. Нет новых зависимостей, миграций и production config overrides. + +SCSS выделен по ответственности и реально используется обеими версиями. Это не увеличение max error budget: каждый stylesheet проверяется прежним лимитом. V2 заявки больше не получают все стили shell/settings. Generic native controls имеют Emulated scope; AnalyticsDrilldown с ViewEncapsulation.None получает только именованные analytics selectors и прежний scoped input. Portal-body правила ограничены именами модальных окон аналитики/экспертов. Внешние computed-style probes до/после совпадают. + +Удаленные Material Icons links не имеют consumers: поиск material-icons/mat-icon/fontIcon/MatIcon не нашел использования. SVG и локальные шрифты Mont не удалены. Это единственная оправданная правка общего index.html вне страниц программ. + +Однократный финальный Impeccable detector отметил существующий `border-left: 4px solid var(--red)` в аналитическом alert. Это сохраненное оформление V1, а не внесенная V2 декорация; generic detector не является основанием менять согласованный UI. Других findings в затронутом scope нет. + +## Checked by tests + +Полный настроенный Vitest-набор: 498 файлов, 3089 passed / 0 failed / 0 pending по последнему результату каждого файла. Существующие три exclusions сохранены. После семи падений селектора glyph × два spec-файла используют прежнее доступное имя кнопки; focus/lifecycle assertions не удалены и не ослаблены. Финальный replay четырех файлов — 107 passed. Все исходные попытки и replay есть в архиве. + +Solution type contract, адресные ESLint 18 TS, Stylelint 19 SCSS, Prettier 29 TS/HTML и git diff --check проходят. Глобальные команды auto-fix не запускались. Финальные новые product файлы входят в эти проверки. + +## Checked by build + +Обычный `NG_BUILD_MAX_WORKERS=2 npm run build:prod` — exit 0. Budgets/angular.json/package dependencies неизменны. Весь набор пяти CSS failures существовал на исходном dev; Google Fonts 403 воспроизведен на 44761cdc и объяснен сетевой политикой для неиспользуемой ссылки. Ошибки исправлены, а не подавлены. Сохраняются исходные toolchain warnings; см. полный лог. + +## Checked in browser + +Настоящий Angular, Chromium, изолированные fixtures. 140 заполненных основных страниц, 400 взаимодействий, 75 дополнительных проверок диалогов/данных, 40 состояний и 5 визуальных контрактов на пяти ширинах; все успешны. Стандартный production-артефакт отдельно: 16 страниц и 30 dialog/data проверок на 390/1440 px; все успешны. + +Повторно проверены Figma metrics/17 свежих PNG и текущий V1. Экспертные модальные окна имеют измеренные presets 560/600/680, аналитика — 880/980/1100; проверяются мобильные ограничения ширины. Заполненный roster: 25 экспертов, selection между страницами и статусы. Просмотр оценок сохраняет 0/false/comment. Проверены реальные click по каждому меню распределения V1/V2, Escape/focus trap/возврат фокуса, error/retry/loading/403/disabled и формы всех настроек. + +Сравнение 140 снимков с `44761cdc` сохраняет размеры страниц. Ожидаемое изменение основных V1 — меню распределения на мобильных ширинах. Подробные пиксельные результаты не подменяют ручную сверку Figma. Историческая Figma и общий текущий редактор сертификатов/формы имеют описанные отличия, вынесенные на финальное визуальное ревью. + +## Not checked / риски + +На реальном dev выполнены публичные GET программ и списков Project/Solution и отрицательная проверка анонимного management доступа. Без сессии организатора остальные endpoints возвращают 401. Не проверены серверные записи/повторное чтение настроек, assignment mutations, оценивание/пересчет, конкурентный preview/apply, реальные роли, email, Excel/PDF и выдача сертификатов. Fixtures доказывают клиентское поведение, но не эти свойства сервера. + +Остаточные риски: реальные данные/большие списки вне fixtures; серверные конфликты и права; браузеры кроме Chromium; расхождения актуальной общей реализации и исторических макетов. Требуется dev-прогон под организатором V1/V2 и финальный просмотр перечисленных визуальных исключений. Merge, deploy и миграции не выполнялись. diff --git a/docs/program-v2-ui-audit/VERIFICATION.md b/docs/program-v2-ui-audit/VERIFICATION.md new file mode 100644 index 000000000..b0d9c9682 --- /dev/null +++ b/docs/program-v2-ui-audit/VERIFICATION.md @@ -0,0 +1,65 @@ + + +# Воспроизведение итоговых проверок + +Актуальный отчет — [ACCEPTANCE.md](ACCEPTANCE.md). Рабочий каталог — корень Angular checkout. Node 24.19.0, Angular 20.3, Chromium `/usr/bin/chromium`. Playwright предоставлен средой; новая зависимость приложения не добавлена. + +## Стандартная сборка + +```sh +NG_BUILD_MAX_WORKERS=2 npm run build:prod +python3 docs/program-v2-ui-audit/serve-production.py +PROGRAM_UI_BASE=http://127.0.0.1:4365 WIDTHS=390,1440 SCREENS=projects,experts,distribution,analytics node docs/program-v2-ui-audit/browser-check.cjs production-acceptance +PROGRAM_UI_BASE=http://127.0.0.1:4365 WIDTHS=390,1440 node docs/program-v2-ui-audit/acceptance-check.cjs production-acceptance +``` + +Build exit 0. Исходные production optimization, fonts inlining и budgets сохранены. Сервер раздает только локальный стандартный `dist/social_platform`, не публикует его. Браузерные скрипты перехватывают внешние API/WebSocket и используют fixtures, в том числе когда в production bundle указан production API. Это не проверка настоящего production сервиса. + +Исторические font/CSS failures на `9ef90b58` и `44761cdc` сохранены в архиве отдельно. Они не являются текущим результатом. Обходной диагностический build-скрипт удален из финального checkout. + +## Браузер и снимки + +```sh +NG_BUILD_MAX_WORKERS=2 npx ng serve social_platform --host 0.0.0.0 --port 4200 --no-open +node docs/program-v2-ui-audit/browser-check.cjs acceptance-final +node docs/program-v2-ui-audit/interaction-check.cjs acceptance-final +node docs/program-v2-ui-audit/acceptance-check.cjs acceptance-final +node docs/program-v2-ui-audit/state-check.cjs +node docs/program-v2-ui-audit/visual-contract.cjs +``` + +Съемка основных страниц ждет populated selectors, спокойную сеть и Mont. Итог: 140 main, 400 interactions, 75 conformance, 40 states, 5 controls. Ширины 320/390/768/1440/1920. `WIDTHS`, `SCREENS`, `CASES`, `ACCEPTANCE_CASES` позволяют адресный повтор. Внутри Playwright нет реальных credentials/записей; fixtures включают действующие C00 Solution документы, 25 экспертов и значения 0/false/comment. + +Сравнение этого этапа снято с неизмененного worktree `44761cdc`, запущенного на 4202: + +```sh +PROGRAM_UI_BASE=http://127.0.0.1:4202 node docs/program-v2-ui-audit/browser-check.cjs acceptance-before +PROGRAM_UI_BASE=http://127.0.0.1:4202 PROGRAM_UI_BASELINE=1 node docs/program-v2-ui-audit/acceptance-check.cjs acceptance-before +``` + +`PROGRAM_UI_BASELINE=1` записывает исходную геометрию, а не объявляет старые presets соответствующими Figma. Три исходные mobile проверки открытия V1 assignment не проходят из-за исправленного дефекта позиционирования; baseline failures сохранены. Финальные результаты объединяют последние адресные повторы по version/screen-or-scenario/width. Исторический первоначальный before `9ef90b58` и after `44761cdc` остаются в первой галерее отдельно. + +## Тесты и код + +```sh +PROGRAM_UI_TEST_OUTPUT=/workspace/artifacts/program-v2-ui-audit/followup/tests python3 docs/program-v2-ui-audit/run-tests.py +npm run test:solution-contract +git diff --check +``` + +Набор Vitest запускается по восемь файлов, один fork worker, heap 1536 MB; конфигурация и три исходных exclusions неизменны. Полный набор 498 файлов/3089 тестов был выполнен; семь selectors × исправлены на доступное имя кнопки, затем четыре затронутых файла повторены (107 passed). Сводка содержит последние исходы каждого файла, исходные логи и replay доступны в архиве. Assertions lifecycle/focus сохранены. + +ESLint выполнен для всех измененных TS относительно dev, Stylelint без auto-fix — для всех SCSS, Prettier — для TS/HTML. SCSS исключен действующим prettierignore и проверяется Stylelint. Глобальные auto-fix/format не запускаются; pre-commit hook с глобальным `lint:scss --fix` заменен адресными проверками перед commit. + +## Figma, dev и галереи + +Figma проверяется через MCP исходного файла `tvog0fpEgWgEtr7KUTjbyE`; карта — figma-inventory.json. Свежие PNG и метрики текущего этапа сохранены в полном архиве. Нельзя заменять их автоматически нарисованным прототипом или выдавать снимки fixtures за live интеграцию. + +Read-only dev probes: `/programs/`, детали 4/49/50/51, опубликованные Project/Solution — 200; управленческие endpoints — 401 без organizer сессии. Изменяющих запросов к реальному сервису нет. После получения авторизованного dev доступа отдельно требуется полный organizer прогон из ACCEPTANCE.md. + +```sh +python3 docs/program-v2-ui-audit/build-gallery.py +GALLERY_BEFORE=acceptance-before GALLERY_AFTER=acceptance-final GALLERY_OUTPUT=acceptance-gallery.html GALLERY_BASELINE=44761cdc python3 docs/program-v2-ui-audit/build-gallery.py +``` + +Открыть gallery.html или acceptance-gallery.html можно автономно: ссылки на локальные PNG, данные встроены, внешних зависимостей/fetch нет. В Cloud браузер блокирует file://, поэтому для ручной проверки использовалась локальная HTTP-раздача. В git оставлены основные пары desktop/mobile; полный архив содержит все 14 экранов/пять ширин и вложенные состояния. Проверка ZIP и SHA256 входит в финальную подготовку. diff --git a/docs/program-v2-ui-audit/acceptance-check.cjs b/docs/program-v2-ui-audit/acceptance-check.cjs new file mode 100644 index 000000000..3ed3ff5e7 --- /dev/null +++ b/docs/program-v2-ui-audit/acceptance-check.cjs @@ -0,0 +1,301 @@ +/** @format */ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { launch, open } = require("./browser-check.cjs"); +const { measure } = require("../responsive/browser-harness.cjs"); +const fixture = require("./fixtures.cjs"); +const phase = process.argv[2] || "acceptance-final"; +const widths = (process.env.WIDTHS || "320,390,768,1440,1920").split(",").map(Number); +const destination = path.join(__dirname, "screenshots", phase, "conformance"); +fs.mkdirSync(destination, { recursive: true }); +const results = [], + probes = []; +const baselineCapture = process.env.PROGRAM_UI_BASELINE === "1"; +const button = (page, name) => page.getByRole("button", { name, exact: true }); + +async function checkDialog(page, width, preset, className = "experts-modal") { + const selector = baselineCapture ? ".modal__body" : `.modal__body.${className}`; + const body = page.locator(selector); + await body.waitFor(); + const expected = Math.min(preset, width - (className === "experts-modal" ? 40 : 32)); + if (!baselineCapture) + await page.waitForFunction( + ({ selector, expected }) => + Math.abs(document.querySelector(selector).getBoundingClientRect().width - expected) < 1, + { selector, expected }, + ); + const actual = await body.evaluate(e => ({ + width: e.getBoundingClientRect().width, + padding: getComputedStyle(e).padding, + })); + if (!baselineCapture) assert.ok(Math.abs(actual.width - expected) < 1); + return { preset, expected, ...actual, matchesReference: Math.abs(actual.width - expected) < 1 }; +} + +async function run() { + for (const width of widths) + for (const version of ["v1", "v2"]) { + const { browser, context, page, requests, errors } = await launch(width); + page.setDefaultTimeout(8000); + async function record(name, action) { + if (process.env.ACCEPTANCE_CASES && !process.env.ACCEPTANCE_CASES.split(",").includes(name)) + return; + const errorStart = errors.length; + try { + const details = await action(); + const metrics = await measure(page); + assert.equal(metrics.overflows.length, 0, JSON.stringify(metrics.overflows)); + assert.ok(metrics.pageWidth <= width + 1); + assert.deepEqual(errors.slice(errorStart), []); + const file = `${version}-${name}-${width}.png`; + await page.screenshot({ path: path.join(destination, file), fullPage: true }); + results.push({ + version, + name, + width, + file, + passed: true, + referenceOnly: baselineCapture, + details, + metrics, + }); + console.log( + `${version} ${name} ${width}: ${baselineCapture ? "CAPTURED BASELINE" : "PASS"}`, + ); + } catch (error) { + results.push({ version, name, width, passed: false, error: error.message }); + console.log(`${version} ${name} ${width}: FAIL ${error.message.split("\n")[0]}`); + } + } + try { + await open(page, version, "analytics", "analytics"); + const probe = await page.evaluate(() => { + const out = {}; + for (const [tag, cls] of [ + ["input", "ui-field"], + ["button", "ui-button"], + ["input", "ui-choice"], + ]) { + const e = document.createElement(tag); + e.className = cls; + if (cls === "ui-choice") e.type = "checkbox"; + document.body.append(e); + const css = getComputedStyle(e); + out[cls] = Object.fromEntries( + [ + "fontSize", + "lineHeight", + "padding", + "borderRadius", + "backgroundColor", + "minHeight", + ].map(k => [k, css[k]]), + ); + e.remove(); + } + return out; + }); + probes.push({ version, width, styles: probe }); + if (process.env.PROBE_ONLY === "1") continue; + + await record("figma-expert-scope", async () => { + await open(page, version, "experts", "experts"); + await page + .locator(".experts__table") + .getByRole("button", { name: /^Действия эксперта:/ }) + .first() + .click(); + await page.getByRole("menuitem", { name: "Изменить кейсы", exact: true }).click(); + return checkDialog(page, width, 600); + }); + await record("figma-expert-quota", async () => { + await open(page, version, "experts", "distribution"); + await page + .locator(".experts__quota") + .getByRole("button", { name: "Изменить", exact: true }) + .click(); + return checkDialog(page, width, 560); + }); + await record("distribution-no-results", async () => { + await open(page, version, "experts", "distribution"); + await page + .locator('.experts__toolbar input[type="search"]') + .fill("Несуществующее решение"); + await page + .getByText(/Проекты не найдены|Сданных решений для распределения не найдено/) + .waitFor(); + const cell = page.locator(".experts__table--projects td[colspan]"); + const geometry = await cell.evaluate(e => ({ + cellWidth: e.getBoundingClientRect().width, + tableWidth: e.closest("table").getBoundingClientRect().width, + })); + if (!baselineCapture) + assert.ok( + geometry.cellWidth >= geometry.tableWidth * 0.8, + "Пустое состояние должно занимать ширину таблицы", + ); + return geometry; + }); + await record("figma-expert-assignments", async () => { + await open(page, version, "experts", version === "v1" ? "distribution" : "experts"); + if (version === "v1") { + await page + .locator(".experts__table") + .getByRole("button", { name: /^Действия проекта:/ }) + .first() + .click(); + await page.getByRole("menuitem", { name: "Изменить экспертов", exact: true }).click(); + } else { + await page + .locator(".experts__table") + .getByRole("button", { name: /^Действия эксперта:/ }) + .first() + .click(); + await page.getByRole("menuitem", { name: "Назначить решения", exact: true }).click(); + } + return checkDialog(page, width, 680); + }); + await record("analytics-assignment-layout", async () => { + await open(page, version, "analytics", "analytics"); + const trigger = button(page, "Открыть назначения"); + await trigger.click(); + const geometry = await checkDialog( + page, + width, + 980, + "analytics-drilldown-body--assignments", + ); + const table = page.locator( + baselineCapture && version === "v2" + ? ".solution-analytics__table table" + : ".analytics-drilldown__table--assignments", + ); + await table.waitFor(); + if (!baselineCapture) + assert.equal( + await table.locator("tbody").evaluate(e => getComputedStyle(e).display), + width < 1000 ? "grid" : "table-row-group", + ); + await page.waitForFunction(() => !!document.activeElement?.closest('[role="dialog"]')); + for (let i = 0; i < 6; i++) { + await page.keyboard.press("Tab"); + assert.ok( + await page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]')), + ); + } + return geometry; + }); + if (version === "v2") + await record("analytics-zero-false-comment", async () => { + await open(page, version, "analytics", "analytics"); + await button(page, "Открыть назначения").click(); + await button(page, "Посмотреть оценку").first().click(); + await page.getByRole("dialog").getByText("0", { exact: true }).waitFor(); + await page.getByRole("dialog").getByText("Нет", { exact: true }).waitFor(); + assert.ok(requests.some(r => /\/solution-analytics\/assignments\/801\/$/.test(r.path))); + assert.ok(!requests.some(r => /\/manager-overview\/assignments/.test(r.path))); + return { solutionAssignmentId: 801, zeroAndFalseRendered: true }; + }); + await record("figma-invite-filled", async () => { + await open(page, version, "experts", "experts"); + await button(page, "Пригласить эксперта").click(); + const geometry = await checkDialog(page, width, 560); + const email = page.getByRole("dialog").locator('input[type="email"]'); + await email.fill("invalid-email"); + await email.blur(); + const invitationWrites = () => + requests.filter( + request => request.method === "POST" && request.path.includes("/management/experts"), + ).length; + const writesBefore = invitationWrites(); + await button(page, "Отправить приглашение").click(); + if (!baselineCapture) + await page.waitForFunction( + () => + document + .querySelector('.modal__body input[type="email"]') + .getAttribute("aria-invalid") === "true", + ); + if (!baselineCapture) + await page + .getByRole("dialog") + .getByRole("alert") + .filter({ hasText: "Введите корректный email эксперта." }) + .waitFor(); + await page.waitForTimeout(250); + assert.equal( + invitationWrites(), + writesBefore, + "Невалидный email не должен отправляться в API", + ); + await page.screenshot({ + path: path.join(destination, `${version}-invite-invalid-${width}.png`), + fullPage: true, + }); + await email.fill("review-expert@example.test"); + if (!baselineCapture) + await page.waitForFunction( + () => + document + .querySelector('.modal__body input[type="email"]') + .getAttribute("aria-invalid") === "false", + ); + await page.waitForFunction(() => + [...document.querySelectorAll(".modal__body button")].some( + button => button.textContent.trim() === "Отправить приглашение" && !button.disabled, + ), + ); + await page.waitForTimeout(250); + assert.equal(await button(page, "Отправить приглашение").isDisabled(), false); + return { ...geometry, invalidEmailBlocked: true }; + }); + await record("populated-expert-pagination", async () => { + await context.route("**/programs/*/management/experts/", route => { + const data = fixture.roster(); + data.experts = Array.from({ length: 25 }, (_, i) => ({ + ...data.experts[i % 5], + id: 200 + i, + userId: 200 + i, + name: `Эксперт программы ${i + 1}`, + email: `review${i + 1}@example.test`, + status: ["joined", "pending", "expired", "revoked"][i % 4], + invitationId: 200 + i, + })); + return route.fulfill({ json: data }); + }); + await open(page, version, "experts", "experts"); + await page.waitForFunction( + () => document.querySelectorAll(".experts__table tr[data-expert-id]").length === 20, + ); + await page + .getByRole("checkbox", { name: "Выбрать всех экспертов по фильтрам", exact: true }) + .check(); + await button(page, "Далее").click(); + await page.waitForFunction( + () => document.querySelectorAll(".experts__table tr[data-expert-id]").length === 5, + ); + const selected = await page + .locator('.experts__table tr[data-expert-id] input[type="checkbox"]') + .evaluateAll(inputs => inputs.every(input => input.checked)); + assert.equal(selected, true); + return { totalExperts: 25, firstPage: 20, secondPage: 5, selectionPreserved: true }; + }); + } finally { + await browser.close(); + } + } + fs.writeFileSync( + path.join(__dirname, `${phase}-scope-probes.json`), + JSON.stringify(probes, null, 2), + ); + fs.writeFileSync( + path.join(__dirname, `${phase}-conformance-results.json`), + JSON.stringify(results, null, 2), + ); + if (results.some(r => !r.passed)) process.exitCode = 1; +} +run().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/docs/program-v2-ui-audit/acceptance-gallery.html b/docs/program-v2-ui-audit/acceptance-gallery.html new file mode 100644 index 000000000..0dbcf1996 --- /dev/null +++ b/docs/program-v2-ui-audit/acceptance-gallery.html @@ -0,0 +1,40 @@ + + +PROCOLLAB — аудит программ V1/V2 + +

Управление программами PROCOLLAB: до / после

+

Настоящие снимки Angular с одинаковыми изолированными данными. «До» — исходный код 44761cdc. Эталон V1 доступен ниже каждой пары.

+

Итог повторной приемки и ограничения · Первоначальная матрица · Проверка требований · Результаты проверок

+
+ +Соответствующий фрейм Figma
+
Интерфейс программы V2 до исправления
+

V2 — после

Оригинал PNG
Интерфейс программы V2 после исправления
+
Показать исходную реализацию V1 для сравнения

V1 — исходный эталон реализации

Оригинал PNG
Исходный интерфейс программы V1
Показать оригинальный фрейм Figma

Figma — исходный макет

Оригинал PNG
Оригинальный фрейм Figma; данные и историческая версия могут отличаться
+

Для длинных страниц прокручивайте каждый снимок. Масштаб 100% сохраняет размер пикселей. Названия сущностей и дополнительные действия V2 сохранены по контракту Solution.

+
Проверка текущей версииРезультат
Стандартная production-сборкаУспешно; исходные budgets и font inlining
Заполненные страницы, обе версии14 экранов × 5 ширин
Вложенные состояния и действияПроверены таблицы, формы, модальные окна, клавиатура
+

Снимки получены с изолированными ответами API. Реальный dev отвечает на публичные GET, но organizer endpoints без сессии возвращают 401. Сохранение/назначение/оценивание на сервере не проверены. Полный pixel match исторической Figma не заявляется; различия навигации, Mont, редактора сертификатов и Solution перечислены в отчете.

+ \ No newline at end of file diff --git a/docs/program-v2-ui-audit/acceptance-visual-regression.json b/docs/program-v2-ui-audit/acceptance-visual-regression.json new file mode 100644 index 000000000..e4b122b71 --- /dev/null +++ b/docs/program-v2-ui-audit/acceptance-visual-regression.json @@ -0,0 +1,1275 @@ +{ + "baseline": "44761cdc", + "pairs": 140, + "identical": 111, + "v1Pairs": 70, + "v1Identical": 54, + "sameDimensions": true, + "details": [ + { + "file": "v1-analytics-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-analytics-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-analytics-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-analytics-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-analytics-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-certificates-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-certificates-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-certificates-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-certificates-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-certificates-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-distribution-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v1-distribution-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-distribution-320.png", + "sameSize": true, + "identical": false, + "changedPixels": 48, + "changedPercent": 0.015, + "bounds": [ + 260, + 959, + 264, + 975 + ] + }, + { + "file": "v1-distribution-390.png", + "sameSize": true, + "identical": false, + "changedPixels": 48, + "changedPercent": 0.012307692307692308, + "bounds": [ + 330, + 911, + 334, + 927 + ] + }, + { + "file": "v1-distribution-768.png", + "sameSize": true, + "identical": false, + "changedPixels": 48, + "changedPercent": 0.00625, + "bounds": [ + 693, + 863, + 697, + 879 + ] + }, + { + "file": "v1-experts-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v1-experts-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-experts-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-experts-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-experts-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-projects-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-projects-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v1-projects-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-projects-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-projects-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-basic-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v1-settings-basic-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-basic-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-basic-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-basic-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-criteria-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v1-settings-criteria-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v1-settings-criteria-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-criteria-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-criteria-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-dates-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-dates-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v1-settings-dates-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-dates-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-dates-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-organizers-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v1-settings-organizers-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-organizers-320.png", + "sameSize": true, + "identical": false, + "changedPixels": 26, + "changedPercent": 0.008125, + "bounds": [ + 40, + 663, + 43, + 763 + ] + }, + { + "file": "v1-settings-organizers-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-organizers-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-project-form-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v1-settings-project-form-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v1-settings-project-form-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-project-form-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-project-form-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-registration-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v1-settings-registration-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v1-settings-registration-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-registration-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-registration-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-analytics-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-analytics-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-analytics-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-analytics-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-analytics-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-certificates-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v2-certificates-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-certificates-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-certificates-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-certificates-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-distribution-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-distribution-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v2-distribution-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-distribution-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-distribution-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-experts-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-experts-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-experts-320.png", + "sameSize": true, + "identical": false, + "changedPixels": 2, + "changedPercent": 0.000625, + "bounds": [ + 53, + 584, + 57, + 587 + ] + }, + { + "file": "v2-experts-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-experts-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-participants-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-participants-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-participants-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-participants-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-participants-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-projects-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-projects-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-projects-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-projects-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-projects-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-results-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v2-results-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-results-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-results-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-results-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-basic-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v2-settings-basic-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v2-settings-basic-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-basic-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-basic-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-criteria-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v2-settings-criteria-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v2-settings-criteria-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-criteria-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-criteria-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-dates-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-dates-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v2-settings-dates-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-dates-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-dates-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-materials-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-materials-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v2-settings-materials-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-materials-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-materials-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-organizers-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-organizers-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-organizers-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-organizers-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-organizers-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-project-form-1440.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-project-form-1920.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00026041666666666666, + "bounds": [ + 566, + 429, + 568, + 484 + ] + }, + { + "file": "v2-settings-project-form-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-project-form-390.png", + "sameSize": true, + "identical": false, + "changedPixels": 15, + "changedPercent": 0.0038461538461538464, + "bounds": [ + 20, + 624, + 22, + 636 + ] + }, + { + "file": "v2-settings-project-form-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-registration-1440.png", + "sameSize": true, + "identical": false, + "changedPixels": 5, + "changedPercent": 0.00034722222222222224, + "bounds": [ + 326, + 429, + 328, + 484 + ] + }, + { + "file": "v2-settings-registration-1920.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-registration-320.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-registration-390.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v2-settings-registration-768.png", + "sameSize": true, + "identical": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + } + ] +} diff --git a/docs/program-v2-ui-audit/api-verification.json b/docs/program-v2-ui-audit/api-verification.json new file mode 100644 index 000000000..e969f466d --- /dev/null +++ b/docs/program-v2-ui-audit/api-verification.json @@ -0,0 +1,388 @@ +{ + "environment": "dev only", + "writes": 0, + "productionUsed": false, + "publicPrograms": { + "count": 18, + "programs": [ + { + "id": 67, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 70, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 73, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 39, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 2, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 5, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 38, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 8, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 50, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 51, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 6, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 4, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 58, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 1, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 49, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 3, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 9, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + }, + { + "id": 7, + "solution_storage_mode": null, + "draft": null, + "is_competitive": null + } + ] + }, + "publicModes": [ + { + "programId": 4, + "status": 200, + "modelFields": { + "id": 4, + "solution_storage_mode": "legacy_project" + }, + "fieldNames": [ + "id", + "name", + "tag", + "city", + "materials", + "image_address", + "cover_image_address", + "mobile_cover_image_address", + "image_framing", + "advertisement_image_address", + "presentation_address", + "registration_link", + "registration_managed", + "datetime_registration_ends", + "datetime_project_submission_ends", + "datetime_evaluation_ends", + "publish_projects_after_finish", + "published_projects_available", + "legacy_team_min_size", + "legacy_team_max_size", + "is_user_manager", + "is_user_expert", + "solution_storage_mode", + "pending_expert_invitation_id", + "courses", + "is_user_member", + "current_application" + ] + }, + { + "programId": 49, + "status": 200, + "modelFields": { + "id": 49, + "solution_storage_mode": "solution" + }, + "fieldNames": [ + "id", + "name", + "tag", + "city", + "materials", + "image_address", + "cover_image_address", + "mobile_cover_image_address", + "image_framing", + "advertisement_image_address", + "presentation_address", + "registration_link", + "registration_managed", + "datetime_registration_ends", + "datetime_project_submission_ends", + "datetime_evaluation_ends", + "publish_projects_after_finish", + "published_projects_available", + "legacy_team_min_size", + "legacy_team_max_size", + "is_user_manager", + "is_user_expert", + "solution_storage_mode", + "pending_expert_invitation_id", + "courses", + "is_user_member", + "current_application" + ] + }, + { + "programId": 50, + "status": 200, + "modelFields": { + "id": 50, + "solution_storage_mode": "solution" + }, + "fieldNames": [ + "id", + "name", + "tag", + "city", + "materials", + "image_address", + "cover_image_address", + "mobile_cover_image_address", + "image_framing", + "advertisement_image_address", + "presentation_address", + "registration_link", + "registration_managed", + "datetime_registration_ends", + "datetime_project_submission_ends", + "datetime_evaluation_ends", + "publish_projects_after_finish", + "published_projects_available", + "legacy_team_min_size", + "legacy_team_max_size", + "is_user_manager", + "is_user_expert", + "solution_storage_mode", + "pending_expert_invitation_id", + "courses", + "is_user_member", + "current_application" + ] + }, + { + "programId": 51, + "status": 200, + "modelFields": { + "id": 51, + "solution_storage_mode": "solution" + }, + "fieldNames": [ + "id", + "name", + "tag", + "city", + "materials", + "image_address", + "cover_image_address", + "mobile_cover_image_address", + "image_framing", + "advertisement_image_address", + "presentation_address", + "registration_link", + "registration_managed", + "datetime_registration_ends", + "datetime_project_submission_ends", + "datetime_evaluation_ends", + "publish_projects_after_finish", + "published_projects_available", + "legacy_team_min_size", + "legacy_team_max_size", + "is_user_manager", + "is_user_expert", + "solution_storage_mode", + "pending_expert_invitation_id", + "courses", + "is_user_member", + "current_application" + ] + } + ], + "publicEntities": [ + { + "url": "https://dev.procollab.ru/programs/4/projects/?limit=1", + "status": 401, + "detail": "{\"detail\":\"Authentication credentials were not provided.\"}" + }, + { + "url": "https://dev.procollab.ru/programs/4/published-projects/?limit=1", + "status": 200, + "count": 8, + "firstRowKeys": [ + "id", + "team_policy", + "name", + "leader", + "short_description", + "image_address", + "industry", + "draft", + "views_count", + "is_company", + "partner_program" + ], + "model": null + }, + { + "url": "https://dev.procollab.ru/programs/49/published-solutions/?limit=1", + "status": 200, + "count": 1, + "firstRowKeys": [ + "application", + "published_at", + "captain", + "accepted_count" + ], + "model": null + } + ], + "management": [ + { + "programId": 4, + "method": "GET", + "url": "https://dev.procollab.ru/programs/4/management/", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "programId": 4, + "method": "GET", + "url": "https://dev.procollab.ru/programs/4/management/settings/basic/", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "programId": 4, + "method": "GET", + "url": "https://dev.procollab.ru/programs/4/management/experts/", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "programId": 4, + "method": "GET", + "url": "https://dev.procollab.ru/programs/4/management/results/", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "programId": 4, + "method": "GET", + "url": "https://dev.procollab.ru/programs/4/management/projects/", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "programId": 49, + "method": "GET", + "url": "https://dev.procollab.ru/programs/49/management/", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "programId": 49, + "method": "GET", + "url": "https://dev.procollab.ru/programs/49/management/settings/basic/", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "programId": 49, + "method": "GET", + "url": "https://dev.procollab.ru/programs/49/management/experts/", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "programId": 49, + "method": "GET", + "url": "https://dev.procollab.ru/programs/49/management/solution-results/", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "programId": 49, + "method": "GET", + "url": "https://dev.procollab.ru/programs/49/management/solution-analytics/assignments/", + "status": 401, + "detail": "Authentication credentials were not provided." + } + ], + "blocker": "No organizer credentials/session; management endpoints return 401 Authentication credentials were not provided.", + "notVerified": [ + "Settings save and re-read", + "Project/Solution mutations", + "Assignment mutations/protected evaluations", + "Evaluation write/recalculation", + "Concurrent preview/apply", + "Real organizer/expert/participant permissions", + "Email/Excel/PDF/certificate delivery" + ] +} diff --git a/docs/program-v2-ui-audit/browser-check.cjs b/docs/program-v2-ui-audit/browser-check.cjs new file mode 100644 index 000000000..5f8e66575 --- /dev/null +++ b/docs/program-v2-ui-audit/browser-check.cjs @@ -0,0 +1,162 @@ +/** @format */ +const { chromium } = require("playwright"); +const fs = require("node:fs"); +const path = require("node:path"); +const fixture = require("./fixtures.cjs"); +const { measure } = require("../responsive/browser-harness.cjs"); +const phase = process.argv[2] || "after"; +const widths = (process.env.WIDTHS || "320,390,768,1440,1920").split(",").map(Number); +const base = process.env.PROGRAM_UI_BASE || "http://127.0.0.1:4200"; +const destination = path.join(__dirname, "screenshots", phase); +fs.mkdirSync(destination, { recursive: true }); +const screens = [ + ["participants", "participants"], + ["projects", "projects"], + ["experts", "experts"], + ["distribution", "experts"], + ["results", "results"], + ["analytics", "analytics"], + ...["basic", "dates", "materials", "project-form", "criteria", "organizers", "registration"].map( + tab => ["settings-" + tab, "settings?tab=" + tab], + ), + ["certificates", "certificates"], +]; +async function launch(width) { + const browser = await chromium.launch({ + executablePath: "/usr/bin/chromium", + headless: true, + args: ["--no-sandbox"], + }); + const context = await browser.newContext({ + viewport: { width, height: 1000 }, + reducedMotion: "reduce", + }); + await context.addCookies( + ["accessToken", "refreshToken", "devAccessToken", "devRefreshToken"].map(name => ({ + name, + value: "isolated-program-visual-fixture", + url: base, + })), + ); + await context.addInitScript(() => localStorage.setItem("cookieConsent", "declined")); + const requests = [], + errors = []; + await context.route("**/*", async route => { + const req = route.request(), + url = new URL(req.url()); + if (url.origin === base) return route.continue(); + if (["dev.procollab.ru", "api.procollab.ru"].includes(url.hostname)) { + let body = null; + try { + body = req.postDataJSON(); + } catch {} + requests.push({ path: url.pathname, query: url.search, method: req.method(), body }); + const result = fixture.response(url, req.method(), body); + return route.fulfill({ json: result ?? {}, headers: { "Access-Control-Allow-Origin": "*" } }); + } + return route.fulfill({ body: "", status: 200 }); + }); + await context.routeWebSocket("**", socket => socket.onMessage(() => {})); + const page = await context.newPage(); + page.on("pageerror", e => errors.push(e.message)); + page.on("console", m => { + if (m.type() === "error") errors.push(m.text()); + }); + return { browser, context, page, requests, errors }; +} +async function open(page, version, route, name) { + await page.goto(`${base}/office/program/${version === "v1" ? 1 : 12}/management/${route}`); + await page.locator(".management__tabs").waitFor(); + if (name === "distribution") + await page.getByRole("button", { name: "Распределение экспертов", exact: true }).click(); + if (name === "distribution") + await page + .locator('.experts__tabs button[aria-current="page"]') + .filter({ hasText: "Распределение экспертов" }) + .waitFor(); + await page.waitForTimeout(180); + await page.waitForLoadState("networkidle"); + await page.evaluate(() => document.fonts.ready); +} +async function assertPopulatedScreen(page, name) { + const selector = { + participants: ".management__participants tbody tr", + projects: ".management__projects tbody tr", + experts: ".experts__table tbody tr, .solution-analytics__table tbody tr", + distribution: ".experts__table--projects tbody tr, .solution-analytics__table tbody tr", + results: ".results__row", + analytics: ".summary-card__value", + "settings-basic": "#program-name", + "settings-dates": ".management__form input", + "settings-materials": ".management__material", + "settings-project-form": ".project-form__row", + "settings-criteria": ".criteria__row", + "settings-organizers": ".organizers__row-actions", + "settings-registration": ".registration__row", + certificates: "app-certificate-canvas", + }[name]; + if (!selector) throw new Error(`Unknown screen: ${name}`); + await page.locator(selector).first().waitFor(); + const count = await page.locator(selector).count(); + if (!count) throw new Error(`No populated content in ${name}`); + await page.mouse.move(0, 0); + await page.waitForTimeout(250); + return { selector, populatedElements: count }; +} +async function capture() { + const results = []; + for (const width of widths) { + const { browser, page, requests, errors } = await launch(width); + for (const version of ["v1", "v2"]) + for (const [name, route] of screens) { + if (process.env.SCREENS && !process.env.SCREENS.split(",").includes(name)) continue; + const startErrors = errors.length; + try { + await open(page, version, route, name); + const readiness = await assertPopulatedScreen(page, name); + const metrics = await measure(page); + const file = `${version}-${name}-${width}.png`; + await page.screenshot({ path: path.join(destination, file), fullPage: true }); + results.push({ + version, + screen: name, + width, + file, + readiness, + ...metrics, + errors: errors.slice(startErrors), + }); + console.log( + `${phase} ${version} ${name} ${width}: overflow=${metrics.overflows.length}, errors=${errors.length - startErrors}`, + ); + } catch (error) { + results.push({ + version, + screen: name, + width, + failure: error.message, + errors: errors.slice(startErrors), + }); + console.log(`${version} ${name}: ${error.message}`); + } + } + fs.writeFileSync( + path.join(__dirname, `${phase}-requests-${width}.json`), + JSON.stringify(requests, null, 2), + ); + await browser.close(); + } + fs.writeFileSync(path.join(__dirname, `${phase}-results.json`), JSON.stringify(results, null, 2)); + if ( + results.some( + r => r.failure || r.errors.length || r.overflows?.length || r.pageWidth > r.width + 1, + ) + ) + process.exitCode = 1; +} +if (require.main === module) + capture().catch(e => { + console.error(e); + process.exitCode = 1; + }); +module.exports = { launch, open, base }; diff --git a/docs/program-v2-ui-audit/build-gallery.py b/docs/program-v2-ui-audit/build-gallery.py new file mode 100644 index 000000000..960dfdab3 --- /dev/null +++ b/docs/program-v2-ui-audit/build-gallery.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Собрать автономную галерею из настоящих снимков Angular, без внешних ресурсов.""" +import json +import os +from pathlib import Path +import sys + +ROOT = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parent +BEFORE = os.environ.get("GALLERY_BEFORE", "before") +AFTER = os.environ.get("GALLERY_AFTER", "after") +OUTPUT = os.environ.get("GALLERY_OUTPUT", "gallery.html") +BASELINE = os.environ.get("GALLERY_BASELINE", "9ef90b58") +LABELS = { + "participants": "Участники", "projects": "Заявки и решения", "experts": "Эксперты", + "distribution": "Распределение экспертов", "results": "Результаты", "analytics": "Аналитика", + "settings-basic": "Настройки — Основное", "settings-dates": "Настройки — Сроки", + "settings-materials": "Настройки — Материалы", "settings-project-form": "Настройки — Форма решения", + "settings-criteria": "Настройки — Оценивание", "settings-organizers": "Настройки — Организаторы", + "settings-registration": "Настройки — Регистрация", "certificates": "Сертификаты", +} +FIGMA = { + "participants": "822-3220", "projects": "179-3341", "experts": "452-3220", + "distribution": "452-3221", "results": "526-3628", "analytics": "753-3219", + "settings-basic": "179-3343", "settings-dates": "179-3497", "settings-materials": "179-3626", + "settings-project-form": "227-3220", "settings-criteria": "267-3220", + "settings-organizers": "582-9039", "settings-registration": "652-3220", "certificates": "904-3220", +} +items = [] +for screen, label in LABELS.items(): + for width in (320, 390, 768, 1440, 1920): + before = f"screenshots/{BEFORE}/v2-{screen}-{width}.png" + after = f"screenshots/{AFTER}/v2-{screen}-{width}.png" + reference = f"screenshots/{BEFORE}/v1-{screen}-{width}.png" + if all((ROOT / file).exists() for file in (before, after)): + items.append({"screen": screen, "label": label, "width": width, "before": before, + "after": after, "reference": reference if (ROOT / reference).exists() else None, + "figma": FIGMA[screen]}) + +for file in sorted((ROOT / "screenshots" / BEFORE / "details").glob("v2-*.png")): + scenario, width = file.stem[3:].rsplit("-", 1) + after = ROOT / "screenshots" / AFTER / "details" / file.name + reference = file.with_name("v1-" + file.name[3:]) + if after.exists(): + items.append({"screen": "detail-" + scenario, "label": "Диалог / состояние — " + scenario, + "width": int(width), "before": str(file.relative_to(ROOT)), + "after": str(after.relative_to(ROOT)), + "reference": str(reference.relative_to(ROOT)) if reference.exists() else None}) + +DIALOGS = { + "figma-invite-filled": ("Приглашение эксперта", "452-3222"), + "figma-expert-scope": ("Кейсы эксперта", "452-3223"), + "figma-expert-quota": ("Квота оценщиков", "452-3233"), + "figma-expert-assignments": ("Назначения эксперта", "452-3230"), + "analytics-assignment-layout": ("Аналитика — назначения", "629-3220"), + "analytics-zero-false-comment": ("Оценки — 0, Нет, комментарий", "629-3220"), + "distribution-no-results": ("Распределение — нет результатов", "452-3221"), + "populated-expert-pagination": ("Эксперты — 25 строк и выбор", "452-3220"), +} +for file in sorted((ROOT / "screenshots" / BEFORE / "conformance").glob("v2-*.png")): + scenario, width = file.stem[3:].rsplit("-", 1) + after = ROOT / "screenshots" / AFTER / "conformance" / file.name + reference = file.with_name("v1-" + file.name[3:]) + if after.exists(): + label, figma = DIALOGS.get(scenario, (scenario, None)) + items.append({"screen": "conformance-" + scenario, "label": label, + "width": int(width), "before": str(file.relative_to(ROOT)), + "after": str(after.relative_to(ROOT)), "figma": figma, + "reference": str(reference.relative_to(ROOT)) if reference.exists() else None}) +for item in items: + frame = item.get("figma") + if frame: + for directory in ("figma/followup", "figma"): + file = ROOT / directory / (frame + ".png") + if file.exists(): + item["figmaImage"] = str(file.relative_to(ROOT)) + break +assert items, "Нет пар снимков до/после" +template = r''' + +PROCOLLAB — аудит программ V1/V2 + +

Управление программами PROCOLLAB: до / после

+

Настоящие снимки Angular с одинаковыми изолированными данными. «До» — исходный код __BASELINE__. Эталон V1 доступен ниже каждой пары.

+

Итог повторной приемки и ограничения · Первоначальная матрица · Проверка требований · Результаты проверок

+
+ +Соответствующий фрейм Figma
+
Интерфейс программы V2 до исправления
+

V2 — после

Оригинал PNG
Интерфейс программы V2 после исправления
+
Показать исходную реализацию V1 для сравнения

V1 — исходный эталон реализации

Оригинал PNG
Исходный интерфейс программы V1
Показать оригинальный фрейм Figma

Figma — исходный макет

Оригинал PNG
Оригинальный фрейм Figma; данные и историческая версия могут отличаться
+

Для длинных страниц прокручивайте каждый снимок. Масштаб 100% сохраняет размер пикселей. Названия сущностей и дополнительные действия V2 сохранены по контракту Solution.

+
Проверка текущей версииРезультат
Стандартная production-сборкаУспешно; исходные budgets и font inlining
Заполненные страницы, обе версии14 экранов × 5 ширин
Вложенные состояния и действияПроверены таблицы, формы, модальные окна, клавиатура
+

Снимки получены с изолированными ответами API. Реальный dev отвечает на публичные GET, но organizer endpoints без сессии возвращают 401. Сохранение/назначение/оценивание на сервере не проверены. Полный pixel match исторической Figma не заявляется; различия навигации, Mont, редактора сертификатов и Solution перечислены в отчете.

+''' +(ROOT / OUTPUT).write_text(template.replace("__BASELINE__", BASELINE).replace("__DEFAULT__", "conformance-figma-invite-filled" if BEFORE.startswith("acceptance") else "experts").replace("__ITEMS__", json.dumps(items, ensure_ascii=False).replace(" + JSON.parse( + JSON.stringify( + contractModule.exports.solutionContractScenarios.find(s => s.name === name).response, + ), + ); +const legacyResult = ({ application, solutionId, ...result }) => ({ + ...result, + id: 31, + projectId: 101, +}); +const paged = results => ({ + results, + count: results.length, + totalCount: results.length, + next: null, + previous: null, +}); +const names = ["Иван Петров", "Анна Миронова", "Олег Соколов", "Мария Иванова", "Денис Петров"]; +const cases = ["Цифровой сервис", "Операционная эффективность"]; +const experts = names.map((name, i) => ({ + id: i < 3 ? 91 + i : `invite:${i}`, + userId: i < 3 ? 91 + i : undefined, + invitationId: i < 3 ? undefined : 100 + i, + name, + email: `expert${i + 1}@example.test`, + avatar: null, + status: ["joined", "joined", "joined", "pending", "expired"][i], + deliveryStatus: "sent", + allCases: i === 1, + caseNames: i === 1 ? [] : [cases[i % 2]], + assigned: i < 3 ? 2 : null, + completed: i < 3 ? 1 : null, + remaining: i < 3 ? 1 : null, +})); +const projects = ["Сервис для студентов", "Чистый маршрут", "Город без отходов"].map((name, i) => ({ + id: 31 + i, + projectId: 101 + i, + name, + leader: { id: 5 + i, name: names[i] }, + submitted: true, + draft: false, + evaluationStatus: i ? "awaiting_evaluation" : "partially_evaluated", + assignmentsTotal: i ? 0 : 2, + assignmentsCompleted: i ? 0 : 1, + caseName: cases[i % 2], + presentationUrl: null, +})); +const roster = () => ({ + revision: "visual-r1", + requiredEvaluators: 2, + managedEvaluation: true, + submittedProjects: 3, + missingAssignments: 4, + projectsMissingExperts: 2, + readyProjects: 0, + cases, + experts, + projects: projects.map((p, i) => ({ + id: p.id, + name: p.name, + caseName: p.caseName, + leaderId: p.leader.id, + teamUserIds: [p.leader.id], + experts: i + ? [] + : [ + { + expertId: 91, + name: names[0], + email: experts[0].email, + completed: true, + protected: true, + }, + { + expertId: 92, + name: names[1], + email: experts[1].email, + completed: false, + protected: false, + }, + ], + })), +}); +const baseProgram = { + ...baseline.response("/programs/1/", "GET", new URLSearchParams()), + name: "Кейс-чемпионат PROCOLLAB", + city: "Москва", + tag: "Кейс-чемпионат", + description: "Решения для развития городских сервисов и образования.", + updatedAt: "2026-10-25T10:00:00Z", + draft: false, + isCompetitive: true, + isDistributedEvaluation: true, + programFormat: "case_championship", + datetimeStarted: "2026-10-01T09:00:00Z", + datetimeRegistrationEnds: "2026-12-01T18:00:00Z", + datetimeProjectSubmissionEnds: "2026-12-10T18:00:00Z", + datetimeEvaluationEnds: "2026-12-20T18:00:00Z", + datetimeFinished: "2026-12-25T18:00:00Z", + materials: [ + { + id: 1, + title: "Положение о программе", + url: "https://example.test/rules", + file: null, + updatedAt: "2026-10-25T10:00:00Z", + }, + ], +}; +const certificate = () => ({ + revision: 1, + draft: { + backgroundId: null, + signatureId: null, + director: "Иван Петров", + position: "Руководитель программы", + showText: true, + audience: "submitted", + timing: "manual", + scheduledAt: null, + sendEmail: true, + subject: "Сертификат {program}", + message: "Здравствуйте, {name}!", + layout: Object.fromEntries( + [ + "title", + "subtitle", + "name", + "program", + "date", + "number", + "director", + "position", + "signature", + ].map((key, i) => [key, { x: 10, y: 5 + i * 9, width: 80, height: 8, fontSize: 18 }]), + ), + }, + assets: { background: null, signature: null }, + eligibleCounts: { all: 5, submitted: 2 }, + batch: null, +}); +const form = () => ({ + updatedAt: baseProgram.updatedAt, + revision: "visual-r1", + hasProjects: true, + cases: cases.map(name => ({ name, projectsCount: 1 })), + fields: [ + { + id: 100, + label: "Краткое описание", + fieldType: "textarea", + required: true, + answersCount: 1, + editableType: false, + options: [], + position: 0, + }, + ], + supportedTypes: ["text", "textarea", "select", "file", "number", "checkbox"], +}); +const registration = () => ({ + draft: { + mode: "native", + fields: [ + { + id: "f" + "a".repeat(32), + label: "Вуз или организация", + type: "text", + helpText: "", + required: true, + source: "own", + options: [], + allowOther: false, + }, + ], + }, + published: null, + revision: 2, + publishedVersion: 0, + publishedAt: null, + externalStatus: "not_connected", + externalUrl: "", + connectionComment: "", + connectionWebsiteUrl: "", + connectionContact: "", + answeredFieldIds: [], + link: null, +}); +function response(url, method, body) { + const { pathname: p, searchParams: q } = url; + const id = Number(p.match(/\/programs\/(\d+)/)?.[1] || 1); + const program = { + ...baseProgram, + id, + solutionStorageMode: id === 12 ? "solution" : "legacy_project", + }; + if (p === "/solution-invitations/") return paged([]); + if (p === "/solution-form-templates/") return scenario("templates"); + if (/\/programs\/\d+\/$/.test(p) || /\/management\/$/.test(p)) return program; + if (/\/settings\/(basic|dates)\/$/.test(p)) return { ...program, ...body }; + if (/\/management\/participants\/selection\/$/.test(p)) return { ids: [1, 2, 3] }; + if (/\/management\/participants\/$/.test(p)) { + let rows = names.slice(0, 3).map((name, i) => ({ + id: 1 + i, + userId: 5 + i, + name, + email: `member${i}@example.test`, + city: "Москва", + region: "Москва", + registeredAt: "2026-10-15T10:00:00Z", + lastActivity: "2026-10-26T10:00:00Z", + registrationAnswers: [{ label: "Вуз", value: "МГУ" }], + })); + if (q.get("search")) rows = rows.filter(r => r.name.includes(q.get("search"))); + return { ...paged(rows), regionOptions: ["Москва", "Санкт-Петербург"] }; + } + if (/\/management\/projects\/$/.test(p)) + return { ...paged(projects), caseOptions: cases, isCompetitive: true }; + if (/\/management\/projects\/31\/evaluation\/$/.test(p)) { + const data = scenario("solution_results"); + return { + projectId: 101, + programLinkId: 31, + name: projects[0].name, + submitted: true, + canRate: false, + experts: [], + criteria: data.criteria, + result: legacyResult(data.groups[0].solutions[0]), + }; + } + if (/\/management\/experts\/$/.test(p)) return roster(); + if (/\/management\/experts\/\d+\/assignments\/$/.test(p)) + return { + revision: "visual-r1", + projects: projects.map(p => ({ + id: p.id, + name: p.name, + caseName: p.caseName, + submitted: true, + assigned: false, + protected: false, + completed: false, + ownTeam: false, + eligible: true, + })), + }; + if (/\/management\/project-form\/$/.test(p)) return form(); + if (/\/management\/criteria\/$/.test(p)) + return { + updatedAt: baseProgram.updatedAt, + revision: "visual-r1", + locked: false, + supportedTypes: ["int", "float", "bool", "str"], + criteria: scenario("solution_results").criteria, + }; + if (/\/management\/organizers\/$/.test(p)) + return { + revision: "visual-r1", + organizerCount: 2, + activeOrganizerCount: 2, + invitationCount: 1, + organizers: [ + { + id: "member:1", + userId: 1, + name: names[0], + email: "organizer@example.test", + avatar: null, + status: "joined", + active: true, + isSelf: true, + canRemove: true, + }, + { + id: "member:2", + userId: 2, + name: names[1], + email: "anna@example.test", + avatar: null, + status: "joined", + active: true, + isSelf: false, + canRemove: true, + }, + ], + }; + if (/\/management\/registration\/$/.test(p)) return registration(); + if (/\/management\/certificates\/$/.test(p)) return certificate(); + if (/\/management\/certificates\/deliveries\/$/.test(p)) + return { ...paged([]), summary: { total: 0, ready: 0, sent: 0, failed: 0 } }; + if (/\/management\/results\/$/.test(p)) { + const data = scenario("solution_results"); + return { + ...data, + programId: 1, + groups: data.groups.map(g => ({ + caseName: g.caseName, + projects: g.solutions.map(legacyResult), + })), + }; + } + if (/\/management\/solution-form\/$/.test(p)) return scenario("form_management"); + if (/\/solution-form\/templates\/$/.test(p)) return scenario("templates"); + if (/\/management\/solution-results\/$/.test(p)) return scenario("solution_results"); + if (/\/management\/solution-analytics\/$/.test(p)) { + const data = scenario("manager_solution_analytics_distributed"); + data.summary.solutions.total = 3; + data.evaluationStatus.assignments = { total: 3, pending: 2, evaluated: 1 }; + data.evaluationStatus.solutions = { + submitted: 3, + awaitingEvaluation: 2, + partiallyEvaluated: 1, + evaluated: 0, + }; + return data; + } + if (/\/solution-analytics\/assignments\/$/.test(p)) { + const data = scenario("manager_solution_assignments_completed"); + const row = data.results[0]; + data.results = ["completed", "pending", "in_progress"].map((status, i) => ({ + ...row, + assignmentId: 801 + i, + status, + expert: { ...row.expert, expertId: 91 + i, userId: 91 + i, fullName: names[i] }, + application: { + ...row.application, + solutionId: i === 2 ? 302 : 301, + name: projects[i === 2 ? 1 : 0].name, + }, + })); + if (q.get("scope") === "completed") + data.results = data.results.filter(row => row.status === "completed"); + if (q.get("scope") === "pending") + data.results = data.results.filter(row => row.status !== "completed"); + return data; + } + if (/\/solution-analytics\/assignments\/\d+\/$/.test(p)) { + const data = scenario("manager_solution_assignment_zero_false_comment"); + data.expert = { ...data.expert, expertId: 91, userId: 91, fullName: names[0] }; + data.application.name = projects[0].name; + return data; + } + if (/\/solution-analytics\/solutions\/$/.test(p)) { + const source = scenario("manager_solution_list_awaiting").results[0]; + let rows = projects.map((pr, i) => ({ + ...source, + application: { kind: "solution", programId: 12, solutionId: 301 + i, name: pr.name }, + captain: { userId: pr.leader.id, fullName: pr.leader.name, avatar: null }, + caseName: pr.caseName, + assignmentsTotal: i ? 0 : 2, + assignmentsCompleted: i ? 0 : 1, + })); + if (q.get("search")) rows = rows.filter(r => r.application.name.includes(q.get("search"))); + if (q.get("case_name")) rows = rows.filter(r => r.caseName === q.get("case_name")); + return { ...paged(rows), submissionApplicable: true, mode: "distributed" }; + } + if (/\/solutions\/\d+\/$/.test(p)) return scenario("submitted_snapshot"); + if (/\/solution-allocation\/$/.test(p)) return scenario("solution_allocation_preview"); + if (/\/solutions\/\d+\/admin-changes\/$/.test(p)) return paged([]); + if (/\/management\/experts\/bulk\/$/.test(p)) return roster(); + if (/\/solution-evaluation\/experts\/\d+\/assignments\/$/.test(p)) + return scenario("solution_assignment_options"); + if (/\/solution-evaluation\/quota\/$/.test(p)) return scenario("solution_quota_preview"); + if (/\/solution-evaluation\/allocation\/$/.test(p)) + return scenario("solution_allocation_preview"); + if (/\/management\/solutions\/\d+\/history\/$/.test(p)) return paged([]); + if (/\/management\/solutions\/\d+\/case\/preview\/$/.test(p)) + return scenario("case_action_preview_ready"); + if (/\/management\/solutions\/archive\/preview\/$/.test(p)) + return scenario("archive_preview_submitted"); + if (/\/management\/solutions\/actions\/preview\/$/.test(p)) + return scenario("solution_action_preview_ready"); + if (/\/management\/solutions\/\d+\/members\/remove\/preview\/$/.test(p)) + return scenario("member_removal_preview_submitted"); + if (/\/management\/registrations\/remove\/preview\/$/.test(p)) + return scenario("registration_removal_preview_member_submitted"); + return baseline.response(p.replace(/\/programs\/12\//, "/programs/1/"), method, q); +} +module.exports = { response, scenario, roster }; diff --git a/docs/program-v2-ui-audit/gallery.html b/docs/program-v2-ui-audit/gallery.html new file mode 100644 index 000000000..4de6f04f3 --- /dev/null +++ b/docs/program-v2-ui-audit/gallery.html @@ -0,0 +1,40 @@ + + +PROCOLLAB — аудит программ V1/V2 + +

Управление программами PROCOLLAB: до / после

+

Настоящие снимки Angular с одинаковыми изолированными данными. «До» — исходный код 9ef90b58. Эталон V1 доступен ниже каждой пары.

+

Итог повторной приемки и ограничения · Первоначальная матрица · Проверка требований · Результаты проверок

+
+ +Соответствующий фрейм Figma
+
Интерфейс программы V2 до исправления
+

V2 — после

Оригинал PNG
Интерфейс программы V2 после исправления
+
Показать исходную реализацию V1 для сравнения

V1 — исходный эталон реализации

Оригинал PNG
Исходный интерфейс программы V1
Показать оригинальный фрейм Figma

Figma — исходный макет

Оригинал PNG
Оригинальный фрейм Figma; данные и историческая версия могут отличаться
+

Для длинных страниц прокручивайте каждый снимок. Масштаб 100% сохраняет размер пикселей. Названия сущностей и дополнительные действия V2 сохранены по контракту Solution.

+
Проверка текущей версииРезультат
Стандартная production-сборкаУспешно; исходные budgets и font inlining
Заполненные страницы, обе версии14 экранов × 5 ширин
Вложенные состояния и действияПроверены таблицы, формы, модальные окна, клавиатура
+

Снимки получены с изолированными ответами API. Реальный dev отвечает на публичные GET, но organizer endpoints без сессии возвращают 401. Сохранение/назначение/оценивание на сервере не проверены. Полный pixel match исторической Figma не заявляется; различия навигации, Mont, редактора сертификатов и Solution перечислены в отчете.

+ \ No newline at end of file diff --git a/docs/program-v2-ui-audit/integration-517/CODE_REVIEW.md b/docs/program-v2-ui-audit/integration-517/CODE_REVIEW.md new file mode 100644 index 000000000..ad745c359 --- /dev/null +++ b/docs/program-v2-ui-audit/integration-517/CODE_REVIEW.md @@ -0,0 +1,81 @@ + + +## Преемственность интерфейса, итог 03.11.2026 + +Весь набор вкладок и 7 разделов настроек V1/V2 сверен на 1440/390px, отдельные длинные/mobile состояния 360px. Действуют Mont и общие компоненты доведенного Project UI; VE01–VE06 не утверждались. Реальные повторные проверки 9c92/20ea выполнены, включая responsive coverage table, отступы и фильтр. Полная область и приватные доказательства перечислены в [VISUAL_EXCEPTIONS](VISUAL_EXCEPTIONS.md). + +Последний source/runtime `ffe677f5cefb4f2412f4b513954269ee1d846a10`:5 CSS-строк убирают разрывы слов в мобильных итогах оценивания и горизонтальную полосу tooltip. Обе версии 360px, нижняя подсказка 390px, Enter/Escape/outside проверены.13 существующих tests/2files, scoped stylelint/Prettier и штатный prod build PASS. Ранее 68/3 и 12/2 (5 пересекаются) не суммируются. + +Браузер восстановлен, draft25/приглашение 37/вступление завершены обычными интерфейсами; flag49=false. Итоговый вердикт NOT READY обусловлен серверными R9/R11 и явно ограниченными NOT TESTED, а не прежней остановкой браузера. + +### R8 — отправитель Project invitation, исправлен + +До исправления `utils/inviteToProjectMapper.ts` брал `invite.user` (recipient). Реальный pending 126 имел sender84/recipient59, а карточка называла 59 приглашающим. Файл до исправления совпадал с base: это существовавшая ошибка, не доказанная регрессия PR. В 8d36 mapper использует firstName/lastName `invite.sender` с русским fallback Пользователь. API, стили и роли не менялись. + +Пройдены 15 существующих тестов InviteRepository/GetMyInvites/AcceptInvite, ESLint, Prettier и обычный prod build с прежними budgets. Реальный reload pending 126 показал sender84; затем обычное принятие POST 200 и команда 403 из 84/59. Повторного письма ради ретеста не было. + +### R9 — P2, OPEN: дубль капитана в backend XLSX draft + +Повтор на реальном draft25 после вступления 341: UI2 участника, workbook Решения teamSize2, Команды 3 строки с userIds84/84/341. Ранее 21/84 давал две строки при размере 1. Frontend сохраняет готовый Blob, строк не создает. + +Доступный backend `SolutionAnalyticsContext.team()` явно добавляет капитана, затем все `solution.members.all()`, где captain уже существует. `solution_program_report.write_solutions()` считает размер через множество ID, но пишет все строки команды. Три релевантных файла совпадают с ранее сообщенным dev image cf48cd7; свежий image этого цикла не запрашивался. Доступный backend checkout не изменялся. + +Приватный `R9-backend-proposed.patch` добавляет исключение captain из membership-компрехеншена. Pure Python method regression на настоящем извлеченном методе: до 84/84/341, после 84/341; роль участника, отсутствие membership капитана и submitted snapshot сохраняются. Это не Django/DB suite и не исправление dev. Нужны отдельное backend изменение и реальный повтор. + +### R11 — P1, OPEN: аудитория сертификатов игнорирует Solution + +На ffe6 реальный GET49/management/certificates/200 вернул eligible_counts all5/submitted0 и draft.audience=submitted. В 49 сданы Solution7/19/20, но UI показывает 0 получателей. Выдача/отправка не запускались. + +Backend `services/certificates.py:recipients()` фильтрует участников через submitted PartnerProgramProject и Project Collaborator, без ветки Solution. Клиент отображает серверный счетчик. Нужен серверный расчет по сданным Solution с проверкой состава/snapshot и admin-интерфейса по backend AGENTS; подмена числа только в клиенте не исправляет аудиторию. Принадлежность R9/R11 к изменениям frontend PR не установлена. + +### Серверные отрицательные сценарии + +На 8d36 V1 grade/removal race:POST project_ids403/expert_ids[] получил 409 после оценки 201; свежий редактор защищает 64 checked/disabled, reload эксперта сохраняет 0/Нет/comment. Отдельно согласованный stale team remove59 после сдачи 403: DELETE 409 с русским сообщением, свежая команда сохраняет 84/59. V1 transport pool=[95] не добавил собственного 95 к 403 и дал shortage 2; общий preview отменен без apply. V2 другой кейс 95/21 дал added0/shortage 1; чужое 19 у обычного 84 вернуло 404. Disabled controls отдельно не выдаются за запрет подмененного grade POST. + +## Продолжение desktop: R7, 02.11.2026 + +На реальной 51 management200 подтвердил is_competitive=false, однако результаты показывали оцененность 0/0 и безбалльное оценивание, а analytics deadlines — активное оценивание. В `b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb` применимость существующего флага теперь скрывает summary/hint/status filter и evaluation deadline. Форматы/API не менялись, новый дизайн не вводился. Две регрессионные проверки упали до исправления; после 30 tests/2files, lint/format и стандартный prod build прошли. Реальный retest51 подтвердил отсутствие неприменимых текстов/контролов, с сохранением case filter и правильного noncompetitive empty state. Записей 50/51 нет. + +R2–R6 и реальные R1/assignment/version/grade conflicts описаны в README и MATRIX с точными SHA. Итог интеграции остается insufficient evidence до оставшихся сценариев; исторические утверждения об отсутствии auth/writes ниже относятся к прежнему облачному этапу. + +## Историческое code review облачного этапа + +Исходный head: `0a589f4cbacb100a186db05fb5044ed9a826107a`. Base: `9ef90b5824b857fe53d52c097e37b279b897d9bf`. Проверены TS-изменения и bindings затронутых flows относительно base, а не только CSS или прежний авторский отчет. Масштабный визуальный аудит не повторялся. + +## Найденный дефект R1 — MAJOR, исправлен + +[SolutionExpertsComponent.applyFilters](../../../projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.ts#L307) в `0a589f4c` всегда вызывал FormControl.setValue, даже при неизмененном значении. Подписки status/caseFilter сбрасывали выбор; status дополнительно сбрасывал страницу. В V1 [applyFilters](../../../projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.ts#L564) уже проверяет изменение значения. + +Воспроизведение: выбрать одно из двух решений одного кейса → открыть фильтры → применить тот же кейс. Выбор исчезает. Следующий [startAllocation](../../../projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.ts#L712) при пустом selection использует весь фильтр. Это может расширить область следующего preview; подтверждение остается отдельным шагом, но список целей уже иной. На реальном сервере ошибочные назначения не выполнялись. + +Два новых unit-сценария на исходном коде упали: выбранные эксперты 21 → 0 и выбранные solution IDs [302] → []. После минимального исправления setValue вызывается только при реальном изменении; tests проверяют сохранение страницы/выбора и preview ids=[302]. Реальное изменение фильтра по-прежнему очищает скрытый выбор. API и структура интерфейса не меняются. + +Исходные результаты: `filter-regression-before.json/.log`; финальные — `review-regression-tests.json/.log` в архиве интеграционного этапа. Это доказательство уровня кода/tests, не реальный organizer E2E. + +## Проверенные цепочки + +| Область | Что прослежено | Вывод code review | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Массовый выбор экспертов | ExpertRoster emits исходный ExpertRow; parents хранят Set ID. V1 selectedExpertRows и V2 bulk берут пересечение с filtered. Checkbox/link/menu не дают двойного toggle. | Отбор выполняется по expert ID и invitation ID, userId не подставляется. Выбор всех относится ко всему фильтру; 20 строк ограничивают только отображение. | +| Фильтрация | Отмена modal, draft/apply/reset, реальные изменения status/case/search, page reset. V1 сохраняет существующие project/expert/case фильтры. | R1 исправлен. Состояния draft и примененного фильтра разделены; API поиска заявок и query/debounce не менялись. | +| Массовые операции | EvaluationUseCase → EvaluationHttpAdapter → experts/bulk. Проверены mixed joined/pending, revision, expertIds, invitationIds и case scope. | Повтор/отзыв смешанного выбора блокируется. Добавлен тест точного payload для массового изменения кейсов. Server-side проверка атомарности без organizer сессии остается открытой. | +| Распределение V1 | Прежние selectedProjects, single replace / bulk add, revision, saved-score protection и shortage. | Обработчики не переписаны. Общий roster не участвует в вычислении Project assignments. | +| Распределение V2 | distributionSelection → request.ids → SolutionAllocation.preview → solutionIds/revision → отдельный apply по planToken. | Project IDs не вводятся. Shortage блокирует confirm; выбор нового эксперта сбрасывает старый preview. Обработчики preview/apply и серверные ограничения не менялись; исправлен только R1. | +| Ручные назначения V2 | options.expertId/revision, protected/eligible/ownTeam/submitted, selectedSolutions → saveApi. | Сохраненная оценка не снимается через UI; refresh после отказа перечитывает revision, сохраняет валидный выбор и не запускает apply сам. Нужна проверка сервера в гонке. | +| Выгрузка экспертов | Parents передают filtered, не visible/selected; exportExperts сохраняет числовые/null значения. | Состав выгрузки не ограничен страницей/чекбоксами. V2 использует Solution counters. Подробные подписи статусов заменили более общие подписи как представление. Добавлен unit-сценарий 21 строки. | +| Выгрузки результатов/аналитики | Неизмененные adapters/use-cases, case/field/search filters, cancellation, отсутствие limit/offset в export. | Новых handler/payload изменений нет. Unit-тест сохраняет фильтры; реальные файлы и серверная полнота требуют live приемки. | +| Аналитика | Общая progress-сводка получает только счетчики/события. V1 fetch прежний, V2 Solution analytics/assignments/scores. | Проценты округляются вниз как раньше; pending/completed, 0/false/comment сохраняются. Assignment response по C00 содержит полный массив results, а не новый client-side Project pager. | +| Настройки / заявки | Binding форм и dialogs → прежние handlers, Solution versions/snapshots, Project links, ProgramUpdated. | API/model/route/guard файлы PR не менял. Наличие неизмененного handler не доказывает серверную сохранность без round trip. | +| Права и scope | Busy/disabled, выбор ID, прямые routes и API; сверены unchanged adapters/domain/guards с base. | UI не подменяет server-side permissions. Анонимные реальные GET отклонены 401; organizer/expert/participant и foreign-object authorization не проверены. | + +Иных конкретно воспроизведенных actionable findings в изменениях PR не найдено. Это ограниченный результат review: нельзя утверждать отсутствие серверных проблем или полную неизменность поведения по одним unit-тестам. Локальный checkout API не содержит весь актуальный Solution-management контур deployed dev; его старые Application/Submission-модели не использованы вместо C00 и реального dev. Backend не изменялся. + +## Verification и ограничения + +- Checked by source: перечисленные цепочки, base/head diff, handlers, ID, payload, cancellation и revision. Новая product-правка — только guard неизмененного фильтра V2. +- Checked by tests: 75 passed в шести релевантных файлах, включая четыре добавленных сценария. V1 41, V2 14, allocation 4, export 2, progress 9, Solution analytics 5. Это unit-тесты с doubles, не интеграция dev. Полный 3089-набор предыдущего этапа повторно не запускался. +- Checked by build: обычный `NG_BUILD_MAX_WORKERS=2 npm run build:prod`, exit 0 после R1; budgets/config не менялись. Адресные ESLint/Prettier и Solution type contract проверяются отдельно в results.json. +- Checked in browser with real dev: изолированный точный baseline, обычная login-форма, публичные Project/Solution GET, anonymous denial и redirect. Нет cookie injection, подмен ответов API, записи credentials, HAR или storageState. +- Not checked: все authenticated organizer/expert/participant writes и отрицательные сценарии от этих ролей; реальное сохранение, назначение/снятие, оценивание/пересчет, экспорт содержимого, ревизионные гонки и почтовый транспорт. Причина — нет авторизованной сессии; предусмотрен MANUAL.md. + +Вердикт code review после R1: исправление проверено локально; полноценная интеграционная приемка и готовность к merge — **insufficient evidence**. Полный runtime verdict — README и MATRIX.csv, без замены NOT TESTED на PASS. diff --git a/docs/program-v2-ui-audit/integration-517/MANUAL.md b/docs/program-v2-ui-audit/integration-517/MANUAL.md new file mode 100644 index 000000000..af5537c83 --- /dev/null +++ b/docs/program-v2-ui-audit/integration-517/MANUAL.md @@ -0,0 +1,95 @@ + + +# Ручная интеграционная приемка PR #517 + +Это проверка кода PR с настоящим dev API до merge. Открытая общая dev-версия приложения не доказывает работу PR. Моки из предыдущего визуального этапа здесь не применяются. Пароли и токены вводятся только в обычной форме приложения в браузере владельца; передавать их в чат или запускать команды с ними не требуется. + +## 1. Запустить изолированный код PR + +В Linux/macOS/WSL, из существующего Angular checkout, без переключения рабочей ветки: + +```sh +git fetch origin refs/pull/517/head:refs/remotes/origin/pr517-acceptance +git worktree add --detach ../procollab-pr517-acceptance origin/pr517-acceptance +cd ../procollab-pr517-acceptance +PR517_EXPECTED_SHA=$(git rev-parse HEAD) +git show --no-patch --format='%H %s' +npm ci +bash docs/program-v2-ui-audit/integration-517/start-preview.sh "$PR517_EXPECTED_SHA" +``` + +Сверить SHA с вкладкой Commits PR #517. Если каталог worktree уже существует, использовать новый пустой путь; не стирать прежние изменения. Скрипт прекращает запуск при несовпадении SHA, измененных исходниках или неожиданной конфигурации API. Сервер слушает только `127.0.0.1:4317`, production-конфигурация не используется. Sentry в development выключен существующим `sentry.config.ts`. Не нужен reverse proxy, отключение CORS, перенос cookies с dev-сайта или изменение общего dev. + +В Cloud точный `0a589f4c` уже проверен таким изолированным запуском: обычная форма входа, реальные GET 200 для V1/V2, GET управления 401 и переход на вход без сессии. После исправления R1 проверять актуальный head PR, а не оставаться на `0a589f4c`. Новый код и проверенные хеши описаны в README этого каталога и итоговом сообщении. + +Остановить preview можно Ctrl+C. Изменения данных dev, сделанные через preview, настоящие: локальный frontend не создает копию базы. + +## 2. Войти обычным способом и подготовить тестовый набор + +1. Создать отдельные гостевые профили браузера для организатора, экспертов и участников. Два окна одного профиля, включая два incognito окна, не обеспечивают разные сессии. Для конфликта достаточно двух вкладок одного organizer-профиля. +2. Открыть `http://127.0.0.1:4317/auth/login` и войти существующим тестовым организатором. Пароль не сохранять в отчет, консоль, скрипты или снимки. Не экспортировать HAR, storageState, cookies, request headers, ответ `/api/token/` или приглашения с секретными ссылками. В матрице достаточно метода, пути без секретных query-параметров, статуса и видимого результата. +3. Открыть локальные ссылки: + - V1: `http://127.0.0.1:4317/office/program/39/management/settings?tab=basic`. + - V2: `http://127.0.0.1:4317/office/program/49/management/settings?tab=basic`. + - Дополнительные V2: 50 — хакатон, 51 — программа с пометкой «Неконкурсная». +4. В реальном ответе `/programs/39/` должен быть `solution_storage_mode: legacy_project`, у 49/50/51 — `solution`. Эти значения уже подтверждены публичными GET. Названия: №39 — `[ТЕСТ] Кейс-чемпионат «Город будущего» — 60 проектов`, №49 — `[QA 26.10] Кейс-чемпионат — решения`, №50 — `[QA 26.10] Хакатон — решения`, №51 — `[QA 26.10] Неконкурсная программа — решения`. Количество работ в названии №39 не является проверенным текущим счетчиком. +5. Проверить, что учетная запись имеет права организатора именно этих программ и данные принадлежат тестовому набору. До входа состав/владение недоступны; названия подтверждают только кандидатов. При отсутствии доступа не обходить права и не переключать режим существующей программы. В матрице отметить блокирующий доступ. +6. Для записей выделить внутри тестовых программ данные с префиксом `PR517-<дата>` и зафиксировать их обычные ID. Нужны: два кейса A/B; две сданные работы одного кейса и еще одна другого; хотя бы один черновик; эксперт E1 для всех кейсов, E2 для A, E3 — участник собственной команды; число оценщиков 2. Для массовой проверки отфильтровать именно этот префикс. Не выбирать все существующие работы программы. +7. Если такой набор отсутствует, подготовить его через обычные UI: для V1 тестовые участники создают Project в «Мои проекты» и сдают его в V1; для V2 капитан создает заявку на странице программы и заполняет/сдает самостоятельное Solution. Участников команды добавлять только из существующих тестовых аккаунтов. Не конвертировать Solution в Project, не переключать storage mode и не менять данные других тестировщиков. Записать реальные ID после создания, не подставлять ID из fixtures. +8. Приглашения/повторы запускать только на заранее настроенную тестовую почтовую заглушку или подтвержденные тестовые адреса под контролем приемки. По публичному API настройки транспорта не видны. Если заглушка/тестовый адрес не подтверждены, сценарий приглашения остается NOT TESTED. Напоминания участникам и выдачу сертификатов не запускать. + +Сроки №39/49 могут относиться к разным этапам. Перед оцениванием убедиться, что сервер разрешает его сейчас. Настройку этапа менять только в выделенном тестовом наборе, если это не влияет на других пользователей. Запрет из-за календаря — бизнес-ограничение, а не автоматически дефект PR. Если нельзя безопасно подготовить этап, соответствующий сценарий остается NOT TESTED. + +Поля программы, критерии, календарь и quota действуют на всю программу. Префикс работ не изолирует эти параметры. Перед такими записями подтвердить, что программа выделена приемке и все ее участники тестовые. Если №39/49 используются параллельно или содержат реальные контакты, ограничиться чтением; глобальные изменения оставить NOT TESTED до получения отдельной тестовой программы через штатные средства администратора. Не делать SQL-копии, миграции или изменение storage mode ради теста. + +## 3. Сохранение настроек — отдельно V1 и V2 + +1. В «Основное» записать исходное описание. Добавить маркер `PR517-<дата>`, сохранить, дождаться успешного ответа и перезагрузить страницу. Маркер должен остаться. Открыть ту же программу в другой вкладке: должно быть серверное значение, а не только состояние первой формы. +2. Так же проверить обратимое поле в «Сроки», тестовый материал, тестовый критерий и поле анкеты регистрации; после каждого сохранения выполнить reload. Существующие критерии/снимки с оценками не удалять и блокировки не обходить. +3. В V1 «Кейсы и поля» изменять только тестовую Project-форму. В V2 проверить форму Solution и ее revision/snapshot: до сдачи изменение разрешается сервером, после сдачи отображается сохраненный snapshot. Старый Project не должен появляться как обязательная сущность V2. +4. «Организаторы»: чтение, поиск, меню и сохранение роли проверить только на дополнительном тестовом организаторе, не удаляя последнего/текущего организатора. Если операция вызывает письмо и тестовый транспорт не подтвержден, не выполнять ее. +5. Вернуть обратимые настройки и повторным чтением подтвердить восстановление. Не удалять поля/критерии, для которых сервер уже запрещает удаление после ответов/оценок. Отдельно отметить неизменяемые тестовые записи. + +## 4. Эксперты, фильтры, массовый выбор и выгрузка + +1. Открыть локально `/office/program/39/management/experts`, затем аналогично 49. Проверить фактические имена, статусы и ID; запомнить исходные счетчики. +2. Выполнить поиск по части имени и email в разном регистре, применить статус, сбросить, проверить no-results. Ввести фильтр в модальном окне и закрыть без применения: таблица не должна измениться. +3. Выбрать несколько экспертов. Открыть «Фильтры» и применить без изменений: выбор и текущая страница сохраняются. Повторить с реальным изменением статуса: выбор сбрасывается, первая страница активна. Это ручная приемка исправления R1. +4. Если roster больше 20: выбрать всех по фильтру и перейти на вторую страницу. Выбор должен охватывать весь фильтр, а не только первую страницу. На меньшем наборе отметить проверку перехода между страницами NOT TESTED; нельзя считать ее пройденной по одному экрану. +5. Проверить, что click по checkbox не переключает его дважды, ссылка профиля и меню не меняют выбор. Смешать принятого и ожидающего экспертов: повторная отправка/отзыв для смешанного выбора не должны выполняться. Массовое изменение кейсов сохраняет только выбранные строки; проверить reload и неизменность остальных. +6. При подтвержденной тестовой почте пригласить только тестового эксперта, выбрать кейсы, проверить pending и корректность кейсов после reload. В отдельном профиле эксперта войти обычным способом, открыть `/office/evaluation?tab=invitations` и принять собственное приглашение через кабинет. Это не требует копирования секретной ссылки из письма. Обновить organizer roster: статус joined и корректные права. Повтор/отзыв проверять только на отдельном ожидающем тестовом приглашении. +7. Установить фильтр, оставить выбранной одну строку, выгрузить экспертов. Excel должен содержать весь фильтр и все его страницы, а не только отмеченную строку/видимую страницу; приглашения, статусы, кейсы и числовые счетчики должны совпасть с серверными данными. В V2 это счетчики назначений Solution. Файл содержит тестовые данные; не прикладывать реальные email к публичному PR. + +## 5. Распределение и снятие назначений + +1. На вкладке «Распределение экспертов» отфильтровать работы с тестовым префиксом. Выбрать одну из двух работ одного кейса, открыть/применить тот же фильтр без изменений и нажать распределение. Preview должен включать только выбранную работу, соседняя работа того же кейса не должна добавиться. Отменить и убедиться reload, что preview ничего не записал. +2. В V1 выбрать тестовые Project/ProgramProject через существующее распределение. Проверить одиночную замену и массовое добавление только для выбранных связей; не путать programLinkId и projectId. В V2 preview/apply должен получать solutionIds, без projectIds/programLinkId. UI уже передает эти сущности отдельно; реальную запись подтвердить reload. +3. Назначить E1/E2 в пределах кейсов и лимита 2, проверить итоговый preview, подтвердить один раз. Перезагрузить roster, распределение и кабинет экспертов: везде должны быть одни и те же назначения; непомеченные тестовые работы не меняются. +4. Снять неоцененное назначение через существующий редактор назначений эксперта и проверить reload. После сохранения оценки повторить попытку снятия — сохраненная оценка и защищенное назначение остаются. +5. Попробовать пул, которому не хватает подходящих экспертов, эксперта другого кейса и эксперта собственной команды. Preview должен сообщить нехватку/исключение; недопустимые назначения не должны появиться, confirm при shortage недоступен. Увеличение/уменьшение quota требует актуального preview, старый preview после изменения числа не применяется. + +## 6. Оценивание, результаты, аналитика и заявки + +1. В отдельных профилях E1/E2 открыть `/office/evaluation` и перейти к назначенной работе. V1 использует `/office/program/39/projects-rating/`, V2 — `/office/program/49/solutions//evaluation`. Брать ID из реальных ссылок, а не вручную из соседней сущности. +2. До записи зафиксировать критерии, шкалы, число назначений и правило результата через «Как считается балл». Для двух оценок выбрать разные допустимые значения; ожидаемый итог рассчитывать по отображаемому правилу, не предполагать простое среднее для всех конфигураций. +3. Сохранить разрешенные ноль, ответ «Нет» и текстовый комментарий в соответствующих критериях, reload. У организатора открыть детализацию назначения и убедиться, что 0/false не потерялись и эксперт/работа правильные. +4. После первой оценки при quota=2 работа остается предварительной/ожидающей; после второй результат и счетчики пересчитываются по правилам программы. Изменить разрешенную оценку E1, проверить повторное чтение результатов и аналитики. При серверной блокировке изменения не обходить ее и записать бизнес-причину. +5. Аналитика: сверить all/completed/pending с реальными назначениями, раскрыть списки и оценки, проверить фильтры и локальное обновление. V2 не должен загружать Project assignments для получения счетчиков Solution. Сравнить выгрузки результатов/аналитики с тем же фильтром и отсутствием ограничения первой страницей. Напоминания не подтверждать. +6. Заявки: V1 открывает существующий Project; V2 открывает отдельное Solution, его форму, команду и историю. В тестовом черновике изменить поле/состав разрешенным способом, сохранить и reload; сдача фиксирует snapshot и соответствующие ограничения. Проверить, что действия других программ и чужой команды недоступны. Архив/удаление не применять к нетестовым записям. +7. На №50 проверить отображение хакатона; на №51 сначала через настройки подтвердить реальную неконкурсность, затем проверить, что неприменимые оценки/назначения не отображаются как нулевые результаты и запрещенные controls недоступны. Название программы само по себе не доказывает этот режим. + +## 7. Конфликты, ограничения и права + +1. Ревизия настроек: открыть редактируемую форму в двух вкладках A/B. В B сохранить новое тестовое значение; в A сохранить старую версию. Для форм с optimistic revision ожидается конфликт 409 без перезаписи B и возможность обновить данные. Не требовать 409 от полей, где контракт использует обычный PATCH без revision; отдельно записать фактическую семантику. +2. Назначения: открыть назначения/preview в A; в B изменить те же тестовые назначения или quota; подтвердить старое в A. Сервер должен отклонить устаревшую revision/план. Перечитать, проверить сохранение валидного локального выбора и отсутствие автоматического повторного apply. Сначала прочитать серверный итог, потом решать, повторять ли действие. +3. Защита оценки: открыть редактор назначения в A, в профиле эксперта B сохранить оценку той же работы, затем в A попытаться снять прежнее неоцененное назначение. Сервер повторно проверяет актуальное состояние и не удаляет сохраненную оценку. Допустим предусмотренный контрактом конфликт/отказ; успешное уничтожение оценки — FAIL. +4. Solution: два капитанских окна одного черновика; B сохраняет/сдает, A пытается сохранить старую expected_version/form revision. Ожидается конфликт без потери B и без автоматического повторения submit. Отдельно проверить устаревшую форму организатора. +5. В профиле обычного участника и эксперта открыть organizer management URL напрямую. Должен быть отказ сервера 403/404 и отсутствие закрытых данных. Анонимный профиль должен перейти на вход, а прямой API GET — 401. Последний сценарий уже выполнен; он не подтверждает проверки авторизованных ролей. +6. Участник одной тестовой команды открывает ссылку другой команды/программы; эксперт пытается открыть неназначенную и собственную работу. Серверные permissions определяют отказ или read-only в зависимости от режима; запись запрещенного результата не происходит. Видимость disabled кнопки сама по себе не доказывает server-side защиту. +7. Не подменять ответы API, версии, роли и permissions через Playwright routes/DevTools overrides, не отключать защиту браузера. Если реальный отказ записи невозможно воспроизвести через штатные действия, пометить прямую server-side запись NOT TESTED, а не считать UI-блокировку достаточным доказательством. + +## 8. Зафиксировать результат + +Заполнять [MATRIX.csv](MATRIX.csv) или таблицу README после каждого сценария отдельно для V1/V2: точный SHA PR, тестовые ID, ожидаемый результат, статус/видимый факт после reload и дефект. PASS ставить только после выполнения; NOT TESTED — при отсутствии роли, данных, почтовой заглушки или подходящего этапа. Не загружать секреты/реальные персональные данные в Git. + +Вернуть обратимые настройки и неоцененные назначения к исходным значениям. Сохраненные оценки и историю не удалять обходными запросами ради уборки; оставить тестовый префикс и перечень созданных тестовых ID. Не выполнять массовую выдачу сертификатов, рассылки, merge или deploy. diff --git a/docs/program-v2-ui-audit/integration-517/MATRIX.csv b/docs/program-v2-ui-audit/integration-517/MATRIX.csv new file mode 100644 index 000000000..a58c06c20 --- /dev/null +++ b/docs/program-v2-ui-audit/integration-517/MATRIX.csv @@ -0,0 +1,73 @@ +ID,Сценарий,Версия программы,Ожидаемый результат,Фактический результат,Статус,Найденные дефекты,Проверенный SHA +P01,Изолированный точный код и dev CORS,V1/V2,Локальный PR; реальные запросы в dev; общая dev-версия не меняется,Чистый worktree d378a0f3; launcher проверил SHA и development/dev API; обычный вход; реальные browser fetch и запись dev; без моков,PASS,—,d378a0f38c5542d6c005cb0be98cd00c8c66a58f +P02,Публичный режим программы,V1 №39,legacy_project,Повтор на d378a0f3: browser GET /programs/39/ 200; legacy_project,PASS,—,d378a0f38c5542d6c005cb0be98cd00c8c66a58f +P03,Публичный режим программ,V2 №49/50/51,solution,Повтор на d378a0f3: browser GET /programs/49/50/51/ 200; solution,PASS,—,d378a0f38c5542d6c005cb0be98cd00c8c66a58f +P04,Анонимный доступ к управлению,V1 №39,"401, закрытые данные не выдаются",d378a0f3: реальные anonymous browser GET management и experts 401; локальный management route переводит на обычный вход,PASS,—,d378a0f38c5542d6c005cb0be98cd00c8c66a58f +P05,Анонимный доступ к управлению,V2 №49,"401, закрытые данные не выдаются",d378a0f3: реальные anonymous browser GET management и experts 401; локальный management route переводит на обычный вход,PASS,—,d378a0f38c5542d6c005cb0be98cd00c8c66a58f +S11,"Настройки: чтение, сохранение всех разделов и повторное чтение",V1 №39,Сохраняются разрешенные изменения; reload возвращает серверное значение,"0152e19c: все 7 разделов прочитаны; описание и конец+1 мин сохранены/reload/возвращены; материал create/edit/delete; optional Project field и draft анкеты сохранены/reload/удалены с возвратом. Критерии уже зафиксированы оценками; организаторы проверены чтением/поиском, их доступ не изменялся frontend. Полная запись всех 7 разделов не заявлена",NOT TESTED,—, +S21,"Приглашение, принятие, повтор и отзыв эксперта",V1 №39,Тестовый invitation lifecycle и кейсы сохраняются,"Два тестовых приглашения созданы ранее; неактивное отозвано. На 1054ed6c user 59 принял свое приглашение 39 через подтверждение, POST 200; после reload pending исчез. Повторная отправка еще не выполнялась: первоначальный лимит 4 писем уже использован",NOT TESTED,—, +S31,"Поиск, фильтры, массовый выбор и смена страницы",V1 №39,"Draft/отмена; весь фильтр; no-op сохраняет выбор, изменение сбрасывает",8d36: выбраны 64 на странице 1 и 95 на странице 2; no-op фильтр сохраняет 2 и страницу 21–27. Выбор всего фильтра дает 27; смена статуса на joined очищает выбор и дает страницу 1–20 из 26. Реальные XLSX:27 со страницы 2 и 26 по фильтру; поиск/no-results проверены ранее на 1d403fe0.,PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +S41,"Назначение, снятие и защита сохраненной оценки",V1 №39,Только выбранные работы; сохраненная оценка защищена; reload подтверждает состав,"8d36: POST project-experts только project_ids403/expert_ids64, затем 404/[64,95], оба 200. Неоцененное 95->404 снято и возвращено 200, промежуточное чтение подтверждает. Старый редактор 403 после оценки отправил expert_ids[] и получил 409; свежий редактор 64 checked/disabled, оценка после reload сохранена.",PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +S51,Оценивание и пересчет результатов,V1 №39,Принятые оценки и quota дают результат по правилу программы,"8d36:403/64 сохранены 0,0,0/Нет/comment POST 201;404/64 сумма 10/Нет и 404/95 сумма 20/Да POST 201x2, reload. При quota 3 результат 404 предварительно 15; quota 2 дает ready 15 и место 6, ready всей программы 20->25. Возврат 3: preview 25->20, PUT 200 и reload3. Исходные оценки не изменялись.",PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +S61,"Аналитика, все/completed/pending и выгрузки",V1 №39,Счетчики и файлы совпадают с данными/фильтром всех страниц,"8d36: аналитика 123 участников/62 проекта/50 сдано, assignments75 completed+48 pending=123. Списки содержат 75/48/123 строк; детализация 403 показывает 0/Нет/comment. XLSX результатов filtered2/all 50, оценки 3/all 123; эксперты 27/26. Полный analytics XLSX: участники 123, проекты 62, эксперты 26, назначения 123; исходные данные не публикуются. Excel runtime recalculation не запускался.",PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +S71,"Работа с Project либо Solution, составом и snapshot",V1 №39,V1 сохраняет Project; V2 самостоятельное Solution и права команды,"8d36:403 создан капитаном 84 через program39 CTA POST 201, link 205; обязательные main/additional поля save/reload, основной save сохраняет кейс.404 создан штатно глобально, новый link 206 и profile337->404 подготовлены через разрешенную Django admin как fixture. Обе работы сданы UI PUT 200;126 принят POST 200, команда 403=[84,59]. Старое исключение 59 после сдачи DELETE 409, свежий просмотр сохраняет обоих. Project остается V1.",PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +S12,"Настройки: чтение, сохранение всех разделов и повторное чтение",V2 №49,Сохраняются разрешенные изменения; reload возвращает серверное значение,0152e19c: описание/material/form/draft анкеты round trips и восстановление выполнены. Календарь исправлен после 409/reopen confirmation и оставлен по отдельному разрешению; числовой 0-10 и boolean критерии добавлены. Организаторы проверены чтением/поиском; полная запись всех 7 разделов не заявлена,NOT TESTED,—, +S22,"Приглашение, принятие, повтор и отзыв эксперта",V2 №49,Тестовый invitation lifecycle и кейсы сохраняются,"На 1054ed6c user 59 принял приглашение 49, POST 200; organizer roster показывает joined и только Город. ExpertId95 отличается от userId59/invitationId7. Неактивное приглашение 6 ранее отозвано. Повтор еще не проверен",NOT TESTED,—, +S32,"Поиск, фильтры, массовый выбор и смена страницы",V2 №49,"Draft/отмена; весь фильтр; no-op сохраняет выбор, изменение сбрасывает","b6ab: live R1 — реальное изменение кейса сбрасывает выбор; no-op сохраняет 19; POST solution_ids[19]/expert_ids[64,95],20 не входит; cancel без записей.8d36 фильтр not_submitted показывает только 21, экспорт содержит только 21. Серверной второй страницы V2 нет: в 49 всего 4 работы; это ограничение данных, а не PASS пагинации.",NOT TESTED,R1 исправлен и live retest пройден,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb; 8d36f4a833b9c664bea52c4806afe3484620f313 +S42,"Назначение, снятие и защита сохраненной оценки",V2 №49,Только выбранные работы; сохраненная оценка защищена; reload подтверждает состав,"b6ab5864: назначение 64->19 PUT 200; старый allocation/apply POST 409; новый preview добавляет 1 и reload19=[64,95],20=0,7=0. Неоцененное 95->19 снято PUT[]200, reload и возвращено PUT[19]200. Гонка снятия 64 после оценки: PUT[]409, после refresh19 checked/disabled, оценка сохранена. Дополнительно 64->20 PUT[19,20]200 сохраняет защищенное 19",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +S52,Оценивание и пересчет результатов,V2 №49,Принятые оценки и quota дают результат по правилу программы,"b6ab5864:19/64 POST 200 с 0/Нет/комментарием +reload;19/95 POST 200 с 6/Да +reload. Результат: предварительно 0 при 1/2 ->3 при 2/2; изменение 64 до 2 ->4. У 20 одна оценка 4; quota 2 дает partial, восстановление 1 дает ready. Оба решения имеют место 1/балл 4; все 3 оценки сохранены",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +S62,"Аналитика, все/completed/pending и выгрузки",V2 №49,Счетчики и файлы совпадают с данными/фильтром всех страниц,"b6ab: quota 2/1 и 3 сохраненные оценки сверены.8d36: после 21 Education сдано 4/оценено 2; временное 64->21 дает 3 completed+1 pending=4,75%. Модальные списки 3/1/4 и 14 листов полного analytics XLSX совпадают: 4 решения/4 assignments/9 строк критериев оценок. Временное 21 снято PUT[19,20]200, защищенные 19/20 сохранены. Дефект draft team XLSX вынесен X14.",PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +S72,"Работа с Project либо Solution, составом и snapshot",V2 №49,V1 сохраняет Project; V2 самостоятельное Solution и права команды,"Ранее 19/20: штатное создание, 0/Нет, сдача и frozen snapshot. На1e2f создан новый draft25 капитаном84, PATCH200/reload0/false. Одно разрешенное приглашение37 получателю341: POST201 и обычное принятие POST200. Reload обоих аккаунтов:84+341, роль PR517 QA участник, pending0. Наffe6 участнику can_edit/manage_team/invite/submit=false; can_leave=true. Draft25 не сдан ради воспроизведения R9.",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb; 1e2fb68d361896aba1a632f759568d4dac70e164; ffe677f5cefb4f2412f4b513954269ee1d846a10 +N01,Конфликт изменений формы/решения,V1/V2,Revision-конфликт без перезаписи чужого изменения; нет auto-retry записи,"0152: форма Solution B200/A409, refetch сохраняет локальный A и сервер B без auto-write. b6ab: само решение 19 B PATCH 200/A PATCH 409; после сдачи staleA409. Конкурирующие записи формы Project V1 не выполнялись; защита сданной команды V1 отдельно N16.",NOT TESTED,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +N02,Устаревший allocation/назначение и гонка с оценкой,V1/V2,Старый план отклонен; актуальная оценка не удаляется,b6ab: V2 старый allocation/apply POST 409 после B manual assignment; новый preview сохраняет назначение. V2 grade/removal race409.8d36 V1 grade/removal race POST 403/[]409 и свежая защита checked/disabled. Автоматический apply V1 не выполнялся: штатный preview относится ко всей программе и включал прежние работы; разрешение охватывает назначения новых работ.,NOT TESTED,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb; 8d36f4a833b9c664bea52c4806afe3484620f313 +N03,"Другой кейс, своя команда, shortage и quota",V1/V2,Недопустимые назначения не появляются; shortage блокирует confirm,"b6ab V2 собственная команда 95/20: preview added0/shortage 2.8d36 V2 другой кейс 95/21: POST 200 added0/shortage 1, confirm disabled. V1 транспортный pool=[95] в реальном POST preview:403 сохраняет только 64, shortage 2;404 сохраняет 64/95, shortage 1; confirm disabled, общий preview отменен. Квоты 39 и 49 возвращены 3/1; сохраненные оценки не удалялись.",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb; 8d36f4a833b9c664bea52c4806afe3484620f313 +N04,Роли и чужие объекты,V1/V2,Авторизованные участник/эксперт не получают organizer права; object scope сохраняется,"Анонимные management401 на d378; эксперт 59 management39/49=403 на 1054; pure participant 84 management39/49=403 на b6ab.8d36 чужое Solution 19 у 84 GET 404 и русское Заявка не найдена, без save. Собственная V1-команда 403 у эксперта 95 недоступна для оценки в UI; V2 own20 GET 200 read-only. Прямой forbidden grade POST не отправляли, серверный write guard чужого объекта не заявлен. Финальный pure member341 наffe6: management49 GET403, чужое19 GET404, собственное25 GET200 с readonly permissions. Прямые forbidden grade writes не выполнялись.",NOT TESTED,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb; 8d36f4a833b9c664bea52c4806afe3484620f313 +N05,Хакатон и неконкурсные ограничения,V2 №50/51,Режим подтвержден настройками; неприменимые действия/метрики корректны,"1054ed6c:50 management200 solution/is_competitive=true, template Хакатон.51 management200 solution/is_competitive=false. R7: было Оценено 0 из 0/безбалльное оценивание/активный этап оценивания; исправлено b6ab и live retest51 пройден, фильтр оценки скрыт. Серверные запрещенные записи 50/51 не выполнялись Наffe6 повторены чтением50 analytics(open) и51 applications/filter/analytics/calendar: неприменимые аналитические метрики скрыты, ошибки календаря51 русские. Противоречивый fixture51 не менялся; запись/оценивание50/51 не выполнялись.",NOT TESTED,R7 исправлен; полный отрицательный сценарий не завершен, +X01,Письма участникам и массовая выдача сертификатов,V1/V2,Без отдельного разрешения не выполняются,Не запускались; изменения получателей/выдачи отсутствуют,NOT TESTED,—, +A01,Безопасность исходных адресов участников и экспертов,V1 №39,Проверить состав по серверу и файлам; название не служит доказательством,Исходные 120 участников и 24 эксперта: уникальные синтетические dev-demo адреса @example.invalid; новые адреса отдельно разрешены пользователем,PASS,—,1d403fe0a1d4f663071d1b6c3deb2205d04645d8 +A02,Выгрузка экспертов за пределами текущей страницы и выбора,V1 №39,Весь фильтр независимо от страницы 2 и одной выбранной строки,24 уникальные строки; числовые счетчики 120/72/48; fingerprint00576c77 совпал с API,PASS,—,1d403fe0a1d4f663071d1b6c3deb2205d04645d8 +A03,Фильтрованная выгрузка экспертов,V1 №39,Учитывает поиск без учета регистра и не ограничена выбором одной строки,9 уникальных строк; счетчики 44/29/15; fingerprint84224e33 совпал с фильтрованным API,PASS,—,1d403fe0a1d4f663071d1b6c3deb2205d04645d8 +A04,Выгрузка всех участников,V1 №39,Все 120 участников независимо от 20 видимых строк,Получено 120 уникальных синтетических адресов; число совпадает с серверным total,PASS,—,1d403fe0a1d4f663071d1b6c3deb2205d04645d8 +A05,Отзыв неподходящего выделенного приглашения,V1 №39,Приглашение 8 становится revoked и сохраняется после reload; нового письма нет,HTTP 204; после reload строка показывает Приглашение отозвано,PASS,—,d378a0f38c5542d6c005cb0be98cd00c8c66a58f +L10,Русские сообщения ошибок API,V1/V2 и вход/приглашения,Английский ответ API не показывается пользователю сырым текстом,8d36: обычная анонимная reset-email форма с несуществующим адресом example.invalid получила реальный POST /auth/reset_password/ HTTP 400 и английский email error unknown account. UI показывает только русский fallback: Не удалось отправить письмо. Проверьте адрес электронной почты. Также реальные 403/404/409 в проверенных program/team flows русские. Общеплатформенная полнота всех возможных ошибок не заявлена.,PASS,R2: реальный отрицательный retest пройден,8d36f4a833b9c664bea52c4806afe3484620f313 +L11,Язык HTML-документа,V1/V2,Русскоязычный интерфейс объявляет lang=ru,b221ce4c: реальная локальная форма сброса объявляет lang=ru; проверено браузером,PASS,R3 исправлен и повторно проверен,b221ce4cc98a61cd901a198fc89066097c2a7960 +L12,Форма нового пароля без ссылки,Вход,Русская ошибка ссылки; отказ не назван неправильным логином/паролем,На b221ce4c без токена показано: Ссылка восстановления недействительна или истекла. Запросите новую ссылку.,PASS,R4: неверное общее сообщение исправлено,b221ce4cc98a61cd901a198fc89066097c2a7960 +L13,Реальный сброс пароля Play station,Вход,Установить пароль по действующей ссылке и выполнить обычный вход,"Пользователь сообщил о выполненном сбросе. Обычный вход Play station подтвержден в E2 и отдельном капитанском контексте 59; фактический ответ reset-confirm не получен, причина исходного отказа не установлена. Синтетический 404 относится только к отдельной диагностике",NOT TESTED,R4 исправлен в UI; причина пользовательского отказа еще не установлена,b221ce4cc98a61cd901a198fc89066097c2a7960 +V01,Сверка интерфейса с действующим V1,V1 №39 / V2 №49,Общие компоненты и стиль V1; отклонения показаны отдельно,"Завершена сверка всех7 вкладок и7 разделов настроек V1/V2:1440/390px, выбранные длинные состояния360px. Mont, тонкие кнопки, общий поиск/фильтры, экспорт иконкой, белые панели выбора, центрированные диалоги. Просмотрены длинные части, брендирование, формы/preview, защищенные назначения, раскрытые оценки, команда/история/смена кейса и3 шага сертификатов. Снимки и DOM-измерения приватно final-acceptance-20261103; loading/частичные кадры исключены. Бизнес-дефекты R9/R11 проверяются отдельно.",PASS,R10 исправлен и повторно проверен,1e2fb68d361896aba1a632f759568d4dac70e164; ffe677f5cefb4f2412f4b513954269ee1d846a10 +S81,Описание и календарь: save/reload/restore,V1 №39,Маркер и+1 минута сохраняются; исходные значения восстановлены,Оба round trips выполнены; календарь PATCH 200; восстановление проверено повторной загрузкой,PASS,—,0152e19c62228663f42d5736015a9bd4e4b1dd15 +S82,Описание: save/reload/restore,V2 №49,Маркер сохраняется; исходное описание возвращается,Маркер и возврат подтверждены после reload,PASS,—,0152e19c62228663f42d5736015a9bd4e4b1dd15 +S83,Материалы create/edit/delete,V1 №39 и V2 №49,Только prefixed материал сохраняется и удаляется; исходно пустой список возвращается,В обеих версиях ссылка example.com создана/изменена/reload/удалена/reload; списки снова пусты. Пустая форма показывает русскую ошибку,PASS,—,0152e19c62228663f42d5736015a9bd4e4b1dd15 +S84,Optional поля и неопубликованная анкета,V1/V2,Сохранение/reload/уборка без публикации и изменения исходных полей,V1 восстановлены 2 поля; V2 восстановлены 4 поля. Анкеты обеих программ снова пустые и неопубликованные. Preview учитывает несохраненное поле,PASS,—,0152e19c62228663f42d5736015a9bd4e4b1dd15 +N11,Устаревшая ревизия формы Solution,V2 №49,"409 сохраняет B на сервере и A локально, без auto-write",B200; A409 + refetch200; B reload: A не записан; B сохранен. Тестовые поля убраны с reload,PASS,—,0152e19c62228663f42d5736015a9bd4e4b1dd15 +V11,Мобильный preview анкеты V1/V2,V1 №39 / V2 №49,Одинаковые общие компоненты; диалог помещается в 390px,"Обе версии: диалог 366.4px,left12,scrollWidth=clientWidth366; document390. Mont подтвержден DOM; парные реальные снимки сохранены локально",PASS,—,0152e19c62228663f42d5736015a9bd4e4b1dd15 +L14,Подсказка удаления draft поля,V2 №49 / общий компонент,"Неопубликованному черновику достаточно сохранения, без предложения публикации","R5 исправлен 1054ed6c; реальный draft field save/reload/remove показал новую подсказку; draft сохранен и восстановлен, публикации нет",PASS,R5 исправлен и повторно проверен,1054ed6c8d03ba4ba9df3c97c04a1bcf93d6bb25 +L15,Лимит комментария в preview,V1/V2,"Footer, поле и счетчик согласованы на 1000",R6: было footer50 при maxlength/counter1000. Исправлено 1054ed6c; реальный preview 39/49 показывает 1000,PASS,R6 исправлен и повторно проверен,1054ed6c8d03ba4ba9df3c97c04a1bcf93d6bb25 +Q11,Квота и пересчет существующих результатов,V1 №39,3->2->3; текущие оценки и назначения сохраняются,Preview20->24; результаты 24/48. Обратный preview 24->20; свежие результаты 20/48 и 3 из 3 подтверждены. Исходная quota 3 восстановлена,PASS,—,1054ed6c8d03ba4ba9df3c97c04a1bcf93d6bb25 +Q12,Сохранение квоты 2,V2 №49,1->2 сохраняется и читается после reload,"На 1054ed6c quota 1->2 сохранена/reload, preview 0->0. На b6ab после оценок исходная quota 1 возвращена через preview 1->2 ready и PUT 200/reload; подробности Q13",PASS,—,1054ed6c8d03ba4ba9df3c97c04a1bcf93d6bb25 +I11,Принятие собственных приглашений,V1/V2,Обычный вход и подтверждение приема дают joined и сохраняются после reload,"Play station user 59 принял 39/49, POST 200x2; pending после reload0. Organizer49 видит joined/Город. Admin эксперт 64 добавлен отдельно через разрешенную админку без invitation API",PASS,—,1054ed6c8d03ba4ba9df3c97c04a1bcf93d6bb25 +A06,Эксперт не получает управление,V1/V2,Management GET 403 у обычного эксперта,"После приема приглашений user 59: management39=403, management49=403; русское сообщение без закрытых данных",PASS,—,1054ed6c8d03ba4ba9df3c97c04a1bcf93d6bb25 +B01,Доступность создания новых Solution,V2 №49,Eligible капитан может подготовить новые тестовые работы при включенном флаге,"Штатный scoped flag49 включался пользователем после preview. Ранее созданы19/20/21; в финальном цикле создан только25 POST201. После создания пользователь вернулfalse: previous_enabled=true/requested_enabled=false/applied=true/changed=true. Приглашение37 и прием выполнены уже после возврата. Текущийfalse, восстановление завершено.",PASS,Временная dev-конфигурация восстановлена,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb; 1e2fb68d361896aba1a632f759568d4dac70e164 +R11,R1: no-op и область preview распределения,V2 №49,Из двух работ одного кейса выбрать 19; no-op сохраняет выбор; preview не расширяется,"Реальная смена кейса очистила выбор. Повтор Город сохранил 19; POST 200 solution_ids[19], expert_ids[64,95], добавляется 2.20 исключено; cancel/reload: все назначения пусты",PASS,R1: live retest пройден,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +N12,Конфликт версий Solution и сдача,V2 №49 / Solution 19,Устаревшая вкладка не перезаписывает B и зафиксированную заявку,"B PATCH 200; A PATCH 409 с русским сообщением; B reload содержит B, локальный A сохранен. После сдачи B staleA снова 409",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +N13,Гонка сохранения оценки и снятия назначения,V2 №49 / Solution 19 / expert 64,Сохраненная оценка и назначение сохраняются,"Редактор открыт/19 снято локально до оценки. После POST оценки сохранение PUT[]409; refresh показывает 19 checked/disabled, E1 reload сохраняет 0/Нет/комментарий",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +N14,Устаревший план распределения,V2 №49 / Solution 19,Изменение назначений B делает старый план A недействительным,"B PUT[19]200 эксперту 64; A allocation/apply POST 409 (отдельный OPTIONS200); русское сообщение, без auto-apply. После обновления новый preview добавляет только 1, существующее 64 сохраняется",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +N15,Своя команда и нехватка экспертов в server preview,V2 №49 / Solution 20 / expert 95,Собственный капитан исключен из пула,"В POST preview solution_ids[20]/expert_ids[95]; сервер 200 план добавляет 0, shortage 2, подтверждение disabled. Apply не выполнялся",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +W11,Новые Solution: сохранение и snapshot,V2 №49 / Solution 19/20,Создание штатно;0/false сохраняются; сдача фиксирует данные/команду,"Два отдельных normal-login капитана 11/59; POST 201, PATCH 200/reload;0/Нет. Submit POST 200/reload: snapshot содержит 0/Нет, teamFrozen, капитан 1. Исходное 7 не изменялось",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +E11,Две оценки и изменение оценки,V2 №49 / Solution 19,0/Нет/комментарий учитываются; полный результат после quota 2,"64:0/Нет/comment POST 200/reload,95:6/Да POST 200/reload.0 preliminary при 1/2 ->3 ready при 2/2; изменение 64 до 2 POST 200/reload ->4",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +Q13,Возврат quota 1 и защита избыточной оценки,V2 №49,2->1 пересчитывает готовность и сохраняет оценки,"Preview ready 1->2, missing1/extra1; PUT 200/reload quota 1.19 сохраняет 2 оценки,20 одну; обе готовые с 4,00 и местом 1.7 остается без назначений/оценок",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +X11,Реальный XLSX: текущая выборка и вся программа,V2 №49,Поиск дает 2 работы; Вся программа игнорирует поиск и дает 3;3 оценки сохранены,"Два листа: filtered IDs19/20, all 19/20/7; оценки 19:(2,6),20:(4), Нет/Да/Нет и комментарии. Числа имеют тип n; SUM/AVERAGEIF/ROUND соответствуют 4/4; fullCalcOnLoad=1. Excel runtime recalculation не запускался. Колонка ID эксперта содержит userId28/59; management expertId64/95 сопоставлены отдельно",PASS,—,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +L16,Неконкурсные метрики и срок оценивания,V2 №51,Неприменимые оценки не показаны нулями или активным этапом,"R7 исправлен: на b6ab server is_competitive=false; нет Оценено 0 из 0/безбалльного hint/фильтра оценки/этапа оценивания, остался фильтр кейса. Чтение 200, записей 51 нет",PASS,R7 исправлен и повторно проверен,b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb +I12,Отправитель и вступление по приглашению в Project,V1 №39 / Project 403,Приглашение показывает sender84; принятие сохраняет роль и состав,"Одно отдельно разрешенное письмо 126 на 59 создано POST 201; до исправления карточка называла получателя 59 приглашающим при API sender84.8d36 показывает Ивана Иванова 84; принятие 126 POST 200, pending исчез; свежий owner team403 содержит 84 и 59 с ролью PR517 QA участник. Повторных писем нет.",PASS,R8 исправлен и live retest пройден; существовал до PR,8d36f4a833b9c664bea52c4806afe3484620f313 +N16,Устаревший редактор команды после сдачи Project,V1 №39 / Project 403,Сервер отклоняет исключение; команда остается прежней,Свежая админка link 205 submitted=true; новый edit route возвращает detail. По отдельному явному разрешению старая вкладка отправила один DELETE collaborators403?id=59&program_link_id=205:409. Русский текст Состав команды зафиксирован после сдачи проекта. Свежий team view подтверждает 84 и 59. Исключение не произошло.,PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +W13,Черновик другого кейса и сдача при выключенном создании,V2 №49 / Solution 21,Фильтр находит draft 21; existing draft можно сдать при creationFlag=false,"Draft filter not_submitted показывает 1 — только 21 Education; XLSX Решения содержит 1 строку 21. После scoped disablefalse штатный submit21 POST 200, reload: сдано, snapshot Education/0/Нет, команда 84 frozen. Полнота team-листа draft XLSX проверяется отдельно X14.",PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +N17,Серверный preview другого кейса,V2 №49 / Solution 21,Эксперт 95 с Город не назначается на Education,"POST 200 solution_ids[21]/expert_ids[95], added0/shortage 1, confirm disabled, apply не запускался.64->21 затем назначен отдельным допустимым preview/apply200 для pending analytics и снят без оценки PUT[19,20]200.",PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +A07,Чужая заявка у обычного участника,V2 №49 / participant 84,Данные чужого решения не выдаются,Штатный detail route19 в отдельной странице обычного 84: GET /programs/49/solutions/19/ HTTP 404; Заявка не найдена / Решение или программа не найдены.; save control отсутствует. Сессия без роли эксперта/организатора; ответы и права не подменялись.,PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +X12,Все страницы и фильтр реального V1 Excel,V1 №39,Экспорт не ограничен видимой страницей и сохраняет числа/формулы,"Со страницы 2 экспертов выгружено 27 строк, joined-фильтр дает 26. Поиск результатов prefix дает 2 рейтинга/3 оценки; Вся программа при активном поиске дает 50/123. Новый 404 имеет входы 0/8/2 и 4/10/6, суммы 10/20, AVERAGEIF/ROUND дают 15;403 нули/Нет. fullCalcOnLoad=1, пересчет Excel runtime не выполнялся.",PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +X13,Непустой pending и полный analytics Excel,V2 №49,3 выполненных и 1 pending совпадают со списками и файлом,"Временное назначение 64->21 apply POST 200 дает 75%/3 из 4. Модальные completed/pending/all содержат 3/1/4 строк; pending — только 21/64. XLSX14 листов: решения 7/19/20/21, назначения 11/13/14/15, grades9 строк без оценок 21. После экспорта 21 снято PUT 200, оставлены 19/20.",PASS,—,8d36f4a833b9c664bea52c4806afe3484620f313 +X14,Уникальность участников команды в XLSX черновика,V2 №49 / draft 21,У команды из одного капитана одна строка участника,"Повтор на draft25/team84+341: серверный Excel содержит1 решение с teamSize2, но3 строки команды с userIds84/84/341. Дубль капитана ранее подтвержден на21. В доступном backend source team() добавляет капитана, затем все membership rows, включая его. Подготовлен отдельный минимальный patch и pure-method regression: до84/84/341, после84/341; роль и submitted snapshot сохраняются. Backend checkout/dev не менялись, Django/DB и dev retest patch не выполнены.",FAIL,R9 OPEN: backend draft team XLSX,8d36f4a833b9c664bea52c4806afe3484620f313; 1e2fb68d361896aba1a632f759568d4dac70e164 +V02,"Аналитика: подписи, даты, подсказки и экспорт",V2 №49,Стиль доведенного V1; клавиатура и меню работают,"9c92: Mont16/22, export32x32, даты12/18 и статусы11/16; desktop top/middle/bottom, Enter/Escape/outside и focus. На1e2f повтор V1/V2 desktop/390/360 и нижние блоки. Наffe6 проверены исправленные мобильные итоги, нижняя подсказка Enter/Escape/outside; прежний overflow scrollbar отсутствует.",PASS,R10 исправлен,9c92cd7fa274477575163ec0443205d47d51b591; 1e2fb68d361896aba1a632f759568d4dac70e164; ffe677f5cefb4f2412f4b513954269ee1d846a10 +V03,Повтор отступов кнопки добавления поля,V2 №49,Внутренние отступы соответствуют общему compact-action,"Реальный повтор1e2f: Добавить поле134.675x32, padding5px20px, Mont12. Форма V1/V2 и footer/preview на390px; редактор Solution на360/390px помещается в окно. Записей настроек при визуальном повторе нет.",PASS,R10 исправлен,1e2fb68d361896aba1a632f759568d4dac70e164 +V04,Таблица заполнения формы и слот фильтра,V2 №49,Общая адаптивная таблица и отступы Project,"На1e2f реальная таблица Материалы решений:4 из4 по каждому полю, включая0/Нет; общие responsive cards на390/360px. Нажатие Число для проверки открыло missing-answer диалог с0 строк, что совпадает со счетчиком. Наffe6 фильтр/выбор заявок используют общий toolbar-slot; desktop/mobile и белая панель сверены с Project.",PASS,R10 исправлен,1e2fb68d361896aba1a632f759568d4dac70e164; ffe677f5cefb4f2412f4b513954269ee1d846a10 +T21,Разрешенное архивирование только тестовой21,V2 №49,Preview и apply затрагивают только указанную новую работу,"9c92: отдельное согласие; preview0 оценок/назначений/ожидающих приглашений; archive/apply POST200,21 исчезла из активной таблицы. Последующие создание25 и вступление37 проверены отдельно в S72. Архивные материалы повторно не читались.",PASS,—,9c92cd7fa274477575163ec0443205d47d51b591 +V05,Мобильные итоги оценивания и нижняя подсказка,V1 №39 / V2 №49,"Слова читаются целиком; подсказка без горизонтальной полосы, клавиатура работает","До: слово Выполнено переносилось внутри слова, label36px; мобильная подсказка показывала горизонтальную полосу от стрелки. ffe6: итоги в одну колонку на<=420px, label18px на360px в обеих версиях; overflow-x:hidden убирает полосу подсказки. Enter/Escape/outside подтверждены живым UI.13 существующих tests/2files, stylelint/format и штатная prod build PASS.",PASS,R10: дополнительные мобильные исправления,ffe677f5cefb4f2412f4b513954269ee1d846a10 +X15,Аудитория сертификатов сданных Solution,V2 №49,Сдавшие Solution7/19/20 включены; у draft25 участники не включены,"Реальный GET /programs/49/management/certificates/200: eligible_counts={all:5,submitted:0}, draft.audience=submitted. UI показывает0 при3 сданных Solution. Backend recipients() фильтрует только PartnerProgramProject и Project Collaborator; Solution не учитываются. Только чтение: выдача и отправка не запускались.",FAIL,R11 OPEN: backend recipients certificates ignore Solution,ffe677f5cefb4f2412f4b513954269ee1d846a10 diff --git a/docs/program-v2-ui-audit/integration-517/README.md b/docs/program-v2-ui-audit/integration-517/README.md new file mode 100644 index 000000000..904407812 --- /dev/null +++ b/docs/program-v2-ui-audit/integration-517/README.md @@ -0,0 +1,59 @@ + + +# Интеграционная приемка PR #517 + +Вердикт на 03.11.2026: **NOT READY**. Подтверждены два серверных дефекта R9/R11. Сверка интерфейса завершена в описанной ниже области; матрица содержит **59 PASS / 11 NOT TESTED / 2 FAIL**. Merge и deploy не выполнялись. + +Последний проверенный продуктный commit и видимый runtime: `ffe677f5cefb4f2412f4b513954269ee1d846a10`. Предыдущий runtime `1e2fb68d361896aba1a632f759568d4dac70e164` использовался для создания 25, приглашения 37 и первого повтора экспорта. У исторических сценариев сохранены исходные SHA в [MATRIX.csv](MATRIX.csv); результаты старых проверок не переименованы в проверки последнего кода. Машиночитаемые сведения — [results.json](results.json). + +## Среда + +Изолированный worktree, development preview `http://127.0.0.1:4317`, настоящий API `https://dev.procollab.ru`. Guard проверяет commit, чистоту исходников и dev-конфигурацию. `1d403fe0a1d4f663071d1b6c3deb2205d04645d8` входит в историю. Пользователь авторизовал отдельные видимые контексты организатора, экспертов и капитанов обычными формами. Пароли, cookies, Authorization, HAR и storageState не считывались и не экспортировались. Ответы и permissions не подменялись. + +№39 — legacy_project; №49 — solution/competitive; №50 — solution/hackathon; №51 — solution/noncompetitive. №50/51 проверены чтением; их данные не менялись. Исторические облачные проверки и mocks отделены от реальных dev E2E. + +## Реальные функциональные результаты + +- R1: no-op фильтра сохраняет выбранное 19, реальная смена кейса сбрасывает выбор. Server preview передал только solution_ids[19], expert_ids[64,95];20 исключено. Отмена не создала назначений. +- Настройки V1/V2: разрешенные save/reload/restore описания, материалов, optional поля и draft анкеты; V1 календарь восстановлен, корректный V2 календарь оставлен по согласованию. Семь разделов прочитаны. Запись каждого раздела не заявлена: критерии зафиксированы, доступ организаторов не менялся ради теста. +- Project403/link205 создан штатно капитаном 84;404/link206 — капитаном 11, новый link и профиль подготовлены разрешенной Django admin. Обе работы сданы PUT200. Приглашение 126 отдельно согласовано, отправлено один раз и принято 200; команда 403 после reload —84/59 с нужной ролью. +- Project403/64:0/0/0/Нет/comment. Project404/64 и 95: суммы 10/20, Нет/Да; среднее 15. POST201/reload. Устаревшее снятие оцененного назначения и отдельно согласованное исключение 59 из сданной 403 дали 409; данные сохранились. Временная quota39=2 дала ready25 вместо 20, возврат 3 восстановил 20; исходные оценки сохранены. +- Эксперты V1: no-op сохранил 2 выбранных на странице 2, весь фильтр выбрал 27; реальный фильтр сбросил выбор/страницу. Excel со страницы 2 содержит 27, joined-фильтр 26. Результаты по prefix —2 работы/3 оценки; Вся программа при поиске —50/123. Полный analytics:123 участника,62 проекта,50 сдано,75 completed/48 pending. Формулы и числа прочитаны из XLSX; Excel runtime recalculation не запускался. +- Solution19/20 созданы обычными капитанами 11/59;0/Нет сохранены и повторно прочитаны. Submit200 фиксирует snapshot/teamFrozen. B-save200/A-stale409 оставляет сервер B и локальный A; после сдачи staleA снова 409. Старый allocation/apply409; новое распределение сохранено. Одна ранняя успешная apply подтверждена UI/reload без захваченного HTTP-статуса — статус не додуман. +- Solution19:64 сначала 0/Нет,95=6/Да; preliminary0 перешел в ready3. Изменение 64 до 2 дало 4. Solution20:64=4/Нет. После возврата quota49=1 обе готовые с 4, все 3 оценки сохранены. Гонка снятия оценившего 64 дала 409. Своя команда 95->20 и другой кейс 95->21 в preview дали added0/shortage, confirm disabled. +- Solution21 другого кейса сдано 200 при creationFlag=false; snapshot Education/0/Нет и frozen team. Временное неоцененное 64->21 дало 75%/3 из 4, списки и полный 14-листовый XLSX совпали; назначение затем снято 200. По отдельному разрешению 21 отправлено в архив, preview0 оценок/назначений/ожидающих приглашений, apply200. +- Финальный цикл команды: создан **draft25** капитаном 84, сохранены 0/false с reload. Получатель 341 зарегистрирован в 49 через разрешенную штатную Django admin, profile352. Одно приглашение 37 создано 201, карточка показывает sender84 и нужную роль;341 принял его обычной кнопкой 200. После reload обоих аккаунтов команда 84/341, pending0, роль PR517 QA участник. Это не отправка повторного письма. +- На последнем runtime обычный member341 получил management49=403, чужое 19=404, собственное 25=200. Permissions запрещают edit/manage_team/invite/submit; leave разрешен. Ранее обычный эксперт и участник получили management403 обеих версий, анонимный пользователь 401. Прямые forbidden grade writes не выполнялись. + +## Интерфейс и тексты + +Ориентир владельца: доведенные вкладки Project и дизайн-система PROCOLLAB. Из `C:\PROCOLLAB AI` восстановлены журналы «go to UI», «Go to UI v2», «Спроектировать сущность решения». Mont является основным шрифтом; Regular/SemiBold/Bold доступны в Figma. Старое ограничение TEMP Inter снято. VE01–VE06 не считались одобренными исключениями; [VISUAL_EXCEPTIONS.md](VISUAL_EXCEPTIONS.md) теперь содержит результаты сверки. + +Проверены все 7 вкладок и 7 подразделов настроек V1/V2 на 1440/390px, выбранные состояния 360px: верх/середина/низ аналитики, формы и footer, брендирование, модальные редакторы и preview, фильтры, защищенные назначения, раскрытые оценки/критерии, белые панели массового выбора, команда/история/смена кейса в одном экране, три шага сертификатов и развернутый редактор. loading/частичные кадры исключены из итогового доказательства. Геометрические проверки подтверждают отсутствие горизонтального переполнения страниц; они дополняют просмотр снимков. + +В 9c92/20ea восстановлены подписи/даты/статусы аналитики, общий tooltip, export32x32, отступы Добавить поле, responsive таблица заполнения формы и общий слот фильтра. Реальный повтор выполнен. В `ffe677f5cefb4f2412f4b513954269ee1d846a10` исправлены найденные при повторе разрывы слов итогов оценивания на узком экране и горизонтальная полоса нижней подсказки. Обе версии на 360px повторно просмотрены; Enter/Escape/outside работают. + +Проверенные 403/404/409 и календарные ошибки русские. R2 реальным unknown-account400 получил английский ответ сервера и русский fallback в UI. R3/R4 разделяют ошибку ссылки восстановления и логина; первоначальная причина отказа reset-confirm Play station не установлена. Пользователь позже входил обычным способом. R5 исправляет подсказку draft, R6 согласует comment1000, R7 скрывает неприменимые аналитические этапы/метрики 51, R8 показывает sender Project invitation. Пользовательские данные и справочники не переписывались. Это не утверждение о каждом возможном сообщении всей платформы. + +## Открытые серверные дефекты + +| Дефект | Реальное доказательство | Что требуется | +| ------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R9, P2 | Draft25: команда 84/341, teamSize2, но лист Команды содержит 84/84/341. Ранее то же на 21 с одним капитаном. | Backend team() добавляет капитана отдельно и повторяет membership. Подготовлен приватный минимальный patch; pure-method проверка до/после подтверждена. Backend не изменен, Django/DB и dev retest patch не выполнены. | +| R11, P1 | GET49/management/certificates/200: eligible_counts all5/submitted0, хотя 7/19/20 сданы. UI аудитории показывает 0. | Backend recipients() учитывает только Project links/Collaborator. Нужен расчет по участникам сданных Solution. Выдача/рассылка не запускались. | + +Frontend сохраняет серверный workbook как Blob и отображает серверную аудиторию; исправления этих значений в клиенте не применялись. Доступные backend-файлы совпадают с ранее сообщенным dev image cf48cd7, но свежая версия контейнера в этом цикле не запрашивалась. Принадлежность R9/R11 к изменениям frontend PR не установлена. + +## Проверки кода + +Исторические наборы: R1—75/6 файлов, R2—183/13, R3/R4—235/22, R5/R6—14/3, R7—30/2 (2 падения до исправления), R8—15/3. Визуальные изменения 9c92—68/3;20ea—12/2,5 пересекаются. Последние два CSS-исправления—13 существующих тестов/2 файла. Scoped lint/format и штатные production builds PASS с исходными budgets; последняя сборка на source `ffe677f5cefb4f2412f4b513954269ee1d846a10`, NG_BUILD_MAX_WORKERS=1. Полный набор не перезапускался, результаты разных запусков не суммируются. + +## Итоговое состояние dev и границы + +- №39 quota3 и исходный календарь. №49 quota1, **creationFlag=false**, возврат подтвержден выводом штатной команды пользователя после создания 25. Дополнительных действий SSH/backend/deploy агент не выполнял. +- По согласованию календарь 49 оставлен: регистрация/сдача 04.11.2026 18:00, оценивание 05.11 18:00, конец 10.11 18:00 Москва. Противоречивый календарь 51 не менялся. +- Prefixed Project403/404, Solution19/20, их оценки и роли остаются;21 в архиве,25 — draft с командой 84/341. Материалы, optional поля и draft анкеты очищены после round trips; публикации анкеты не было. Критерии 123/124 зафиксированы разрешенными оценками. +- Письма: ранее 4 expert invitations, одно Project126 и одно отдельно разрешенное Solution37. На admin писем нет, resend нет. SMTP-доставка в почтовом ящике отдельно не проверялась. Массовых писем и выдачи сертификатов нет. +- NOT TESTED остаются: запись всех 7 разделов; полный resend lifecycle; реальная V2 пагинация >20 работ; V1 form concurrency и общий auto apply/stale plan; прямые forbidden grade writes; запись/оценивание 50/51; первоначальный reset-confirm; массовые письма/сертификаты. Подробные частичные результаты сохранены в матрице. + +Приватная галерея, исходные снимки/XLSX и безопасные проекции запросов: `.acceptance-517/final-acceptance-20261103/visual-review.html`. В Git входят только сводные документы без учетных данных и персональных выгрузок. Для полного PASS нужны backend R9/R11 и их повтор на реальном dev, а также отдельно разрешенные либо явно исключенные оставшиеся сценарии. diff --git a/docs/program-v2-ui-audit/integration-517/VISUAL_EXCEPTIONS.md b/docs/program-v2-ui-audit/integration-517/VISUAL_EXCEPTIONS.md new file mode 100644 index 000000000..f8ef60cec --- /dev/null +++ b/docs/program-v2-ui-audit/integration-517/VISUAL_EXCEPTIONS.md @@ -0,0 +1,33 @@ + + +# Соответствие доведенным вкладкам Project + +Прежний запрос одобрить VE01–VE06 заменен указанием владельца: Solution следует доведенным вкладкам Project и дизайн-системе. Ответ «ничего не понял» не являлся одобрением исключений. Mont каноничен; доступность Regular/SemiBold/Bold подтверждена в Figma03.11. TEMP Inter отражал старое ограничение и не используется как основание для смены шрифта. + +Из `C:\PROCOLLAB AI` прочитаны журналы «go to UI» (`01a14598-6e48-7de3-a32c-bce1e950dd2e`), «Go to UI v2» (`01a179c0-1eee-7f61-bc04-6902da8a4348`), «Спроектировать сущность решения» (`01a168b0-eccc-7c73-911e-6db67e95c15b`). Ориентиры: тонкие кнопки, общий поиск и фильтры, экспорт иконкой, белые плавающие панели, центрированные диалоги; команда внутри работы и смена кейса в одном экране. Исторические команды на merge/deploy не выполнялись. + +Геометрические образцы: [эксперты](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=452-3220), [аналитика](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=753-3219). Поздние доведенные V1-разделы имеют приоритет перед ранним статичным набором вкладок. + +## Завершенная сверка + +| Область | Реально просмотрено | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Все вкладки | Участники, проекты/заявки, эксперты, результаты, аналитика, настройки, сертификаты V1 №39/V2 №49;1440/390px | +| Настройки | Все 7 подразделов, длинные части и footer; брендирование/телефонный кадр; editor/preview поля, критериев и анкеты; материал и приглашение организатора без отправки | +| Таблицы и действия | Общие поиск/фильтры, экспорт 32x32, menu, белая панель выбора; назначение/защищенные оценки, мастер распределения/квота без apply | +| Детализация | Материалы/команда/история Solution25, смена кейса в одном диалоге; команда Project403; раскрытые результаты/критерии; аналитика верх/середина/низ, missing-answer диалог | +| Аналитика mobile | 390/360px; таблица заполнения как общие responsive cards,0/Нет считаются заполненными; нижняя подсказка Enter/Escape/outside | +| Сертификаты | Шаблон и развернутый редактор, выдача и загруженный журнал обеих версий; без issuance, save или mail. Ошибка аудитории R11 учитывается отдельно как FAIL | +| Режимы | №50 analytics open mode; №51 неприменимые метрики/фильтры/этапы скрыты. Противоречивый календарь 51 показывает русский validation, данные не менялись | + +## Исправления и доказательства + +9c92: подписи аналитики 16/22, даты 12/18, статусы 11/16, общий TooltipComponent, рамка экспорта, отступы Добавить поле.20ea: общая responsive таблица заполнения, отступ итогов, toolbar-slot фильтра. Их реальный повтор выполнен на `1e2fb68d361896aba1a632f759568d4dac70e164`. Padding кнопки 134.675x32/5px20px; диалоги формы на 360/390px помещаются в окно. + +На `ffe677f5cefb4f2412f4b513954269ee1d846a10` добавлены два минимальных CSS-исправления: итоги оценивания в одну колонку при<=420px (слово Выполнено снова в одну строку), горизонтальная полоса подсказки убрана. Проверены обе версии 360px и нижняя подсказка 390px, клавиатура и клик снаружи.13 существующих tests/2files, lint/format и штатная prod build PASS; исходные budgets сохранены. + +Приватно сохранены 185 уникальных кадров этого цикла, из них 147 пригодных кадров последнего runtime. Все финальные документы имеют lang=ru, Mont загружен, переполнения page/office не обнаружены. Геометрия дополняет просмотр исходных снимков и парных контактных листов. Loading-кадры и частичный 39-form-bottom не служат доказательством завершенного состояния; неверные старые tab=fields исключены ранее. В агрегате исправлена hardcoded SHA метка capture helper на известной границе перезапуска; исходные снимки не менялись. + +Галерея: приватно `.acceptance-517/final-acceptance-20261103/visual-review.html`, измерения `visual-records.json`, ограничения `visual-summary.json`, происхождение `provenance-correction.json`. Персональные изображения не добавлены в Git. Старый [облачный gallery](../acceptance-gallery.html) является историческим источником. + +Сверка вкладок в этой области завершена. Backend R9/R11 и NOT TESTED функциональные сценарии перечислены в [README](README.md) и [MATRIX](MATRIX.csv); визуальный PASS не означает готовность PR к слиянию. diff --git a/docs/program-v2-ui-audit/integration-517/anonymous-readiness.cjs b/docs/program-v2-ui-audit/integration-517/anonymous-readiness.cjs new file mode 100644 index 000000000..5c75db674 --- /dev/null +++ b/docs/program-v2-ui-audit/integration-517/anonymous-readiness.cjs @@ -0,0 +1,107 @@ +/** @format */ +// Только реальные публичные GET и anonymous denial. Не выполняет login или запись данных. +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const { execFileSync } = require("node:child_process"); +const { createHash } = require("node:crypto"); +const { chromium } = require("playwright"); +const expected = process.argv[2]; +assert.match(expected ?? "", /^[0-9a-f]{40}$/, "Нужен полный SHA проверяемого приложения"); +const git = args => execFileSync("git", args, { encoding: "utf8" }).trim(); +const commit = git(["rev-parse", "HEAD"]); +assert.equal(commit, expected); +assert.equal(git(["diff", "--name-only", "HEAD", "--", "projects"]), ""); +assert.equal(git(["ls-files", "--others", "--exclude-standard", "--", "projects"]), ""); +const base = "http://127.0.0.1:4317"; +const api = "https://dev.procollab.ru"; +const sourcePath = + "projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.ts"; +(async () => { + const browser = await chromium.launch({ + executablePath: "/usr/bin/chromium", + headless: true, + args: ["--no-sandbox"], + }); + try { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const blocked = new Set(); + const methods = []; + await context.route("**/*", route => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === api && !["GET", "HEAD", "OPTIONS"].includes(request.method())) { + throw new Error("Readiness не разрешает изменяющие запросы"); + } + if ([base, api, "https://skills.dev.procollab.ru"].includes(url.origin)) { + if (url.origin === api) methods.push(request.method()); + return route.continue(); + } + blocked.add(url.origin); + return route.abort(); + }); + const page = await context.newPage(); + await page.goto(`${base}/auth/login`); + await page.locator('input[type="email"]').waitFor(); + await page.locator('input[type="password"]').waitFor(); + const reads = await page.evaluate(async () => { + const result = []; + for (const path of [ + "/programs/39/", + "/programs/49/", + "/programs/39/management/experts/", + "/programs/49/management/experts/", + ]) { + const response = await fetch("https://dev.procollab.ru" + path, { + credentials: "omit", + }); + const body = await response.json(); + result.push({ + path, + status: response.status, + mode: body.solution_storage_mode ?? null, + detail: body.detail ?? null, + }); + } + return result; + }); + assert.deepEqual( + reads.map(row => row.status), + [200, 200, 401, 401], + ); + assert.equal(reads[0].mode, "legacy_project"); + assert.equal(reads[1].mode, "solution"); + const redirects = []; + for (const id of [39, 49]) { + await page.goto(`${base}/office/program/${id}/management/experts`); + await page.waitForURL("**/auth/login**"); + redirects.push({ programId: id, login: true }); + } + assert.ok(methods.every(method => ["GET", "HEAD", "OPTIONS"].includes(method))); + process.stdout.write( + JSON.stringify( + { + commit, + sourceSha256: createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"), + appSourceClean: true, + normalLoginRendered: true, + origin: base, + api, + reads, + anonymousRedirects: redirects, + blockedThirdPartyOrigins: [...blocked], + apiMocked: false, + authorizationAttempted: false, + credentialsRecorded: false, + writes: 0, + }, + null, + 2, + ) + "\n", + ); + } finally { + await browser.close(); + } +})().catch(() => { + process.stderr.write("Anonymous readiness не завершена; секретные данные не записывались.\n"); + process.exitCode = 1; +}); diff --git a/docs/program-v2-ui-audit/integration-517/results.json b/docs/program-v2-ui-audit/integration-517/results.json new file mode 100644 index 000000000..31233cd60 --- /dev/null +++ b/docs/program-v2-ui-audit/integration-517/results.json @@ -0,0 +1,787 @@ +{ + "reviewedBaseline": "0a589f4cbacb100a186db05fb5044ed9a826107a", + "base": "9ef90b5824b857fe53d52c097e37b279b897d9bf", + "codeFix": { + "id": "R1", + "source": "projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.ts", + "sha256": "f1e2fbf89be238e89eadbcd743962babff212c1a978191edcdc3ec52aa105c51", + "beforeUnit": { + "tests": 12, + "failed": 2 + }, + "afterUnit": { + "files": 6, + "tests": 75, + "passed": 75, + "failed": 0 + }, + "apiChanged": false, + "visualChanged": false + }, + "standardProductionBuild": { + "command": "NG_BUILD_MAX_WORKERS=1 npm run build:prod", + "exitCode": 0, + "originalBudgets": true, + "log": "private .acceptance-517/final-acceptance-20261103/mobile-fixes-build.log", + "source": "ffe677f5cefb4f2412f4b513954269ee1d846a10" + }, + "baselineBrowser": { + "commit": "0a589f4cbacb100a186db05fb5044ed9a826107a", + "sourceSha256": "464f6f633d9ce3fa45bc224bc6672164b1eb1babc5471a99587c7ba7292435f0", + "appSourceClean": true, + "normalLoginRendered": true, + "origin": "http://127.0.0.1:4317", + "api": "https://dev.procollab.ru", + "reads": [ + { + "path": "/programs/39/", + "status": 200, + "mode": "legacy_project", + "detail": null + }, + { + "path": "/programs/49/", + "status": 200, + "mode": "solution", + "detail": null + }, + { + "path": "/programs/39/management/experts/", + "status": 401, + "mode": null, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/49/management/experts/", + "status": 401, + "mode": null, + "detail": "Authentication credentials were not provided." + } + ], + "anonymousRedirects": [ + { + "programId": 39, + "login": true + }, + { + "programId": 49, + "login": true + } + ], + "blockedThirdPartyOrigins": [], + "apiMocked": false, + "authorizationAttempted": false, + "credentialsRecorded": false, + "writes": 0 + }, + "connectivity": [ + { + "path": "/programs/?limit=1", + "method": "GET", + "status": 200, + "cors": { + "access-control-allow-origin": "*" + }, + "detail": null + }, + { + "path": "/api/token/", + "method": "OPTIONS", + "status": 200, + "cors": { + "access-control-allow-origin": "*", + "access-control-allow-headers": "accept, accept-encoding, authorization, content-type, dnt, origin, user-agent, x-csrftoken, x-requested-with", + "access-control-allow-methods": "DELETE, GET, OPTIONS, PATCH, POST, PUT", + "access-control-max-age": "86400" + }, + "detail": null + }, + { + "path": "/programs/49/management/", + "method": "OPTIONS", + "status": 200, + "cors": { + "access-control-allow-origin": "*", + "access-control-allow-headers": "accept, accept-encoding, authorization, content-type, dnt, origin, user-agent, x-csrftoken, x-requested-with", + "access-control-allow-methods": "DELETE, GET, OPTIONS, PATCH, POST, PUT", + "access-control-max-age": "86400" + }, + "detail": null + }, + { + "path": "/auth/users/current/", + "method": "GET", + "status": 401, + "cors": {}, + "detail": "Authentication credentials were not provided." + } + ], + "anonymousManagement": [ + { + "path": "/programs/39/management/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/39/management/settings/basic/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/39/management/experts/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/39/management/projects/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/39/management/results/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/49/management/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/49/management/settings/basic/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/49/management/experts/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/49/management/solution-analytics/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/49/management/solution-analytics/solutions/?limit=1", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/49/management/solution-analytics/assignments/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + }, + { + "path": "/programs/49/management/solution-results/", + "method": "GET", + "status": 401, + "detail": "Authentication credentials were not provided." + } + ], + "matrixSummary": { + "PASS": 59, + "NOT TESTED": 11, + "FAIL": 2 + }, + "integrationAcceptance": "NOT READY: confirmed backend R9/R11; visual comparison complete, 11 bounded scenarios remain NOT TESTED", + "mailSent": true, + "certificateDelivery": false, + "merge": false, + "deploy": false, + "rawEvidence": "Separate integration artifact; no credentials/HAR/storageState", + "codeChecks": [ + { + "name": "ESLint updated TS/spec", + "exitCode": 0 + }, + { + "name": "Prettier updated TS/spec", + "exitCode": 0 + }, + { + "name": "npm run test:solution-contract", + "exitCode": 0 + }, + { + "name": "bash -n start-preview.sh", + "exitCode": 0 + }, + { + "name": "node --check anonymous-readiness.cjs", + "exitCode": 0 + }, + { + "date": "2026-11-03", + "source": "20ea3590756d8053479dd3fca318bc4ea68233ca", + "phase1Tests": 68, + "phase2Tests": 12, + "overlap": 5, + "scopedChecks": "PASS", + "standardProductionBuild": 0, + "realBrowserSource": "9c92cd7fa274477575163ec0443205d47d51b591" + } + ], + "historicalScope": "baselineBrowser/codeFix, previousDesktopRun, desktopFunctionalRun20261102 and previousVisualRun20261103 are historical evidence. currentRun is the final continuation; old per-scenario SHAs have not been retagged.", + "currentRun": { + "date": "2026-11-03", + "timezone": "Europe/Moscow", + "sourceHead": "ffe677f5cefb4f2412f4b513954269ee1d846a10", + "testedProductCommit": "ffe677f5cefb4f2412f4b513954269ee1d846a10", + "earlierRuntime": "1e2fb68d361896aba1a632f759568d4dac70e164", + "origin": "http://127.0.0.1:4317", + "api": "https://dev.procollab.ru", + "apiMocked": false, + "verdict": "NOT READY", + "acceptanceComplete": false, + "visualComparisonCompleteWithinDocumentedScope": true, + "designTarget": "Polished Project tabs + PROCOLLAB design system; Mont canonical, Figma Inter restriction obsolete. VE01-VE06 were never approved.", + "realVisual": { + "summary": { + "uniqueFrames": 185, + "finalRuntimeFrames": 147, + "runtimeCounts": { + "1e2fb68d361896aba1a632f759568d4dac70e164": 35, + "ffe677f5cefb4f2412f4b513954269ee1d846a10": 150 + }, + "pageOverflow": [], + "allFinalFramesLangRu": true, + "allFinalFramesMontReady": true, + "excluded": { + "39-form-bottom-desktop": "partial first screen, not footer", + "49-allocation-start-final-390": "loading; superseded by loaded frame", + "49-certificate-delivery-final-390": "loading; superseded by loaded frame", + "39-certificate-delivery-final-390": "loading; superseded by loaded frame" + }, + "screenshotsMissing": [], + "note": "Geometry checks supplement visual inspection. English user data and email labels are not error messages. Program51 calendar validation is Russian; its inconsistent fixture was not changed." + }, + "privateEvidence": ".acceptance-517/final-acceptance-20261103", + "gallery": "visual-review.html", + "allManagementTabs": true, + "allSevenSettingsSections": true, + "scope": "1440/390, selected360; long pages, branding, filters, field/questionnaire/criteria previews and editors, assignments, expanded results, selection, team/history/case dialog, 3 certificate steps", + "runtimeProvenance": "provenance-correction.json corrects helper metadata at known sequential preview restart; original pixels unchanged" + }, + "codeChecks": { + "source": "ffe677f5cefb4f2412f4b513954269ee1d846a10", + "existingTests": 13, + "files": 2, + "stylelint": "PASS", + "prettier": "PASS", + "productionBuildExitCode": 0, + "originalBudgets": true, + "fullSuiteRepeated": false, + "priorVisualRuns": [ + { + "source": "9c92cd7fa274477575163ec0443205d47d51b591", + "tests": 68, + "files": 3 + }, + { + "source": "20ea3590756d8053479dd3fca318bc4ea68233ca", + "tests": 12, + "files": 2, + "overlapWithPrior": 5 + } + ] + }, + "browserStop": { + "active": false, + "recovered": true, + "mechanism": "Visible authenticated QA Chrome profiles and ordinary local UI; no native Chrome window workaround after a current safeguard stop" + }, + "team25": { + "captainUserId": 84, + "status": "draft", + "createStatus": 201, + "saveStatus": 200, + "number": 0, + "boolean": false, + "members": [84, 341], + "role341": "PR517 QA участник", + "invitationId": 37, + "inviteStatus": 201, + "acceptStatus": 200, + "pending": 0, + "registrationProfile341": 352, + "reloadConfirmedBothAccounts": true + }, + "pureMember341": { + "management49": 403, + "foreign19": 404, + "own25": 200, + "canEdit": false, + "canManageTeam": false, + "canInvite": false, + "canSubmit": false, + "canLeave": true + }, + "activeChanges": { + "creationFlag49": false, + "restoreRequired": false, + "quota39": 3, + "quota49": 1, + "calendar49KeptByApproval": true, + "solution21Archived": true, + "solution25DraftKept": true, + "registrationProfile352Added": true + }, + "mail": { + "oneSolutionInvitationCreated": 37, + "recipientUserId": 341, + "sentOnce": true, + "accepted": true, + "resend": false, + "adminMail": false, + "smtpInboxDeliveryVerified": false + }, + "openDefects": [ + { + "id": "R9", + "severity": "P2", + "status": "OPEN", + "server": true, + "draft25ActualTeamRows": [84, 84, 341], + "expected": [84, 341], + "proposedPatch": "private R9-backend-proposed.patch", + "pureMethodCheck": "PASS", + "djangoDbTestsRun": false, + "backendModified": false, + "regressionOfPRProven": false + }, + { + "id": "R11", + "severity": "P1", + "status": "OPEN", + "server": true, + "program49EligibleCounts": { + "all": 5, + "submitted": 0 + }, + "expectedSubmittedAtLeast": 3, + "source": "backend recipients() only Project membership", + "issuanceAttempted": false, + "regressionOfPRProven": false + } + ], + "open": [ + "Backend R9/R11 fix and real retest", + "Every-settings-section write not tested: locked criteria and organizer changes excluded", + "Extra expert invitation resend not authorized", + "Real V2 pagination >20 works absent", + "V1 form concurrency and whole-program automatic apply/stale plan not exercised", + "Direct forbidden grade writes not exercised", + "50/51 write/evaluation negative scenarios read-only scope", + "Original Play station reset-confirm root cause not captured", + "Participant bulk mail and certificate issuance not authorized" + ], + "credentialsRecorded": false, + "certificateDelivery": false, + "merge": false, + "deploy": false + }, + "matrixSummaryScope": "72 rows; historical scenarios retain their actual tested SHAs. New visual and role reads on ffe677f5cefb4f2412f4b513954269ee1d846a10; functional team25/invitation37 on 1e2fb68d361896aba1a632f759568d4dac70e164.", + "previousDesktopRun": { + "date": "2026-11-02", + "timezone": "Europe/Moscow", + "testedProductCommit": "b6ab5864ebc0ff577bc90b915606d1f6d4cd66fb", + "origin": "http://127.0.0.1:4317", + "api": "https://dev.procollab.ru", + "apiMocked": false, + "verdict": "INSUFFICIENT EVIDENCE", + "acceptanceComplete": false, + "programs": { + "39": "legacy_project", + "49": "solution/competitive", + "50": "solution/competitive/hackathon", + "51": "solution/noncompetitive" + }, + "newSolutions": [ + { + "solutionId": 19, + "captainUserId": 11 + }, + { + "solutionId": 20, + "captainUserId": 59 + } + ], + "grades": [ + { + "solutionId": 19, + "expertId": 64, + "userId": 28, + "initialScore": 0, + "score": 2, + "boolean": false + }, + { + "solutionId": 19, + "expertId": 95, + "userId": 59, + "score": 6, + "boolean": true + }, + { + "solutionId": 20, + "expertId": 64, + "userId": 28, + "score": 4, + "boolean": false + } + ], + "live": { + "R1SelectedSolutionIds": [19], + "unselected20UntouchedByR1": true, + "staleSolutionStatus": 409, + "staleAllocationApplyStatus": 409, + "gradeRemovalRaceStatus": 409, + "quota49Restored": 1, + "quota39Restored": 3, + "ready49": 2, + "submitted49": 3, + "R7NoncompetitiveRetest": true + }, + "codeChecks": { + "R7TargetedTests": 30, + "files": 2, + "beforeFailed": 2, + "eslint": "PASS", + "prettier": "PASS", + "standardProductionBuildExitCode": 0, + "originalBudgets": true, + "fullSuiteRepeated": false + }, + "exports": { + "filteredResultIds": [19, 20], + "allResultIds": [19, 20, 7], + "gradeRows": 3, + "numericInputsAndFormulasChecked": true, + "excelRuntimeRecalculationPerformed": false, + "exportIdColumn": "ID эксперта contains user IDs28/59; management Expert IDs64/95 mapped separately" + }, + "activeChanges": { + "creationFlag49": true, + "creationFlag49MustRestore": true, + "creationFlag49RestoreTarget": false, + "calendar49KeptByExplicitApproval": true, + "prefixedCriteriaLockedByNewGrades": true, + "newScopedRolesRemain": true, + "existingSolution7Unchanged": true + }, + "open": [ + "V1 new Project and scoring", + "other case/draft/team membership/pure participant", + "nonempty pending and remaining analytics exports", + "remaining V1 conflicts", + "visual comparisons and VE01-06 decision", + "restore creation flag49 false" + ], + "credentialsRecorded": false, + "additionalMailSent": false, + "merge": false, + "deploy": false + }, + "desktopFunctionalRun20261102": { + "date": "2026-11-02", + "timezone": "Europe/Moscow", + "testedProductCommit": "8d36f4a833b9c664bea52c4806afe3484620f313", + "origin": "http://127.0.0.1:4317", + "api": "https://dev.procollab.ru", + "apiMocked": false, + "verdict": "NOT READY", + "acceptanceComplete": false, + "programs": { + "39": "legacy_project", + "49": "solution/competitive", + "50": "solution/competitive/hackathon (read only)", + "51": "solution/noncompetitive (read only)" + }, + "newProjects": [ + { + "projectId": 403, + "programLinkId": 205, + "captainUserId": 84, + "submitted": true, + "teamUserIds": [84, 59] + }, + { + "projectId": 404, + "programLinkId": 206, + "captainUserId": 11, + "submitted": true, + "linkFixtureCreatedThroughAdmin": true + } + ], + "newSolutions": [ + { + "solutionId": 19, + "captainUserId": 11, + "submitted": true + }, + { + "solutionId": 20, + "captainUserId": 59, + "submitted": true + }, + { + "solutionId": 21, + "captainUserId": 84, + "case": "Образование", + "submitted": true, + "grades": 0 + } + ], + "solutionGrades": [ + { + "solutionId": 19, + "expertId": 64, + "userId": 28, + "initialScore": 0, + "score": 2, + "boolean": false + }, + { + "solutionId": 19, + "expertId": 95, + "userId": 59, + "score": 6, + "boolean": true + }, + { + "solutionId": 20, + "expertId": 64, + "userId": 28, + "score": 4, + "boolean": false + } + ], + "projectGrades": [ + { + "projectId": 403, + "expertId": 64, + "userId": 28, + "numbers": [0, 0, 0], + "boolean": false + }, + { + "projectId": 404, + "expertId": 64, + "userId": 28, + "numbers": [0, 8, 2], + "boolean": false + }, + { + "projectId": 404, + "expertId": 95, + "userId": 59, + "numbers": [4, 10, 6], + "boolean": true + } + ], + "live": { + "R1SelectedSolutionIds": [19], + "V1GradeRemovalRaceStatus": 409, + "V2GradeRemovalRaceStatus": 409, + "V2StaleAllocationApplyStatus": 409, + "submittedTeamRemoval403Status": 409, + "foreignSolution19Participant84Status": 404, + "resetUnknownAccountStatus": 400, + "resetServerEnglishToRussianUI": true, + "quota39": 3, + "quota49": 1, + "V1ReadyAtQuota2": 25, + "V1ReadyAfterRestore3": 20, + "V1Submitted": 50, + "V1CompletedAssignments": 75, + "V1PendingAssignments": 48, + "V2Submitted": 4, + "V2Ready": 2, + "temporaryAssignment64to21Removed": true, + "R8SenderRetestedBeforeAccept126": true + }, + "codeChecks": { + "R8TargetedTests": 15, + "R8TestFiles": 3, + "eslint": "PASS", + "prettier": "PASS", + "standardProductionBuildExitCode": 0, + "originalBudgets": true, + "fullSuiteRepeated": false, + "R7EarlierRed": 2, + "R7EarlierGreenTests": 30 + }, + "exports": { + "V1ExpertsAllFromPage2": 27, + "V1ExpertsFilteredJoined": 26, + "V1FilteredResults": 2, + "V1AllResults": 50, + "V1FilteredRatingRows": 3, + "V1AllRatingRows": 123, + "V1AnalyticsAssignments": 123, + "V2AnalyticsSolutionIds": [7, 19, 20, 21], + "V2AnalyticsAssignmentsWithPending": 4, + "V2AnalyticsCompleted": 3, + "draftTeamDuplicate": "R9", + "numericInputsAndFormulasChecked": true, + "excelRuntimeRecalculationPerformed": false, + "resultsExpertIdColumnContainsUserId": true, + "managementExpertIdMapping": { + "64": 28, + "95": 59 + } + }, + "activeChanges": { + "creationFlag49": false, + "creationFlag49MustRestore": false, + "quota39Restored": 3, + "quota49Restored": 1, + "calendar49KeptByExplicitApproval": true, + "prefixedCriteriaLockedByNewGrades": true, + "newScopedRolesRemain": true, + "existingSolution7Unchanged": true + }, + "openDefects": [ + { + "id": "R9", + "severity": "P2", + "status": "OPEN", + "scope": "server-generated draft team XLSX", + "regressionOfPRProven": false, + "frontendWorkaroundApplied": false + } + ], + "open": [ + "Solution team joining requires an extra active account without application49; user asked", + "V2 real pagination requires more than4 works", + "V1 automatic allocation apply affects old works and was not applied", + "V1 form concurrency and forbidden direct grade writes not exercised", + "extra invitation resend mail not authorized", + "original Play station reset-confirm response/root cause unavailable", + "VE01-VE06 owner decision pending" + ], + "mail": { + "expertInvitationsPreviouslyAuthorized": 4, + "additionalParticipantInvitation": { + "id": 126, + "projectId": 403, + "programLinkId": 205, + "recipientUserId": 59, + "count": 1, + "accepted": true + }, + "adminMailSent": false, + "resetProbeUsedActiveRecipient": false, + "resendSent": false + }, + "credentialsRecorded": false, + "certificateDelivery": false, + "merge": false, + "deploy": false + }, + "previousVisualRun20261103": { + "date": "2026-11-03", + "timezone": "Europe/Moscow", + "sourceHead": "20ea3590756d8053479dd3fca318bc4ea68233ca", + "testedProductCommit": "9c92cd7fa274477575163ec0443205d47d51b591", + "priorFunctionalEvidence": "desktopFunctionalRun20261102; each matrix row retains its actual SHA", + "origin": "http://127.0.0.1:4317", + "api": "https://dev.procollab.ru", + "apiMocked": false, + "verdict": "NOT READY", + "acceptanceComplete": false, + "designTarget": "polished Project tabs plus PROCOLLAB design system; Mont confirmed available in Figma; no blanket VE approval pending", + "realVisual": { + "desktopPairs": 13, + "mobileFirstScreenPairs": 11, + "mobileWidth": 390, + "analyticsScrollLevels": ["top", "middle", "bottom"], + "metricLabel": "16px/22px Mont", + "exportTrigger": "32x32", + "tooltipKeyboard": true, + "exportMenuFocusReturn": true, + "excludedWrongRoute": "two tab=fields captures; actual tab=project-form not retested" + }, + "codeChecks": [ + { + "phase": "9c92", + "tests": 68, + "files": 3, + "lint": 0, + "format": 0, + "productionBuild": 0 + }, + { + "phase": "20ea", + "tests": 12, + "files": 2, + "overlapWithFirst": 5, + "stylelint": 0, + "format": 0, + "productionBuild": 0 + } + ], + "browserStop": "Computer Use stopped by tool because Windows current browser URL could not be reliably determined; no subsequent browser actions or alternative-control bypass.", + "activeChanges": { + "program49CreationEnabled": false, + "restoredBy": "user supported disable command; previous_enabled=true requested_enabled=false applied=true changed=true", + "quota39": 3, + "quota49": 1, + "solution21": "archived with explicit approval; apply200 and removed from active list; archive content readback pending", + "newDraftCreated": false, + "newInvitationSent": false, + "additionalUserId": 341 + }, + "mail": { + "additionalParticipantInvitationsSent": 0, + "oneInvitationToPystou2AuthorizedAndUnused": true, + "noAdminMail": true + }, + "openDefects": [ + { + "id": "R9", + "status": "OPEN", + "scope": "server-generated draft XLSX team duplicate; PR provenance unestablished" + }, + { + "id": "R10", + "status": "source fixed; partial real verification", + "remaining": "final coverage/filter source, add-field padding, mobile analytics/form, long/modal states" + } + ], + "open": [ + "S11: Настройки: чтение, сохранение всех разделов и повторное чтение", + "S21: Приглашение, принятие, повтор и отзыв эксперта", + "S12: Настройки: чтение, сохранение всех разделов и повторное чтение", + "S22: Приглашение, принятие, повтор и отзыв эксперта", + "S32: Поиск, фильтры, массовый выбор и смена страницы", + "S72: Работа с Project либо Solution, составом и snapshot", + "N01: Конфликт изменений формы/решения", + "N02: Устаревший allocation/назначение и гонка с оценкой", + "N04: Роли и чужие объекты", + "N05: Хакатон и неконкурсные ограничения", + "X01: Письма участникам и массовая выдача сертификатов", + "L13: Реальный сброс пароля Play station", + "V01: Сверка интерфейса с действующим V1", + "X14: Уникальность участников команды в XLSX черновика", + "V03: Повтор отступов кнопки добавления поля", + "V04: Таблица заполнения формы и слот фильтра" + ], + "credentialsRecorded": false, + "certificateDelivery": false, + "merge": false, + "deploy": false + } +} diff --git a/docs/program-v2-ui-audit/integration-517/start-preview.sh b/docs/program-v2-ui-audit/integration-517/start-preview.sh new file mode 100644 index 000000000..7ce403d29 --- /dev/null +++ b/docs/program-v2-ui-audit/integration-517/start-preview.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Запуск только зафиксированного кода PR с dev API. Секреты не читаются и не выводятся. +pr517_script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +pr517_repo="$(git -C "$pr517_script_dir" rev-parse --show-toplevel)" +pr517_expected="${1:-}" +if [[ ! "$pr517_expected" =~ ^[0-9a-f]{40}$ ]]; then + echo "Передайте полный SHA проверяемого PR первым аргументом." >&2 + exit 2 +fi +cd -- "$pr517_repo" +pr517_actual="$(git rev-parse HEAD)" +if [[ "$pr517_actual" != "$pr517_expected" ]]; then + echo "HEAD отличается от переданного SHA. Запуск остановлен." >&2 + exit 3 +fi +if ! git diff --quiet HEAD -- projects || [[ -n "$(git ls-files --others --exclude-standard -- projects)" ]]; then + echo "Исходники приложения отличаются от commit. Запуск остановлен." >&2 + exit 4 +fi +node <<'NODE' +const fs = require("node:fs"); +const env = fs.readFileSync("projects/social_platform/src/environments/environment.ts", "utf8"); +const config = JSON.parse(fs.readFileSync("angular.json", "utf8")); +const architect = config.projects.social_platform.architect; +if (!/production:\s*false/.test(env) || !/apiUrl:\s*"https:\/\/dev\.procollab\.ru"/.test(env) || + !/websocketUrl:\s*"wss:\/\/dev\.procollab\.ru\/ws"/.test(env) || + architect.serve.configurations.development.buildTarget !== "social_platform:build:development" || + architect.build.configurations.development.fileReplacements?.length) { + throw new Error("Development-конфигурация не соответствует проверенному запуску с dev API."); +} +NODE +printf 'PR #517 commit: %s\n' "$pr517_actual" +printf 'Локально: http://127.0.0.1:4317/auth/login\nAPI: https://dev.procollab.ru\n' +exec env NG_BUILD_MAX_WORKERS="${NG_BUILD_MAX_WORKERS:-2}" \ + ./node_modules/.bin/ng serve social_platform --configuration=development \ + --host 127.0.0.1 --port 4317 --no-open diff --git a/docs/program-v2-ui-audit/interaction-check.cjs b/docs/program-v2-ui-audit/interaction-check.cjs new file mode 100644 index 000000000..7edce6574 --- /dev/null +++ b/docs/program-v2-ui-audit/interaction-check.cjs @@ -0,0 +1,382 @@ +/** @format */ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { launch, open } = require("./browser-check.cjs"); +const { measure } = require("../responsive/browser-harness.cjs"); +const fixtures = require("./fixtures.cjs"); +const phase = process.argv[2] || "after"; +const widths = (process.env.WIDTHS || "320,390,768,1440,1920").split(",").map(Number); +const destination = path.join(__dirname, "screenshots", phase, "details"); +fs.mkdirSync(destination, { recursive: true }); +const results = []; +const button = (page, name) => page.getByRole("button", { name, exact: true }); +const dialog = page => page.getByRole("dialog"); +const menu = async (page, container) => { + const root = + container === ".experts__table" && !(await page.locator(container).count()) + ? ".solution-analytics__table" + : container; + await page + .locator(root) + .getByRole("button", { name: /Действия|Назначения/ }) + .first() + .click(); + await page.getByRole("menu").waitFor(); +}; +const scenarios = [ + ["participants-filters", "participants", p => p.locator(".management__filter-trigger").click()], + ["participants-sort", "participants", p => p.locator(".management__sort-trigger").click()], + [ + "participant-answers", + "participants", + async p => { + await menu(p, ".management__table"); + await p.getByRole("menuitem", { name: "Посмотреть анкету" }).click(); + }, + ], + ["projects-filters", "projects", p => p.locator(".management__filter-trigger").click()], + ["projects-menu", "projects", p => menu(p, ".management__table")], + [ + "legacy-evaluation", + "projects/31/evaluation", + async p => { + await p.locator("app-result-project-scores").waitFor(); + await p.getByRole("link", { name: "Открыть проект", exact: true }).waitFor(); + assert.equal(await p.getByRole("link", { name: "Открыть решение", exact: true }).count(), 0); + }, + "v1", + ], + [ + "solution-detail", + "projects", + async p => { + await p.locator(".management__solution-link").first().click(); + await p.locator("app-solution-fields").waitFor(); + }, + "v2", + ], + [ + "solution-team", + "projects", + async p => { + await p.locator(".management__solution-link").first().click(); + await button(p, "Команда").click(); + }, + "v2", + ], + [ + "solution-history", + "projects", + async p => { + await p.locator(".management__solution-link").first().click(); + await button(p, "История изменений").click(); + }, + "v2", + ], + [ + "solution-case-change", + "projects", + async p => { + await menu(p, ".management__table"); + await p.getByRole("menuitem", { name: "Сменить кейс", exact: true }).click(); + await dialog(p).locator("select").selectOption("Операционная эффективность"); + }, + "v2", + ], + [ + "solution-archive-preview", + "projects", + async p => { + await menu(p, ".management__table"); + await p.getByRole("menuitem", { name: "Открепить от программы", exact: true }).click(); + await p.getByRole("heading", { name: "Открепить от программы", exact: true }).waitFor(); + }, + "v2", + ], + [ + "solution-submit-preview", + "projects", + async p => { + await p.context().route("**/programs/12/management/solution-analytics/solutions/**", r => { + const data = fixtures.response(new URL(r.request().url()), "GET"); + data.results[0] = { + ...data.results[0], + submissionStatus: "draft", + evaluationStatus: "not_submitted", + assignmentsTotal: 0, + assignmentsCompleted: 0, + }; + return r.fulfill({ json: data }); + }); + await p.reload(); + await p.locator(".management__table tbody input[type=checkbox]").first().check(); + await button(p, "Сдать решения").click(); + await p.getByRole("heading", { name: "Сдать решение за команду", exact: true }).waitFor(); + }, + "v2", + ], + [ + "solution-member-preview", + "projects", + async p => { + await p.locator(".management__solution-link").first().click(); + await button(p, "Команда").click(); + await button(p, "Исключить участника").click(); + await p.getByRole("heading", { name: "Исключить участника", exact: true }).waitFor(); + }, + "v2", + ], + [ + "registration-removal-preview", + "participants", + async p => { + await menu(p, ".management__table"); + await p.getByRole("menuitem", { name: "Удалить из программы", exact: true }).click(); + await p + .getByRole("heading", { name: "Удалить участников из программы?", exact: true }) + .waitFor(); + }, + "v2", + ], + ["experts-invite", "experts", p => button(p, "Пригласить эксперта").click()], + [ + "experts-filter-dialog", + "experts", + async p => { + await p.locator(".experts__filter-trigger").click(); + await dialog(p).waitFor(); + }, + ], + ["experts-menu", "experts", p => menu(p, ".experts__table")], + [ + "experts-scope", + "experts", + async p => { + await menu(p, ".experts__table"); + await p.getByRole("menuitem", { name: "Изменить кейсы" }).click(); + }, + ], + [ + "experts-bulk", + "experts", + async p => { + await p.locator(".experts__table tbody input[type=checkbox]").first().check(); + await p.getByRole("region", { name: /Действия/ }).count(); + }, + ], + [ + "experts-filters", + "experts", + async (p, v) => { + if (phase.startsWith("before") && v === "v2") { + await p.locator('select[aria-label="Статус эксперта"]').selectOption("expired"); + return; + } + const trigger = p.locator(".experts__filter-trigger"); + await trigger.click(); + await dialog(p).waitFor(); + await dialog(p).locator("select").first().selectOption("expired"); + await button(p, "Применить").click(); + await p.waitForFunction( + () => document.querySelectorAll(".experts__table tbody tr[data-expert-id]").length === 1, + ); + assert.equal(await p.locator(".experts__table tbody tr[data-expert-id]").count(), 1); + await trigger.click(); + await button(p, "Сбросить").click(); + await button(p, "Применить").click(); + await p.waitForFunction( + () => document.querySelectorAll(".experts__table tbody tr[data-expert-id]").length === 5, + ); + assert.equal(await p.locator(".experts__table tbody tr[data-expert-id]").count(), 5); + }, + ], + [ + "experts-no-results", + "experts", + async p => { + await p.locator(".experts__toolbar input[type=search]").fill("Несуществующий эксперт"); + await p.getByText(/Эксперты не найдены|Экспертов не найдено/).waitFor(); + }, + ], + [ + "expert-solution-assignments", + "experts", + async p => { + await menu(p, ".experts__table"); + await p.getByRole("menuitem", { name: "Назначить решения" }).click(); + }, + "v2", + ], + [ + "distribution-quota", + "experts", + async (p, v) => { + await button(p, "Распределение экспертов").click(); + if (phase.startsWith("before") && v === "v2") + await p.getByRole("button", { name: /^Оценщиков:/ }).click(); + else await p.locator(".experts__quota").getByRole("button", { name: "Изменить" }).click(); + }, + ], + [ + "distribution-row-menu", + "experts", + async (p, v) => { + await button(p, "Распределение экспертов").click(); + const table = v === "v1" ? ".experts__table--projects" : ".experts__table--solutions"; + const triggerName = v === "v1" ? /^Действия проекта:/ : /^Назначения решения:/; + await p.locator(table).waitFor(); + const rows = p.locator(`${table} tbody tr`); + for (const row of await rows.all()) { + const trigger = row.getByRole("button", { name: triggerName }); + const parent = await row.boundingBox(), + control = await trigger.boundingBox(); + assert.ok( + parent && + control && + control.x >= parent.x && + control.x + control.width <= parent.x + parent.width + 1, + "Меню должно помещаться по ширине строки", + ); + assert.ok( + control.y >= parent.y && control.y + control.height <= parent.y + parent.height + 1, + "Меню должно принадлежать своей строке", + ); + } + await rows.first().getByRole("button", { name: triggerName }).click(); + await p.getByRole("menuitem", { name: /^(Изменить|Назначить) экспертов$/ }).waitFor(); + }, + ], + [ + "distribution-allocation", + "experts", + async p => { + await button(p, "Распределение экспертов").click(); + await p.getByRole("button", { name: /^Распределить/ }).click(); + }, + ], + ["results-filters", "results", p => p.getByRole("button", { name: /^Фильтры/ }).click()], + ["results-formula", "results", p => button(p, "Как считается балл").click()], + ["results-export", "results", p => button(p, "Выгрузить результаты в Excel").click()], + ["results-scores", "results", p => p.locator(".results__row").first().click()], + [ + "settings-basic-save", + "settings?tab=basic", + async (p, v, requests) => { + await p.locator("#program-name").fill("Кейс-чемпионат — проверка сохранения"); + await button(p, "Сохранить изменения").click(); + await p.getByText("Изменения сохранены.", { exact: true }).waitFor(); + assert.ok( + requests.some( + r => + r.method === "PATCH" && + r.path === `/programs/${v === "v1" ? 1 : 12}/management/settings/basic/`, + ), + ); + }, + ], + ["settings-material", "settings?tab=materials", p => button(p, "Добавить материал").click()], + ["settings-field-editor", "settings?tab=project-form", p => button(p, "Добавить поле").click()], + [ + "settings-field-preview", + "settings?tab=project-form", + p => p.getByRole("button", { name: /^Предпросмотр формы/ }).click(), + ], + ["criteria-editor", "settings?tab=criteria", p => button(p, "Добавить критерий").click()], + [ + "criteria-preview", + "settings?tab=criteria", + p => p.getByRole("button", { name: /^Предпросмотр/ }).click(), + ], + [ + "organizers-invite", + "settings?tab=organizers", + p => p.locator(".organizers__heading > button").click(), + ], + ["registration-editor", "settings?tab=registration", p => button(p, "Добавить поле").click()], + ["registration-preview", "settings?tab=registration", p => button(p, "Предпросмотр").click()], + ["certificate-issue", "certificates", p => button(p, "2. Выдача").click()], + ["certificate-journal", "certificates", p => button(p, "3. Отправление").click()], + [ + "certificate-editor", + "certificates", + p => p.getByRole("button", { name: /Открыть редактор|Развернуть редактор/ }).click(), + ], + ["analytics-export", "analytics", p => button(p, "Выгрузить данные программы").click()], + [ + "analytics-lower", + "analytics", + async p => { + await p + .getByRole("heading", { name: "Динамика активности", exact: true }) + .scrollIntoViewIfNeeded(); + }, + ], + [ + "analytics-progress", + "analytics", + async p => { + await p + .getByRole("heading", { name: "Ход оценивания", exact: true }) + .scrollIntoViewIfNeeded(); + }, + ], + [ + "analytics-assignments", + "analytics", + async (p, v) => { + if (phase.startsWith("before") && v === "v2") + await p.getByRole("button", { name: /^Все назначения/ }).click(); + else await button(p, "Открыть назначения").click(); + await dialog(p).waitFor(); + }, + ], +]; +async function run() { + for (const width of widths) { + const { browser, page, requests, errors } = await launch(width); + page.setDefaultTimeout(6000); + for (const version of ["v1", "v2"]) + for (const [name, route, action, only] of scenarios) { + if (only && only !== version) continue; + if (process.env.CASES && !process.env.CASES.split(",").includes(name)) continue; + const errorStart = errors.length; + try { + await open(page, version, route, name); + await action(page, version, requests); + await page.waitForTimeout(100); + const metrics = await measure(page); + const file = `${version}-${name}-${width}.png`; + await page.screenshot({ path: path.join(destination, file), fullPage: true }); + assert.equal(metrics.overflows.length, 0, JSON.stringify(metrics.overflows)); + assert.ok(metrics.pageWidth <= width + 1, `width ${metrics.pageWidth} > ${width}`); + assert.equal(errors.length - errorStart, 0, errors.slice(errorStart).join("\n")); + results.push({ version, scenario: name, width, file, metrics, passed: true }); + console.log(`${version} ${name} ${width}: PASS`); + } catch (error) { + results.push({ + version, + scenario: name, + width, + passed: false, + error: error.message.split("\n").slice(0, 5).join("\n"), + }); + console.log(`${version} ${name} ${width}: FAIL ${error.message.split("\n")[0]}`); + } + } + fs.writeFileSync( + path.join(__dirname, `${phase}-interaction-requests-${width}.json`), + JSON.stringify(requests, null, 2), + ); + await browser.close(); + } + fs.writeFileSync( + path.join(__dirname, `${phase}-interaction-results.json`), + JSON.stringify(results, null, 2), + ); + if (results.some(r => !r.passed)) process.exitCode = 1; +} +run().catch(e => { + console.error(e); + process.exitCode = 1; +}); diff --git a/docs/program-v2-ui-audit/run-tests.py b/docs/program-v2-ui-audit/run-tests.py new file mode 100644 index 000000000..18a1af471 --- /dev/null +++ b/docs/program-v2-ui-audit/run-tests.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Полный настроенный Vitest-набор небольшими процессами для памяти Cloud.""" +import json +import os +from pathlib import Path +import subprocess +import time + +ROOT = Path(__file__).resolve().parents[2] +OUTPUT = Path(os.environ.get("PROGRAM_UI_TEST_OUTPUT", str(ROOT.parent / "artifacts/program-v2-ui-audit/tests"))) +OUTPUT.mkdir(parents=True, exist_ok=True) +EXCLUDED = { + "projects/social_platform/src/app/ui/pages/projects/projects.component.spec.ts", + "projects/social_platform/src/app/ui/pages/program/program.component.spec.ts", + "projects/social_platform/src/app/ui/pages/profile/edit/editor-submit-button.directive.spec.ts", +} +files = sorted(str(p.relative_to(ROOT)) for prefix in ("projects/core", "projects/ui", "projects/social_platform") for p in (ROOT / prefix).rglob("*.spec.ts") if str(p.relative_to(ROOT)) not in EXCLUDED) +outcomes = {} +attempts = [] + +def import_report(report): + data = json.loads(report.read_text()) + for item in data.get("testResults", []): + name = str(Path(item["name"]).relative_to(ROOT)) + assertions = item.get("assertionResults", []) + outcomes[name] = {"file": name, "status": item["status"], "tests": len(assertions), "passed": sum(a["status"] == "passed" for a in assertions), "failed": sum(a["status"] == "failed" for a in assertions), "pending": sum(a["status"] in ("pending", "todo", "skipped") for a in assertions)} + return data + +# Возобновление разрешено только явно, в том же неизмененном checkout текущей проверки. +if os.environ.get("PROGRAM_UI_RESUME") == "1": + for report in sorted(OUTPUT.glob("batch-*.json"), key=lambda p: p.stat().st_mtime): + data = json.loads(report.read_text()) + if data.get("success"): + import_report(report) + +remaining = [f for f in files if outcomes.get(f, {}).get("status") != "passed"] +run_id = str(int(time.time())) + +def check(selected): + number = len(attempts) + 1 + report = OUTPUT / f"run-{run_id}-{number}.json" + command = ["node", "--max-old-space-size=1536", "node_modules/vitest/vitest.mjs", "run", "--maxWorkers=1", "--no-file-parallelism", "--pool=forks", "--reporter=default", "--reporter=json", f"--outputFile.json={report}", *selected] + with (OUTPUT / f"run-{run_id}-{number}.log").open("w") as output: + result = subprocess.run(command, cwd=ROOT, stdout=output, stderr=subprocess.STDOUT, env={**os.environ, "NODE_OPTIONS": "--max-old-space-size=1536"}) + attempts.append({"batch": number, "files": len(selected), "exitCode": result.returncode}) + if report.exists(): + data = import_report(report) + print(f"batch {number}: files={len(selected)} tests={data.get('numTotalTests', 0)} exit={result.returncode}", flush=True) + else: + print(f"batch {number}: files={len(selected)} exit={result.returncode}", flush=True) + if result.returncode in (-6, -9, 137) and len(selected) > 1: + middle = len(selected) // 2 + check(selected[:middle]) + check(selected[middle:]) + +print(f"configured_files={len(files)} already_passed={len(outcomes)} remaining={len(remaining)}", flush=True) +for index in range(0, len(remaining), 8): + check(remaining[index:index + 8]) + +results = [outcomes[f] for f in files if f in outcomes] +summary = {"runner": "vitest", "pool": "forks", "maxWorkers": 1, "files": len(files), "filesReported": len(results), "excludedByExistingConfig": sorted(EXCLUDED), "tests": sum(r["tests"] for r in results), "passed": sum(r["passed"] for r in results), "failed": sum(r["failed"] for r in results), "pending": sum(r["pending"] for r in results), "success": len(results) == len(files) and all(r["status"] == "passed" for r in results), "attempts": attempts, "fileResults": results} +(ROOT / "docs/program-v2-ui-audit/tests-results.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n") +print(json.dumps({k: v for k, v in summary.items() if k not in ("attempts", "fileResults")}, ensure_ascii=False), flush=True) +raise SystemExit(0 if summary["success"] else 1) diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-analytics-assignment-layout-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-analytics-assignment-layout-1440.png new file mode 100644 index 000000000..1e479631b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-analytics-assignment-layout-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-analytics-assignment-layout-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-analytics-assignment-layout-390.png new file mode 100644 index 000000000..7e0a42c77 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-analytics-assignment-layout-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-assignments-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-assignments-1440.png new file mode 100644 index 000000000..40f1d62de Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-assignments-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-quota-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-quota-1440.png new file mode 100644 index 000000000..1c275a9c5 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-quota-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-quota-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-quota-390.png new file mode 100644 index 000000000..795c6a60e Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-quota-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-scope-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-scope-1440.png new file mode 100644 index 000000000..c80dee2e7 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-scope-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-scope-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-scope-390.png new file mode 100644 index 000000000..4fe7976e9 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-expert-scope-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-invite-filled-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-invite-filled-1440.png new file mode 100644 index 000000000..8a0dc2a7b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-invite-filled-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-invite-filled-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-invite-filled-390.png new file mode 100644 index 000000000..c4878263f Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v1-figma-invite-filled-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-assignment-layout-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-assignment-layout-1440.png new file mode 100644 index 000000000..a5b2e6487 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-assignment-layout-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-assignment-layout-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-assignment-layout-390.png new file mode 100644 index 000000000..11160a8ae Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-assignment-layout-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-zero-false-comment-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-zero-false-comment-1440.png new file mode 100644 index 000000000..a0277d08d Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-zero-false-comment-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-zero-false-comment-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-zero-false-comment-390.png new file mode 100644 index 000000000..8bcc1c4e1 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-analytics-zero-false-comment-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-assignments-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-assignments-1440.png new file mode 100644 index 000000000..ac668f2b8 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-assignments-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-assignments-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-assignments-390.png new file mode 100644 index 000000000..6e6c7430c Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-assignments-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-quota-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-quota-1440.png new file mode 100644 index 000000000..b8b2cd359 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-quota-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-quota-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-quota-390.png new file mode 100644 index 000000000..732f90a31 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-quota-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-scope-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-scope-1440.png new file mode 100644 index 000000000..f90943a64 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-scope-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-scope-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-scope-390.png new file mode 100644 index 000000000..0d7923ff0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-expert-scope-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-invite-filled-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-invite-filled-1440.png new file mode 100644 index 000000000..56ac016f8 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-invite-filled-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-invite-filled-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-invite-filled-390.png new file mode 100644 index 000000000..d83627740 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/conformance/v2-figma-invite-filled-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-analytics-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-analytics-1440.png new file mode 100644 index 000000000..7a44b9c6f Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-analytics-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-analytics-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-analytics-390.png new file mode 100644 index 000000000..452ff314b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-analytics-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-certificates-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-certificates-1440.png new file mode 100644 index 000000000..a8b7e72f0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-certificates-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-certificates-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-certificates-390.png new file mode 100644 index 000000000..ece01a804 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-certificates-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-distribution-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-distribution-1440.png new file mode 100644 index 000000000..361d3d9a0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-distribution-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-distribution-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-distribution-390.png new file mode 100644 index 000000000..9ebf5ef56 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-distribution-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-experts-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-experts-1440.png new file mode 100644 index 000000000..2af9f0fb7 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-experts-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-experts-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-experts-390.png new file mode 100644 index 000000000..635688da0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-experts-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-participants-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-participants-1440.png new file mode 100644 index 000000000..735b4999a Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-participants-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-participants-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-participants-390.png new file mode 100644 index 000000000..a08193a46 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-participants-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-projects-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-projects-1440.png new file mode 100644 index 000000000..20407f4b2 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-projects-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-projects-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-projects-390.png new file mode 100644 index 000000000..f61b446ed Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v1-projects-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-analytics-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-analytics-1440.png new file mode 100644 index 000000000..79811bc27 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-analytics-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-analytics-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-analytics-390.png new file mode 100644 index 000000000..104827de1 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-analytics-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-certificates-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-certificates-1440.png new file mode 100644 index 000000000..c68a04642 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-certificates-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-certificates-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-certificates-390.png new file mode 100644 index 000000000..5e9738b16 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-certificates-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-distribution-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-distribution-1440.png new file mode 100644 index 000000000..2bc78a287 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-distribution-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-distribution-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-distribution-390.png new file mode 100644 index 000000000..7dde3d926 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-distribution-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-experts-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-experts-1440.png new file mode 100644 index 000000000..64badbf27 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-experts-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-experts-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-experts-390.png new file mode 100644 index 000000000..bf5ae4737 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-experts-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-participants-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-participants-1440.png new file mode 100644 index 000000000..f8619ee40 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-participants-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-participants-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-participants-390.png new file mode 100644 index 000000000..10e9040e7 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-participants-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-projects-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-projects-1440.png new file mode 100644 index 000000000..1d4f8cdf0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-projects-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-projects-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-projects-390.png new file mode 100644 index 000000000..1acc60cee Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-before/v2-projects-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-analytics-assignment-layout-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-analytics-assignment-layout-1440.png new file mode 100644 index 000000000..b9126eba7 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-analytics-assignment-layout-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-analytics-assignment-layout-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-analytics-assignment-layout-390.png new file mode 100644 index 000000000..696e493e6 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-analytics-assignment-layout-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-assignments-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-assignments-1440.png new file mode 100644 index 000000000..e147b8720 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-assignments-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-assignments-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-assignments-390.png new file mode 100644 index 000000000..8b5bd79d6 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-assignments-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-quota-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-quota-1440.png new file mode 100644 index 000000000..180b32e46 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-quota-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-quota-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-quota-390.png new file mode 100644 index 000000000..61fb5198b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-quota-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-scope-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-scope-1440.png new file mode 100644 index 000000000..16d97f60a Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-scope-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-scope-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-scope-390.png new file mode 100644 index 000000000..eb8d9530b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-expert-scope-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-invite-filled-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-invite-filled-1440.png new file mode 100644 index 000000000..d4516eede Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-invite-filled-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-invite-filled-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-invite-filled-390.png new file mode 100644 index 000000000..439e03b1d Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v1-figma-invite-filled-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-assignment-layout-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-assignment-layout-1440.png new file mode 100644 index 000000000..5b3f3fc2d Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-assignment-layout-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-assignment-layout-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-assignment-layout-390.png new file mode 100644 index 000000000..6f98f52b2 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-assignment-layout-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-zero-false-comment-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-zero-false-comment-1440.png new file mode 100644 index 000000000..12f16ebf8 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-zero-false-comment-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-zero-false-comment-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-zero-false-comment-390.png new file mode 100644 index 000000000..201fc3688 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-analytics-zero-false-comment-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-assignments-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-assignments-1440.png new file mode 100644 index 000000000..87bc3ed46 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-assignments-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-assignments-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-assignments-390.png new file mode 100644 index 000000000..fe3eb9975 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-assignments-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-quota-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-quota-1440.png new file mode 100644 index 000000000..8805d7c07 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-quota-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-quota-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-quota-390.png new file mode 100644 index 000000000..8cdb3e905 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-quota-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-scope-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-scope-1440.png new file mode 100644 index 000000000..64e9613dd Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-scope-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-scope-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-scope-390.png new file mode 100644 index 000000000..2b20e362b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-expert-scope-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-invite-filled-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-invite-filled-1440.png new file mode 100644 index 000000000..03dd56ba9 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-invite-filled-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-invite-filled-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-invite-filled-390.png new file mode 100644 index 000000000..60281b021 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/conformance/v2-figma-invite-filled-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-analytics-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-analytics-1440.png new file mode 100644 index 000000000..7a44b9c6f Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-analytics-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-analytics-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-analytics-390.png new file mode 100644 index 000000000..452ff314b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-analytics-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-certificates-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-certificates-1440.png new file mode 100644 index 000000000..a8b7e72f0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-certificates-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-certificates-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-certificates-390.png new file mode 100644 index 000000000..ece01a804 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-certificates-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-distribution-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-distribution-1440.png new file mode 100644 index 000000000..ea7fcf45b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-distribution-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-distribution-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-distribution-390.png new file mode 100644 index 000000000..17e1bca9a Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-distribution-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-experts-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-experts-1440.png new file mode 100644 index 000000000..3367fd099 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-experts-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-experts-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-experts-390.png new file mode 100644 index 000000000..635688da0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-experts-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-participants-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-participants-1440.png new file mode 100644 index 000000000..735b4999a Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-participants-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-participants-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-participants-390.png new file mode 100644 index 000000000..a08193a46 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-participants-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-projects-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-projects-1440.png new file mode 100644 index 000000000..20407f4b2 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-projects-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-projects-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-projects-390.png new file mode 100644 index 000000000..f61b446ed Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v1-projects-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-analytics-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-analytics-1440.png new file mode 100644 index 000000000..79811bc27 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-analytics-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-analytics-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-analytics-390.png new file mode 100644 index 000000000..104827de1 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-analytics-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-certificates-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-certificates-1440.png new file mode 100644 index 000000000..2a8714ba6 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-certificates-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-certificates-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-certificates-390.png new file mode 100644 index 000000000..5e9738b16 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-certificates-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-distribution-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-distribution-1440.png new file mode 100644 index 000000000..2bc78a287 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-distribution-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-distribution-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-distribution-390.png new file mode 100644 index 000000000..7dde3d926 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-distribution-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-experts-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-experts-1440.png new file mode 100644 index 000000000..64badbf27 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-experts-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-experts-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-experts-390.png new file mode 100644 index 000000000..bf5ae4737 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-experts-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-participants-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-participants-1440.png new file mode 100644 index 000000000..f8619ee40 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-participants-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-participants-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-participants-390.png new file mode 100644 index 000000000..10e9040e7 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-participants-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-projects-1440.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-projects-1440.png new file mode 100644 index 000000000..1d4f8cdf0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-projects-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-projects-390.png b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-projects-390.png new file mode 100644 index 000000000..1acc60cee Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/acceptance-final/v2-projects-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-analytics-1440.png b/docs/program-v2-ui-audit/screenshots/after/v2-analytics-1440.png new file mode 100644 index 000000000..5a73e5bd6 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-analytics-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-analytics-390.png b/docs/program-v2-ui-audit/screenshots/after/v2-analytics-390.png new file mode 100644 index 000000000..d93e905ad Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-analytics-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-certificates-1440.png b/docs/program-v2-ui-audit/screenshots/after/v2-certificates-1440.png new file mode 100644 index 000000000..c68a04642 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-certificates-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-certificates-390.png b/docs/program-v2-ui-audit/screenshots/after/v2-certificates-390.png new file mode 100644 index 000000000..5e9738b16 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-certificates-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-distribution-1440.png b/docs/program-v2-ui-audit/screenshots/after/v2-distribution-1440.png new file mode 100644 index 000000000..29eb86668 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-distribution-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-distribution-390.png b/docs/program-v2-ui-audit/screenshots/after/v2-distribution-390.png new file mode 100644 index 000000000..0505f9ed6 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-distribution-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-experts-1440.png b/docs/program-v2-ui-audit/screenshots/after/v2-experts-1440.png new file mode 100644 index 000000000..eecdf0af9 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-experts-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-experts-390.png b/docs/program-v2-ui-audit/screenshots/after/v2-experts-390.png new file mode 100644 index 000000000..193bb9d48 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-experts-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-projects-1440.png b/docs/program-v2-ui-audit/screenshots/after/v2-projects-1440.png new file mode 100644 index 000000000..1d4f8cdf0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-projects-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-projects-390.png b/docs/program-v2-ui-audit/screenshots/after/v2-projects-390.png new file mode 100644 index 000000000..1acc60cee Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-projects-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-settings-project-form-1440.png b/docs/program-v2-ui-audit/screenshots/after/v2-settings-project-form-1440.png new file mode 100644 index 000000000..6e447c2c1 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-settings-project-form-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/after/v2-settings-project-form-390.png b/docs/program-v2-ui-audit/screenshots/after/v2-settings-project-form-390.png new file mode 100644 index 000000000..2b042f55f Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/after/v2-settings-project-form-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-analytics-1440.png b/docs/program-v2-ui-audit/screenshots/before/v1-analytics-1440.png new file mode 100644 index 000000000..7a44b9c6f Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-analytics-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-analytics-390.png b/docs/program-v2-ui-audit/screenshots/before/v1-analytics-390.png new file mode 100644 index 000000000..452ff314b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-analytics-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-certificates-1440.png b/docs/program-v2-ui-audit/screenshots/before/v1-certificates-1440.png new file mode 100644 index 000000000..bd6ae3af5 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-certificates-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-certificates-390.png b/docs/program-v2-ui-audit/screenshots/before/v1-certificates-390.png new file mode 100644 index 000000000..ece01a804 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-certificates-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-distribution-1440.png b/docs/program-v2-ui-audit/screenshots/before/v1-distribution-1440.png new file mode 100644 index 000000000..ea7fcf45b Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-distribution-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-distribution-390.png b/docs/program-v2-ui-audit/screenshots/before/v1-distribution-390.png new file mode 100644 index 000000000..9ebf5ef56 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-distribution-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-experts-1440.png b/docs/program-v2-ui-audit/screenshots/before/v1-experts-1440.png new file mode 100644 index 000000000..3367fd099 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-experts-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-experts-390.png b/docs/program-v2-ui-audit/screenshots/before/v1-experts-390.png new file mode 100644 index 000000000..635688da0 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-experts-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-projects-1440.png b/docs/program-v2-ui-audit/screenshots/before/v1-projects-1440.png new file mode 100644 index 000000000..f51592734 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-projects-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-projects-390.png b/docs/program-v2-ui-audit/screenshots/before/v1-projects-390.png new file mode 100644 index 000000000..f61b446ed Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-projects-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-settings-project-form-1440.png b/docs/program-v2-ui-audit/screenshots/before/v1-settings-project-form-1440.png new file mode 100644 index 000000000..84a22cde4 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-settings-project-form-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v1-settings-project-form-390.png b/docs/program-v2-ui-audit/screenshots/before/v1-settings-project-form-390.png new file mode 100644 index 000000000..7a0461f18 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v1-settings-project-form-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-analytics-1440.png b/docs/program-v2-ui-audit/screenshots/before/v2-analytics-1440.png new file mode 100644 index 000000000..19057e8c7 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-analytics-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-analytics-390.png b/docs/program-v2-ui-audit/screenshots/before/v2-analytics-390.png new file mode 100644 index 000000000..609a1c174 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-analytics-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-certificates-1440.png b/docs/program-v2-ui-audit/screenshots/before/v2-certificates-1440.png new file mode 100644 index 000000000..c68a04642 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-certificates-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-certificates-390.png b/docs/program-v2-ui-audit/screenshots/before/v2-certificates-390.png new file mode 100644 index 000000000..5e9738b16 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-certificates-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-distribution-1440.png b/docs/program-v2-ui-audit/screenshots/before/v2-distribution-1440.png new file mode 100644 index 000000000..6e6b16380 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-distribution-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-distribution-390.png b/docs/program-v2-ui-audit/screenshots/before/v2-distribution-390.png new file mode 100644 index 000000000..554596052 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-distribution-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-experts-1440.png b/docs/program-v2-ui-audit/screenshots/before/v2-experts-1440.png new file mode 100644 index 000000000..db5223743 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-experts-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-experts-390.png b/docs/program-v2-ui-audit/screenshots/before/v2-experts-390.png new file mode 100644 index 000000000..5a51001fb Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-experts-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-projects-1440.png b/docs/program-v2-ui-audit/screenshots/before/v2-projects-1440.png new file mode 100644 index 000000000..aa5bbc02d Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-projects-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-projects-390.png b/docs/program-v2-ui-audit/screenshots/before/v2-projects-390.png new file mode 100644 index 000000000..50863f446 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-projects-390.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-settings-project-form-1440.png b/docs/program-v2-ui-audit/screenshots/before/v2-settings-project-form-1440.png new file mode 100644 index 000000000..6e447c2c1 Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-settings-project-form-1440.png differ diff --git a/docs/program-v2-ui-audit/screenshots/before/v2-settings-project-form-390.png b/docs/program-v2-ui-audit/screenshots/before/v2-settings-project-form-390.png new file mode 100644 index 000000000..2b042f55f Binary files /dev/null and b/docs/program-v2-ui-audit/screenshots/before/v2-settings-project-form-390.png differ diff --git a/docs/program-v2-ui-audit/serve-production.py b/docs/program-v2-ui-audit/serve-production.py new file mode 100644 index 000000000..48dd5fffe --- /dev/null +++ b/docs/program-v2-ui-audit/serve-production.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Локальная SPA-раздача оптимизированного артефакта, без публикации.""" +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] / "dist/social_platform" + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(ROOT), **kwargs) + + def do_GET(self): + if not Path(self.translate_path(self.path)).exists() and not Path(self.path.split("?")[0]).suffix: + self.path = "/index.html" + super().do_GET() + +ThreadingHTTPServer(("127.0.0.1", 4365), Handler).serve_forever() diff --git a/docs/program-v2-ui-audit/source-identity.json b/docs/program-v2-ui-audit/source-identity.json new file mode 100644 index 000000000..7ee475699 --- /dev/null +++ b/docs/program-v2-ui-audit/source-identity.json @@ -0,0 +1,215 @@ +{ + "base": "9ef90b5824b857fe53d52c097e37b279b897d9bf", + "targetBranch": "dev", + "workingBranch": "fix/program-v2-design-system", + "verifiedAtUtc": "2026-11-01T19:07:56.894725+00:00", + "runtime": { + "node": "v24.19.0", + "angular": "20.3.23", + "chromium": "Chromium 154.0.8037.57 built on Debian GNU/Linux 13 (trixie)" + }, + "productFiles": [ + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics-dialog.scss", + "sha256": "f160b4392fc4bcd8dbc05b7dfcf88c22c64c49d907adab120eb5626b0493d61f" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics-table.scss", + "sha256": "72e4177f3ba01d23d9bc6bad0f251d91a822dd1f40d0d1d7af08d164bf7cac3e" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss", + "sha256": "120d4aa1d62516803b0ebd33477ffd9ddceee1339d6b968a8e2b92c1ba430055" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts", + "sha256": "f71edec99a803b38f236079a23492d7a590ac0c824078504cc61b824053bbff1" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html", + "sha256": "5a0d097124a43a39b2106129296b3acc64bc10148a47cae6fccad97c50420e49" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss", + "sha256": "f1e3ac54ee89592503e27629852bb94257a7d01dff81e3806688012d681be9dd" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts", + "sha256": "3eb9ad806c7dd824474af77ac5033be39dec8eabbbbd8b60849540425c5dd32f" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts", + "sha256": "1d4d55b456fe8a14f2b1bd5a5a3b478801237d97a84efeab9e0dd5779f65f61e" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.html", + "sha256": "8185e2761412f943249eb90b292af5a40e6d84506668bd8fd6cdbdecaab3730d" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.scss", + "sha256": "1f4f73ad46e5bb2288e26945fe3faa62ed058f74dfad97b2c78b99ec1a4cbae1" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.ts", + "sha256": "14d2194e7dfd33027765ed8e8b9d3a563e181bcf17995d8e3adab32abbaf340e" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.html", + "sha256": "bdd849238043f66150dcf01ce3f25aa91b1edb154ec6f7020888f2d11f123f3f" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.spec.ts", + "sha256": "3526ea7a1fb0133382c8ed7d7dc472a8ef46ee63eeecb5ed03ca5c67a81ec159" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.ts", + "sha256": "3c64eb5a5b7fd394567ee0ee3aa7c60c94b5a826b080bafaa0547f2192b6e140" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.html", + "sha256": "039c74a53c8c657ef0be9ced15d0291359437ab19b0ff005a06fef1e921e6b9e" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.scss", + "sha256": "a537c5aade08960886fdd72bf6ab16f4322ccbd507233da57eca3eed6a5dc425" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.ts", + "sha256": "d02dd257c59983582ea39bde41e647addb404dfa9ab823b3c9cdebb760cb5c82" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.scss", + "sha256": "38b1548c9528319cab37791c1c3515550dadcb932474b639f910b71045c0072f" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.ts", + "sha256": "a6d4464f057411a5804d501ac63f2d3cb80afe37b988bdb0d6fe956895d950db" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/expert-presentation.ts", + "sha256": "8ec83708ff427ab8b15086a976f796d9feeb3b62410fd7f8ce74a2943520a3f2" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.html", + "sha256": "cd94cda9922fc5e5c02dfee4caa0b425a1c2a51b2eb2a7c4ff4fad69cd8cabc8" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.scss", + "sha256": "dfbff52691b5f6ef34c45c437fc4e93e28da219fef6db56240b32b9253d31693" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.ts", + "sha256": "1c19b8017bece25229f4658cb4e894df0ca04d49a3e12abe438948c721011903" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/expert-table.scss", + "sha256": "0100252cb152be5137af2d3dc6a34dfe9bd9d6797b6c417edf271beb76978356" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html", + "sha256": "02b0da358730db9aff27056706a4e4bd7db5db9f921ac1000e611e897c9b2370" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.scss", + "sha256": "93b9c20949b187d85ed837a9fbe3710f7808759dc1756a22a276d95326e0a220" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.ts", + "sha256": "12c38b8cb78cb04f5542d4c1a949c46b2abdbf2fff529b0cfac767786550ac99" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.html", + "sha256": "e4615eb87f9bd565a177f0496f46a488df6e365ad4f82965af2694162fc910d2" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.spec.ts", + "sha256": "62db830d0ab495c6bf4226719ab546a80f8cea1d9f59b6e39a4ac546eb5aef0f" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.ts", + "sha256": "464f6f633d9ce3fa45bc224bc6672164b1eb1babc5471a99587c7ba7292435f0" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/management-list.scss", + "sha256": "30ab4b7f7acd6d15c3a5f23bde6e1036929601f2bc9301c49bb55321cad9f1fb" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/management.component.html", + "sha256": "a5cb23eb20e7f2419f861dddf9dce1f4c65d161890b09d7f6d755ca77ec7438f" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/management.component.scss", + "sha256": "b10ee84c7d0549650abf41f444c335566fc7fea776fe2c9a4c03856bf53a373d" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/management.component.ts", + "sha256": "d3453f89fc3ad393ca8f0351b0006ed91126b7713bd72c47603beaf27576bc26" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/program-native-button.scss", + "sha256": "6e1bc8b90b4d31f6d304281b4da42be7bcee34a6ece5160c59f9a2067682c309" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/program-native-choice.scss", + "sha256": "0742f328ee3a359c7caaa8747114fd80b9896f5be8031fe1ec83f9766a8bd098" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/program-native-field.scss", + "sha256": "69a8112ab618e8e073394d9af00b546a9491682cf1e9c1009ab7f53be82885b6" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-allocation.component.ts", + "sha256": "832ed7a600382abe7a41a8169f50d0c62065e1ab14e7b7f9fbf10ec752ea3531" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-applications.component.html", + "sha256": "422f02fc8bc94137e4c962d9c62cf9e4393964192c96505c86a09593d893fecf" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-applications.component.scss", + "sha256": "65ead9141a7c96712b5cfa5260dcb025e2da0b0030a1d7244ad9fb8c22879a45" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-applications.component.ts", + "sha256": "a42b3bd9874b27032cc3cd647f69a37b8688e8f13f04695043064a31f788cfb9" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-case-action.component.html", + "sha256": "ab6cbca320c07d829be9209e7650b3034c5d4e5ceb29850d6fb4a5052e1a3581" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-case-action.component.ts", + "sha256": "8c0788e41a520699e35547200d3f30a1a851fc409c19d7b2207431ed61c2f117" + }, + { + "path": "projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-registration-removal.component.ts", + "sha256": "d5b41c88d10ed8e4a2fc2682fa5d4bce49c7acb50eeb6e401ba17d2dfe5b77b8" + }, + { + "path": "projects/social_platform/src/index.html", + "sha256": "523760273902354c7c8c6d961ed6d152ce7329e89063978d0d0d69a89c540e1f" + }, + { + "path": "projects/social_platform/src/styles/_program-analytics-dialog.scss", + "sha256": "50007d9262a470088f590653e558fcbaf9248d10359cb249ea7facff2aec4f27" + }, + { + "path": "projects/social_platform/src/styles/_program-expert-roster.scss", + "sha256": "e65b214958a9d134666ebe090dcb3be28568eaa44e183c4643b5a4d00187dd99" + }, + { + "path": "projects/social_platform/src/styles/_program-management-controls.scss", + "sha256": "182cae1fcf1c84a3eedafba52f1032bf0e260bdd0e973cb0ef027fda066b1a49" + } + ], + "unchangedContracts": [ + "projects/social_platform/src/app/domain", + "projects/social_platform/src/app/infrastructure/adapters", + "projects/social_platform/src/app/ui/routes" + ], + "migrations": false, + "merged": false, + "deployed": false, + "priorImplementation": "44761cdc", + "productFingerprint": "21da4ebef54a5584bcc24f70f4d5ff9970d241fa27fde578b6f8240be18c027b" +} diff --git a/docs/program-v2-ui-audit/state-check.cjs b/docs/program-v2-ui-audit/state-check.cjs new file mode 100644 index 000000000..17d634507 --- /dev/null +++ b/docs/program-v2-ui-audit/state-check.cjs @@ -0,0 +1,181 @@ +/** @format */ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { launch, open, base } = require("./browser-check.cjs"); +const fixtures = require("./fixtures.cjs"); +const { measure } = require("../responsive/browser-harness.cjs"); +const destination = path.join(__dirname, "screenshots", "after", "states"); +fs.mkdirSync(destination, { recursive: true }); +const results = []; +async function record(name, width, action) { + const { browser, context, page, errors } = await launch(width); + page.setDefaultTimeout(7000); + try { + await action({ context, page }); + await page.waitForTimeout(100); + const metrics = await measure(page); + assert.equal(metrics.overflows.length, 0, JSON.stringify(metrics.overflows)); + assert.ok(metrics.pageWidth <= width + 1); + const runtimeErrors = errors.filter( + e => !e.startsWith("Failed to load resource:") && !/\[ERROR\] \[HTTP\].* (403|503) /.test(e), + ); + assert.deepEqual(runtimeErrors, []); + const file = `${name}-${width}.png`; + await page.screenshot({ path: path.join(destination, file), fullPage: true }); + results.push({ name, width, file, passed: true, metrics }); + console.log(`${name} ${width}: PASS`); + } catch (error) { + results.push({ name, width, passed: false, error: error.message }); + console.log(`${name} ${width}: FAIL ${error.message.split("\n")[0]}`); + } finally { + await browser.close(); + } +} +async function run() { + for (const width of [320, 390, 768, 1440, 1920]) { + await record("experts-empty", width, async ({ context, page }) => { + await context.route("**/programs/12/management/experts/", r => + r.fulfill({ json: { ...fixtures.roster(), experts: [] } }), + ); + await open(page, "v2", "experts", "experts"); + await page + .getByText("Экспертов пока нет. Пригласите первого эксперта по email.", { exact: true }) + .waitFor(); + assert.equal( + await page.getByRole("button", { name: "Выгрузить экспертов в Excel" }).isDisabled(), + true, + ); + }); + await record("experts-error-retry", width, async ({ context, page }) => { + let calls = 0, + recover = false; + await context.route("**/programs/12/management/experts/", r => { + calls++; + return r.fulfill( + !recover + ? { status: 503, json: { detail: "Временная ошибка" } } + : { json: fixtures.roster() }, + ); + }); + await open(page, "v2", "experts", "experts"); + await page + .getByRole("alert") + .filter({ hasText: "Не удалось загрузить экспертов" }) + .waitFor({ timeout: 20000 }); + await page.screenshot({ + path: path.join(destination, `experts-error-${width}.png`), + fullPage: true, + }); + recover = true; + await page.getByRole("button", { name: "Повторить", exact: true }).click(); + await page.waitForFunction( + () => document.querySelectorAll(".experts__table tr[data-expert-id]").length === 5, + ); + assert.ok(calls >= 2); + }); + await record("experts-loading", width, async ({ context, page }) => { + let release; + const pending = new Promise(resolve => { + release = resolve; + }); + await context.route("**/programs/12/management/experts/", async r => { + await pending; + await r.fulfill({ json: fixtures.roster() }); + }); + await page.goto(`${base}/office/program/12/management/experts`); + await page.getByText("Загружаем экспертов и решения…", { exact: true }).waitFor(); + assert.equal( + await page.getByRole("button", { name: "Пригласить эксперта" }).isDisabled(), + true, + ); + await page.screenshot({ + path: path.join(destination, `experts-pending-${width}.png`), + fullPage: true, + }); + release(); + await page.waitForFunction( + () => document.querySelectorAll(".experts__table tr[data-expert-id]").length === 5, + ); + }); + await record("analytics-loading", width, async ({ context, page }) => { + let release; + const pending = new Promise(resolve => { + release = resolve; + }); + await context.route("**/programs/12/management/solution-analytics/", async r => { + await pending; + await r.fulfill({ json: fixtures.scenario("manager_solution_analytics_distributed") }); + }); + await page.goto(`${base}/office/program/12/management/analytics`); + await page.locator("app-solution-analytics .summary--loading").waitFor(); + await page.screenshot({ + path: path.join(destination, `analytics-pending-${width}.png`), + fullPage: true, + }); + release(); + await page.locator("app-solution-analytics .summary-card__value").first().waitFor(); + }); + await record("no-access", width, async ({ context, page }) => { + await context.route("**/programs/12/management/", r => + r.fulfill({ + status: 403, + json: { detail: "Недостаточно прав для управления программой." }, + }), + ); + await page.goto(`${base}/office/program/12/management/settings?tab=basic`); + await page.locator(".management__card.ui-alert--error").waitFor(); + assert.equal(await page.locator("#program-name").count(), 0); + }); + await record("noncompetitive-solutions", width, async ({ context, page }) => { + await context.route("**/programs/12/management/", r => + r.fulfill({ + json: { + ...fixtures.response(new URL(r.request().url()), "GET"), + isCompetitive: false, + programFormat: "noncompetitive", + }, + }), + ); + await open(page, "v2", "projects", "projects"); + await page.locator(".management__table tbody input[type=checkbox]").first().check(); + assert.equal( + await page.getByRole("button", { name: "Сдать решения", exact: true }).isDisabled(), + true, + ); + assert.equal( + await page.getByRole("button", { name: "Назначить экспертов", exact: true }).isDisabled(), + true, + ); + await page.getByText("Не применяется", { exact: true }).first().waitFor(); + }); + for (const version of ["v1", "v2"]) + await record(`${version}-filters-keyboard`, width, async ({ page }) => { + await open(page, version, "experts", "experts"); + const trigger = page.locator(".experts__filter-trigger"); + await trigger.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("dialog").waitFor(); + await page.waitForFunction(() => !!document.activeElement?.closest('[role="dialog"]')); + for (let i = 0; i < 7; i++) { + await page.keyboard.press("Tab"); + assert.equal( + await page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]')), + true, + ); + } + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + await page.waitForFunction( + () => document.activeElement === document.querySelector(".experts__filter-trigger"), + ); + assert.equal(await trigger.evaluate(el => el === document.activeElement), true); + }); + } + fs.writeFileSync(path.join(__dirname, "state-results.json"), JSON.stringify(results, null, 2)); + if (results.some(r => !r.passed)) process.exitCode = 1; +} +run().catch(e => { + console.error(e); + process.exitCode = 1; +}); diff --git a/docs/program-v2-ui-audit/v1-visual-regression.json b/docs/program-v2-ui-audit/v1-visual-regression.json new file mode 100644 index 000000000..eadf6df31 --- /dev/null +++ b/docs/program-v2-ui-audit/v1-visual-regression.json @@ -0,0 +1,562 @@ +[ + { + "file": "v1-analytics-1440.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-analytics-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-analytics-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-analytics-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-analytics-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-certificates-1440.png", + "identical": false, + "sameSize": true, + "changedPixels": 5, + "changedPercent": 0.0003472, + "bounds": [326, 429, 328, 484] + }, + { + "file": "v1-certificates-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-certificates-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-certificates-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-certificates-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-distribution-1440.png", + "identical": false, + "sameSize": true, + "changedPixels": 5, + "changedPercent": 0.0003472, + "bounds": [326, 429, 328, 484] + }, + { + "file": "v1-distribution-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-distribution-320.png", + "identical": false, + "sameSize": true, + "changedPixels": 2, + "changedPercent": 0.000625, + "bounds": [53, 814, 57, 817] + }, + { + "file": "v1-distribution-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-distribution-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-experts-1440.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-experts-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-experts-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-experts-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-experts-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-1440.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-participants-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-projects-1440.png", + "identical": false, + "sameSize": true, + "changedPixels": 5, + "changedPercent": 0.0003472, + "bounds": [326, 429, 328, 484] + }, + { + "file": "v1-projects-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-projects-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-projects-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-projects-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-1440.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-results-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-basic-1440.png", + "identical": false, + "sameSize": true, + "changedPixels": 5, + "changedPercent": 0.0003472, + "bounds": [326, 429, 328, 484] + }, + { + "file": "v1-settings-basic-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-basic-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-basic-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-basic-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-criteria-1440.png", + "identical": false, + "sameSize": true, + "changedPixels": 5, + "changedPercent": 0.0003472, + "bounds": [326, 429, 328, 484] + }, + { + "file": "v1-settings-criteria-1920.png", + "identical": false, + "sameSize": true, + "changedPixels": 5, + "changedPercent": 0.0002604, + "bounds": [566, 429, 568, 484] + }, + { + "file": "v1-settings-criteria-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-criteria-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-criteria-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-dates-1440.png", + "identical": false, + "sameSize": true, + "changedPixels": 5, + "changedPercent": 0.0003472, + "bounds": [326, 429, 328, 484] + }, + { + "file": "v1-settings-dates-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-dates-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-dates-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-dates-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-1440.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-materials-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-organizers-1440.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-organizers-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-organizers-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-organizers-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-organizers-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-project-form-1440.png", + "identical": false, + "sameSize": true, + "changedPixels": 5, + "changedPercent": 0.0003472, + "bounds": [326, 429, 328, 484] + }, + { + "file": "v1-settings-project-form-1920.png", + "identical": false, + "sameSize": true, + "changedPixels": 5, + "changedPercent": 0.0002604, + "bounds": [566, 429, 568, 484] + }, + { + "file": "v1-settings-project-form-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-project-form-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-project-form-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-registration-1440.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-registration-1920.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-registration-320.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-registration-390.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + }, + { + "file": "v1-settings-registration-768.png", + "identical": true, + "sameSize": true, + "changedPixels": 0, + "changedPercent": 0.0, + "bounds": null + } +] diff --git a/docs/program-v2-ui-audit/verification-summary.json b/docs/program-v2-ui-audit/verification-summary.json new file mode 100644 index 000000000..ffb5516be --- /dev/null +++ b/docs/program-v2-ui-audit/verification-summary.json @@ -0,0 +1,106 @@ +{ + "scope": "Program management UI acceptance after 44761cdc", + "baseline": "44761cdc", + "widths": [ + 320, + 390, + 768, + 1440, + 1920 + ], + "browser": { + "beforeMain": 140, + "afterMain": 140, + "afterInteractions": 400, + "afterConformance": 75, + "afterStates": 40, + "visualContracts": 5, + "standardArtifactMain": 16, + "standardArtifactConformance": 30, + "failed": 0, + "api": "Isolated fixtures, never live integration" + }, + "tests": { + "runner": "vitest", + "pool": "forks", + "maxWorkers": 1, + "files": 498, + "filesReported": 498, + "excludedByExistingConfig": [ + "projects/social_platform/src/app/ui/pages/profile/edit/editor-submit-button.directive.spec.ts", + "projects/social_platform/src/app/ui/pages/program/program.component.spec.ts", + "projects/social_platform/src/app/ui/pages/projects/projects.component.spec.ts" + ], + "tests": 3089, + "passed": 3089, + "failed": 0, + "pending": 0, + "success": true, + "finalReplay": { + "files": 4, + "tests": 107, + "passed": 107, + "report": "tests/final-regression.json", + "reason": "Close buttons use their existing accessible names after switching text glyphs to SVG. No assertions removed." + } + }, + "codeChecks": [ + { + "name": "eslint", + "files": 18, + "exitCode": 0, + "scope": "complete PR vs origin/dev" + }, + { + "name": "stylelint", + "files": 19, + "exitCode": 0, + "scope": "complete PR vs origin/dev" + }, + { + "name": "prettier", + "files": 29, + "exitCode": 0, + "scope": "complete PR vs origin/dev" + }, + { + "name": "git diff --check", + "exitCode": 0 + }, + { + "name": "npm run test:solution-contract", + "exitCode": 0 + } + ], + "productionBuild": { + "name": "standard-production-final", + "command": "NG_BUILD_MAX_WORKERS=2 npm run build:prod", + "exitCode": 0, + "originalBudgets": true, + "fontInlining": true, + "angularConfigChanged": false, + "errors": [], + "log": "logs/program-v2-followup-standard-final.log", + "artifactChecks": { + "populatedPages": 16, + "dialogData": 30, + "api": "isolated fixtures" + } + }, + "visualComparison": { + "baseline": "44761cdc", + "pairs": 140, + "identical": 111, + "v1Pairs": 70, + "v1Identical": 54, + "sameDimensions": true + }, + "liveApi": { + "reachable": true, + "publicReads": "200; Project and Solution independently", + "organizerScenarios": "NOT VERIFIED: no organizer session, endpoints return 401", + "productionUsed": false, + "writes": 0 + }, + "acceptance": "Draft review appropriate; merge acceptance open for live organizer integration and documented historical Figma/common-V1 differences" +} diff --git a/docs/program-v2-ui-audit/visual-contract.cjs b/docs/program-v2-ui-audit/visual-contract.cjs new file mode 100644 index 000000000..a6b37a592 --- /dev/null +++ b/docs/program-v2-ui-audit/visual-contract.cjs @@ -0,0 +1,94 @@ +/** @format */ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { launch, open } = require("./browser-check.cjs"); +const results = []; +(async () => { + for (const width of [320, 390, 768, 1440, 1920]) { + const { browser, page, requests, errors } = await launch(width); + const versions = {}; + for (const version of ["v1", "v2"]) { + await open(page, version, "experts", "experts"); + versions[version] = await page.evaluate(() => { + const measure = selector => { + const node = document.querySelector(selector), + box = node.getBoundingClientRect(), + style = getComputedStyle(node); + return { + width: box.width, + height: box.height, + font: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + lineHeight: style.lineHeight, + radius: style.borderRadius, + color: style.color, + background: style.backgroundColor, + }; + }; + return { + fontLoaded: document.fonts.check("12px Mont"), + invite: measure(".experts__invite"), + export: measure(".experts__export"), + filter: measure(".experts__filter-trigger"), + search: measure(".experts__search input"), + table: measure(".experts__table"), + avatar: measure(".experts__person app-avatar"), + exportIcon: measure(".experts__export svg"), + checkbox: measure(".experts__table tbody input"), + }; + }); + assert.equal(versions[version].fontLoaded, true); + assert.equal(versions[version].invite.height, 32); + assert.equal(versions[version].export.width, 32); + assert.equal(versions[version].exportIcon.width, 18); + assert.equal(versions[version].exportIcon.height, 18); + for (const control of ["invite", "export", "filter", "search", "table"]) + assert.match(versions[version][control].font, /Mont/); + await page.locator(".experts__invite").hover(); + await page.waitForTimeout(300); + const hover = await page + .locator(".experts__invite") + .evaluate(n => getComputedStyle(n).backgroundColor); + versions[version].hover = hover; + await page.locator(".experts__filter-trigger").focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + versions[version].focus = await page.locator(".experts__filter-trigger").evaluate(n => ({ + width: getComputedStyle(n).outlineWidth, + style: getComputedStyle(n).outlineStyle, + })); + assert.equal(versions[version].focus.width, "2px"); + } + for (const control of [ + "invite", + "export", + "filter", + "search", + "avatar", + "exportIcon", + "checkbox", + "hover", + "focus", + ]) + assert.deepEqual(versions.v2[control], versions.v1[control], `${control} at ${width}`); + const start = requests.length; + await open(page, "v2", "analytics", "analytics"); + const legacyRequests = requests + .slice(start) + .filter(r => /manager-overview.*assignments/.test(r.path)); + assert.deepEqual(legacyRequests, []); + assert.equal(errors.length, 0, errors.join("\n")); + results.push({ width, versions, noLegacyAssignmentRequestsInV2: true, passed: true }); + console.log(`visual contract ${width}: PASS`); + await browser.close(); + } + fs.writeFileSync( + path.join(__dirname, "visual-contract-results.json"), + JSON.stringify(results, null, 2), + ); +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/projects/core/src/lib/models/error/user-error-message.spec.ts b/projects/core/src/lib/models/error/user-error-message.spec.ts new file mode 100644 index 000000000..93967edb3 --- /dev/null +++ b/projects/core/src/lib/models/error/user-error-message.spec.ts @@ -0,0 +1,65 @@ +/** @format */ + +import { userErrorMessage } from "./user-error-message"; + +describe("userErrorMessage", () => { + const fallback = "Проверьте заполненные поля."; + + it.each([ + [ + "We couldn't find an account associated with that email. Please try a different e-mail address.", + "Аккаунт с этой почтой не найден. Проверьте адрес электронной почты.", + ], + ["This field is required.", "Заполните обязательное поле."], + ["This field may not be blank.", "Поле не должно быть пустым."], + ["This password is too common.", "Этот пароль слишком распространен. Придумайте другой."], + ["This password is entirely numeric.", "Пароль не должен состоять только из цифр."], + [ + "The password is too similar to the email.", + "Пароль слишком похож на данные аккаунта. Придумайте другой.", + ], + [ + "This password is too short. It must contain at least 12 characters.", + "Пароль должен содержать не менее 12 символов.", + ], + ["Enter a valid email address.", "Введите корректный адрес электронной почты."], + ["No active account found with the given credentials", "Неверная почта или пароль."], + [ + "Ensure this field has no more than 255 characters.", + "Максимальное количество символов: 255.", + ], + ["Ensure this field has at least 8 characters.", "Минимальное количество символов: 8."], + ["Ensure this value is greater than or equal to 0.", "Значение должно быть не меньше 0."], + ["Ensure this value is less than or equal to 10.5.", "Значение должно быть не больше 10.5."], + ])("переводит сообщение API: %s", (message, expected) => { + expect(userErrorMessage(message, fallback)).toBe(expected); + }); + + it.each([ + undefined, + null, + 0, + false, + {}, + "", + "Unexpected server failure", + "Project «Город» is unavailable.", + ])("показывает русское пояснение вместо неизвестного ответа: %s", message => + expect(userErrorMessage(message, fallback)).toBe(fallback), + ); + + it("сохраняет русское пояснение и пользовательские названия, адреса и ссылки", () => { + const message = + "Не найден проект «Ёж Solution» для expert@example.test. Ссылка: https://example.test/file.pdf"; + expect(userErrorMessage(message, fallback)).toBe(message); + }); + + it("обрабатывает массив ошибок без объектов и повторяющихся технических сообщений", () => { + expect( + userErrorMessage( + ["This field is required.", "Unexpected error", "Another error", {}], + fallback, + ), + ).toBe(`Заполните обязательное поле. ${fallback}`); + }); +}); diff --git a/projects/core/src/lib/models/error/user-error-message.ts b/projects/core/src/lib/models/error/user-error-message.ts new file mode 100644 index 000000000..8d278345d --- /dev/null +++ b/projects/core/src/lib/models/error/user-error-message.ts @@ -0,0 +1,81 @@ +/** @format */ + +const DEFAULT_MESSAGE = "Не удалось выполнить действие. Попробуйте еще раз."; + +const TRANSLATIONS: ReadonlyArray = [ + [ + /^we couldn['’]t find an account associated with (?:that|this) e-?mail\..*$/i, + "Аккаунт с этой почтой не найден. Проверьте адрес электронной почты.", + ], + [/^this field is required\.?$/i, "Заполните обязательное поле."], + [/^this field may not be (?:blank|null)\.?$/i, "Поле не должно быть пустым."], + [ + /^(?:enter a valid|invalid) e-?mail(?: address)?\.?$/i, + "Введите корректный адрес электронной почты.", + ], + [ + /^(?:a user with (?:that|this) (?:e-?mail|e-?mail address) already exists|a user is already registered with this e-?mail address)\.?$/i, + "Аккаунт с этой почтой уже зарегистрирован.", + ], + [ + /^(?:no active account found with the given credentials|unable to log in with provided credentials|invalid (?:e-?mail|username) or password)\.?$/i, + "Неверная почта или пароль.", + ], + [/^user account is disabled\.?$/i, "Аккаунт недоступен. Обратитесь в поддержку."], + [/^authentication credentials were not provided\.?$/i, "Войдите в аккаунт, чтобы продолжить."], + [/^you do not have permission to perform this action\.?$/i, "У вас нет прав для этого действия."], + [/^not found\.?$/i, "Запрашиваемые данные не найдены."], + [ + /^(?:the two password fields didn['’]t match|passwords do not match)\.?$/i, + "Пароли не совпадают.", + ], + [ + /^(?:a valid (?:integer|number) is required|enter a (?:whole|valid) number)\.?$/i, + "Введите корректное число.", + ], + [/^invalid value\.?$/i, "Проверьте введенное значение."], + [/^this password is too common\.?$/i, "Этот пароль слишком распространен. Придумайте другой."], + [/^this password is entirely numeric\.?$/i, "Пароль не должен состоять только из цифр."], + [ + /^the password is too similar to the .+\.?$/i, + "Пароль слишком похож на данные аккаунта. Придумайте другой.", + ], + [/^".*" is not a valid choice\.?$/i, "Выберите допустимый вариант из списка."], + [/^invalid pk .*object does not exist\.?$/i, "Выбранный объект не найден. Обновите данные."], +]; + +/** Localizes display text only. Error codes, field paths and response metadata stay untouched. */ +export function userErrorMessage(message: unknown, fallback = DEFAULT_MESSAGE): string { + if (Array.isArray(message)) { + const messages = message + .filter((value): value is string => typeof value === "string" && !!value.trim()) + .map(value => userErrorMessage(value, fallback)); + return [...new Set(messages)].join(" ") || fallback; + } + if (typeof message !== "string" || !message.trim()) return fallback; + const text = message.trim(); + const translation = TRANSLATIONS.find(([pattern]) => pattern.test(text)); + if (translation) return translation[1]; + + const maxLength = text.match(/^ensure this field has no more than (\d+) characters\.?$/i); + if (maxLength) return `Максимальное количество символов: ${maxLength[1]}.`; + const minLength = text.match(/^ensure this field has at least (\d+) characters\.?$/i); + if (minLength) return `Минимальное количество символов: ${minLength[1]}.`; + const minPasswordLength = text.match( + /^this password is too short\. it must contain at least (\d+) characters\.?$/i, + ); + if (minPasswordLength) + return `Пароль должен содержать не менее ${minPasswordLength[1]} символов.`; + const minValue = text.match( + /^ensure this value is greater than or equal to (-?\d+(?:\.\d+)?)\.?$/i, + ); + if (minValue) return `Значение должно быть не меньше ${minValue[1]}.`; + const maxValue = text.match(/^ensure this value is less than or equal to (-?\d+(?:\.\d+)?)\.?$/i); + if (maxValue) return `Значение должно быть не больше ${maxValue[1]}.`; + + // A Russian object name quoted inside an English error is not translated prose. + const prose = text + .replace(/"[^"]*"|'[^']*'|«[^»]*»|“[^”]*”/g, "") + .replace(/https?:\/\/\S+|\S+@\S+/g, ""); + return /[А-Яа-яЁё]/.test(prose) ? text : fallback; +} diff --git a/projects/core/src/lib/models/index.ts b/projects/core/src/lib/models/index.ts index bee55a5da..6fbb1f4f2 100644 --- a/projects/core/src/lib/models/index.ts +++ b/projects/core/src/lib/models/index.ts @@ -2,6 +2,7 @@ export * from "./error/error-code"; export * from "./error/error-message"; +export * from "./error/user-error-message"; export * from "./edit-step"; export * from "./auth/http.model"; export * from "./auth/refresh-response.model"; diff --git a/projects/social_platform/src/app/api/auth/facades/auth-password.service.spec.ts b/projects/social_platform/src/app/api/auth/facades/auth-password.service.spec.ts new file mode 100644 index 000000000..b6d0d8292 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-password.service.spec.ts @@ -0,0 +1,103 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { ActivatedRoute, convertToParamMap, Router } from "@angular/router"; +import { of } from "rxjs"; +import { ValidationService } from "@corelib"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { AsyncState, initial } from "@domain/shared/async-state"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { PasswordError } from "@domain/auth/results/password.result"; +import { SetPasswordUseCase } from "../use-cases/set-password.use-case"; +import { ResetPasswordUseCase } from "../use-cases/reset-password.use-case"; +import { AuthUIInfoService } from "./ui/auth-ui-info.service"; +import { AuthPasswordService } from "./auth-password.service"; + +describe("Установка нового пароля через фасад", () => { + const password = "Pr517-unit-only-Aa1!"; + const token = "pr517-unit-only-reset-token"; + function setup( + query: Record = { token }, + result: Result = ok(undefined), + ) { + const execute = vi.fn(() => of(result)); + const router = { + navigate: vi.fn().mockResolvedValue(true), + navigateByUrl: vi.fn().mockResolvedValue(true), + }; + const logger = { error: vi.fn(), debug: vi.fn() }; + const ui = { + passwordForm: new FormBuilder().group({ password, passwordRepeated: password }), + resetForm: new FormBuilder().group({ email: "qa@example.test" }), + password$: signal>(initial()), + credsSubmitInitiated: signal(false), + }; + TestBed.configureTestingModule({ + providers: [ + AuthPasswordService, + { + provide: ActivatedRoute, + useValue: { + queryParams: of(query), + snapshot: { queryParamMap: convertToParamMap(query) }, + }, + }, + { provide: SetPasswordUseCase, useValue: { execute } }, + { provide: ResetPasswordUseCase, useValue: { execute: vi.fn() } }, + { provide: Router, useValue: router }, + { provide: ValidationService, useValue: { getFormValidation: () => true } }, + { provide: AuthUIInfoService, useValue: ui }, + { provide: LoggerService, useValue: logger }, + ], + }); + return { service: TestBed.inject(AuthPasswordService), execute, ui, router, logger }; + } + it("объясняет отсутствие ссылки до отправки пароля", () => { + const { service, execute, ui } = setup({}); + service.init(); + service.onSubmitSetPassword(); + expect(execute).not.toHaveBeenCalled(); + expect(ui.password$()).toEqual({ status: "failure", error: { kind: "invalid_token" } }); + }); + it("при отказе сохраняет значения и не пишет пароль или токен в журнал", () => { + const { service, execute, ui, router, logger } = setup( + { token }, + fail({ kind: "invalid_token" }), + ); + service.onSubmitSetPassword(); + expect(execute).toHaveBeenCalledExactlyOnceWith(password, token); + expect(ui.passwordForm.value).toEqual({ password, passwordRepeated: password }); + expect(router.navigate).not.toHaveBeenCalled(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain(password); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain(token); + }); + it.each([ + ["registration_program", "49", "/program-registration", "49"], + [ + "expert_invitation", + "pr517-unit-expert-invitation-12345", + "/expert-invitation", + "pr517-unit-expert-invitation-12345", + ], + [ + "organizer_invitation", + "pr517-unit-organizer-invitation-12345", + "/organizer-invitation", + "pr517-unit-organizer-invitation-12345", + ], + ])("после успеха сохраняет контекст %s и предлагает обычный вход", (key, value, route, id) => { + const { service, router } = setup({ token, [key]: value }); + service.onSubmitSetPassword(); + expect(router.navigate).toHaveBeenCalledExactlyOnceWith([route, id], { + queryParams: { mode: "login" }, + }); + }); + it("обычный сброс после успеха возвращает на форму входа", () => { + const { service, router } = setup(); + service.onSubmitSetPassword(); + expect(router.navigateByUrl).toHaveBeenCalledExactlyOnceWith("/auth/login"); + }); +}); diff --git a/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts b/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts index 9ef9cb95d..48491e4c6 100644 --- a/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts +++ b/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts @@ -11,6 +11,7 @@ import { SetPasswordUseCase } from "../use-cases/set-password.use-case"; import { toAsyncState } from "@domain/shared/to-async-state"; import { PasswordError } from "@domain/auth/results/password.result"; import { AppRoutes } from "@api/paths/app-routes"; +import { failure } from "@domain/shared/async-state"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; /** Управляет сбросом и установкой пароля, а также состоянием форм пароля. */ @@ -40,7 +41,7 @@ export class AuthPasswordService { init(): void { const token = this.route.snapshot.queryParamMap.get("token"); if (!token) { - this.logger.error("Token is missing"); + this.password$.set(failure({ kind: "invalid_token" })); } } @@ -75,8 +76,12 @@ export class AuthPasswordService { this.credsSubmitInitiated.set(true); const token = this.route.snapshot.queryParamMap.get("token"); + if (!token) { + this.password$.set(failure({ kind: "invalid_token" })); + return; + } + if ( - !token || !this.validationService.getFormValidation(this.passwordForm) || this.password$().status === "loading" ) @@ -114,7 +119,7 @@ export class AuthPasswordService { .navigateByUrl(AppRoutes.auth.login()) .then(() => this.logger.debug("SetPasswordComponent")); } else { - this.logger.error("Error setting password:", result.error); + this.logger.error("Не удалось установить новый пароль.", { kind: result.error.kind }); } }), toAsyncState(), diff --git a/projects/social_platform/src/app/api/auth/facades/auth-register.service.ts b/projects/social_platform/src/app/api/auth/facades/auth-register.service.ts index 08890ec0f..2e721b8ba 100644 --- a/projects/social_platform/src/app/api/auth/facades/auth-register.service.ts +++ b/projects/social_platform/src/app/api/auth/facades/auth-register.service.ts @@ -4,7 +4,7 @@ import { computed, DestroyRef, inject, Injectable } from "@angular/core"; import { Router } from "@angular/router"; import { tap } from "rxjs"; import { AuthUIInfoService } from "./ui/auth-ui-info.service"; -import { ValidationService } from "@corelib"; +import { ValidationService, userErrorMessage } from "@corelib"; import { RegisterUseCase } from "../use-cases/register.use-case"; import { toAsyncState } from "@domain/shared/to-async-state"; import { RegisterError } from "@domain/auth/results/register.result"; @@ -41,7 +41,13 @@ export class AuthRegisterService { readonly serverErrors = computed(() => { const state = this.register$(); if (isFailure(state) && state.error.kind === "validation_error") { - return Object.values(state.error.errors).flat(); + return [ + ...new Set( + Object.values(state.error.errors) + .flat() + .map(message => userErrorMessage(message, "Проверьте данные регистрации.")), + ), + ]; } return []; }); diff --git a/projects/social_platform/src/app/api/auth/facades/ui/auth-ui-info.service.ts b/projects/social_platform/src/app/api/auth/facades/ui/auth-ui-info.service.ts index 6a21890e8..ecfe44129 100644 --- a/projects/social_platform/src/app/api/auth/facades/ui/auth-ui-info.service.ts +++ b/projects/social_platform/src/app/api/auth/facades/ui/auth-ui-info.service.ts @@ -8,6 +8,7 @@ import { LoginError, LoginResult } from "@domain/auth/results/login.result"; import { PasswordError } from "@domain/auth/results/password.result"; import { RegisterError } from "@domain/auth/results/register.result"; import { AsyncState, initial, isFailure, isLoading } from "@domain/shared/async-state"; +import { resetPasswordErrorMessage, setPasswordErrorMessage } from "../../password-error"; /** Хранит формы и состояние auth-страниц: логин, регистрация и сброс пароля. */ @Injectable() @@ -22,6 +23,13 @@ export class AuthUIInfoService { readonly login$ = signal>(initial()); readonly loginIsSubmitting = computed(() => isLoading(this.login$())); readonly errorWrongAuth = computed(() => isFailure(this.login$())); + readonly loginErrorMessage = computed(() => { + const state = this.login$(); + if (!isFailure(state)) return null; + return state.error.kind === "wrong_credentials" + ? "Неправильный логин или пароль" + : "Не удалось войти. Повторите попытку позже."; + }); // Состояние сброса пароля. readonly credsSubmitInitiated = signal(false); @@ -32,6 +40,14 @@ export class AuthUIInfoService { return isFailure(s) && s.error.kind === "server_error"; }); readonly errorRequest = computed(() => isFailure(this.password$())); + readonly setPasswordErrorMessage = computed(() => { + const state = this.password$(); + return isFailure(state) ? setPasswordErrorMessage(state.error) : null; + }); + readonly resetPasswordErrorMessage = computed(() => { + const state = this.password$(); + return isFailure(state) ? resetPasswordErrorMessage(state.error) : null; + }); // Состояние регистрации. readonly registerAgreement = signal(false); diff --git a/projects/social_platform/src/app/api/auth/password-error.spec.ts b/projects/social_platform/src/app/api/auth/password-error.spec.ts new file mode 100644 index 000000000..213223c01 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/password-error.spec.ts @@ -0,0 +1,69 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { + resetPasswordErrorMessage, + setPasswordError, + setPasswordErrorMessage, +} from "./password-error"; + +describe("Ошибки установки и сброса пароля", () => { + it.each([404, 410])("отказ %s по ссылке не превращается в ошибку логина", status => { + const error = setPasswordError( + new HttpErrorResponse({ status, error: { detail: "Test-only invalid reset link" } }), + ); + expect(error).toEqual({ kind: "invalid_token" }); + expect(setPasswordErrorMessage(error)).toBe( + "Ссылка восстановления недействительна или истекла. Запросите новую ссылку.", + ); + }); + it("отличает валидацию одноразовой ссылки от требований к новому паролю", () => { + expect( + setPasswordError( + new HttpErrorResponse({ status: 400, error: { token: ["This field may not be blank."] } }), + ), + ).toEqual({ kind: "invalid_token" }); + const error = setPasswordError( + new HttpErrorResponse({ + status: 400, + error: { + password: [ + "This password is too short. It must contain at least 12 characters.", + "This password is too common.", + ], + }, + }), + ); + expect(error).toEqual({ + kind: "validation_error", + messages: [ + "Пароль должен содержать не менее 12 символов. Этот пароль слишком распространен. Придумайте другой.", + ], + }); + }); + it.each([0, 400, 401, 403, 404, 410, 422, 500, 503])( + "не называет сбой сброса %s неправильным логином или паролем", + status => { + const error = setPasswordError(new HttpErrorResponse({ status })); + expect(setPasswordErrorMessage(error)).not.toMatch(/неправильный логин|неверный пароль/i); + expect(setPasswordErrorMessage(error)).toMatch(/[А-Яа-я]/); + }, + ); + it("не переносит тело HTTP-ошибки с секретными полями в состояние формы", () => { + const error = setPasswordError( + new HttpErrorResponse({ + status: 404, + error: { + password: "unit-only-sensitive-value", + token: "unit-only-sensitive-token", + }, + }), + ); + expect(JSON.stringify(error)).not.toContain("unit-only-sensitive"); + }); + it("ошибка сервера запроса письма не объявляет аккаунт незарегистрированным", () => { + expect(resetPasswordErrorMessage({ kind: "server_error", status: 500 })).toBe( + "Не удалось отправить письмо. Попробуйте позже.", + ); + }); +}); diff --git a/projects/social_platform/src/app/api/auth/password-error.ts b/projects/social_platform/src/app/api/auth/password-error.ts new file mode 100644 index 000000000..07d814959 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/password-error.ts @@ -0,0 +1,58 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { userErrorMessage } from "@core/lib/models/error/user-error-message"; +import { PasswordError } from "@domain/auth/results/password.result"; + +const PASSWORD_VALIDATION_MESSAGE = + "Пароль не соответствует требованиям. Проверьте его и повторите попытку."; + +export function setPasswordError(error: unknown): PasswordError { + if (!(error instanceof HttpErrorResponse)) return { kind: "unknown", cause: error }; + if (error.status === 0) return { kind: "network", status: 0, cause: "unknown" }; + if (error.status >= 500) return { kind: "server_error", status: 500 }; + + const body = error.error && typeof error.error === "object" ? error.error : {}; + if ( + error.status === 404 || + error.status === 410 || + typeof body.token === "string" || + Array.isArray(body.token) + ) + return { kind: "invalid_token" }; + + const passwordErrors = body.password ?? body.newPassword ?? body.new_password; + if (typeof passwordErrors === "string" || Array.isArray(passwordErrors)) { + return { + kind: "validation_error", + messages: [userErrorMessage(passwordErrors, PASSWORD_VALIDATION_MESSAGE)], + }; + } + if (error.status === 400 || error.status === 422) return { kind: "bad_request", status: 400 }; + return { kind: "unknown" }; +} + +export function setPasswordErrorMessage(error: PasswordError): string { + switch (error.kind) { + case "invalid_token": + return "Ссылка восстановления недействительна или истекла. Запросите новую ссылку."; + case "validation_error": + return error.messages.join(" ") || PASSWORD_VALIDATION_MESSAGE; + case "network": + return "Не удалось связаться с сервером. Проверьте подключение и повторите попытку."; + case "server_error": + return "Не удалось сохранить новый пароль. Попробуйте позже."; + case "bad_request": + return "Не удалось установить новый пароль. Проверьте ссылку восстановления и требования к паролю."; + default: + return "Не удалось установить новый пароль. Повторите попытку или запросите новую ссылку."; + } +} + +export function resetPasswordErrorMessage(error: PasswordError): string { + if (error.kind === "network") + return "Не удалось связаться с сервером. Проверьте подключение и повторите попытку."; + if (error.kind === "bad_request") + return "Не удалось отправить письмо. Проверьте адрес электронной почты."; + return "Не удалось отправить письмо. Попробуйте позже."; +} diff --git a/projects/social_platform/src/app/api/auth/use-cases/set-password.use-case.ts b/projects/social_platform/src/app/api/auth/use-cases/set-password.use-case.ts index ec62b0c3f..ce8896f9e 100644 --- a/projects/social_platform/src/app/api/auth/use-cases/set-password.use-case.ts +++ b/projects/social_platform/src/app/api/auth/use-cases/set-password.use-case.ts @@ -4,19 +4,18 @@ import { inject, Injectable } from "@angular/core"; import { catchError, map, Observable, of } from "rxjs"; import { fail, ok, Result } from "@domain/shared/result.type"; import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { PasswordError } from "@domain/auth/results/password.result"; +import { setPasswordError } from "../password-error"; -/** Сценарий: установить новый пароль по токену сброса; ошибка → `unknown`. */ +/** Устанавливает пароль по одноразовой ссылке; различает ошибку ссылки, пароля и соединения. */ @Injectable({ providedIn: "root" }) export class SetPasswordUseCase { private readonly authRepositoryPort = inject(AuthRepositoryPort); - execute( - password: string, - token: string, - ): Observable> { + execute(password: string, token: string): Observable> { return this.authRepositoryPort.setPassword(password, token).pipe( map(() => ok(undefined)), - catchError(error => of(fail({ kind: "unknown" as const, cause: error }))), + catchError(error => of(fail(setPasswordError(error)))), ); } } diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-two-ui-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-two-ui-info.service.ts index 264d75d34..c4b4edcb2 100644 --- a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-two-ui-info.service.ts +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-two-ui-info.service.ts @@ -1,5 +1,7 @@ /** @format */ +import { userErrorMessage } from "@core/lib/models/error/user-error-message"; + import { inject, Injectable, signal } from "@angular/core"; import { NonNullableFormBuilder } from "@angular/forms"; import { UserInput } from "@domain/auth/user.model"; @@ -43,7 +45,7 @@ export class OnboardingStageTwoUIInfoService { applySubmitErrorModal(err: any): void { if (err.status === 400) { this.isChooseSkill.set(true); - this.isChooseSkillText.set(err.error[0]); + this.isChooseSkillText.set(userErrorMessage(err.error?.[0])); } } } diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-zero-ui-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-zero-ui-info.service.ts index aa82759af..7abb18c40 100644 --- a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-zero-ui-info.service.ts +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-zero-ui-info.service.ts @@ -1,5 +1,7 @@ /** @format */ +import { userErrorMessage } from "@core/lib/models/error/user-error-message"; + import { inject, Injectable, signal } from "@angular/core"; import { FormArray, FormBuilder, Validators } from "@angular/forms"; import { generateOptionsList } from "@utils/generate-options-list"; @@ -508,6 +510,6 @@ export class OnboardingStageZeroUIInfoService { applySkipRegistrationModalError(error: any): void { this.isModalErrorYear.set(true); - this.isModalErrorYearText.set(error.error?.message || "Ошибка сохранения"); + this.isModalErrorYearText.set(userErrorMessage(error.error?.message, "Ошибка сохранения")); } } diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-save-error.utils.spec.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-save-error.utils.spec.ts index 909661088..1fbbd6314 100644 --- a/projects/social_platform/src/app/api/profile/facades/edit/profile-save-error.utils.spec.ts +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-save-error.utils.spec.ts @@ -46,4 +46,18 @@ describe("profile save error utils", () => { it("should return generic text only for unparseable response", () => { expect(resolveProfileSaveErrorText()).toBe("Ошибка при сохранении профиля"); }); + it("не выводит неизвестный английский ответ и техническое имя поля", () => { + expect( + resolveProfileSaveErrorText({ + error: { unknown_backend_field: ["Unexpected upstream failure"] }, + }), + ).toBe("Поле: Ошибка при сохранении профиля"); + }); + it("сохраняет русскую причину и пользовательское название", () => { + expect( + resolveProfileSaveErrorText({ + error: { title: ["Название «My Project» уже используется."] }, + }), + ).toBe("название: Название «My Project» уже используется."); + }); }); diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-save-error.utils.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-save-error.utils.ts index 23d66f3c9..6f45a8100 100644 --- a/projects/social_platform/src/app/api/profile/facades/edit/profile-save-error.utils.ts +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-save-error.utils.ts @@ -1,5 +1,7 @@ /** @format */ +import { userErrorMessage } from "@core/lib/models/error/user-error-message"; + type ValidationBody = unknown; const GENERIC_SAVE_ERROR = "Ошибка при сохранении профиля"; @@ -74,10 +76,10 @@ export const translateProfileSaveMessage = (message: unknown): string => { if (!text) return ""; const translated = MESSAGE_TRANSLATIONS.find(([pattern]) => pattern.test(text))?.[1]; - return translated ?? text; + return translated ?? userErrorMessage(text, GENERIC_SAVE_ERROR); }; -const fieldLabel = (key: string): string => FIELD_LABELS[key] ?? key; +const fieldLabel = (key: string): string => FIELD_LABELS[key] ?? "Поле"; const collectionItemLabel = (key: string, index: number): string => { const base = COLLECTION_ITEM_LABELS[key] ?? fieldLabel(key); diff --git a/projects/social_platform/src/app/api/solution/solution-error.spec.ts b/projects/social_platform/src/app/api/solution/solution-error.spec.ts new file mode 100644 index 000000000..a151fbb8f --- /dev/null +++ b/projects/social_platform/src/app/api/solution/solution-error.spec.ts @@ -0,0 +1,57 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { solutionError } from "./solution-error"; + +describe("solutionError localization", () => { + it("переводит только отображаемые сообщения, сохраняя коды, ID, пути и метаданные C00", () => { + const result = solutionError( + new HttpErrorResponse({ + status: 409, + error: { + code: "revision_conflict", + detail: "Revision is stale", + errors: [ + { + path: "answers.42", + code: "min_value", + detail: "Ensure this value is greater than or equal to 0.", + field_id: 42, + criterion_id: 7, + }, + ], + meta: { revision: "opaque-English-revision", expected_version: 0, protected: false }, + }, + }), + ); + expect(result).toEqual({ + code: "revision_conflict", + detail: "Данные изменились. Обновите страницу и повторите действие.", + errors: [ + { + path: "answers.42", + code: "min_value", + detail: "Значение должно быть не меньше 0.", + fieldId: 42, + criterionId: 7, + }, + ], + meta: { revision: "opaque-English-revision", expectedVersion: 0, protected: false }, + }); + }); + + it("сохраняет русскую бизнес-причину отказа", () => { + expect( + solutionError( + new HttpErrorResponse({ + status: 409, + error: { + code: "protected_assignment", + detail: "Назначение защищено сохраненной оценкой.", + errors: [], + }, + }), + ).detail, + ).toBe("Назначение защищено сохраненной оценкой."); + }); +}); diff --git a/projects/social_platform/src/app/api/solution/solution-error.ts b/projects/social_platform/src/app/api/solution/solution-error.ts index 8304c8ef7..d6f7c709b 100644 --- a/projects/social_platform/src/app/api/solution/solution-error.ts +++ b/projects/social_platform/src/app/api/solution/solution-error.ts @@ -1,6 +1,7 @@ /** @format */ import { HttpErrorResponse } from "@angular/common/http"; +import { userErrorMessage } from "@core/lib/models/error/user-error-message"; import camelcaseKeys from "camelcase-keys"; import { SolutionApiError, SolutionIssue } from "@domain/solution/solution-contract.model"; @@ -29,7 +30,7 @@ export function solutionError(error: unknown): SolutionApiError { { path: row["path"], code: row["code"], - detail: row["detail"], + detail: userErrorMessage(row["detail"], "Проверьте заполненное поле."), fieldId: typeof row["field_id"] === "number" ? row["field_id"] : null, ...(typeof row["criterion_id"] === "number" && Number.isSafeInteger(row["criterion_id"]) @@ -41,7 +42,14 @@ export function solutionError(error: unknown): SolutionApiError { : []; return { code: body["code"], - detail: body["detail"], + detail: userErrorMessage( + body["detail"], + error.status === 409 + ? "Данные изменились. Обновите страницу и повторите действие." + : error.status === 400 || error.status === 422 + ? "Проверьте заполненные поля и повторите действие." + : "Не удалось выполнить действие. Попробуйте еще раз.", + ), errors, meta: body["meta"] && typeof body["meta"] === "object" && !Array.isArray(body["meta"]) diff --git a/projects/social_platform/src/app/domain/auth/results/password.result.ts b/projects/social_platform/src/app/domain/auth/results/password.result.ts index 9ad2b76dd..6de6151e4 100644 --- a/projects/social_platform/src/app/domain/auth/results/password.result.ts +++ b/projects/social_platform/src/app/domain/auth/results/password.result.ts @@ -1,6 +1,8 @@ /** @format */ export type PasswordError = + | { kind: "invalid_token" } + | { kind: "validation_error"; messages: string[] } | { kind: "bad_request"; status: 400 } | { kind: "server_error"; status: 500 } | { kind: "network"; status: 0; cause: "unknown" } diff --git a/projects/social_platform/src/app/ui/pages/auth/login/login.component.html b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html index 9ba8a37cc..e59edcfe5 100644 --- a/projects/social_platform/src/app/ui/pages/auth/login/login.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html @@ -88,9 +88,9 @@ } } - @if (errorWrongAuth()) { -
- {{ errorMessage.AUTH_WRONG_AUTH }} + @if (requestErrorMessage(); as message) { + } diff --git a/projects/social_platform/src/app/ui/pages/auth/login/login.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/login/login.component.spec.ts index 30cd290f2..f3b626bc4 100644 --- a/projects/social_platform/src/app/ui/pages/auth/login/login.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.spec.ts @@ -62,6 +62,23 @@ describe("LoginComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + it("не называет неизвестный сбой входа неверными учетными данными", () => { + const ui = fixture.debugElement.injector.get(AuthUIInfoService); + ui.login$.set({ status: "failure", error: { kind: "unknown" } }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + "Не удалось войти. Повторите попытку позже.", + ); + expect(fixture.nativeElement.textContent).not.toContain("Неправильный логин или пароль"); + }); + it("сохраняет сообщение о неверных данных при отказе авторизации", () => { + const ui = fixture.debugElement.injector.get(AuthUIInfoService); + ui.login$.set({ status: "failure", error: { kind: "wrong_credentials" } }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + "Неправильный логин или пароль", + ); + }); it("keeps the native password input and visibility control in adjacent sibling slots", () => { const password = fixture.nativeElement.querySelector( diff --git a/projects/social_platform/src/app/ui/pages/auth/login/login.component.ts b/projects/social_platform/src/app/ui/pages/auth/login/login.component.ts index e1721c649..c525346c3 100644 --- a/projects/social_platform/src/app/ui/pages/auth/login/login.component.ts +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.ts @@ -47,7 +47,7 @@ export class LoginComponent implements OnInit { protected readonly loginForm = this.authUIInfoService.loginForm; protected readonly loginIsSubmitting = this.authUIInfoService.loginIsSubmitting; - protected readonly errorWrongAuth = this.authUIInfoService.errorWrongAuth; + protected readonly requestErrorMessage = this.authUIInfoService.loginErrorMessage; protected readonly errorMessage = ErrorMessage; diff --git a/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.html b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.html index 2b7b117f1..328df0bd2 100644 --- a/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.html @@ -24,8 +24,8 @@

Забыли пароль?

{{ errorMessage.VALIDATION_REQUIRED }}
} - @if (errorServer()) { -
Аккаунт с таким email не зарегистрирован
+ @if (requestErrorMessage(); as message) { + } } diff --git a/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.spec.ts index e73eb7ca2..f1789570f 100644 --- a/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.spec.ts @@ -9,6 +9,7 @@ import { AuthRepository } from "@infrastructure/repository/auth/auth.repository" import { provideNgxMask } from "ngx-mask"; import { provideRouter } from "@angular/router"; import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; describe("ResetPasswordComponent", () => { let component: ResetPasswordComponent; @@ -47,4 +48,13 @@ describe("ResetPasswordComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + it("ошибка сервера не объявляет существующий аккаунт незарегистрированным", () => { + const ui = fixture.debugElement.injector.get(AuthUIInfoService); + ui.password$.set({ status: "failure", error: { kind: "server_error", status: 500 } }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + "Не удалось отправить письмо. Попробуйте позже.", + ); + expect(fixture.nativeElement.textContent).not.toContain("не зарегистрирован"); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.ts b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.ts index 2e6eedfbc..b6ef0d84f 100644 --- a/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.ts +++ b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.ts @@ -34,7 +34,7 @@ export class ResetPasswordComponent implements OnInit { protected readonly resetForm = this.authUIInfoService.resetForm; protected readonly isSubmitting = this.authUIInfoService.isSubmitting; - protected readonly errorServer = this.authUIInfoService.errorServer; + protected readonly requestErrorMessage = this.authUIInfoService.resetPasswordErrorMessage; protected readonly errorMessage = ErrorMessage; diff --git a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html index fe2b019e8..4a2693bdc 100644 --- a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html @@ -160,9 +160,9 @@

Новый пароль

} } - @if (errorRequest()) { -
- {{ errorMessage.AUTH_WRONG_AUTH }} + @if (requestErrorMessage(); as message) { + } { let component: SetPasswordComponent; @@ -47,4 +48,26 @@ describe("SetPasswordComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + it("показывает ошибку ссылки вместо ошибки логина и сохраняет новый пароль в форме", () => { + const ui = fixture.debugElement.injector.get(AuthUIInfoService); + ui.passwordForm.setValue({ + password: "Pr517-unit-only-Aa1!", + passwordRepeated: "Pr517-unit-only-Aa1!", + }); + ui.password$.set({ status: "failure", error: { kind: "invalid_token" } }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + "Ссылка восстановления недействительна или истекла.", + ); + expect(fixture.nativeElement.textContent).not.toContain("Неправильный логин или пароль"); + expect(ui.passwordForm.value.password).toBe("Pr517-unit-only-Aa1!"); + }); + it("ошибку сервера показывает как сбой сохранения пароля", () => { + const ui = fixture.debugElement.injector.get(AuthUIInfoService); + ui.password$.set({ status: "failure", error: { kind: "server_error", status: 500 } }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + "Не удалось сохранить новый пароль. Попробуйте позже.", + ); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts index 78ffcbf41..efa42a803 100644 --- a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts +++ b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts @@ -33,7 +33,7 @@ export class SetPasswordComponent implements OnInit { protected readonly passwordForm = this.authUIInfoService.passwordForm; protected readonly isSubmitting = this.authUIInfoService.isSubmitting; - protected readonly errorRequest = this.authUIInfoService.errorRequest; + protected readonly requestErrorMessage = this.authUIInfoService.setPasswordErrorMessage; protected readonly credsSubmitInitiated = this.authUIInfoService.credsSubmitInitiated; protected readonly errorMessage = ErrorMessage; diff --git a/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.spec.ts b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.spec.ts index 7738b17be..1d050b4e8 100644 --- a/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.spec.ts @@ -183,6 +183,33 @@ describe("Путь приглашённого эксперта", () => { expect(api.resetPassword).toHaveBeenCalledWith(token, "expert@example.com"); expect(component.mode()).toBe("sent"); }); + it("ошибка восстановления показана по-русски и сохраняет контекст приглашения", () => { + const { component, api, token, fixture } = setup(); + api.resetPassword.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { + detail: + "We couldn't find an account associated with that email. Please try a different e-mail address.", + }, + }), + ), + ); + component.changeMode("reset"); + const invitation = component.invitation(); + component.resetPassword(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain( + "Аккаунт с этой почтой не найден. Проверьте адрес электронной почты.", + ); + expect(fixture.nativeElement.textContent).not.toContain("We couldn't"); + expect(component.mode()).toBe("reset"); + expect(component.busy()).toBe(false); + expect(component.invitation()).toBe(invitation); + expect(api.resetPassword).toHaveBeenCalledExactlyOnceWith(token, "expert@example.com"); + }); it("принятое приглашение позволяет восстановить пароль после выхода", () => { const { component, api, fixture, invitation } = setup(); api.invitation.mockReturnValue(of({ ...invitation, status: "accepted", accountExists: true })); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics-dialog.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics-dialog.scss new file mode 100644 index 000000000..173949829 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics-dialog.scss @@ -0,0 +1,77 @@ +/* @format */ + +/* Общая оболочка детализации аналитики; API и состояния остаются у V1/V2. */ + +.analytics-drilldown { + width: 100%; + min-width: 0; + font-size: var(--ui-font-body); + line-height: 1.5; + color: var(--black); + overflow-wrap: anywhere; + + h2 { + font-size: 20px; + font-weight: 600; + } + + h3 { + font-size: var(--ui-font-h3); + font-weight: 600; + } + + ul { + padding: 0; + list-style: none; + } + + button { + font: inherit; + cursor: pointer; + } + + button:focus-visible, + a:focus-visible, + input:focus-visible, + h2:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + } + + app-avatar { + flex-shrink: 0; + } + + &__header { + display: flex; + gap: 12px; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 20px; + } + + &__close { + flex: 0 0 32px; + width: 32px; + height: 32px; + color: var(--dark-grey); + background: transparent; + border: 0; + border-radius: 8px; + } +} + +.analytics-drilldown .ui-button.ui-button.analytics-drilldown__close { + flex: 0 0 32px; + width: 32px; + min-width: 32px; + height: 32px; + min-height: 32px; + padding: 0; + color: var(--dark-grey); + background: transparent; + border: 0; + border-radius: 8px; + + &:hover { background: var(--light-gray); } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics-table.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics-table.scss new file mode 100644 index 000000000..325458e14 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics-table.scss @@ -0,0 +1,370 @@ +/* @format */ + +@use "styles/responsive"; + +/* Общее представление таблиц и статусов аналитики V1/V2; данные остаются в consumers. */ + +.analytics-case { + .analytics-case__table { + small { + display: block; + margin-top: 4px; + font-size: var(--ui-font-small); + } + + @include responsive.apply-desktop { + th, + td { + padding: 10px 8px; + } + + th { + font-size: 13px; + } + + th:first-child, + td:first-child { + width: 32%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 28%; + } + + th:nth-child(3), + td:nth-child(3) { + width: 20%; + } + + th:last-child, + td:last-child { + width: 20%; + } + } + } +} + +.analytics-drilldown { + &__identity { + display: flex; + gap: 8px; + align-items: center; + min-width: 0; + } + + &__badge { + display: inline-block; + padding: 3px 7px; + font-size: var(--ui-font-small); + font-weight: 600; + line-height: 18px; + color: var(--dark-grey); + background: var(--light-gray); + border-radius: 6px; + } + + &__badge--pending { + color: var(--gold-dark); + background: var(--gold-light); + } + + &__badge--in_progress { + color: var(--blue-dark); + background: var(--accent-light); + } + + &__badge--completed { + color: var(--green-dark); + background: var(--green-light); + } + + &__badge--critical { + color: var(--red-dark); + background: var(--red-light); + } + + &__table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + + thead { + display: none; + } + + tbody { + display: grid; + gap: 12px; + } + + tr { + display: block; + padding: 12px; + border: 1px solid var(--medium-grey-for-outline); + border-radius: 12px; + } + + td { + display: block; + padding-block: 4px; + } + + td::before { + display: block; + font-size: var(--ui-font-small); + font-weight: 600; + color: var(--dark-grey); + content: attr(data-label); + } + + @include responsive.apply-desktop { + thead { + display: table-header-group; + } + + tbody { + display: table-row-group; + } + + tr { + display: table-row; + padding: 0; + border: 0; + } + + th, + td { + display: table-cell; + padding: 12px 8px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--medium-grey-for-outline); + } + + th { + font-size: 13px; + font-weight: 600; + } + + td::before { + display: none; + } + + th:first-child { + width: 25%; + } + + th:last-child { + width: 15%; + } + } + } + + &__table--projects { + @include responsive.apply-desktop { + th:first-child { + width: 18%; + } + } + } + + &__table--assignments { + tr { + display: grid; + gap: 12px; + padding: 14px; + } + + td { + padding: 0; + line-height: 22px; + } + + td::before { + margin-bottom: 4px; + } + + .analytics-drilldown__identity { + gap: 12px; + font-weight: 500; + } + + .analytics-drilldown__assignment-status { + display: flex; + flex-flow: column wrap; + gap: 12px 14px; + align-items: flex-start; + } + + .analytics-drilldown__badge { + display: inline-flex; + gap: 6px; + align-items: center; + min-height: 28px; + padding: 4px 9px; + border-radius: 8px; + + &::before { + flex: 0 0 6px; + width: 6px; + height: 6px; + content: ""; + background: currentcolor; + border-radius: 50%; + } + } + + .analytics-drilldown__action { + padding: 4px 0; + line-height: 20px; + + @media screen and (max-width: 999px) { + font-size: var(--ui-font-body); + font-weight: 500; + } + + @media screen and (min-width: 1000px) { + font-size: 13px; + font-weight: 500; + } + } + + @include responsive.apply-desktop { + border-spacing: 0; + border-collapse: separate; + + .analytics-drilldown__assignment-status { + flex-direction: row; + align-items: center; + } + + tr { + display: table-row; + height: 72px; + padding: 0; + } + + th, + td { + padding: 16px 12px; + vertical-align: middle; + } + + thead tr { + height: auto; + } + + th { + padding-block: 13px; + color: var(--dark-grey); + background: var(--light-gray); + border-bottom: 0; + + &:first-child { + width: 27%; + border-radius: 8px 0 0 8px; + } + + &:nth-child(2) { + width: 31%; + } + + &:nth-child(3) { + width: 29%; + } + + &:last-child { + width: 13%; + border-radius: 0 8px 8px 0; + } + } + + th:nth-child(3), + td:nth-child(3) { + padding-inline: 8px; + } + } + } +} + +.analytics-drilldown__table th.attention-check-cell, +.analytics-drilldown__table td.attention-check-cell { + width: 44px; + padding: 0; + + &::before { + display: none; + } +} + +.attention-check { + display: grid; + place-items: center; + width: 44px; + height: 44px; + cursor: pointer; + + input { + width: 18px; + height: 18px; + margin: 0; + cursor: inherit; + accent-color: var(--accent); + } + + input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + } +} + +.attention-check__label { + display: none; +} + +@media (max-width: 999px) { + .analytics-drilldown__table:has(th.attention-check-cell) { + thead { + display: block; + } + + thead tr { + padding: 0; + border: 0; + } + + thead th:not(.attention-check-cell) { + display: none; + } + + th.attention-check-cell { + width: auto; + } + + thead .attention-check { + display: flex; + gap: 12px; + width: auto; + font-size: 12px; + } + + .attention-check__label { + display: inline; + } + } +} + +.analytics-drilldown__table tr.attention-selected-row { + background: color-mix(in srgb, var(--accent-light) 35%, var(--light-white)); +} + +@media (min-width: 1000px) { + .analytics-drilldown__table--assignments th { + &:first-child { width: 24%; } + &:nth-child(2) { width: 27%; } + &:nth-child(3) { width: 18%; } + &:nth-child(4) { width: 19%; } + &:last-child { width: 12%; } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss index 77ca541f8..f2abf966c 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss @@ -3,7 +3,8 @@ @use "styles/responsive"; @use "styles/program-management-controls"; -.analytics .ui-button.ui-button:not(.attention__action, .evaluation__assignment-action) { +.analytics .ui-button.ui-button:not(.attention__action, +.evaluation__assignment-action) { @include program-management-controls.compact-action; } @@ -190,7 +191,9 @@ &__label { min-width: 0; + font-size: var(--ui-font-h3); font-weight: 600; + line-height: 22px; color: var(--black); overflow-wrap: anywhere; } @@ -551,64 +554,6 @@ } } -.status-list { - display: grid; - gap: 10px; - margin-top: 16px; - - li { - display: flex; - gap: 16px; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - background: var(--light-gray); - border-radius: 8px; - - span { - font-weight: 500; - line-height: 18px; - color: var(--black); - } - - strong { - line-height: 20px; - color: var(--black); - - @media screen and (max-width: 999px) { - font-size: var(--ui-font-h2); - font-weight: 700; - } - - @media screen and (min-width: 1000px) { - font-size: 17px; - font-weight: 700; - } - } - } - - &--compact { - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; - margin-top: 8px; - - li { - flex-direction: column; - gap: 4px; - align-items: flex-start; - } - - @media screen and (max-width: 520px) { - grid-template-columns: 1fr; - - li { - flex-direction: row; - justify-content: space-between; - } - } - } -} - .evaluation { grid-column: 1 / -1; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts index 04200d612..082d310a7 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts @@ -484,7 +484,7 @@ describe("ProgramAnalyticsComponent", () => { expect(card.previousElementSibling?.getAttribute("data-testid")).toBe("attention-block"); }); - it("does not show a submission deadline in a noncompetitive program", () => { + it("does not show submission or evaluation deadlines in a noncompetitive program", () => { const base = overview(); data.set({ ...base, cases: { ...base.cases, submissionApplicable: false } }); analytics.scheduleDates.set({ @@ -497,8 +497,10 @@ describe("ProgramAnalyticsComponent", () => { const card = fixture.nativeElement.querySelector( '[data-testid="program-deadlines"]', ) as HTMLElement; - expect(card.querySelectorAll("time")).toHaveLength(2); + expect(card.querySelectorAll("time")).toHaveLength(1); + expect(card.querySelector('[data-deadline="registration"]')).not.toBeNull(); expect(card.querySelector('[data-deadline="submission"]')).toBeNull(); + expect(card.querySelector('[data-deadline="evaluation"]')).toBeNull(); }); it("shows an empty state when no valid dates are available", () => { diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts index 062c15945..a70de471e 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts @@ -47,6 +47,10 @@ describe("Attention views: real overlay", () => { Array.from(dialog().querySelectorAll("button")).find( item => item.textContent?.trim() === text, )!; + const closeButton = () => + dialog().querySelector( + 'button[aria-label="Закрыть детализацию аналитики"]', + )!; beforeEach(async () => { actions.preview.mockReset().mockReturnValue( @@ -497,7 +501,7 @@ describe("Attention views: real overlay", () => { ); else if (method === "backdrop") document.querySelector(".modal__overlay")!.click(); - else button("×").click(); + else closeButton().click(); await detached; await fixture.whenStable(); expect(close).toHaveBeenCalledOnce(); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html index e2178c513..0e9980769 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html @@ -64,7 +64,7 @@

{{ title() aria-label="Закрыть детализацию аналитики" (click)="closeAnalyticsModal()" > - × + diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss index 317b3282e..942eaa816 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss @@ -1,23 +1,11 @@ +@use "styles/program-analytics-dialog"; +@use "styles/ui-foundation"; + /** @format */ @use "styles/responsive"; -@use "styles/ui-foundation"; @use "styles/program-management-controls"; - -.modal__body.analytics-drilldown-body.analytics-drilldown-body--case { - box-sizing: border-box; - width: min(880px, calc(100% - 24px)); - padding: 20px; - overflow-y: auto; - - @media screen and (max-width: 999px) { - max-height: calc(var(--visual-viewport-height, 100dvh) - 32px); - } - - @media screen and (min-width: 1000px) { - max-height: calc(100dvh - 32px); - } -} +@include program-analytics-dialog.body; .analytics-case { &__metrics { @@ -132,154 +120,9 @@ } } } - - .analytics-case__table { - small { - display: block; - margin-top: 4px; - font-size: var(--ui-font-small); - } - - @include responsive.apply-desktop { - th, - td { - padding: 10px 8px; - } - - th { - font-size: 13px; - } - - th:first-child, - td:first-child { - width: 32%; - } - - th:nth-child(2), - td:nth-child(2) { - width: 28%; - } - - th:nth-child(3), - td:nth-child(3) { - width: 20%; - } - - th:last-child, - td:last-child { - width: 20%; - } - } - } -} - -.modal__body.analytics-drilldown-body { - box-sizing: border-box; - width: min(880px, calc(100vw - 24px)); - padding: 20px; - overflow-y: auto; - - @media screen and (max-width: 999px) { - max-height: calc(var(--visual-viewport-height, 100dvh) - 32px); - } - - @media screen and (min-width: 1000px) { - max-height: calc(100dvh - 32px); - } -} - -.modal__body.analytics-drilldown-body.analytics-drilldown-body--assignments { - width: min(980px, calc(100vw - 32px)); - padding: 16px; - - @include responsive.apply-desktop { - padding: 28px; - } - - .analytics-drilldown__header { - align-items: center; - margin-bottom: 24px; - - h2 { - line-height: 30px; - - @media screen and (max-width: 999px) { - font-size: var(--ui-font-h1); - } - - @media screen and (min-width: 1000px) { - font-size: 24px; - } - } - } - - .analytics-drilldown__close { - display: grid; - place-items: center; - padding: 0; - line-height: 1; - - &:hover { - background: var(--light-gray); - } - } } .analytics-drilldown { - width: 100%; - min-width: 0; - font-size: var(--ui-font-body); - line-height: 1.5; - color: var(--black); - overflow-wrap: anywhere; - - h2 { - font-size: 20px; - font-weight: 600; - } - - h3 { - font-size: var(--ui-font-h3); - font-weight: 600; - } - - ul { - padding: 0; - list-style: none; - } - - button { - font: inherit; - cursor: pointer; - } - - button:focus-visible, - a:focus-visible, - input:focus-visible, - h2:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; - } - - &__header { - display: flex; - gap: 12px; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 20px; - } - - &__close { - flex: 0 0 32px; - width: 32px; - height: 32px; - font-size: 26px !important; - color: var(--dark-grey); - background: transparent; - border: 0; - border-radius: 8px; - } - &__action { padding: 6px 0; color: var(--accent-dark); @@ -333,17 +176,6 @@ margin-top: 16px; } - &__identity { - display: flex; - gap: 8px; - align-items: center; - min-width: 0; - } - - app-avatar { - flex-shrink: 0; - } - &__secondary { color: var(--dark-grey); } @@ -407,240 +239,6 @@ &__expert > .analytics-drilldown__identity { flex-basis: 100%; } - - &__badge { - display: inline-block; - padding: 3px 7px; - font-size: var(--ui-font-small); - font-weight: 600; - line-height: 18px; - color: var(--dark-grey); - background: var(--light-gray); - border-radius: 6px; - } - - &__badge--pending { - color: var(--gold-dark); - background: var(--gold-light); - } - - &__badge--in_progress { - color: var(--blue-dark); - background: var(--accent-light); - } - - &__badge--completed { - color: var(--green-dark); - background: var(--green-light); - } - - &__badge--critical { - color: var(--red-dark); - background: var(--red-light); - } - - &__table { - width: 100%; - table-layout: fixed; - border-collapse: collapse; - - thead { - display: none; - } - - tbody { - display: grid; - gap: 12px; - } - - tr { - display: block; - padding: 12px; - border: 1px solid var(--medium-grey-for-outline); - border-radius: 12px; - } - - td { - display: block; - padding-block: 4px; - } - - td::before { - display: block; - font-size: var(--ui-font-small); - font-weight: 600; - color: var(--dark-grey); - content: attr(data-label); - } - - @include responsive.apply-desktop { - thead { - display: table-header-group; - } - - tbody { - display: table-row-group; - } - - tr { - display: table-row; - padding: 0; - border: 0; - } - - th, - td { - display: table-cell; - padding: 12px 8px; - text-align: left; - vertical-align: top; - border-bottom: 1px solid var(--medium-grey-for-outline); - } - - th { - font-size: 13px; - font-weight: 600; - } - - td::before { - display: none; - } - - th:first-child { - width: 25%; - } - - th:last-child { - width: 15%; - } - } - } - - &__table--projects { - @include responsive.apply-desktop { - th:first-child { - width: 18%; - } - } - } - - &__table--assignments { - tr { - display: grid; - gap: 12px; - padding: 14px; - } - - td { - padding: 0; - line-height: 22px; - } - - td::before { - margin-bottom: 4px; - } - - .analytics-drilldown__identity { - gap: 12px; - font-weight: 500; - } - - .analytics-drilldown__assignment-status { - display: flex; - flex-flow: column wrap; - gap: 12px 14px; - align-items: flex-start; - } - - .analytics-drilldown__badge { - display: inline-flex; - gap: 6px; - align-items: center; - min-height: 28px; - padding: 4px 9px; - border-radius: 8px; - - &::before { - flex: 0 0 6px; - width: 6px; - height: 6px; - content: ""; - background: currentcolor; - border-radius: 50%; - } - } - - .analytics-drilldown__action { - padding: 4px 0; - line-height: 20px; - - @media screen and (max-width: 999px) { - font-size: var(--ui-font-body); - font-weight: 500; - } - - @media screen and (min-width: 1000px) { - font-size: 13px; - font-weight: 500; - } - } - - @include responsive.apply-desktop { - border-spacing: 0; - border-collapse: separate; - - .analytics-drilldown__assignment-status { - flex-direction: row; - align-items: center; - } - - tr { - display: table-row; - height: 72px; - padding: 0; - } - - th, - td { - padding: 16px 12px; - vertical-align: middle; - } - - thead tr { - height: auto; - } - - th { - padding-block: 13px; - color: var(--dark-grey); - background: var(--light-gray); - border-bottom: 0; - - &:first-child { - width: 27%; - border-radius: 8px 0 0 8px; - } - - &:nth-child(2) { - width: 31%; - } - - &:nth-child(3) { - width: 29%; - } - - &:last-child { - width: 13%; - border-radius: 0 8px 8px 0; - } - } - - th:nth-child(3), - td:nth-child(3) { - padding-inline: 8px; - } - } - } } .analytics-drilldown .ui-button.ui-button { @@ -654,102 +252,6 @@ max-height: none; } -.modal__body.analytics-drilldown-body.analytics-drilldown-body--attention { - box-sizing: border-box; - width: min(1100px, calc(100vw - 24px)); - max-height: calc(100dvh - 32px); - padding: 20px; - overflow-y: auto; -} - -.attention-selection { - display: flex; - flex-wrap: wrap; - gap: 8px 16px; - align-items: center; - margin: 16px 0; -} - -.attention-sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} - -.analytics-drilldown__table th.attention-check-cell, -.analytics-drilldown__table td.attention-check-cell { - width: 44px; - padding: 0; - - &::before { - display: none; - } -} - -.attention-check { - display: grid; - place-items: center; - width: 44px; - height: 44px; - cursor: pointer; - - input { - width: 18px; - height: 18px; - margin: 0; - cursor: inherit; - accent-color: var(--accent); - } - - input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; - } -} - -.attention-check__label { - display: none; -} - -@media (max-width: 999px) { - .analytics-drilldown__table:has(th.attention-check-cell) { - thead { - display: block; - } - - thead tr { - padding: 0; - border: 0; - } - - thead th:not(.attention-check-cell) { - display: none; - } - - th.attention-check-cell { - width: auto; - } - - thead .attention-check { - display: flex; - gap: 12px; - width: auto; - font-size: 12px; - } - - .attention-check__label { - display: inline; - } - } -} - -.analytics-drilldown__table tr.attention-selected-row { - background: color-mix(in srgb, var(--accent-light) 35%, var(--light-white)); -} - .attention-reminder { margin: 20px 0; @@ -883,16 +385,6 @@ border: 0; } -@media (min-width: 1000px) { - .analytics-drilldown__table--assignments th { - &:first-child { width: 24%; } - &:nth-child(2) { width: 27%; } - &:nth-child(3) { width: 18%; } - &:nth-child(4) { width: 19%; } - &:last-child { width: 12%; } - } -} - .attention-actions { position: sticky; bottom: -20px; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts index b622be851..f8f347a3f 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts @@ -40,6 +40,10 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { Array.from(dialog().querySelectorAll("button")).find(item => item.textContent?.includes(text), )!; + const closeButton = () => + dialog().querySelector( + 'button[aria-label="Закрыть детализацию аналитики"]', + )!; beforeEach(async () => { assignments.execute.mockReset().mockReturnValue(of(ok([assignment()]))); @@ -191,7 +195,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { ); } else if (method === "backdrop") { document.querySelector(".modal__overlay")!.click(); - } else button("×").click(); + } else closeButton().click(); await detached; await fixture.whenStable(); expect(close).toHaveBeenCalledOnce(); @@ -206,7 +210,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { trigger.remove(); const focus = vi.spyOn(trigger, "focus"); const detached = firstValueFrom(modal.overlayRef!.detachments()); - button("×").click(); + closeButton().click(); await detached; expect(focus).not.toHaveBeenCalled(); }); @@ -215,7 +219,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { await open(); const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); const detached = firstValueFrom(modal.overlayRef!.detachments()); - button("×").click(); + closeButton().click(); await detached; await fixture.whenStable(); await open("pending"); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts index 32c9b01b4..3124f4964 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts @@ -59,7 +59,11 @@ import { TransientNoticeComponent } from "@ui/primitives/transient-notice/transi @Component({ selector: "app-analytics-drilldown", templateUrl: "./analytics-drilldown.component.html", - styleUrl: "./analytics-drilldown.component.scss", + styleUrls: [ + "../analytics-table.scss", + "../analytics-dialog.scss", + "./analytics-drilldown.component.scss", + ], imports: [ TransientNoticeComponent, StateComponent, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.html new file mode 100644 index 000000000..88d9e7744 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.html @@ -0,0 +1,85 @@ + +@if (totals().total > 0) { +
+
+ {{ percentageLabel() }}% + назначений выполнено +
+
+ +
+

+ Готово {{ totals().evaluated }} из {{ totals().total }} +

+
+ + +
+ +
+} @else { +

+ {{ mode() === "distributed" ? "Эксперты еще не назначены" : "Нет назначений экспертов" }} +

+ @if (showEmptyAction()) { + + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.scss new file mode 100644 index 000000000..5fe90af06 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.scss @@ -0,0 +1,7 @@ +/** @format */ +@use "./evaluation-progress.component"; + +:host { + display: block; + min-width: 0; +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.ts new file mode 100644 index 000000000..ff9998bf1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress-summary.component.ts @@ -0,0 +1,27 @@ +/** @format */ +import { ChangeDetectionStrategy, Component, computed, input, output } from "@angular/core"; +import { ButtonDirective, IconComponent } from "@uilib"; + +// Только представление счетчиков: V1/V2 самостоятельно получают назначения из своих API. +@Component({ + selector: "app-evaluation-progress-summary", + imports: [ButtonDirective, IconComponent], + templateUrl: "./evaluation-progress-summary.component.html", + styleUrl: "./evaluation-progress-summary.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EvaluationProgressSummaryComponent { + readonly totals = input.required<{ total: number; evaluated: number; pending: number }>(); + readonly mode = input(null); + readonly showEmptyAction = input(false); + readonly openAssignments = output<{ + scope: "all" | "completed" | "pending"; + trigger: HTMLElement; + }>(); + protected readonly percentage = computed(() => { + const { total, evaluated } = this.totals(); + return total > 0 ? (evaluated / total) * 100 : null; + }); + // Незавершенные назначения не округляются до 100%. + protected readonly percentageLabel = computed(() => Math.floor(this.percentage() ?? 0)); +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.html index 89a542bb2..4301ad53b 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.html @@ -2,74 +2,11 @@ @if (totals().total > 0) {
-
-
- {{ percentageLabel() }}% - назначений выполнено -
-
- -
-

- Готово {{ totals().evaluated }} из {{ totals().total }} -

-
- - -
- -
+
{ + it("сохраняет вход V2 в пустой список назначений без зависимости от Project API", () => { + TestBed.configureTestingModule({ imports: [EvaluationProgressSummaryComponent] }); + const fixture = TestBed.createComponent(EvaluationProgressSummaryComponent); + fixture.componentRef.setInput("totals", { total: 0, evaluated: 0, pending: 0 }); + fixture.componentRef.setInput("mode", "distributed"); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("button")).toBeNull(); + fixture.componentRef.setInput("showEmptyAction", true); + fixture.detectChanges(); + const action = vi.fn(); + fixture.componentInstance.openAssignments.subscribe(action); + const trigger = fixture.nativeElement.querySelector("button") as HTMLButtonElement; + trigger.click(); + expect(action).toHaveBeenCalledExactlyOnceWith({ scope: "all", trigger }); + fixture.destroy(); + }); +}); + describe("AnalyticsEvaluationProgressComponent", () => { const requests = { execute: vi.fn() }; const status = ( diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.ts index 411447586..52cb82ce2 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/evaluation-progress/evaluation-progress.component.ts @@ -11,7 +11,7 @@ import { signal, } from "@angular/core"; import { ButtonDirective } from "@uilib"; -import { IconComponent } from "@ui/primitives"; +import { EvaluationProgressSummaryComponent } from "./evaluation-progress-summary.component"; import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { ProgramAnalyticsAssignment, @@ -39,7 +39,7 @@ interface ExpertProgress { @Component({ selector: "app-analytics-evaluation-progress", - imports: [ButtonDirective, IconComponent], + imports: [ButtonDirective, EvaluationProgressSummaryComponent], templateUrl: "./evaluation-progress.component.html", styleUrl: "./evaluation-progress.component.scss", changeDetection: ChangeDetectionStrategy.OnPush, @@ -57,12 +57,6 @@ export class AnalyticsEvaluationProgressComponent { signal>(initial()); protected readonly totals = computed(() => this.status().assignments); - protected readonly percentage = computed(() => { - const { total, evaluated } = this.totals(); - return total > 0 ? (evaluated / total) * 100 : null; - }); - // Never display 100% while an assignment is still unfinished. - protected readonly percentageLabel = computed(() => Math.floor(this.percentage() ?? 0)); protected readonly pending = computed(() => isLoading(this.expertState())); protected readonly failed = computed(() => isFailure(this.expertState())); protected readonly experts = computed(() => { diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.html index c0ccc1f3d..d943ba8fb 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.html @@ -3,73 +3,133 @@

Аналитика

- - @if (data()) { +
-
- - - @if (data()?.solutionFunnel?.submissionApplicable) { - - - } -
- } + @if (data()) { + +
+ + + @if (data()?.solutionFunnel?.submissionApplicable) { + + + } +
+ } +
@if (exporting()) {

Подготавливаем Excel…

} @if (exportError()) { -

{{ exportError() }}

+

{{ exportError() }}

}
@if (pending()) { -

Загружаем аналитику программы…

+
+ +

Загружаем аналитику программы…

+ +
} @else if (error()) { } @else if (data(); as overview) {
-
+
+
+ +
-

Участники

+
+

Участники

+ +
{{ overview.summary.participants.total }}
-
+
+
+ +
-

Заявки

+
+

Заявки

+ +
{{ overview.summary.solutions.total }}
-
+
+
+ +
-

- {{ overview.solutionFunnel.submissionApplicable ? "Сдано" : "В командах" }} -

+
+

+ {{ overview.solutionFunnel.submissionApplicable ? "Сдано" : "В командах" }} +

+ +
{{ overview.solutionFunnel.submissionApplicable ? overview.solutionFunnel.submitted @@ -77,16 +137,37 @@

}}

-
+
+
+ +
-

Эксперты

+
+

Эксперты

+ +
{{ overview.summary.experts.total }}
-

Требует внимания

+
+

Требует внимания

+ +

-

Сроки программы

+
+

Сроки программы

+ +
    @for (deadline of deadlines(); track deadline.key) {
  • Сроки программы >

    {{ deadline.label }}

    - +
    - {{ deadline.status }} + {{ deadline.status }}
  • }
@@ -181,7 +273,16 @@

{{ deadline.label }}

-

Воронка участников

+
+

Воронка участников

+ +
Воронка участников />
-

Воронка заявок

+
+

Воронка заявок

+ +
Воронка заявок @if (overview.evaluationStatus.applicable) {
-

Ход оценивания

-

- {{ - overview.evaluationStatus.mode === "distributed" - ? "Распределенное оценивание" - : "Открытое оценивание" - }} -

- @if (overview.evaluationStatus.assignments; as totals) { -
- - - +
+
+

Ход оценивания

+

+ {{ + overview.evaluationStatus.mode === "distributed" + ? "Распределенное оценивание" + : "Открытое оценивание" + }} +

+ +
+ @if (overview.evaluationStatus.assignments; as totals) { + } @if (overview.evaluationStatus.solutions; as solutions) { -

+

Сдано: {{ solutions.submitted }} · Оценено: {{ solutions.evaluated }} · Частично оценено: {{ solutions.partiallyEvaluated }}

@@ -260,10 +361,32 @@

Ход оценивания

}
-

Кейсы

+
+

Кейсы

+ +
@if (!overview.cases.configured) {

Кейсы не настроены для этой программы

} + @if (overview.cases.items.length || overview.cases.withoutCase.solutionsTotal) { + + }
    @for (row of overview.cases.items; track row.name) {
  • @@ -281,11 +404,27 @@

    Кейсы

    " > {{ row.name }}Заявки: {{ row.solutionsTotal }} + >{{ row.solutionsTotal }}Заявки @if (overview.cases.submissionApplicable) { - Сдано: {{ row.submitted }} + Сдано{{ row.submitted }} } - Участники: {{ row.participantsTotal }} + Участники{{ row.participantsTotal }}
  • } @@ -301,47 +440,97 @@

    Кейсы

    " > Без выбранного кейсаЗаявки: {{ overview.cases.withoutCase.solutionsTotal }} + >{{ overview.cases.withoutCase.solutionsTotal }}Заявки @if (overview.cases.submissionApplicable) { - Сдано: {{ overview.cases.withoutCase.submitted }} + Сдано{{ overview.cases.withoutCase.submitted }} } - Участники: {{ overview.cases.withoutCase.participantsTotal }} + Участники{{ overview.cases.withoutCase.participantsTotal }} }
-

Материалы решений

-

- Учитываются только поля, применимые к заявке. Число 0 и ответ «Нет» считаются - заполненными. -

- @for (field of overview.fieldCoverage; track field.fieldId) { - - } - @if (!overview.fieldCoverage.length) { +
+

Материалы решений

+ +
+ @if (overview.fieldCoverage.length) { + + + + + + + + + + @for (field of overview.fieldCoverage; track field.fieldId) { + + + + + + } + +
Поле формыЗаполненоОбязательных без ответа
+ + {{ field.answered }} из {{ field.applicable }}{{ field.requiredUnanswered }}
+ } @else {

Поля формы пока не настроены

}
-

Регионы участников

+
+

Регионы участников

+ +
@if (overview.summary.participantRegions.items.length) { Регионы участников

У участников пока не указаны регионы

}
-
-

Динамика активности

+
+
+
+

Динамика активности

+ @if (activityPoints().length) { +

Последние 30 дней

+ } +
+ +
@if (activityPoints().length) { Динамика активности -
-
+
+

{{ title() }}

@if (message()) {

{{ message() }}

} @if (actionError()) { -

{{ actionError() }}

+

{{ actionError() }}

} @if (preview(); as reviewed) {

Отправить напоминание

@@ -425,7 +639,7 @@

Отправить напоминание

Загружаем оценку…

} @if (detailError()) { -

{{ detailError() }}

+

{{ detailError() }}

} @if (detail(); as scores) {

{{ scores.application.name }} · {{ scores.expert.fullName }}

- +
@@ -443,8 +657,8 @@

{{ scores.application.name }} · {{ scores.expert.fullName }}

@for (criterion of scores.scores; track criterion.criterionId) { - - + + } @@ -455,36 +669,60 @@

{{ scores.application.name }} · {{ scores.expert.fullName }}

Загружаем назначения…

} @if (listError()) { -

{{ listError() }}

+

{{ listError() }}

}
-
Критерий
{{ criterion.name }}{{ criterionValue(criterion) }}{{ criterion.name }}{{ criterionValue(criterion) }}
+
- + @for (row of visibleAssignments(); track row.assignmentId) { - - + - - + @@ -512,16 +750,18 @@

{{ scores.application.name }} · {{ scores.expert.fullName }}

Загружаем список…

} @if (listError()) { -

{{ listError() }}

+

{{ listError() }}

} @if (!listPending() && !listError()) {
-
Эксперт Решение СтатусОценка
{{ row.expert.fullName }} + + + {{ row.application.name }} {{ statusLabel[row.status] }} + + {{ statusLabel[row.status] }} + @if (row.status === "completed") { + } @else { + — }
+
@if (canRemind()) { - + } @@ -531,9 +771,9 @@

{{ scores.application.name }} · {{ scores.expert.fullName }}

@if (modal() === "solutions") { @for (row of list()?.results; track row.application.solutionId) { - + @if (canRemind()) { - } - + - + - - - + + } } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.scss index 0b077ef50..342d947f0 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.scss @@ -1,6 +1,12 @@ /** @format */ +@use "styles/program-analytics-dialog"; @use "styles/ui-foundation"; +@use "styles/program-management-controls"; + +::ng-deep { + @include program-analytics-dialog.body; +} :host { display: block; @@ -13,12 +19,20 @@ .ui-button:not(.attention__action).ui-button { @include ui-foundation.button; + @include program-management-controls.compact-action; } .ui-choice { @include ui-foundation.choice; } +.analytics__header-actions { + display: flex; + gap: 12px; + align-items: center; + margin-left: auto; +} + .solution-analytics__actions { display: flex; flex-wrap: wrap; @@ -34,17 +48,11 @@ .solution-analytics__dialog { display: grid; gap: 16px; - max-height: 78dvh; - padding: 24px; - overflow: auto; - font-family: Mont, sans-serif; + p { margin: 0; } } -.solution-analytics__heading { - display: flex; - gap: 16px; - align-items: flex-start; - justify-content: space-between; +.ui-alert.ui-alert.ui-alert { + @include program-management-controls.status-notice; } .solution-analytics__table { @@ -52,22 +60,34 @@ overflow-x: auto; } -.solution-analytics__table table { - width: 100%; - border-collapse: collapse; +.solution-analytics__evaluation-totals { + margin: 12px 0 0; + font-size: 12px; + line-height: 18px; + color: var(--dark-grey); } -.solution-analytics__table th, -.solution-analytics__table td { - padding: 12px; - text-align: left; - overflow-wrap: anywhere; - border-bottom: 1px solid var(--light-gray); +.solution-analytics__coverage { + margin-top: 12px; + + .ui-button.ui-button { + justify-content: flex-start; + max-width: 100%; + padding-inline: 0; + text-align: left; + white-space: normal; + } + + @media (min-width: 1000px) { + td { vertical-align: middle; } + th:first-child { width: 50%; } + th:last-child { width: 25%; } + } } -.solution-analytics__table th { - overflow-wrap: normal; - white-space: nowrap; +.analytics__header-actions .regions__export.ui-button.ui-button, +.solution-analytics__dialog .regions__export.ui-button.ui-button { + @include program-management-controls.export-trigger; } .solution-analytics__sr { @@ -77,9 +97,3 @@ overflow: hidden; clip-path: inset(50%); } - -@media (max-width: 600px) { - .solution-analytics__dialog { - padding: 16px; - } -} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.ts index 818775d4c..da5421c4c 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/solution-analytics.component.ts @@ -16,12 +16,23 @@ import { RouterLink } from "@angular/router"; import { Observable, Subject, takeUntil, timer } from "rxjs"; import { CdkMenuModule } from "@angular/cdk/menu"; import { IconComponent } from "@ui/primitives/icon/icon.component"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; import { AnalyticsActivityChartComponent } from "./activity-chart/activity-chart.component"; import { Result } from "@domain/shared/result.type"; import { saveAs } from "file-saver"; import { A11yModule } from "@angular/cdk/a11y"; -import { ButtonDirective, ChoiceDirective, FieldDirective, TableDirective } from "@uilib"; +import { EvaluationProgressSummaryComponent } from "./evaluation-progress/evaluation-progress-summary.component"; +import { MatProgressBarModule } from "@angular/material/progress-bar"; +import { + AlertDirective, + AvatarComponent, + ButtonDirective, + ChoiceDirective, + FieldDirective, + TableDirective, +} from "@uilib"; import { AppRoutes } from "@api/paths/app-routes"; +import { presentationHref } from "@utils/program-case-analytics"; import { ModalComponent } from "@ui/primitives/modal/modal.component"; import { AnalyticsFunnelChartComponent } from "./funnel-chart/funnel-chart.component"; import { AnalyticsRegionChartComponent } from "./region-chart/region-chart.component"; @@ -62,6 +73,10 @@ type View = selector: "app-solution-analytics", imports: [ ReactiveFormsModule, + AlertDirective, + AvatarComponent, + MatProgressBarModule, + EvaluationProgressSummaryComponent, RouterLink, A11yModule, ButtonDirective, @@ -74,12 +89,25 @@ type View = AnalyticsActivityChartComponent, CdkMenuModule, IconComponent, + TooltipComponent, ], templateUrl: "./solution-analytics.component.html", - styleUrls: ["./analytics.component.scss", "./solution-analytics.component.scss"], + styleUrls: [ + "./analytics.component.scss", + "./analytics-table.scss", + "./analytics-dialog.scss", + "./solution-analytics.component.scss", + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SolutionAnalyticsComponent { + readonly href = presentationHref; + readonly initials = (name: string) => + name + .split(/\s+/) + .slice(0, 2) + .map(part => part[0]) + .join(""); readonly programId = input.required(); readonly dates = input(null); private readonly overviewApi = inject(GetSolutionManagerAnalyticsUseCase); @@ -100,7 +128,14 @@ export class SolutionAnalyticsComponent { private readonly cancelExport = new Subject(); private trigger: HTMLElement | null = null; readonly data = signal(null); + readonly caseMaximum = computed(() => { + const cases = this.data()?.cases; + return cases + ? Math.max(0, cases.withoutCase.solutionsTotal, ...cases.items.map(row => row.solutionsTotal)) + : 0; + }); readonly pending = signal(false); + readonly activeTooltip = signal(null); readonly error = signal(""); readonly exporting = signal(false); readonly exportError = signal(""); @@ -234,6 +269,7 @@ export class SolutionAnalyticsComponent { }); } load(): void { + this.activeTooltip.set(null); this.cancel.next(); this.cancelExport.next(); this.exporting.set(false); @@ -250,6 +286,12 @@ export class SolutionAnalyticsComponent { else this.error.set("Не удалось загрузить аналитику этой программы."); }); } + showTooltip(key: string): void { + this.activeTooltip.set(key); + } + hideTooltip(key: string): void { + if (this.activeTooltip() === key) this.activeTooltip.set(null); + } download(kind: "program" | "solutions" | "submitted" | "scores", filtered = false): void { if (this.exporting()) return; this.exporting.set(true); diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.scss b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.scss index 97b9a5347..3bf9a7729 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.scss @@ -1,5 +1,4 @@ /** @format */ -@use "styles/ui-foundation"; @use "styles/program-management-controls"; :host { @@ -11,7 +10,6 @@ } .ui-button.ui-button { - @include ui-foundation.button; @include program-management-controls.compact-action; &.ui-button--secondary:focus-visible { @@ -22,14 +20,7 @@ } .ui-field { - @include ui-foundation.control; - width: 100%; - font-size: 12px; -} - -.ui-choice { - @include ui-foundation.choice; } h2, diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.ts b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.ts index 2799e2e59..c121b9e7d 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.ts @@ -69,7 +69,12 @@ interface CertificateEditState { { provide: ProgramCertificatesRepositoryPort, useClass: ProgramCertificatesHttpAdapter }, ], templateUrl: "./certificates.component.html", - styleUrl: "./certificates.component.scss", + styleUrls: [ + "../program-native-button.scss", + "../program-native-field.scss", + "../program-native-choice.scss", + "./certificates.component.scss", + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ManagementCertificatesComponent { diff --git a/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html index 32127f6d1..7358b4059 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html @@ -222,7 +222,7 @@

Предпросмотр формы экспер

Целые баллы проверяются по заданной шкале. Ноль и «Нет» — допустимые ответы. Комментарий — - до 50 символов. + до 1000 символов.

Выбрать + Выбрать + {{ modal() === "solutions" ? "Решение" : "Участник" }} {{ modal() === "solutions" ? "Кейс" : "Город" }}
+ {{ scores.application.name }} · {{ scores.expert.fullName }} /> + - + {{ row.caseName ?? "Без кейса" }} {{ row.evaluationStatus === "not_applicable" ? "Без оценивания" @@ -569,8 +809,8 @@

{{ scores.application.name }} · {{ scores.expert.fullName }}

} } @else { @for (row of participants(); track row.userId) { -
+
{{ scores.application.name }} · {{ scores.expert.fullName }} (change)="toggle(row.userId)" /> + {{ row.fullName }} {{ row.city ?? "—" }}Зарегистрирован{{ row.city ?? "—" }}Зарегистрирован
+ + + + + + + + + + + + + @for (expert of rows(); track expert.id) { + + + + + + + + + + } @empty { + + + + } + +
+ + ЭкспертКейсыСтатусНазначеноОцененоДействия
+ + +
+ + @if (expert.userId) { + {{ + expert.name || expert.email + }} + } @else { + {{ expert.name || "Имя пока не указано" }} + } + +
+
{{ scopeLabel(expert) }} + {{ label(expert) }} + @if (expert.status === "pending") { +

{{ deliveryLabel(expert) }}

+ } +
{{ expert.assigned ?? "—" }}{{ expert.completed ?? "—" }} + +
+ {{ emptyText() }} +
+
+ {{ total() ? page() * pageSize() + 1 : 0 }}–{{ page() * pageSize() + rows().length }} из + {{ total() }} +
+
diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.scss b/projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.scss new file mode 100644 index 000000000..14eb7f724 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.scss @@ -0,0 +1,25 @@ +/** @format */ +@use "styles/program-management-controls"; + +:host { + display: block; + min-width: 0; +} + +.ui-button.ui-button { + @include program-management-controls.compact-action; +} + +.experts__roster { + display: grid; + gap: 16px; + min-width: 0; + font-size: 12px; +} + +.experts__roster p { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--grey-for-text); +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.ts b/projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.ts new file mode 100644 index 000000000..ecc4251a8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/expert-roster.component.ts @@ -0,0 +1,61 @@ +/** @format */ + +import { + expertStatusLabel, + expertStatusDescription, + expertDeliveryLabel, + expertInitials, + expertScopeLabel, +} from "./expert-presentation"; +import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { AvatarComponent, ButtonDirective, ChoiceDirective } from "@uilib"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ExpertRow } from "@domain/program/evaluation-workspace.model"; +import { presentationHref } from "@utils/program-case-analytics"; +import { + RowAction, + RowActionEvent, + RowActionsComponent, +} from "@ui/primitives/row-actions/row-actions.component"; + +// Общее представление roster. Отбор строк, назначения и все операции остаются в V1/V2. +@Component({ + selector: "app-expert-roster", + imports: [RouterLink, AvatarComponent, ButtonDirective, ChoiceDirective, RowActionsComponent], + templateUrl: "./expert-roster.component.html", + styleUrls: [ + "../program-native-button.scss", + "../program-native-choice.scss", + "./expert-table.scss", + "./expert-roster.component.scss", + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExpertRosterComponent { + readonly rows = input.required(); + readonly selectedIds = input.required>(); + readonly allSelected = input(false); + readonly busy = input(false); + readonly loading = input(false); + readonly assignmentLabel = input("Проекты"); + readonly page = input(0); + readonly pageSize = input(20); + readonly total = input(0); + readonly emptyText = input("Экспертов пока нет. Пригласите первого эксперта по email."); + readonly actions = input.required<(expert: ExpertRow) => RowAction[]>(); + readonly toggleAll = output(); + readonly toggleRow = output(); + readonly rowClick = output<{ event: MouseEvent; expert: ExpertRow }>(); + readonly action = output<{ expert: ExpertRow; event: RowActionEvent }>(); + readonly pageChange = output(); + readonly paths = AppRoutes; + readonly href = presentationHref; + + readonly scopeLabel = expertScopeLabel; + readonly label = expertStatusLabel; + readonly statusDescription = (row: ExpertRow) => + expertStatusDescription(row, this.assignmentLabel()); + readonly deliveryLabel = expertDeliveryLabel; + readonly initials = expertInitials; +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/expert-table.scss b/projects/social_platform/src/app/ui/pages/program/management/experts/expert-table.scss new file mode 100644 index 000000000..a448c5c0c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/expert-table.scss @@ -0,0 +1,5 @@ +/** @format */ +@use "styles/program-expert-roster"; + +// Одна геометрия для roster V1/V2 и таблицы распределения. +@include program-expert-roster.styles; diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html index 429be1107..e71b94623 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html @@ -95,141 +95,27 @@

Эксперты программы

@if (loading() && !snapshot()) {

Загружаем экспертов…

} -
- -
- - - - - - - - - - - - - - @for (expert of visible(); track expert.id) { - - - - - - - - - - } @empty { - - - - } - -
- - ЭкспертКейсыСтатусНазначеноОцененоДействия
- - -
- - @if (expert.userId) { - {{ expert.name || expert.email }} - } @else { - {{ expert.name || "Имя пока не указано" }} - } - -
-
{{ scopeLabel(expert) }} - {{ label(expert) }} - @if (expert.status === "pending") { -

{{ deliveryLabel(expert) }}

- } -
{{ expert.assigned ?? "—" }}{{ expert.completed ?? "—" }} - -
- {{ - search.value || status.value !== "all" - ? "Эксперты не найдены. Измените поиск или статус." - : "Экспертов пока нет. Пригласите первого эксперта по email." - }} -
-
- {{ filtered().length ? page() * pageSize + 1 : 0 }}–{{ - page() * pageSize + visible().length - }} - из {{ filtered().length }} -
+
} @else { @if (snapshot(); as state) { @@ -497,7 +383,7 @@

Проекты на оценку

[open]="modal() !== null" (openChange)="close()" labelledBy="experts-dialog-title" - bodyClass="modal__body--no-flex experts-modal" + [bodyClass]="dialogBodyClass(modal())" >
@@ -509,7 +395,7 @@

{{ dialogTitle() }}

(click)="close()" [disabled]="busy()" > - × +
@if (dialogError()) { @@ -524,7 +410,7 @@

{{ dialogTitle() }}

@case ("invite") {
} - @if (loading()) { -

Загружаем экспертов и решения…

+ @if (tab() === "distribution") { +
+ Количество экспертов на каждое решение: {{ required() }} +
}
-
-

{{ tab() === "experts" ? "Эксперты программы" : "Распределение экспертов" }}

+
+

{{ tab() === "experts" ? "Эксперты программы" : "Решения на оценку" }}

@if (tab() === "experts") { } @else {
- {{ + tab() === "experts" ? "Поиск экспертов" : "Поиск решений" + }} + +
+ @if (exportError()) { +

Не удалось выгрузить экспертов. Повторите попытку.

+ } + @if (exportSuccess()) { + Excel с экспертами по текущим фильтрам готов. + } + @if (loading()) { +

Загружаем экспертов и решения…

+ } + @if (tab() === "experts") { + - @if (tab() === "experts") { - - } @else { - Все решения +
+ + + + + + + + + + + + + + @for (row of filteredSolutions(); track row.application.solutionId) { + + + + + + + + + } @empty { + + + } - - } - - @if (tab() === "experts" && selectedExperts().size) { -
- Выбрано: {{ selectedExperts().size }}
+
+ Распределение экспертов по решениям +
+ + РешениеКейсЭксперты и статус оценкиОценкиДействия
+ + + {{ row.application.name }} + {{ row.caseName ?? "Без кейса" }} + @for (assignment of assignments(); track assignment.assignmentId) { + @if (assignment.application.solutionId === row.application.solutionId) { +

+ {{ assignment.expert.fullName }} · + {{ assignment.status === "completed" ? "Оценено" : "Ожидается оценка" }} +

+ } + } + @if (!counts(row.application.solutionId).assigned) { + Эксперты не назначены + } +

+ Назначено: {{ counts(row.application.solutionId).assigned }} из {{ required() }} +

+
+ {{ counts(row.application.solutionId).completed }} из {{ required() }} + + +
+ Сданных решений для распределения не найдено. Измените поиск или фильтры. +
+

+ Решений по фильтрам: {{ filteredSolutions().length }} +

+ } +
+ @if (tab() === "experts" && selectedExperts().size) { +
+
+ Выбрано экспертов: {{ selectedExperts().size }} +
+
+ +
+
+ } + @if (tab() === "distribution" && distributionSelection().size) { +
+
+ Выбрано решений: {{ distributionSelection().size }}
- } -
- - @if (tab() === "experts") { - - - - - - - - - - - - - - @for (expert of filtered(); track expert.id) { - - - - - - - - - - - } - - } @else { - - - - - - - - - - - @for (row of filteredSolutions(); track row.application.solutionId) { - - - - - - - - } - - } -
ВыбратьЭкспертКейсыСтатусНазначеноОцененоДействия
- - -
- {{ expert.name || expert.email }} -
-
{{ expert.allCases ? "Все кейсы" : expert.caseNames?.join(", ") }} - {{ - expert.status === "joined" - ? "В программе" - : expert.status === "pending" - ? "Ожидает подтверждения" - : expert.status === "expired" - ? "Срок приглашения истек" - : "Приглашение отозвано" - }} - @if (expert.deliveryStatus === "failed") { - Не удалось отправить письмо - } @else if (expert.deliveryStatus === "queued") { - Письмо в очереди - } - {{ expert.assigned ?? "—" }}{{ expert.completed ?? "—" }} - -
РешениеКейсНазначеноОцененоЭксперты
- {{ row.application.name }} - {{ row.caseName ?? "Без кейса" }}{{ counts(row.application.solutionId).assigned }} из {{ required() }}{{ counts(row.application.solutionId).completed }} - @for (assignment of assignments(); track assignment.assignmentId) { - @if (assignment.application.solutionId === row.application.solutionId) { - {{ assignment.expert.fullName - }}{{ assignment.status === "completed" ? " · оценено" : "" }} - } - } -
-
- @if (!loading() && (tab() === "experts" ? !filtered().length : !filteredSolutions().length)) { -

- {{ - tab() === "experts" - ? "Экспертов не найдено" - : "Сданных решений для распределения не найдено" - }} -

- } -
+ +
+ }
+ + +
+

+ {{ tab() === "experts" ? "Фильтры экспертов" : "Фильтры распределения" }} +

+ +
+ @if (tab() === "experts") { + + } @else { + + } +
+ +
+ +
-
+ >
+

{{ modal() === "invite" @@ -271,12 +399,19 @@

: "Назначить решения" }}

-
@if (dialogError()) { -

{{ dialogError() }}

+

{{ dialogError() }}

@@ -284,18 +419,20 @@

@if (modal() === "invite" || modal() === "scope") { @if (modal() === "invite") { @if (email.touched && email.invalid) { -

Введите корректный email эксперта.

+

Введите корректный email эксперта.

} } - @for (name of cases(); track name) { - } - - +
+ +
} @else if (modal() === "assignments") {

{{ selected()?.name || selected()?.email }}. Сохраненные оценки защищены; их назначения @@ -339,7 +480,7 @@

@if (options(); as loaded) { @for (solution of loaded.solutions; track solution.solutionId) { - } - +
+ +
} @else if (!dialogError()) {

Загружаем решения…

} @@ -388,23 +538,31 @@

{{ p.missingAssignments }}. Лишних назначений: {{ p.surplusAssignments }}. Сохраненные оценки и назначения остаются.

- +
+ +
} @else { - +
+ +
} }

({ exportExperts: vi.fn(async () => {}) })); const option = (id: number, assigned: boolean, protectedScore = false) => ({ solutionId: id, name: `Решение ${id}`, @@ -49,6 +53,7 @@ function setup() { }), ), saveCaseScope: vi.fn(() => of({})), + bulkExperts: vi.fn(() => new Subject()), }; const assignments = { execute: vi.fn(() => @@ -112,6 +117,124 @@ function setup() { }; } describe("Solution expert management", () => { + it("разделяет ID экспертов и приглашений в массовом действии и запрещает повтор для смешанного выбора", () => { + const { c, expert, roster } = setup(); + c.experts.set([ + expert, + { ...expert, id: "invite:77", userId: null, status: "pending", invitationId: 77 }, + ]); + c.selectAllExperts(); + c.bulk("resend"); + c.bulk("revoke"); + expect(roster.bulkExperts).not.toHaveBeenCalled(); + c.bulk("cases"); + c.scopeAll.set(false); + c.scopeCases.set(new Set(["Город"])); + c.bulk("cases"); + expect(roster.bulkExperts).toHaveBeenCalledExactlyOnceWith(12, { + action: "cases", + revision: "r1", + expertIds: [11], + invitationIds: [77], + allCases: false, + caseNames: ["Город"], + }); + }); + it("выгружает весь фильтр экспертов независимо от страницы и массового выбора", async () => { + const { c, expert } = setup(); + vi.mocked(exportExperts).mockClear(); + c.experts.set(Array.from({ length: 21 }, (_, i) => ({ ...expert, id: i + 1 }))); + c.page.set(1); + c.toggleExpert(21); + await c.downloadExperts(); + expect(exportExperts).toHaveBeenCalledTimes(1); + expect(vi.mocked(exportExperts).mock.calls[0][0]).toBe(12); + expect(vi.mocked(exportExperts).mock.calls[0][1]).toHaveLength(21); + expect(c.exportSuccess()).toBe(true); + expect(c.exporting()).toBe(false); + }); + it("сохраняет выбранных экспертов и страницу при применении неизмененного фильтра", () => { + const { c, expert, roster } = setup(); + c.experts.set( + Array.from({ length: 21 }, (_, i) => ({ ...expert, id: i + 1, name: `Эксперт ${i + 1}` })), + ); + c.status.setValue("joined"); + c.selectAllExperts(); + c.page.set(1); + c.openFilters(); + c.applyFilters(); + expect(c.selectedExperts().size).toBe(21); + expect(c.page()).toBe(1); + expect(c.status.value).toBe("joined"); + expect(c.filtersOpen()).toBe(false); + expect(roster.workspace).toHaveBeenCalledTimes(1); + }); + it("не расширяет распределение до всех решений после применения неизмененного фильтра", () => { + const { c } = setup(); + const source = solutionContractScenarios.find(s => s.name === "manager_solution_list_awaiting")! + .response as SolutionManagerSolutions; + const row = source.results[0]; + c.solutions.set([row, { ...row, application: { ...row.application, solutionId: 302 } }]); + c.tab.set("distribution"); + c.caseFilter.setValue(JSON.stringify(row.caseName)); + c.toggleDistribution(302); + c.openFilters(); + c.applyFilters(); + expect([...c.distributionSelection()]).toEqual([302]); + c.startAllocation(); + expect(c.allocation()).toEqual({ programId: 12, ids: [302] }); + }); + it("applies the filter draft only on confirmation and resets selection without changing the roster API", () => { + const { c, roster } = setup(); + c.selectAllExperts(); + expect(c.selectedExperts().size).toBe(1); + c.openFilters(); + c.filterDraft.controls.status.setValue("pending"); + expect(c.filtered()).toHaveLength(1); + c.filtersOpen.set(false); + c.openFilters(); + expect(c.filterDraft.controls.status.value).toBe("all"); + c.filterDraft.controls.status.setValue("pending"); + c.applyFilters(); + expect(c.filtered()).toHaveLength(0); + expect(c.selectedExperts().size).toBe(0); + expect(c.filtersOpen()).toBe(false); + expect(roster.workspace).toHaveBeenCalledTimes(1); + }); + it("selects the filtered roster across pages and clears hidden selections when searching", () => { + const { c, expert } = setup(); + c.experts.set( + Array.from({ length: 21 }, (_, i) => ({ ...expert, id: i + 1, name: `Эксперт ${i + 1}` })), + ); + c.selectAllExperts(); + expect(c.selectedExperts().size).toBe(21); + c.page.set(1); + expect(c.visible().map(e => e.id)).toEqual([21]); + c.search.setValue("Эксперт 21"); + expect(c.page()).toBe(0); + expect(c.visible().map(e => e.id)).toEqual([21]); + expect(c.selectedExperts().size).toBe(0); + c.selectAllExperts(); + expect([...c.selectedExperts()]).toEqual([21]); + }); + it("allocates only selected solution IDs and clears distribution selection when the case changes", () => { + const { c } = setup(); + const source = solutionContractScenarios.find(s => s.name === "manager_solution_list_awaiting")! + .response as SolutionManagerSolutions; + const row = source.results[0]; + c.solutions.set([row, { ...row, application: { ...row.application, solutionId: 302 } }]); + c.toggleDistribution(302); + c.startAllocation(); + expect(c.allocation()).toEqual({ programId: 12, ids: [302] }); + c.allocationBusy.set(true); + c.startAllocation([301]); + expect(c.allocation()?.ids).toEqual([302]); + c.allocationBusy.set(false); + c.caseFilter.setValue(JSON.stringify(row.caseName)); + expect(c.distributionSelection().size).toBe(0); + c.startAllocation(); + expect(c.allocation()?.ids).toEqual([301, 302]); + }); it("ignores every Project counter in the existing expert roster", () => { const { c } = setup(); expect(c.experts()[0]).toMatchObject({ assigned: 1, completed: 0, remaining: 1 }); diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.ts b/projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.ts index 036419491..fc14fad92 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/solution-experts.component.ts @@ -1,4 +1,11 @@ /** @format */ + +import { + expertScopeLabel, + expertStatusLabel, + expertDeliveryLabel, + expertDialogBodyClass, +} from "./expert-presentation"; import { ChangeDetectionStrategy, Component, @@ -12,7 +19,7 @@ import { untracked, } from "@angular/core"; import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; -import { FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; import { RouterLink } from "@angular/router"; import { catchError, @@ -28,11 +35,11 @@ import { } from "rxjs"; import { A11yModule } from "@angular/cdk/a11y"; import { - AvatarComponent, ButtonDirective, ChoiceDirective, FieldDirective, - TableDirective, + AlertDirective, + IconComponent, } from "@uilib"; import { RowAction, @@ -62,6 +69,8 @@ import { import { ModalComponent } from "@ui/primitives/modal/modal.component"; import { SolutionAllocationComponent } from "../solution-applications/solution-allocation.component"; import { exportExperts } from "@utils/export-experts"; +import { ExpertRosterComponent } from "./expert-roster.component"; +import { TransientNoticeComponent } from "@ui/primitives/transient-notice/transient-notice.component"; /** Program experts keep their existing invitation/case-scope API. Assignment IDs and counters come only from solutions. */ @Component({ @@ -73,21 +82,28 @@ import { exportExperts } from "@utils/export-experts"; ButtonDirective, ChoiceDirective, FieldDirective, - TableDirective, - AvatarComponent, + AlertDirective, + IconComponent, RowActionsComponent, ModalComponent, SolutionAllocationComponent, + ExpertRosterComponent, + TransientNoticeComponent, ], templateUrl: "./solution-experts.component.html", styleUrls: [ + "../program-native-button.scss", + "../program-native-field.scss", + "../program-native-choice.scss", + "./expert-table.scss", "./experts.component.scss", - "../../detail/analytics/solution-analytics.component.scss", ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SolutionExpertsComponent { + readonly dialogBodyClass = expertDialogBodyClass; readonly programId = input.required(); + readonly floatingPosition = input<{ center: number; width: number } | null>(null); readonly confirmDiscard = input.required<() => Promise>(); readonly statusChange = output<{ dirty: boolean; busy: boolean }>(); private readonly roster = inject(EvaluationUseCase); @@ -110,7 +126,18 @@ export class SolutionExpertsComponent { readonly allocationBusy = signal(false); readonly error = signal(""); readonly dialogError = signal(""); - readonly message = signal(""); + readonly message = signal("", { equal: () => false }); + readonly exporting = signal(false); + readonly exportError = signal(false); + readonly exportSuccess = signal(false, { equal: () => false }); + readonly filtersOpen = signal(false); + readonly filterDraft = new FormGroup({ + status: new FormControl("all", { nonNullable: true }), + case: new FormControl("all", { nonNullable: true }), + }); + readonly page = signal(0); + readonly pageSize = 20; + readonly distributionSelection = signal(new Set()); readonly tab = signal<"experts" | "distribution">("experts"); readonly modal = signal<"invite" | "scope" | "assignments" | "quota" | null>(null); readonly selected = signal(null); @@ -221,6 +248,74 @@ export class SolutionExpertsComponent { (this.caseValue() === "all" || JSON.stringify(row.caseName) === this.caseValue()), ), ); + readonly visible = computed(() => + this.filtered().slice(this.page() * this.pageSize, (this.page() + 1) * this.pageSize), + ); + readonly allExpertsSelected = computed( + () => !!this.filtered().length && this.filtered().every(e => this.selectedExperts().has(e.id)), + ); + readonly allSolutionsSelected = computed( + () => + !!this.filteredSolutions().length && + this.filteredSolutions().every(row => + this.distributionSelection().has(row.application.solutionId), + ), + ); + readonly filterCount = computed(() => + Number((this.tab() === "experts" ? this.statusValue() : this.caseValue()) !== "all"), + ); + readonly distributionActions: RowAction[] = [{ id: "assign", label: "Назначить экспертов" }]; + readonly rosterActions = (expert: ExpertRow) => this.rowActions(expert); + selectAllExperts(): void { + if (this.busy() || this.loading()) return; + this.selectedExperts.set( + this.allExpertsSelected() ? new Set() : new Set(this.filtered().map(e => e.id)), + ); + } + expertRowClick(event: MouseEvent, expert: ExpertRow): void { + const target = event.target; + if ( + event.defaultPrevented || + !(target instanceof Element) || + target.closest("a,button,input,label,select,textarea,app-row-actions,.experts__row-actions") + ) + return; + if (target.ownerDocument.defaultView?.getSelection()?.toString()) return; + this.toggleExpert(expert.id); + } + clearDistributionSelection(): void { + this.distributionSelection.set(new Set()); + } + toggleDistribution(id: number): void { + if (this.busy() || this.loading() || this.allocationBusy()) return; + const ids = new Set(this.distributionSelection()); + ids.has(id) ? ids.delete(id) : ids.add(id); + this.distributionSelection.set(ids); + } + selectAllSolutions(): void { + if (this.busy() || this.loading() || this.allocationBusy()) return; + this.distributionSelection.set( + this.allSolutionsSelected() + ? new Set() + : new Set(this.filteredSolutions().map(row => row.application.solutionId)), + ); + } + openFilters(): void { + this.filterDraft.setValue({ status: this.status.value, case: this.caseFilter.value }); + this.filtersOpen.set(true); + } + applyFilters(): void { + const control = this.tab() === "experts" ? this.status : this.caseFilter; + const value = + this.tab() === "experts" + ? this.filterDraft.controls.status.value + : this.filterDraft.controls.case.value; + if (value !== control.value) control.setValue(value); + this.filtersOpen.set(false); + } + resetFilterDraft(): void { + this.filterDraft.setValue({ status: "all", case: "all" }); + } toggleExpert(id: number | string): void { if (this.busy()) return; const ids = new Set(this.selectedExperts()); @@ -260,6 +355,18 @@ export class SolutionExpertsComponent { ); } constructor() { + this.search.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.page.set(0); + this.clearSelectedExperts(); + this.distributionSelection.set(new Set()); + }); + this.status.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.page.set(0); + this.clearSelectedExperts(); + }); + this.caseFilter.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => this.distributionSelection.set(new Set())); timer(4000, 4000) .pipe( filter( @@ -321,6 +428,9 @@ export class SolutionExpertsComponent { this.assignments.set([]); this.solutions.set([]); this.selectedExperts.set(new Set()); + this.distributionSelection.set(new Set()); + this.page.set(0); + this.filtersOpen.set(false); const id = this.programId(); forkJoin({ roster: this.roster.workspace(id), assignments: this.assignmentsApi.execute(id) }) .pipe(takeUntil(this.cancel), takeUntilDestroyed(this.destroyRef)) @@ -599,11 +709,15 @@ export class SolutionExpertsComponent { "Число оценщиков изменено. Готовность решений пересчитана.", ); } - startAllocation(): void { - if (!this.busy() && !this.loading() && this.solutions().length) + startAllocation( + ids = this.distributionSelection().size + ? [...this.distributionSelection()] + : this.filteredSolutions().map(row => row.application.solutionId), + ): void { + if (!this.busy() && !this.allocationBusy() && !this.loading() && ids.length) this.allocation.set({ programId: this.programId(), - ids: this.filteredSolutions().map(row => row.application.solutionId), + ids, }); } async close(): Promise { @@ -620,24 +734,28 @@ export class SolutionExpertsComponent { this.dialogError.set(""); } async downloadExperts(): Promise { + if (this.exporting()) return; + this.exporting.set(true); + this.exportError.set(false); try { await exportExperts( this.programId(), this.filtered().map(e => ({ name: e.name ?? "", email: e.email, - cases: e.allCases - ? "Все кейсы" - : (e.caseNames ?? []).map(c => c ?? "Без кейса").join(", "), - status: e.status === "joined" ? "В программе" : "Приглашен", - delivery: e.deliveryStatus ?? "", + cases: expertScopeLabel(e), + status: expertStatusLabel(e), + delivery: expertDeliveryLabel(e), assigned: e.assigned, completed: e.completed, remaining: e.remaining, })), ); + this.exportSuccess.set(true); } catch { - this.error.set("Не удалось выгрузить экспертов."); + this.exportError.set(true); + } finally { + this.exporting.set(false); } } } diff --git a/projects/social_platform/src/app/ui/pages/program/management/management-list.scss b/projects/social_platform/src/app/ui/pages/program/management/management-list.scss new file mode 100644 index 000000000..72492f3a5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/management-list.scss @@ -0,0 +1,626 @@ +/* @format */ + +@use "styles/program-management-controls"; + +/* Общие таблицы и списки участников/проектов V1 и заявок Solution V2. */ + +.management { + a:not(.ui-button, .management__primary-link):hover { + text-decoration: underline; + } + + &__card { + min-width: 0; + padding: 20px; + background: var(--light-white); + border-radius: 15px; + } + + &__muted { + font-size: 12px; + font-weight: 400; + line-height: 1.5; + color: var(--grey-for-text); + overflow-wrap: anywhere; + } + + &__search { + margin: 20px 0; + + input { + width: 100%; + } + } + + &__table { + width: 100%; + margin: 20px 0 0; + font-size: 12px; + table-layout: fixed; + border-collapse: collapse; + + th { + font-weight: 600; + background: var(--light-gray); + } + + th, + td { + padding: 12px 8px; + text-align: left; + overflow-wrap: anywhere; + vertical-align: middle; + border-bottom: 1px solid var(--medium-grey-for-outline); + } + } + + &__list-heading { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + } + + &__participant-count span { + color: var(--grey-for-text); + } + + &__participant-filters { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + margin: 0; + + label { + width: auto; + min-width: 0; + } + } + + &__participants th:nth-child(1) { + width: 36px; + } + + &__participants th:nth-child(2) { + width: 32px; + } + + &__participants th:nth-child(3) { + width: 19%; + } + + &__participants th:nth-child(4) { + width: 23%; + } + + &__participants th:nth-child(5) { + width: 14%; + } + + &__participants th:nth-child(6) { + width: 14%; + } + + &__participants th:nth-child(8), + &__col-actions { + width: 44px; + } + + &__row-actions { + padding: 4px !important; + text-align: right !important; + } + + &__bulk-hint { + grid-column: 1 / -1; + color: var(--black); + } + + &__removal-projects { + max-height: 25dvh; + padding-left: 20px; + margin: 0; + overflow: auto; + } + + &__row-selected { + background: var(--light-gray); + } + + &__col-select { + width: 40px; + } + + &__pick { + padding: 0 !important; + + label { + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; + cursor: pointer; + } + + input { + margin: 0; + accent-color: var(--accent); + } + } + + &__selection { + position: fixed; + bottom: 24px; + left: 50%; + z-index: 20; + box-sizing: border-box; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto 32px; + gap: 12px; + align-items: center; + width: min(704px, calc(100vw - 40px)); + max-width: calc(100vw - 32px); + max-height: calc(100dvh - 48px); + padding: 12px; + margin: 0; + overflow-y: auto; + overscroll-behavior: contain; + background: var(--light-white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); + transform: translateX(-50%); + + > button, + > a { + flex-shrink: 0; + min-height: 32px; + padding: 5px 16px; + font-size: 12px; + font-weight: 400; + line-height: 20px; + } + + &--obscured { + pointer-events: none; + visibility: hidden; + } + } + + &__selection-name { + display: grid; + flex: 1; + gap: 4px; + min-width: 0; + overflow-wrap: anywhere; + + span { + color: var(--grey-for-text); + } + + strong { + /* stylelint-disable-next-line value-no-vendor-prefix */ + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + } + + &__clear-selection { + grid-row: 1; + grid-column: 4; + padding: 5px !important; + } + + p.management__selection-hint { + grid-column: 2 / 4; + margin: 0; + } + + &__list-loading { + padding: 28px 0; + color: var(--grey-for-text); + } + + &__primary-link { + display: inline-flex; + align-items: center; + justify-content: center; + + @include program-management-controls.compact-action; + + min-width: 0; + color: var(--white) !important; + text-align: center; + background: var(--accent); + border-radius: 30px; + } + + &__empty { + display: grid; + gap: 16px; + justify-items: start; + padding: 28px 0; + } + + &__sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } +} + +@media (max-width: 999px) { + .management { + &__card { + padding: 16px; + } + + &__table { + display: block; + + colgroup, + thead { + display: none; + } + + tbody { + display: block; + } + + tr { + position: relative; + display: block; + padding: 16px 0; + border-bottom: 1px solid var(--medium-grey-for-outline); + } + + td { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 8px; + padding: 4px 0; + border: 0; + } + + td::before { + color: var(--grey-for-text); + content: attr(data-label); + } + + td.management__name { + display: block; + padding: 0 44px 12px 0; + font-size: 14px; + line-height: 21px; + } + + td.management__name::before { + display: none; + } + + td.management__pick { + position: absolute; + top: 8px; + right: 0; + display: block; + width: 44px; + } + + td.management__pick::before { + display: none; + } + } + + &__selection { + bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + + > button:not([aria-label]), + > a { + width: 100%; + padding-inline: 8px; + } + + > button[aria-label] { + grid-row: 1; + grid-column: 2; + align-self: start; + justify-self: end; + width: 32px; + } + + > button, + > a { + position: relative; + margin-block: 6px; + + &::after { + position: absolute; + inset: -6px 0; + content: ""; + } + } + + @media (max-width: 359px) { + > button:not([aria-label]), + > a { + grid-column: 1 / -1; + } + } + } + + &__selection-name { + grid-row: 1; + grid-column: 1 / -1; + padding-right: 44px; + } + + p.management__selection-hint { + grid-column: 1 / -1; + } + } +} + +.management__card ::ng-deep app-pagination .ui-button.ui-button { + @include program-management-controls.compact-action; +} + +.management__col-number { + width: 36px; +} + +.management__bulk { + grid-template-columns: minmax(0, 1fr) auto auto 32px; +} + +.management__bulk--participants { + grid-template-columns: minmax(0, 1fr) 32px auto 32px; +} + +.management__toolbar--participants, +.management__toolbar--projects { + display: flex; + gap: 12px; + align-items: center; + margin-block: 16px; + + .management__search { + flex: 1; + min-width: 0; + margin: 0; + } +} + +.management__toolbar-actions { + display: flex; + gap: 12px; +} + +.management__toolbar-slot { + display: flex; + align-items: center; + min-height: 44px; + + .management__filter-trigger { width: 120px; } + .management__sort-trigger { width: 200px; } + + .ui-button { + gap: 8px; + padding-inline: 12px; + white-space: nowrap; + } +} + +@media (max-width: 999px) { + .management__toolbar--participants, + .management__toolbar--projects { + flex-direction: column; + gap: 8px; + align-items: stretch; + } + + .management__toolbar-actions { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + gap: 8px; + } + + .management__toolbar-slot .ui-button { + width: 100%; + padding-inline: 8px; + } +} + +.management__table td.management__row-actions { + width: 44px; +} + +.management__mobile-tools { + display: none; +} + +.management__pick-all { + display: inline-flex; + gap: 8px; + align-items: center; + justify-content: center; + min-width: 28px; + min-height: 32px; + cursor: pointer; +} + +.management__profile-link { + display: inline-block; + padding-block: 4px; + font-weight: 600; + cursor: pointer; +} + +.management__export.ui-button.ui-button { + @include program-management-controls.export-trigger; +} + +.management .management__remove { + color: var(--black); + background: var(--red-light); + border-color: var(--red-light); + + &:hover:not(:disabled) { + color: var(--black); + background: color-mix(in srgb, var(--red) 20%, var(--light-white)); + border-color: var(--red); + } +} + +@media (max-width: 999px) { + .management__mobile-tools { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: space-between; + } + + .management__pick-all { + justify-content: start; + min-height: 44px; + } + + .management__table tr { + padding-top: 56px; + } + + .management__table td.management__pick { + position: absolute; + top: 8px; + left: 0; + display: block; + width: 44px; + } + + .management__table td.management__row-actions { + position: absolute; + top: 8px; + right: 0; + display: flex; + justify-content: end; + } + + .management__table td.management__pick::before, + .management__table td.management__row-actions::before { + display: none; + } + + .management__bulk { + grid-template-columns: minmax(0, 1fr) 32px; + } + + .management__bulk--participants { + grid-template-columns: 44px minmax(0, 1fr) 32px; + + > .management__bulk-summary { + grid-row: 1; + grid-column: 1 / 3; + } + } + + .management__bulk > button:not(.management__clear-selection) { + grid-column: 1 / -1; + } + + .management__bulk > button.management__clear-selection { + grid-column: 2; + } + + .management__bulk--participants > button.management__clear-selection { + grid-column: 3; + } + + .management__bulk > button.management__bulk-export { + grid-row: 2; + grid-column: 1; + justify-self: start; + width: 32px; + } + + .management__bulk > button.management__remove { + grid-row: 2; + grid-column: 2 / -1; + } +} + +.management__toolbar-actions.management__toolbar-actions--projects { + display: flex; + justify-content: flex-end; +} + +.management__bulk-summary { + display: grid; + gap: 4px; + align-self: center; + min-width: 0; + overflow-wrap: anywhere; +} + +.management__bulk-summary p { + margin: 0; + font-size: 12px; + line-height: 18px; +} + +.management__bulk--projects { + grid-template-columns: minmax(0, 1fr) auto 32px; + align-items: center; +} + +.management__bulk-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.management__bulk--projects > button.management__clear-selection { + grid-column: 3; + align-self: center; +} + +.management__draft-badge { + display: block; + width: fit-content; + margin-top: 4px; + + @include program-management-controls.status-badge; +} + +@media (max-width: 999px) { + .management__bulk-actions { + display: grid; + grid-row: 2; + grid-column: 1 / -1; + } + + .management__bulk--projects { grid-template-columns: minmax(0, 1fr) 32px; } + + .management__bulk--projects > .management__bulk-summary { + grid-row: 1; + grid-column: 1; + } + + .management__bulk--projects > button.management__clear-selection { + grid-row: 1; + grid-column: 2; + } + + .management__bulk--projects > button:not(.management__clear-selection) { + grid-row: 2; + grid-column: 1 / -1; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.html b/projects/social_platform/src/app/ui/pages/program/management/management.component.html index 5eff9dd36..d1d00705e 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.html @@ -76,6 +76,7 @@

{{ requestError() }}

@if (section() === "experts") { @if (program.solutionStorageMode === "solution") { {{ requestError() }} } @if (section() === "projects" && program.solutionStorageMode === "solution") { diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.scss b/projects/social_platform/src/app/ui/pages/program/management/management.component.scss index 69e81eabc..77154e9c7 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.scss @@ -1,6 +1,4 @@ /** @format */ - -@use "styles/ui-foundation"; @use "styles/program-management-controls"; .management__participant-answers { @@ -16,20 +14,9 @@ // Native adapters share the existing foundation on desktop as well as mobile. .ui-button.ui-button { - @include ui-foundation.button; @include program-management-controls.compact-action; } -.ui-field { - @include ui-foundation.control; - - font-size: 12px; -} - -.ui-choice { - @include ui-foundation.choice; -} - .management { min-width: 0; max-width: 863px; @@ -66,10 +53,6 @@ text-decoration: none; } - a:not(.ui-button, .management__primary-link):hover { - text-decoration: underline; - } - .management &__tabs a:hover, .management &__settings-tabs a:hover { text-decoration: none; @@ -109,29 +92,6 @@ } } - &__card { - min-width: 0; - padding: 20px; - background: var(--light-white); - border-radius: 15px; - } - - &__muted { - font-size: 12px; - font-weight: 400; - line-height: 1.5; - color: var(--grey-for-text); - overflow-wrap: anywhere; - } - - &__search { - margin: 20px 0; - - input { - width: 100%; - } - } - &__filters { display: flex; gap: 12px; @@ -152,218 +112,6 @@ } } - &__table { - width: 100%; - margin: 20px 0 0; - font-size: 12px; - table-layout: fixed; - border-collapse: collapse; - - th { - font-weight: 600; - background: var(--light-gray); - } - - th, - td { - padding: 12px 8px; - text-align: left; - overflow-wrap: anywhere; - vertical-align: middle; - border-bottom: 1px solid var(--medium-grey-for-outline); - } - } - - &__list-heading { - display: flex; - gap: 16px; - align-items: center; - justify-content: space-between; - } - - &__participant-count span { - color: var(--grey-for-text); - } - - &__participant-filters { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 16px; - margin: 0; - - label { - width: auto; - min-width: 0; - } - } - - &__participants th:nth-child(1) { - width: 36px; - } - - &__participants th:nth-child(2) { - width: 32px; - } - - &__participants th:nth-child(3) { - width: 19%; - } - - &__participants th:nth-child(4) { - width: 23%; - } - - &__participants th:nth-child(5) { - width: 14%; - } - - &__participants th:nth-child(6) { - width: 14%; - } - - &__participants th:nth-child(8), - &__col-actions { - width: 44px; - } - - &__row-actions { - padding: 4px !important; - text-align: right !important; - } - - &__bulk-hint { - grid-column: 1 / -1; - color: var(--black); - } - - &__removal-projects { - max-height: 25dvh; - padding-left: 20px; - margin: 0; - overflow: auto; - } - - &__row-selected { - background: var(--light-gray); - } - - &__col-select { - width: 40px; - } - - &__pick { - padding: 0 !important; - - label { - display: flex; - align-items: center; - justify-content: center; - min-height: 44px; - cursor: pointer; - } - - input { - margin: 0; - accent-color: var(--accent); - } - } - - &__selection { - position: fixed; - bottom: 24px; - left: 50%; - z-index: 20; - box-sizing: border-box; - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto 32px; - gap: 12px; - align-items: center; - width: min(704px, calc(100vw - 40px)); - max-width: calc(100vw - 32px); - max-height: calc(100dvh - 48px); - padding: 12px; - margin: 0; - overflow-y: auto; - overscroll-behavior: contain; - background: var(--light-white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 8px; - box-shadow: 0 4px 12px rgb(0 0 0 / 15%); - transform: translateX(-50%); - - > button, - > a { - flex-shrink: 0; - min-height: 32px; - padding: 5px 16px; - font-size: 12px; - font-weight: 400; - line-height: 20px; - } - - &--obscured { - pointer-events: none; - visibility: hidden; - } - } - - &__selection-name { - display: grid; - flex: 1; - gap: 4px; - min-width: 0; - overflow-wrap: anywhere; - - span { - color: var(--grey-for-text); - } - - strong { - /* stylelint-disable-next-line value-no-vendor-prefix */ - display: -webkit-box; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - } - } - - &__clear-selection { - grid-row: 1; - grid-column: 4; - padding: 5px !important; - } - - p.management__selection-hint { - grid-column: 2 / 4; - margin: 0; - } - - &__list-loading { - padding: 28px 0; - color: var(--grey-for-text); - } - - &__primary-link { - display: inline-flex; - align-items: center; - justify-content: center; - - @include program-management-controls.compact-action; - - min-width: 0; - color: var(--white) !important; - text-align: center; - background: var(--accent); - border-radius: 30px; - } - - &__empty { - display: grid; - gap: 16px; - justify-items: start; - padding: 28px 0; - } - &__settings-tabs { display: flex; margin-bottom: 20px; @@ -487,17 +235,6 @@ overflow-wrap: anywhere; } } - - &__sr { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; - } } .management__team { @@ -666,10 +403,6 @@ } } - &__card { - padding: 16px; - } - &__filters { display: grid; grid-template-columns: minmax(0, 1fr); @@ -679,111 +412,6 @@ } } - &__table { - display: block; - - colgroup, - thead { - display: none; - } - - tbody { - display: block; - } - - tr { - position: relative; - display: block; - padding: 16px 0; - border-bottom: 1px solid var(--medium-grey-for-outline); - } - - td { - display: grid; - grid-template-columns: 112px minmax(0, 1fr); - gap: 8px; - padding: 4px 0; - border: 0; - } - - td::before { - color: var(--grey-for-text); - content: attr(data-label); - } - - td.management__name { - display: block; - padding: 0 44px 12px 0; - font-size: 14px; - line-height: 21px; - } - - td.management__name::before { - display: none; - } - - td.management__pick { - position: absolute; - top: 8px; - right: 0; - display: block; - width: 44px; - } - - td.management__pick::before { - display: none; - } - } - - &__selection { - bottom: calc(16px + env(safe-area-inset-bottom, 0px)); - display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); - - > button:not([aria-label]), - > a { - width: 100%; - padding-inline: 8px; - } - - > button[aria-label] { - grid-row: 1; - grid-column: 2; - align-self: start; - justify-self: end; - width: 32px; - } - - > button, - > a { - position: relative; - margin-block: 6px; - - &::after { - position: absolute; - inset: -6px 0; - content: ""; - } - } - - @media (max-width: 359px) { - > button:not([aria-label]), - > a { - grid-column: 1 / -1; - } - } - } - - &__selection-name { - grid-row: 1; - grid-column: 1 / -1; - padding-right: 44px; - } - - p.management__selection-hint { - grid-column: 1 / -1; - } - &__materials-heading { display: grid; gap: 20px; @@ -817,10 +445,6 @@ } } -.management ::ng-deep app-pagination .ui-button.ui-button { - @include program-management-controls.compact-action; -} - @media (max-width: 999px) { .management__settings-tabs a { margin-block: 6px; @@ -839,56 +463,11 @@ } } -.management__col-number { - width: 36px; -} - .ui-alert.ui-alert.ui-alert { @include program-management-controls.status-notice; } // Row actions and bulk selection. -.management__bulk { - grid-template-columns: minmax(0, 1fr) auto auto 32px; -} - -.management__bulk--participants { - grid-template-columns: minmax(0, 1fr) 32px auto 32px; -} - -.management__toolbar--participants, -.management__toolbar--projects { - display: flex; - gap: 12px; - align-items: center; - margin-block: 16px; - - .management__search { - flex: 1; - min-width: 0; - margin: 0; - } -} - -.management__toolbar-actions { - display: flex; - gap: 12px; -} - -.management__toolbar-slot { - display: flex; - align-items: center; - min-height: 44px; - - .management__filter-trigger { width: 120px; } - .management__sort-trigger { width: 200px; } - - .ui-button { - gap: 8px; - padding-inline: 12px; - white-space: nowrap; - } -} .management__sort-trigger[aria-expanded="true"] .management__sort-chevron { transform: rotate(180deg); @@ -1013,192 +592,6 @@ min-height: 44px; } -@media (max-width: 999px) { - .management__toolbar--participants, - .management__toolbar--projects { - flex-direction: column; - gap: 8px; - align-items: stretch; - } - - .management__toolbar-actions { - display: grid; - grid-template-columns: 104px minmax(0, 1fr); - gap: 8px; - } - - .management__toolbar-slot .ui-button { - width: 100%; - padding-inline: 8px; - } -} - -.management__table td.management__row-actions { - width: 44px; -} - -.management__mobile-tools { - display: none; -} - -.management__pick-all { - display: inline-flex; - gap: 8px; - align-items: center; - justify-content: center; - min-width: 28px; - min-height: 32px; - cursor: pointer; -} - -.management__profile-link { - display: inline-block; - padding-block: 4px; - font-weight: 600; - cursor: pointer; -} - -.management .management__export { - width: 32px; - min-width: 32px; - padding: 5px; - background: var(--light-white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 8px; -} - -.management .management__remove { - color: var(--black); - background: var(--red-light); - border-color: var(--red-light); - - &:hover:not(:disabled) { - color: var(--black); - background: color-mix(in srgb, var(--red) 20%, var(--light-white)); - border-color: var(--red); - } -} - -@media (max-width: 999px) { - .management__mobile-tools { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - justify-content: space-between; - } - - .management__pick-all { - justify-content: start; - min-height: 44px; - } - - .management__table tr { - padding-top: 56px; - } - - .management__table td.management__pick { - position: absolute; - top: 8px; - left: 0; - display: block; - width: 44px; - } - - .management__table td.management__row-actions { - position: absolute; - top: 8px; - right: 0; - display: flex; - justify-content: end; - } - - .management__table td.management__pick::before, - .management__table td.management__row-actions::before { - display: none; - } - - .management__bulk { - grid-template-columns: minmax(0, 1fr) 32px; - } - - .management__bulk--participants { - grid-template-columns: 44px minmax(0, 1fr) 32px; - - > .management__bulk-summary { - grid-row: 1; - grid-column: 1 / 3; - } - } - - .management__bulk > button:not(.management__clear-selection) { - grid-column: 1 / -1; - } - - .management__bulk > button.management__clear-selection { - grid-column: 2; - } - - .management__bulk--participants > button.management__clear-selection { - grid-column: 3; - } - - .management__bulk > button.management__bulk-export { - grid-row: 2; - grid-column: 1; - justify-self: start; - width: 32px; - } - - .management__bulk > button.management__remove { - grid-row: 2; - grid-column: 2 / -1; - } -} - -.management__toolbar-actions.management__toolbar-actions--projects { - display: flex; - justify-content: flex-end; -} - -.management__bulk-summary { - display: grid; - gap: 4px; - align-self: center; - min-width: 0; - overflow-wrap: anywhere; -} - -.management__bulk-summary p { - margin: 0; - font-size: 12px; - line-height: 18px; -} - -.management__bulk--projects { - grid-template-columns: minmax(0, 1fr) auto 32px; - align-items: center; -} - -.management__bulk-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.management__bulk--projects > button.management__clear-selection { - grid-column: 3; - align-self: center; -} - -.management__draft-badge { - display: block; - width: fit-content; - margin-top: 4px; - - @include program-management-controls.status-badge; -} - .management__evaluation { display: grid; gap: 16px; @@ -1210,27 +603,4 @@ @media (max-width: 999px) { .management--lists:has(.management__projects) { padding-bottom: 260px; } - - .management__bulk-actions { - display: grid; - grid-row: 2; - grid-column: 1 / -1; - } - - .management__bulk--projects { grid-template-columns: minmax(0, 1fr) 32px; } - - .management__bulk--projects > .management__bulk-summary { - grid-row: 1; - grid-column: 1; - } - - .management__bulk--projects > button.management__clear-selection { - grid-row: 1; - grid-column: 2; - } - - .management__bulk--projects > button:not(.management__clear-selection) { - grid-row: 2; - grid-column: 1 / -1; - } } diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts index 40c0a7adc..e6b9811dc 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts @@ -21,6 +21,7 @@ import { ActivatedRoute, NavigationEnd, Router, RouterLink } from "@angular/rout import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { HttpErrorResponse } from "@angular/common/http"; +import { userErrorMessage } from "@core/lib/models/error/user-error-message"; import { authLoginUrl } from "@core/lib/models/auth/auth-return-url"; import { debounceTime, distinctUntilChanged, filter, finalize, startWith } from "rxjs"; import { saveAs } from "file-saver"; @@ -92,7 +93,13 @@ import { TransientNoticeComponent } from "@ui/primitives/transient-notice/transi @Component({ selector: "app-program-management", templateUrl: "./management.component.html", - styleUrl: "./management.component.scss", + styleUrls: [ + "./program-native-button.scss", + "./program-native-field.scss", + "./program-native-choice.scss", + "./management.component.scss", + "./management-list.scss", + ], imports: [ TransientNoticeComponent, RouterLink, @@ -1316,7 +1323,10 @@ export class ProgramManagementComponent implements OnInit { if (error.error?.code === "reopen_confirmation_required") void this.confirm( "Снова открыть прием?", - error.error.detail, + userErrorMessage( + error.error.detail, + "Изменение дат снова откроет прием. Подтвердите сохранение новых сроков.", + ), "Подтвердить и сохранить", ).then(yes => { if (yes) this.save(true); @@ -1563,7 +1573,10 @@ export class ProgramManagementComponent implements OnInit { if (error?.status === 401) return "Войдите в аккаунт, чтобы продолжить."; if (error?.status === 404) return "Программа или выбранный объект не найдены."; if (error?.status === 409) - return error.error?.detail ?? "Данные изменились. Обновите страницу перед сохранением."; + return userErrorMessage( + error.error?.detail, + "Данные изменились. Обновите страницу перед сохранением.", + ); if (error?.status === 400) return "Проверьте введенные значения и ссылки. Изменения сохранены в форме."; return "Не удалось выполнить запрос. Проверьте соединение и повторите попытку."; diff --git a/projects/social_platform/src/app/ui/pages/program/management/program-native-button.scss b/projects/social_platform/src/app/ui/pages/program/management/program-native-button.scss new file mode 100644 index 000000000..de9a123c6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/program-native-button.scss @@ -0,0 +1,15 @@ +/** @format */ +@use "styles/ui-foundation"; + +// Единая основа native Button для экранов программ; размеры задает consumer. +.ui-button.ui-button { + @include ui-foundation.button; +} + +// В исходном общем блоке Sass размещал text-variant после compact declarations. +// Сохраняем его desktop padding при загрузке основы отдельным ресурсом. +@media (min-width: 1000px) { + .ui-button.ui-button.ui-button--text { + padding-inline: 12px; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/program-native-choice.scss b/projects/social_platform/src/app/ui/pages/program/management/program-native-choice.scss new file mode 100644 index 000000000..650a9de17 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/program-native-choice.scss @@ -0,0 +1,6 @@ +/** @format */ +@use "styles/ui-foundation"; + +.ui-choice { + @include ui-foundation.choice; +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/program-native-field.scss b/projects/social_platform/src/app/ui/pages/program/management/program-native-field.scss new file mode 100644 index 000000000..e2f286d02 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/program-native-field.scss @@ -0,0 +1,9 @@ +/** @format */ +@use "styles/ui-foundation"; + +// Общие состояния Field, включая мобильный размер и focus/error. +.ui-field { + @include ui-foundation.control; + + font-size: 12px; +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.html b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.html index 46f9ab9ff..b2978fa3f 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.html @@ -83,7 +83,7 @@

{{ title() }}

{{ item.name }}
    @for (error of item.errors; track $index) { -
  • {{ error }}
  • +
  • {{ displayError(error, "Действие с этим проектом недоступно.") }}
  • }
diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.ts b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.ts index 4b7bd3de4..b4aea30b6 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.ts @@ -13,6 +13,7 @@ import { } from "@angular/core"; import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { HttpErrorResponse } from "@angular/common/http"; +import { userErrorMessage } from "@core/lib/models/error/user-error-message"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Subject, finalize, takeUntil } from "rxjs"; import { AlertDirective, ButtonDirective, FieldDirective, IconComponent } from "@uilib"; @@ -61,6 +62,7 @@ export class ManagementProjectActionsComponent { readonly loading = signal(false); readonly applying = signal(false); readonly error = signal(null); + readonly displayError = userErrorMessage; readonly blocked = computed(() => this.preview()?.items.filter(item => item.errors.length) ?? []); readonly title = computed( () => @@ -193,7 +195,11 @@ export class ManagementProjectActionsComponent { if (error.status === 401 || error.status === 403) return "Нет доступа к действиям с проектами этой программы."; if (error.status === 404) return "Проект больше не связан с этой программой. Обновите список."; - if (typeof error.error?.detail === "string") return error.error.detail; + if (typeof error.error?.detail === "string") + return userErrorMessage( + error.error.detail, + "Не удалось проверить или выполнить действие. Повторите проверку.", + ); return "Не удалось проверить или выполнить действие. Повторите проверку."; } } diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.ts b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.ts index 12448170b..a81f9cb92 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/project-form/project-form.component.ts @@ -21,6 +21,7 @@ import { Validators, } from "@angular/forms"; import { HttpErrorResponse } from "@angular/common/http"; +import { userErrorMessage } from "@core/lib/models/error/user-error-message"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { finalize } from "rxjs"; import { @@ -388,11 +389,8 @@ export class ManagementProjectFormComponent { if (error.status === 400) { const detail = error.error?.fields ?? error.error?.cases ?? error.error?.detail; return ( - (typeof detail === "string" - ? detail - : Array.isArray(detail) && typeof detail[0] === "string" - ? detail.join(" ") - : "Проверьте название, тип и варианты ответа.") + " Изменения остались в форме." + userErrorMessage(detail, "Проверьте название, тип и варианты ответа.") + + " Изменения остались в форме." ); } return "Не удалось загрузить или сохранить настройки. Проверьте соединение и повторите попытку."; diff --git a/projects/social_platform/src/app/ui/pages/program/management/registration/registration.component.ts b/projects/social_platform/src/app/ui/pages/program/management/registration/registration.component.ts index a5daf684c..684edf450 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/registration/registration.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/registration/registration.component.ts @@ -234,7 +234,11 @@ export class ManagementRegistrationComponent { const field = this.editing(); this.draft.update(d => ({ ...d, fields: d.fields.filter(f => f.id !== field?.id) })); this.dialog.set(null); - this.message.set("Поле убрано из черновика. Примените изменения публикацией формы."); + this.message.set( + this.snapshot()?.published + ? "Поле убрано из черновика. Примените изменения публикацией формы." + : "Поле убрано из черновика. Сохраните черновик, чтобы применить изменения.", + ); } save(publish = false) { const snapshot = this.snapshot(); diff --git a/projects/social_platform/src/app/ui/pages/program/management/results/results.component.html b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.html index 1f19902c8..85f7a6c7d 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/results/results.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.html @@ -92,16 +92,18 @@

- Оценено {{ readyCount() }} из {{ matchingCount() }} - {{ - matchingCount() - | pluralize - : (solutionMode() - ? ["решения", "решений", "решений"] - : ["проекта", "проектов", "проектов"]) - }} + @if (submissionApplicable()) { + Оценено {{ readyCount() }} из {{ matchingCount() }} + {{ + matchingCount() + | pluralize + : (solutionMode() + ? ["решения", "решений", "решений"] + : ["проекта", "проектов", "проектов"]) + }} + } @if (updatedTime(); as time) { } @@ -118,7 +120,7 @@

>{{ statusFilter.value === "pending" ? "Показать все" : "Показать" }} } - @if (!data.hasNumericCriteria) { + @if (submissionApplicable() && !data.hasNumericCriteria) {

Оценивание без баллов

} @if (exportSuccess()) { @@ -335,13 +337,15 @@

- + @if (submissionApplicable()) { + + }

@@ -65,7 +66,7 @@ import { ModalComponent } from "@ui/primitives/modal/modal.component";

В программе пока нет принявших приглашение экспертов.

} @if (error()) { -

{{ error() }}

+

{{ error() }}

@@ -105,7 +106,12 @@ import { ModalComponent } from "@ui/primitives/modal/modal.component";
`, - styleUrl: "./solution-applications.component.scss", + styleUrls: [ + "../program-native-button.scss", + "../program-native-field.scss", + "../program-native-choice.scss", + "./solution-applications.component.scss", + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SolutionAllocationComponent { diff --git a/projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-applications.component.html b/projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-applications.component.html index a104a6767..fa8e127ce 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-applications.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/solution-applications/solution-applications.component.html @@ -1,7 +1,7 @@
@if (formError()) { -

Не удалось загрузить настройки полей: {{ formError() }}

+

Не удалось загрузить настройки полей: {{ formError() }}

}
@@ -39,23 +39,27 @@

Заявки

/>
- +
+ +
@if (loading()) {

Загружаем заявки…

} @else if (error()) { -

{{ error() }}

+

{{ error() }}

} @else if (!rows().length) {

@@ -175,31 +179,45 @@

Заявки

> } @if (message()) { -

{{ message() }}

+ {{ + message() + }} } @if (selected().size && !archived()) {
- Выбрано заявок: {{ selected().size }} + +
+
} @@ -207,6 +225,7 @@

Заявки

@@ -218,7 +237,7 @@

Фильтры заявок

aria-label="Закрыть" (click)="filtersOpen.set(false); restoreFocus()" > - × +