diff --git a/docs/feature-specs/analytics-case-drilldown.md b/docs/feature-specs/analytics-case-drilldown.md new file mode 100644 index 000000000..fd2afaecc --- /dev/null +++ b/docs/feature-specs/analytics-case-drilldown.md @@ -0,0 +1,407 @@ + + +# Аналитика программы: детализация кейсов и выгрузка проектов + +**Статус:** Specification approved for implementation, 28.09.2026. Дизайн и решения Q1–Q4 утверждены пользователем; это не отметка о завершении реализации. +**Scope:** существующий Angular / legacy Project flow, вкладка `/office/program/:programId/analytics`. +Требования пользователя обязательны; описанный ниже compatible opt-in API contract принят для v1. + +**Implementation handoff:** исследовательские refs и S-ссылки ниже сохранены как исходный snapshot. +Актуальные dev bases, пути модулей, API, AC audit и результаты реализации приведены в +[итоговом review](../qa/analytics-case-drilldown-review.md). Бизнес-требования и Q1–Q4 не изменены. + +## Approved design + +Утверждён [Figma section 30:1518](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=30-1518). +Состояния: [selected case — 31:1523](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=31-1523), +[without case — 31:1643](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=31-1643), +[loading — 31:1744](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=31-1744), +[empty — 31:1798](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=31-1798), +[no search results — 31:1864](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=31-1864), +[error/retry — 31:1925](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=31-1925), +[mobile — 31:1981](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=31-1981). +Утверждённые UI-колонки: «Проект / Лидер или команда / Сдача решения / Презентация». +В выбранном case drilldown отдельная колонка «Кейс» не показывается; scoped case JSON и явная XLSX-колонка сохраняются. +Регион остаётся в API/XLSX, размер команды — дополнительной информацией колонки «Лидер или команда». +Синтетические значения макета не меняют правила count/pagination этой спецификации. + +## Problem + +Блок «Кейсы» показывает распределение проектов и участников, но не позволяет перейти от числа к конкретным проектам. +Менеджеру нужно открыть выбранный кейс, увидеть проекты и презентации, затем выгрузить проекты всего блока или одного кейса. +Состав списка и файла должен соответствовать агрегатам, включая группу «Без выбранного кейса». + +## Research / источники и границы достоверности + +Исследованы локальные source snapshots, документы и существующие assertions тестов. Live UI/API/БД не проверялись. +Frontend: checkout `procollab-design-system-v1`, HEAD `cbcb5bffbaeb13a37176411a3ebda035618a5879`. +Сравнение с локальным `origin/master` `01d59ad59578d26777625b0a32658acbf52ec6fc` не выявило изменений analytics UI/facades/domain и program adapter. +Backend: рабочий HEAD `28799c07cc2613614806ba750860f6bd38836791`; для production-направления дополнительно прочитан +локальный `origin/master` **`2fbc7225d41d59602cc1f870dc66707e2d028420`** через `git show`, без checkout/reset. +Case grouping, export builder, project filters, models и permissions совпадают между этими backend refs. +В `origin/master` NEXTGEN routes ограничены флагом; legacy `/project-analytics/` остаётся самостоятельным контуром. +Свежесть remote refs и deployed SHA не подтверждены. Перед реализацией повторить сверку с целевой веткой. + +| ID | Источник | Что установлено | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| S01 | [analytics.component.ts](../../projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts), [template](../../projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html) | `caseRows`, отсутствие case click handler, агрегаты, общие export actions | +| S02 | [analytics model](../../projects/social_platform/src/app/domain/program/program-analytics.model.ts) | `ProgramAnalyticsCases`, `withoutCase`, четыре счётчика, отдельная assignment-модель | +| S03 | [analytics info service](../../projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts), [routes](../../projects/social_platform/src/app/ui/routes/program/detail.routes.ts) | Route/resolver context, `isUserManager`, cancellation и error mapping | +| S04 | [drilldown component](../../projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts), [template](../../projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html), [state service](../../projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts) | Существующая modal/table/search/pagination и управление focus/context | +| S05 | [program HTTP adapter](../../projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts), [repository port](../../projects/social_platform/src/app/domain/program/ports/program.repository.port.ts) | `getAllProjects`, `createProgramFilters`, manager analytics endpoints | +| S06 | [ExportFileService](../../projects/social_platform/src/app/api/export-file/export-file.service.ts), [ExportFileInfoService](../../projects/social_platform/src/app/api/export-file/facades/export-file-info.service.ts), [saveFile](../../projects/social_platform/src/app/utils/export-file.ts) | Blob → file-saver → `.xlsx`, существующие all/submitted/rates flows | +| S07 | [case analytics service](../../../api/partner_programs/services/project_case_analytics.py), [case field service](../../../api/partner_programs/services/case_fields.py), [models](../../../api/partner_programs/models.py) | Точное `name="case"`, выбор у связи Project × Program, группировка без кейса | +| S08 | [API URLs на production ref](https://github.com/PROCOLLAB-github/api/blob/2fbc7225d41d59602cc1f870dc66707e2d028420/partner_programs/urls.py), [views на том же ref](https://github.com/PROCOLLAB-github/api/blob/2fbc7225d41d59602cc1f870dc66707e2d028420/partner_programs/views.py), [project filters](../../../api/partner_programs/services/project_filters.py) | Существующие manager list/filter/export и их ограничения | +| S09 | [ProjectListSerializer на production ref](https://github.com/PROCOLLAB-github/api/blob/2fbc7225d41d59602cc1f870dc66707e2d028420/projects/serializers.py), [Project model](../../../api/projects/models.py) | List не содержит presentation/region/link case; `presentation_address` хранится у Project | +| S10 | [export builder](../../../api/partner_programs/services/exports.py), [xlsx utils](../../../api/core/utils.py) | Колонки, openpyxl, prefetched project/link data, MIME/filename | +| S11 | [permissions](../../../api/partner_programs/permissions.py), [analytics views](../../../api/partner_programs/project_analytics_views.py), [permission mixin](../../../api/partner_programs/submission_assignment_views.py) | Server-side program scope и различия порядка 403/404 | +| S12 | [program serializer на production ref](https://github.com/PROCOLLAB-github/api/blob/2fbc7225d41d59602cc1f870dc66707e2d028420/partner_programs/serializers/programs.py), [project access](../../../api/projects/access.py), [project permissions](../../../api/projects/permissions.py) | `is_user_manager` не равен staff; права просмотра проекта не дают права на аналитику | +| S13 | [analytics service](../../../api/partner_programs/services/project_analytics.py), [attention serializer](../../../api/partner_programs/serializers/project_analytics_attention.py) | Уже вычисляемые evaluation statuses, даты сдачи, safe leader и assignment counters | +| S14 | [case tests](../../../api/partner_programs/tests/test_project_case_analytics.py), [list/export tests](../../../api/partner_programs/tests/test_program_filters.py), [export row tests](../../../api/partner_programs/tests/test_exports.py), [access tests](../../../api/partner_programs/tests/test_program_filter_access.py) | Assertions по buckets, multi-program, `.xlsx`, permissions; в этой задаче не запускались | +| S15 | [Angular cases tests](../../projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts), [drilldown tests](../../projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts), [attention tests](../../projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts) | Нулевые кейсы, скрытие withoutCase, поиск, stale requests и UI состояния | + +Локальные API-ссылки рассчитаны на соседний `api` checkout. Версии изменившихся файлов выше закреплены commit-ссылками. +Документы [project analytics](../../../api/docs/project-analytics-api.md) и [case field](../../../api/docs/program-case-field.md) использованы как навигация; факты сверены с кодом. + +## Current behavior + +1. Overview загружается через `GET /programs/{programId}/project-analytics/`. Блок `cases` содержит + `configured`, `submission_applicable`, `items[{name, participants_total, projects_total, not_submitted, submitted}]` и `without_case` [S01–S03, S07]. +2. В UI кейсы — неинтерактивные `li.case-row`. Есть название, число проектов, сравнительная полоса, + число участников и, для конкурсной программы, «Сдано / Не сдано». Список сохраняет порядок backend, включая нулевые варианты [S01]. +3. «Без выбранного кейса» показывается только при `projectsTotal > 0` и наличии настроенных вариантов. + Если определения кейса или options нет, UI показывает сообщение и скрывает весь список, хотя backend относит все связи к `without_case` [S01, S07, S14–S15]. +4. Кейс — не отдельная Case entity. Это точный текущий option системного `PartnerProgramField.name="case"`. + Значение берётся из `PartnerProgramFieldValue.value_text` конкретного `PartnerProgramProject` [S07]. +5. Без кейса считаются missing value, пустое/пробельное значение, устаревший option и неточное совпадение + (например, `a` или `A` при текущем `A`). Frontend не должен самостоятельно нормализовать эти значения [S07, S14]. +6. Количество проектов — число связей в выбранной программе. Уникальность пары Project × Program обеспечена моделью. + Один Project в двух программах может иметь разные case/submitted. Participants — уникальные зарегистрированные в этой программе + лидеры/Collaborator users внутри bucket; один человек может попасть в несколько buckets. Это не сумма размеров команд [S07]. +7. Другие метрики уже открывают `AnalyticsDrilldownComponent`: modal с таблицей, server-side поиском по Enter, + страницами по 25, retry, Escape, focus trap/return и отменой запросов при смене контекста. Case view отсутствует [S04]. +8. Вверху аналитики уже есть «Все проекты», «Сданные проекты», «Оценки проектов». Для первых двух вызывается + `GET /programs/{id}/export-projects/`, во втором случае с `only_submitted=1`. В самом блоке «Кейсы» export action отсутствует [S01, S06]. +9. Формат project export — `.xlsx`, лист «Проекты». Базовые колонки: №, название, описание, регион, ссылка на презентацию, + размер/состав команды, имя лидера; затем все program fields по `pk` с их текущим `label` [S10]. + Поле case может попасть туда как дополнительное, но обязательное название столбца «Кейс» и отображение bucket без кейса не гарантированы. + Сейчас exporter не обрабатывает case selector или поиск, только `only_submitted` [S08, S10]. + +## Target behavior + +- Каждая отображаемая строка/карточка кейса открывает детализацию, включая настроенный кейс с нулём проектов. +- Расширить существующую analytics modal новым case view, сохраняя страницу/route и прочие drilldowns. +- Заголовок: `Кейс: {название}`; для служебной группы — `Без выбранного кейса`. +- Список содержит колонки **«Проект»**, **«Лидер или команда»**, **«Сдача решения»**, **«Презентация»** со ссылкой на презентацию. + Отдельной колонки «Кейс» внутри выбранного bucket нет. Регион остаётся в API/XLSX; размер команды — вторичной информацией рядом с лидером. + Даты привязки/сдачи доступны для вторичной информации и выгрузки; не требуют ещё двух обязательных широких колонок. +- В заголовке детализации показываются уже существующие метрики bucket, не сумма текущей страницы. +- В блоке «Кейсы» появляется `Выгрузить все проекты`; внутри modal — `Выгрузить все проекты кейса`. +- Обе выгрузки включают все строки выбранного scope, а не только открытую страницу. Общая включает группу без кейса. +- Экспорт сохраняет весь кейс независимо от поиска в modal; рядом явно указать «Все проекты кейса, без учёта поиска» (Q1). +- При отсутствии определения/options сохранить пояснение и показать bucket без кейса, если в нём есть проекты (Q2). + Это явное UI-изменение текущего empty state, а не изменение правил группировки. + +## Users / roles + +| Роль | Текущее право API | Текущий Angular UI | Цель v1 | +| -------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Manager выбранной программы | List/analytics/export разрешены | `isUserManager=true`, аналитика доступна | Drilldown и обе выгрузки | +| Staff / superuser | API разрешён независимо от membership | Без manager membership `is_user_manager=false`, analytics facade блокирует | Сохранить API-доступ и существующий UI gate; доступ UI не расширять (Q3) | +| Только expert | Manager list/export/analytics запрещены | Нет manager-доступа | Не расширять | +| Только participant / project leader / collaborator | Manager endpoints запрещены | Нет manager-доступа | Не расширять | +| Manager другой программы / посторонний | 403 для существующей чужой программы | Нет доступа | Не расширять | +| Anonymous | Авторизация обязательна; существующие DRF flows дают 401 | Приватный flow / повторная авторизация | Не расширять | + +Комбинация ролей использует имеющееся manager/admin право, а не запрет по наличию participant/expert-роли [S03, S11–S12, S14]. + +## Доступные данные и метрики + +«Есть в модели/service» не означает «уже приходит в текущем project list JSON». + +| Поле / метрика | Реальный источник | Доступность сейчас | Решение v1 | +| ---------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| Project ID / название | `Project.id/name` | List, analytics attention, export | Обязательно; название — существующая ссылка `/office/projects/{id}` | +| Кейс / bucket | Текущая программа → system field → link field value | Overview aggregate; raw field в XLSX | Обязательно; добавить явный scoped JSON, не брать `Project.partner_program` | +| Презентация | `Project.presentation_address` | Detail DTO (`presentationAddress` после casing), XLSX; отсутствует в ProjectListSerializer | Обязательно; включить в новый режим списка | +| Лидер | `Project.leader`, user name | List содержит ID; attention имеет safe identity; XLSX имя | Показать имя, без email/телефона/auth-полей | +| Регион | `Project.region` | Detail, aggregates, XLSX; отсутствует в базовом list | Сохранённое значение в API/XLSX; отдельная UI-колонка не входит в утверждённый дизайн | +| Размер команды | `Collaborator` + лидер | Export `_calc_team_size`: `1 + число collaborator rows` | Повторить существующую export semantics; не называть числом зарегистрированных участников | +| Сдача решения | `PartnerProgramProject.submitted` | Overview, attention/assignment services | Только у текущей связи; для noncompetitive отображать «Не требуется» | +| Дата сдачи | `PartnerProgramProject.datetime_submitted` | Attention/assignment JSON | Вторичная информация / XLSX; null не заменять датой проекта | +| Дата привязки | `PartnerProgramProject.datetime_created` | Not-submitted drilldown | Вторичная информация / XLSX; это не дата создания Project | +| Участники / всего / сдано / не сдано по кейсу | `build_project_case_analytics` | Overview | Повторить в header выбранного bucket; не пересчитывать из страницы | +| Оценивание / назначено / завершено | `_annotated_solution_rows`, assignment services | Attention endpoints и overview; open/distributed semantics различаются | Не входит в v1 (Q4) | +| Описание / состав команды / остальные program fields | Project, Collaborator, link field values | Legacy XLSX и частично detail | Существующая полная выгрузка сохраняется; не добавлять всё в drilldown | + +Источник презентации — именно **Project**, не `PartnerProgram.presentation_address`, не URL cover и не произвольное file-поле программы. +Это актуальная редактируемая ссылка проекта, не snapshot на момент сдачи. Без значения UI показывает «Не добавлена», файл — пустую ячейку. +UI открывает обычную безопасную HTTP(S)-ссылку в новой вкладке с `noopener`; доступность внешнего документа заранее не обещается. +Не загружать ProjectDetail по одному запросу на каждую строку и не делать HEAD-запросы ко всем презентациям [S09–S10]. + +## Business rules + +1. Единственная область данных — выбранная программа и её `PartnerProgramProject`; не смешивать Application/Submission/Evaluation. +2. Список включает все связанные проекты независимо от `draft`, `is_public` и `submitted`, как исходные case aggregates. + Признак `draft` не заменяет состояние сдачи решения. +3. Для конкретного кейса использовать точное текущее имя option. Порядковый frontend key `case:{index}` не является ID кейса. +4. Selector служебного bucket должен быть отдельным типом; строка «Без выбранного кейса» может быть настоящим названием option. +5. Missing/blank/obsolete/nonexact — один existing without_case bucket. Не исправлять сохранённые значения при чтении. +6. При неизменных данных число строк без search равно `projects_total` bucket; сумма project buckets с without_case равна общему числу связей. + Участников между buckets не суммировать. Метрики bucket относятся ко всем его проектам, даже при применённом поиске. +7. При `submission_applicable=false` не показывать «Не сдано» как проблему; raw submitted хранится, но UI/XLSX status — «Не требуется». +8. Export all содержит весь scope программы; export selected/without_case — весь соответствующий bucket. Общий export не зависит от открытой modal. +9. Новые операции read-only; lifecycle, deadlines, case selection/locking и evaluation rules сохраняются. +10. Модальное окно при каждом новом открытии сбрасывает search/offset и старые данные. Смена программы закрывает его и отменяет загрузку. +11. Поиск v1 — по имени проекта, `icontains` после trim; применяется по Enter, очистка/новый search сбрасывают offset. +12. Если данные изменились между overview и detail/export, допустима разница между моментами чтения. После refresh на стабильных данных counts должны сходиться; + не обещать транзакционный snapshot между независимыми HTTP-запросами. + +## UI changes + +- Сохранить композицию и шкалу блока «Кейсы», добавив семантический button для открытия каждой строки и видимый focus. + Enter/Space должны работать; нулевые настроенные кейсы остаются открываемыми. +- При пустом bucket без кейса сохранять текущее скрытие строки; если уже открытая группа стала пустой, показывать корректный empty state. +- Расширить существующие `AnalyticsDrilldownComponent` / state service новым view, не создавать второй независимый modal или table primitive. +- Desktop — именованные table columns; mobile — существующие labelled rows с переносом длинных названий и ссылок. +- Сохранить header/close во время loading и error, focus trap, Escape/backdrop close, возврат focus на исходную карточку. +- Loading, empty, no results, error/retry, unauthorized, forbidden и not_found различаются; error не превращается в «0 проектов». +- Export имеет локальные pending/error, блокирует повторный клик, не блокирует закрытие modal и не скрывает список при ошибке. + При смене программы/кейса поздний ответ не скачивается под новым контекстом/именем файла. +- На стабильном пустом scope export-кнопка disabled; если данные опустели уже после клика, сервер может вернуть XLSX только с заголовками. +- Существующие верхние «Все проекты / Сданные проекты / Оценки проектов» сохраняют прежнее поведение и формат. + +Design sources: [Figma DS](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1), +[design-system.json](../../design-system.json), [Analytics pattern](../design-system/patterns/analytics.md), +[Table](../design-system/components/table.md), [Modal](../design-system/components/modal.md), +[Button](../design-system/components/button.md), [Search](../design-system/components/search.md), [Pagination](../design-system/components/pagination.md), [States](../design-system/states.md). +Mont остаётся canonical; Inter только Figma preview. Общего runtime Table primitive пока нет: использовать существующую analytics table family. +D01–D05 остаются PROPOSED. Feature-дизайн утверждён отдельно, ссылки приведены в Approved design; это не утверждение глобальных DS-миграций. + +## API changes — утверждённое совместимое расширение + +**Новые endpoints не требуются.** Реализовать явный opt-in режим `view=case_analytics` у двух существующих manager endpoints. +Имена новых query/response полей ниже — утверждённый контракт реализации, а не утверждение об их наличии в deployed API. + +### Почему не использовать текущий ответ без изменений + +- `GET /programs/{id}/projects/` уже возвращает нужную исходную population и имеет server-side permissions, + но его `ProjectListSerializer` не содержит presentation, case/link context, регион и необходимые даты [S08–S09]. +- `POST /programs/{id}/projects/filter/` умеет `{"filters":{"case":["A"]}}` для допустимого option, + но требует filterable field и значения из options; отсутствующие/устаревшие значения не выражают without_case. + Он возвращает тот же сокращённый project serializer [S08]. +- Существующие attention endpoints отбирают только несданные/ожидающие оценки проекты и не покрывают полную case population [S13]. +- Frontend-fetch всех страниц и всех ProjectDetail создаст N+1 и расхождения counts. Это не замена расширению API. + +### Список + +```text +GET /programs/{programId}/projects/?view=case_analytics&case_scope=selected&case_name={encodedExactName}&limit=25&offset=0&search={query} +GET /programs/{programId}/projects/?view=case_analytics&case_scope=without_case&limit=25&offset=0 +GET /programs/{programId}/projects/?view=case_analytics&case_scope=all&limit=25&offset=0 +``` + +- Без `view` — прежний serializer, pagination и поведение; opt-in — отдельная проекция manager list внутри существующего view/service flow. +- Новый query validator: `case_scope` обязателен (`all | selected | without_case`); для selected обязательно точное непустое `case_name`. + В других scopes `case_name` запрещён. Неизвестный option или несовместимые параметры — 400, не fallback на все проекты. +- `case_name` не trim/casefold; кодировать стандартным HttpParams, сохраняя `+`, `&`, `%`, Unicode. +- `limit`: default 25, диапазон 1–100; `offset >= 0`; search — по правилу выше. Это ограничения нового режима, не глобальная смена legacy pagination. +- Стабильный порядок нового списка: `PartnerProgramProject.datetime_created ASC, pk ASC`. +- Формат страницы: `count`, `next`, `previous`, `results`, плюс `selection`, `cases_configured`, `submission_applicable`, `case_metrics`. + `count` учитывает search; `case_metrics` для selected/without_case повторяет четыре метрики полного bucket без search. + Для all `case_metrics=null`: не вводить сумму уникальных участников разных кейсов. +- `selection` содержит `scope` и `case_name` (null вне selected); `results` содержит строки следующей формы: + +```json +{ + "program_project_id": 421, + "project": { + "id": 81, + "name": "Название проекта", + "presentation_address": "https://example.org/presentation.pdf", + "region": "Москва" + }, + "case": { "kind": "selected", "name": "Точное название кейса" }, + "leader": { "user_id": 15, "full_name": "Имя Фамилия" }, + "team_size": 3, + "linked_at": "2026-09-01T10:00:00Z", + "submitted": true, + "submitted_at": "2026-09-20T10:00:00Z" +} +``` + +Пример синтетический. Для without_case: `case.kind="without_case", case.name=null`; UI ставит системную подпись. +`presentation_address`, `region`, `submitted_at` допускают null; отсутствие leader обрабатывается защитно как null. +`submitted_at` отдавать null для несданной связи; исторический null у сданной не заменять выдуманной датой. +Backend выбирает field/link только из текущей программы; не использовать singular `ProjectListSerializer._get_program_link()`. +Новый serializer локален manager view и не расширяет публичный ProjectListSerializer. +Безопасная идентичность лидера — по существующему analytics contract, без полной User-сериализации. + +### Выгрузка + +```text +GET /programs/{programId}/export-projects/?view=case_analytics&case_scope=all +GET /programs/{programId}/export-projects/?view=case_analytics&case_scope=selected&case_name={encodedExactName} +GET /programs/{programId}/export-projects/?view=case_analytics&case_scope=without_case +``` + +- Повторно использовать exporter/openpyxl/response helper, общую server-side выборку и bucket classifier со списком/overview. +- Новый режим не принимает `limit`, `offset`, `search`, `only_submitted`; противоречивые параметры возвращают 400. + Это исключает случайную выгрузку только страницы или только сданных проектов. Без `view` старый `only_submitted` сохраняется. +- `.xlsx`, лист «Проекты»; обязательная фиксированная колонка **«Кейс»** независимо от текущего label системного поля. +- Порядок новых колонок: №, Название проекта, Кейс, Ссылка на презентацию, Лидер, Регион, + Размер команды, Сдача решения, Дата привязки к программе, Дата сдачи решения. +- В without_case писать «Без выбранного кейса»; не подменять эту подпись raw obsolete значением. +- URL презентации писать полностью как значение, без построения `HYPERLINK`-формулы из пользовательского ввода. + Имена/кейсы с ведущим `=` должны оставаться текстом. Текущий `sanitize_excel_value` чистит управляющие символы/длину, + но сам по себе не гарантирует literal cell type; это отдельная проверка нового режима. +- Для дат использовать однозначный текст ISO 8601 с timezone; null — пустая ячейка. В UI — существующий DatePipe. +- Состав и порядок строк — как полный список нового режима без search; файл all включает все buckets, включая без кейса. +- Имя файла должно различать «все кейсы», выбранный кейс и without_case; program/case label санитизировать. + Согласовать frontend filename с backend, поскольку текущий `saveFile` формирует имя самостоятельно [S06, S10]. + +### Compatibility / внедрение + +Старые URL без нового режима, JSON fields, pagination и XLSX columns остаются прежними. +Generic POST filter, project CRUD, верхние legacy exports и `/project-analytics/` агрегаты не меняют семантику. +Общий bucket selector — небольшая функция существующего case analytics service, не новый доменный слой/сервисная архитектура. +Сохранять bounded queries/select_related/prefetch; не добавлять запрос на каждую строку или каждый кейс. +Внедрять backend → проверить новые selectors/формат → frontend. Старый backend может молча игнорировать новые query params: +новый UI должен проверять наличие согласованного response contract; fallback на нефильтрованный список/export запрещён. +Кнопки нового export активировать только после подтверждения нового list-контракта для соответствующего program/scope; +успешный legacy overview сам по себе не подтверждает поддержку нового режима. Rollout list и export выполняется одной backend-версией. + +## Data changes + +Новых таблиц, моделей Case, колонок и data migrations не требуется. +Добавляются только query/response DTO и новый режим формирования XLSX из существующих Project/Program link/field/user данных. +Raw case values и historical submitted timestamps не нормализуются и не исправляются этой feature. +Новые поля в API не являются новыми бизнес-сущностями. + +## Permissions + +- Расширять только существующие manager endpoints с `IsAdminOrManagerOfProgram`; frontend checks не являются защитой. +- Любой selector/search/export проходит server-side проверку конкретной программы до возврата её данных. +- Передача имени кейса другой программы не открывает её связи; одинаковые имена не расширяют scope. +- Права видеть отдельный Project или filters schema не дают право видеть analytics/export программы. +- Сохранять существующий порядок ошибок выбранных endpoints: для несуществующей программы обычный manager получает отказ + permission (403), staff/superuser проходят permission и получают 404. У `/project-analytics/` lookup через ProgramPermissionMixin + устроен иначе; не переносить его 404-семантику в существующий list/export молча [S08, S11]. +- Request существующей чужой программы: 403; anonymous: 401. Проверить ответы и отсутствие project/presentation payload. +- Не включать email, телефон, user auth attributes, case values другой программы или полную private profile модель. +- Новый drilldown/export не даёт права редактировать проекты, кейсы, состав команды или оценку. + +## Edge cases + +| Ситуация | Ожидаемое поведение | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| Настроенный кейс, проектов 0 | Click работает, count=0, empty message, export disabled | +| Missing / пустой / пробельный / старый / неточный case value | Та же группа without_case в overview/list/XLSX | +| Нет определения case или options | Пояснение сохранено; показывать without_case при projects_total>0 по Q2, all export доступен | +| Настоящий option называется «Без выбранного кейса» | Отличается от служебной группы по typed selector, не по label | +| Project участвует в двух программах | Кейс/сдача/даты только нужной связи; presentation может быть общей текущей ссылкой Project | +| Нет презентации / ссылка удалена после загрузки | Пустое состояние; не подставлять presentation программы; ошибка внешнего URL не скрывает проект | +| Noncompetitive | Проекты доступны; статус сдачи «Не требуется», не false alarm «Не сдано» | +| Search дал 0 при непустом кейсе | «По запросу ничего не найдено»; export всего кейса остаётся доступен и явно подписан | +| Переключение кейса/программы, закрытие во время запроса | Cancel/reset; late result не попадает в другой scope и не запускает чужой download | +| Case option удалён между открытием и запросом | 400 с понятным сообщением и обновлением overview; не fallback на all | +| Проект сменил кейс/удалён между page/export запросами | Новые ответы отражают текущее состояние; refresh, без ложного обещания snapshot | +| Очень длинное имя / Unicode / символы query | Полный текст доступен, layout не ломается, HttpParams сохраняет exact matching | +| Несколько страниц / offset за count | count корректен, results могут быть []; экспорт всё равно полный scope | +| Network/5xx/403 при export | Контролируемая локальная ошибка, без файла и без маскировки ошибки как успеха | + +## Out of scope + +- Изменение case definitions, переименование options, assignment экспертов, lifecycle, дедлайнов и правил оценивания. +- Перенос на React / Application / Submission и создание новой архитектуры или export platform. +- Изменение доступа staff/superuser к Angular-вкладке (Q3); новые permissions и массовое исправление соседних flows. +- Новый route/deep link для каждого кейса, sorting UI, CSV/PDF, zip с файлами презентаций, preview документа. +- Суммарный рейтинг/средний балл проекта, новые evaluation метрики или аналитика участников по строкам проекта. +- Исторический snapshot презентации/данных, уведомления, auto-refresh и миграция ошибочных legacy values. +- Миграция всей DS, новый shared Table/Modal, изменение Mont или Figma-файла в рамках этой spec-задачи. + +## Acceptance criteria + +Критерии ниже — цели будущей реализации, **не выполненные проверки текущей задачи**. +Q1–Q4 и UI-дизайн утверждены; выполнение AC подтверждается отдельно backend/frontend evidence. + +| ID | Проверяемый результат | Как проверить | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| AC01 | Manager программы открывает любой отображаемый кейс кликом/Enter/Space; в modal правильные title/scope, включая нулевой кейс | Angular component test + browser keyboard | +| AC02 | Без search при стабильных данных count детализации равен projects_total карточки; ни одна строка другого кейса/программы не попадает в список | Backend fixtures и list/overview parity | +| AC03 | В каждой строке есть название и презентация из Project.presentation_address; отсутствие показано явно. Case остаётся в scoped JSON/XLSX, но отдельной UI-колонки «Кейс» нет | Serializer/DTO/component tests | +| AC04 | Лидер/команда и сдача показаны по согласованным sources; регион доступен в API/XLSX, размер команды — во вторичной UI-информации; значения не требуют N запросов ProjectDetail | Backend query check + browser/network | +| AC05 | Missing/blank/whitespace/obsolete/nonexact values попадают в without_case одинаково в overview/list/export; настоящий одноимённый option не смешивается с ним | Case fixtures с `A`, `a`, `A`, obsolete и literal label | +| AC06 | При отсутствии case definition/options существующие проекты доступны через without_case по принятому Q2; при полностью пустой программе false data не создаются | API/component fixtures | +| AC07 | Общий XLSX из блока содержит каждый project link программы один раз, включая without_case и все страницы; есть «Кейс» и полная ссылка на презентацию | Прочитать workbook, сравнить строки с all queryset | +| AC08 | XLSX из selected/without_case содержит только полный выбранный bucket, включая строки за текущей страницей; не зависит от search по принятому Q1 | Fixture >25 проектов, search, page 2, сравнение IDs/названий | +| AC09 | Legacy list/filter и legacy all/submitted/rates export без нового режима сохраняют прежний формат, payload и semantics | Existing backend/frontend contract/export tests | +| AC10 | Поиск по Enter фильтрует имя проекта, сбрасывает offset; pagination стабильна, count и range корректны; retry сохраняет search/scope | Facade/component + backend pagination tests | +| AC11 | Participant, expert-only, foreign manager не получают list/export даже прямым HTTP-запросом; staff API-доступ сохранён; 401/403/404 соответствуют существующему endpoint behavior | Permission matrix API tests | +| AC12 | Для Project в двух программах detail/export используют case/submitted/link dates выбранной программы и не показывают field values второй | Multi-program backend test | +| AC13 | Noncompetitive UI/XLSX показывает «Не требуется», не требует submit и не меняет raw flags; submitted historical null date отображается без выдуманной даты | Serializer/component/export tests | +| AC14 | Loading/empty/no results/error/forbidden/not_found различимы; ошибки не превращаются в 0, пустой файл или success | Facade/component negative scenarios | +| AC15 | После смены scope/route/закрытия поздний ответ не меняет UI и не скачивает файл; повторный export click во время pending не создаёт второй запрос | Cancellation/race tests | +| AC16 | Mobile labels, длинные русские названия, focus trap, Escape и возврат focus работают; Mont и существующий Analytics/Table/Modal contract сохранены | Browser responsive/keyboard + DS audit | +| AC17 | Invalid selector, unknown option, неподходящие query params дают 400; не возникает тихой выгрузки all вместо выбранного кейса | Backend query validation + integration old-server scenario | +| AC18 | `.xlsx` открывается, заголовки/имя файла корректны, URL не обрезан, пользовательские `=`-значения остаются текстом; нулевой результат после запроса — header-only workbook | Workbook assertions | +| AC19 | Новый режим не делает writes и не меняет query budget overview; detail queries не растут линейно с числом строк из-за N+1 | DB write/query capture, 1 и 100 строк | + +## Risks + +- **Расхождение scopes:** generic filter не эквивалентен without_case; classifier должен быть общим с существующей аналитикой. +- **Compatibility:** опциональный режим обязан быть изолирован от default ProjectListSerializer и старых XLSX consumers. +- **Старый backend:** новые query params сейчас игнорируются; включение UI до backend способно выдать все проекты вместо кейса. + Нужен backend-first rollout, подтверждённый list contract и интеграционная проверка export. +- **Права staff:** API и UI сейчас различаются; исправление без решения будет расширением scope. +- **Семантика команды:** export считает 1+Collaborator rows, а case participants — distinct зарегистрированных users. + Не унифицировать формулы молча; отдельно показать пользователю смысл метрик. +- **Объём:** существующий XLSX builder формирует workbook в памяти; максимальный размер программ и допустимое время выгрузки не измерены. + Сохранить текущий подход, проверить репрезентативный объём; background export — только по измеренной необходимости. +- **Живые данные:** список, overview и XLSX могут расходиться во времени; snapshot/версионирование вне scope. +- **Презентация:** ссылка общая для Project, может быть внешней/недоступной; не обещать архивную версию или доступ без авторизации. +- **Source baseline:** production ref не равен подтверждённому deployed SHA; перед implementation проверить актуальные изменения и CI. + +## Implementation areas / план следующего этапа + +| Шаг | Область | Результат / AC | +| --- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| 1 | Выполнено: Q1–Q4 и дизайн `procollab-design-screen` утверждены; см. Approved design | Основание реализации; runtime AC пока не отмечаются выполненными | +| 2 | API: `partner_programs/services/project_case_analytics.py`, существующие list/export views, отдельные query/response serializers | Общий typed selector, scoped list projection, новый режим без changes default; AC02–05, AC09, AC11–13, AC17, AC19 | +| 3 | API: `partner_programs/services/exports.py`, существующие xlsx helpers | Scope-aware workbook с фиксированными case/presentation columns; AC07–09, AC18 | +| 4 | Angular: program domain/port/repository/HTTP adapter и use-case; `ProgramAnalyticsDrilldownService` | Новый DTO и case context без смешивания с ProjectDetail; pagination/search/cancellation; AC02–05, AC10, AC14–15 | +| 5 | Angular: analytics component/template/styles, existing drilldown component/template/styles, export service/facade/filename helper | Кликабельные кейсы и две выгрузки с независимыми состояниями; AC01, AC06–08, AC13–16 | +| 6 | Backend tests + Angular targeted tests/build + browser QA | Evidence для AC01–19, compatibility верхних exports и прочих drilldowns | +| 7 | `procollab-review`, затем `procollab-pr` | Requirement audit AC → code → evidence → verdict; связанные API/Angular PR и порядок rollout | + +API тесты расширять рядом с `test_project_case_analytics.py`, `test_program_filters.py`, `test_exports.py`, +`test_program_filter_access.py`; regression также охватывает `test_project_analytics_api.py` и `test_project_analytics_attention_api.py`. +Frontend опираться на существующие analytics/drilldown/facade/adapter specs и добавить export assertions для нового режима. +Конкретные test labels/команды выбрать по актуальному repo; Angular source baseline использует Vitest `npm run test:ci` и `npm run build:pr`. +Даты/ошибки/права и XLSX проверять assertions, а не только snapshots DOM. Runtime проверки выполняются на этапе реализации. + +## Resolved questions / assumptions + +| ID | Утверждённое решение | Влияние | +| --- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| Q1 | Export внутри case drilldown выгружает весь кейс независимо от поиска | Export не принимает search/limit/offset; AC08 | +| Q2 | without_case показывается при наличии проектов даже без case definition/options | Пояснение сохраняется; backend grouping не меняется; AC06 | +| Q3 | Staff/superuser UI access в этой feature не расширяется | Существующий Angular gate и server-side API permissions сохраняются | +| Q4 | Evaluation metrics не входят в v1 | Нет новых evaluation полей, колонок или вычислений в case contract | + +Assumptions: target — legacy Angular аналитика, обычная таблица в существующей modal, `.xlsx`, текущая ссылка Project, +без отдельного deep link на кейс и без редактирования. Технические имена opt-in параметров/DTO приняты для реализации. +Неизвестный deployed SHA остаётся ограничением исходного исследования, а не открытым продуктовым вопросом. + +## Verification этой specification + +- **Checked by source:** frontend/API models, views, permissions, case grouping, presentation source, exporter, DS patterns и assertions существующих тестов; сравнение relevant refs. +- **Checked by tests:** product/runtime tests не запускались; выполнена только статическая проверка ссылок/форматирования документа. +- **Checked by build:** не запускался; runtime code не менялся. +- **Checked in browser:** не выполнялось; доступные состояния установлены по source. +- **Not checked:** deployed SHA, live dataset/permissions/ссылки презентаций, производительность XLSX, runtime browser/accessibility приёмка. + +Итог specification/design этапа — утверждённые требования и макет, не готовая runtime feature. Backend и Angular verification фиксируются на соответствующих этапах реализации. + +Backend implementation evidence (28.09.2026): [API contract, актуальный source baseline и AC audit](../../../api-analytics-case-drilldown/docs/analytics-case-drilldown-api.md). +Этот отчёт отделяет backend PASS от оставшейся Angular/browser приёмки; утверждённые требования выше не меняются. diff --git a/docs/qa/analytics-case-drilldown-review.md b/docs/qa/analytics-case-drilldown-review.md new file mode 100644 index 000000000..5426c6e02 --- /dev/null +++ b/docs/qa/analytics-case-drilldown-review.md @@ -0,0 +1,162 @@ + + +# Analytics → Case drilldown: итоговый review и handoff + +Дата: 28.09.2026. Последовательность: procollab-angular → verification → procollab-review → procollab-pr. +Вердикт: **READY TO MERGE INTO DEV** в пределах проверенного scope; live dev acceptance выполняет пользователь. +Требования: [approved specification / AC01–AC19](../feature-specs/analytics-case-drilldown.md). +Backend: [api#760](https://github.com/PROCOLLAB-github/api/pull/760), target `dev`, base `29be99d`. +Angular: ветка `feature/dev-analytics-case-drilldown-angular`, target `dev`, base `69f84902`. +Исходная backend-работа сохранена в `608bf09`; в dev перенесены только изменения feature, +с адаптацией имён существующих модулей. Angular base обновлена без конфликтов и потери изменений. + +## Результат и scope + +Расширены существующие Analytics / AnalyticsDrilldown: selected, without_case, компактные метрики, +четыре колонки, поиск, pagination, общий и scoped XLSX. Новых routes, modal/table систем нет. +Case export всегда относится ко всему bucket; search и страница на него не влияют. +Without_case доступен без definition/options при наличии проектов. Staff UI gate и evaluation rules сохранены. +React, Figma, Design System и permissions не изменены. Merge и deploy не выполнялись. + +Изменённые области: + +- Backend: `services/case_analytics.py`, новый `services/project_case_drilldown.py`, + `serializers/project_case_drilldown.py`, `services/exports.py`, `views.py`, feature tests и API/review docs. +- Angular domain/transport: `program-case-analytics.model.ts`, repository port/repository/HTTP adapter, + два use-case, `program-case-projects.service.ts`, `program-case-analytics.ts`. +- Existing UI: Analytics и AnalyticsDrilldown `.ts/.html/.scss`, drilldown/info facade integration. +- Проверки: новые facade/use-case/HTTP/component/utility tests и fixture; существующие analytics tests + дополнены dependency provider, scope/click assertions; `vitest.config.ts` исправляет test environment. +- Документация: approved specification, этот audit, восемь QA screenshots. Debug harness/logs в PR не включены. + +Runtime-contract проверяется до включения export и повторно непосредственно перед запросом файла. +HTTP 200 с legacy/malformed DTO либо неправильным scope не разрешает выгрузку. +Смена кейса/программы, закрытие modal и destroy отменяют list и export; поиск отменяет только list. + +## API + +Существующие `GET /programs/{id}/projects/` и `GET /programs/{id}/export-projects/`: +`view=case_analytics&case_scope=all|selected|without_case`; для selected обязателен exact `case_name`. +List допускает `search`, `limit` (1–100, default 25), `offset` (>=0); export их отвергает. +Неизвестные, повторные, пустые и несовместимые параметры дают 400, без fallback на all. +Response: `count/next/previous/results`, `selection`, `cases_configured`, `submission_applicable`, `case_metrics`. +Строка содержит project/program-project IDs, название, регион, презентацию, typed case, лидера, +размер команды, linked/submitted dates и raw submitted flag. Angular использует существующий camelcase interceptor. +`Project.presentation_address` — единственный источник презентации; дополнительный ProjectDetail не запрашивается. +XLSX содержит явный «Кейс», полную ссылку и literal text; legacy list/export без view сохраняются. +[Полный API contract](https://github.com/PROCOLLAB-github/api/blob/feature/dev-analytics-case-drilldown-backend/docs/analytics-case-drilldown-api.md). + +## Acceptance criteria audit + +Все строки ниже проверены по исходным требованиям, коду и фактическим запускам, а не по авторскому summary. +Сокращения: B — `api/partner_programs/tests/test_project_case_drilldown.py`; +F — Angular `api/program/facades/detail/program-case-projects.service.spec.ts`; +C — `ui/pages/program/detail/analytics/drilldown/analytics-case.component.spec.ts`; +U — `api/program/use-cases/program-case-projects.use-case.spec.ts`; +H — `infrastructure/adapters/program/program-case-http.integration.spec.ts`. +Angular пути отсчитываются от `projects/social_platform/src/app/`. + +| AC | Статус | Конкретное evidence | +| ---- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AC01 | PASS | Analytics component click tests для обычного, without и zero case; реальные button semantics; browser click/Enter/Space, title/scope. | +| AC02 | PASS | B `test_selected_projection_and_full_bucket_metrics`, classifier parity; facade отображает server count и full-bucket metrics. | +| AC03 | PASS | B projection/null-presentation; C selected проверяет ровно четыре колонки и явное отсутствие презентации; URL из project DTO. | +| AC04 | PASS | B projection и constant-query assertions; C лидер/команда/submission; HTTP adapter вызывает один scoped list, без ProjectDetail. | +| AC05 | PASS | B classifier parity для missing/blank/whitespace/obsolete/nonexact и настоящего option «Без выбранного кейса»; typed selection сохраняется в Angular. | +| AC06 | PASS | B without-definition/options/empty-program; Analytics component tests и C without_case; browser without_case при отсутствующей конфигурации. | +| AC07 | PASS | B pagination/full exports: полный all workbook, каждый link один раз, явный Case и полная презентация; F all; browser общий download. | +| AC08 | PASS | B full exports (>25 строк); F search/no-results сохраняет full metrics/export; U/H export query без search/limit/offset; browser selected/without downloads. | +| AC09 | PASS | B legacy list/all/submitted; существующие test_exports, filters, case/manager/attention/not-submitted/assignment suites; прежние Angular drilldown tests. | +| AC10 | PASS | B pagination/search; F offset reset/range/retry; browser Enter, очистка поиска, no results и page 2. | +| AC11 | PASS | B permission matrix: manager, чужой manager, expert, participant, anonymous, staff/superuser; прежний UI gate source diff; U/C 401/403/404. | +| AC12 | PASS | B `test_same_project_in_two_programs_has_independent_case_submission_and_dates`: scoped case/flags/dates в list и workbook. | +| AC13 | PASS | B noncompetitive/null/historical-date assertions; C «Не требуется», отсутствие evaluation; browser noncompetitive. | +| AC14 | PASS | U/C контролируемые network/invalid/unauthorized/forbidden/not-found/unsupported; browser loading/empty/no-results/error/retry; ошибка не подменяется нулями. | +| AC15 | PASS | F отдельные late Subjects для case/program/close/destroy, list/export unsubscribe и duplicate click; C close/program; существующий route cancellation test. | +| AC16 | PASS | Browser 1440×1000, 390/320/768 px, длинные русские названия, mobile labels без horizontal overflow, Tab/Shift+Tab trap, Escape/focus return; Mont computed style. | +| AC17 | PASS | B strict negative query matrix; U malformed/wrong-scope/legacy rejection; H реальный HTTP+interceptor: legacy 200 не вызывает export. | +| AC18 | PASS | B workbook assertions: фиксированные headers, полный URL, ISO dates, order, header-only, literal `= + - @` и Excel error values; filename tests + browser download. | +| AC19 | PASS | B query/write capture: overview 4 SQL; list <=10 / export <=7 на 1 и 100 строках, serializer 0 дополнительных SQL; writes отсутствуют. | + +PASS относится к указанным способам проверки. Это не приёмка live dev/production или сертификация accessibility. + +## Visual continuity / Impeccable + +Production references по исходному Angular коду: Analytics «Кейсы»; AnalyticsDrilldown attention; +AnalyticsDrilldown assignments. Сохранены Mont, белый modal, прежние border/radius/tokens, +тонкие разделители таблицы, вторичный серый текст, существующие primary/outline Button и search input. +Расширяется существующий AnalyticsDrilldown pattern. Новые DS primitives/tokens не понадобились. +Figma: [section и семь approved states](../feature-specs/analytics-case-drilldown.md#approved-design). +Сопоставлены side-by-side полученные Figma screenshots и локальный runtime desktop/mobile. +Live deployed production reference недоступен; visual baseline взят из существующего кода. + +Impeccable применён как secondary quality layer: Operate, layout, critique и refinement/polish. +**Impeccable refinement must preserve the incumbent PROCOLLAB visual world.** + +Три приоритетных critique findings устранены: + +1. Экспорт с длинным русским label сжимался в маленьком Button: использован canonical big variant, + 220 px desktop / полная ширина mobile; понятен приоритет действия. +2. Shared modal style уменьшал case modal до 712 px: case-only body selector восстанавливает approved 880 px, + без изменения остальных modal; таблица сохраняет рабочую плотность. +3. На mobile «Найти» конкурировал с полем, «Очистить» переносился отдельно: input занимает строку, + действия сгруппированы ниже; порядок чтения и focus совпадает с DOM. + +Checklist: hierarchy ✓; cognitive load ✓; grouping/rhythm ✓; density ✓; component consistency ✓; +loading/empty/no-results/error/disabled ✓; responsive ✓; semantics/keyboard/focus ✓; +нет новых gradients, вложенных card grids, декоративных pills или motion ✓. +Impeccable detect сообщил только существующий attention side border; он сохранён по production priority. +Полная проверка screen reader, zoom и WCAG contrast не выполнялась; новые цветовые tokens не вводились. + +Screenshots: [overview](analytics-case-drilldown/overview-desktop.png), +[selected](analytics-case-drilldown/selected-desktop.png), [without](analytics-case-drilldown/without-desktop.png), +[loading](analytics-case-drilldown/loading-desktop.png), [empty](analytics-case-drilldown/empty-desktop.png), +[no results](analytics-case-drilldown/no-results-desktop.png), [error](analytics-case-drilldown/error-desktop.png), +[mobile 390](analytics-case-drilldown/selected-390.png). + +## Фактически выполненные проверки + +- Backend local PostgreSQL 18 ICU und: 187 feature/regression tests PASS; после усиления XLSX header assertion — повторные 13 PASS. +- [Backend PostgreSQL 15 CI](https://github.com/PROCOLLAB-github/api/actions/runs/36462349748): полный suite **933 tests PASS**, + Django/model/migration checks и миграции на пустую БД PASS; отдельный Lint job PASS. +- Angular на актуальной base `69f84902`: `npm run test:ci -- --maxWorkers=4` — **400 files / 1934 tests PASS**, 123.83 s. + Включены новые HTTP integration tests с настоящим camelcase interceptor и блокировкой legacy ответа. +- `npm run build:pr` — PASS; существующие Sass deprecation warnings остаются. +- `npm run lint:ts` — PASS, 0 errors / 6 существующих unused-disable warnings вне feature. +- Prettier: **1583 Git-файла PASS** с теми же repository config/ignore после нормализации CRLF → LF в памяти. + Обычный `npm run format:check` на Windows working tree дал FAIL из-за CRLF и локального `.cache` harness; + исходные файлы вне feature не форматировались. Linux checkout получает LF, debug cache в PR отсутствует. +- Browser QA: реальные Analytics/AnalyticsDrilldown/shared components и facades в изолированном локальном harness; + mock repository/API fixtures. Проверены семь состояний, zero/noncompetitive, поиск/page 2, три scope выгрузки, + legacy contract blocking, keyboard/focus и отсутствие JS errors. Это не live full-stack e2e. +- `git diff --check` и source/diff review выполнены для feature scope. + +Первый backend-прогон на collation C выявил ошибки кириллического поиска среды; ICU-прогон прошёл без изменения поиска. +Первый Angular runner смешивал Node/Vite экземпляры Angular (NG0401/NG0203); `vitest.config.ts` теперь +inline-ит Angular и Angular consumers. Версии dependencies и runtime loading не изменялись. +Существующие три исключения Vitest сохранены; тесты feature не исключались. + +## Findings и границы проверки + +После исправлений открытых BLOCKER / MAJOR / MINOR в feature не осталось. +Помимо visual findings исправлен conditional case branch, чтобы прежний assignments state не рендерился одновременно. +Backend XLSX headers сравниваются с literal ожидаемым списком, а не с константой exporter. +Изменения тестовой конфигурации обоснованы воспроизводимыми runner errors и проверены полным suite. + +Не проверено: live dev/production data и deployed SHA; full-stack browser с настоящим auth/API; +production image/build; Excel GUI; production-scale память/время XLSX; полный screen-reader/zoom/contrast audit. +Корректность workbook проверена чтением openpyxl; XLSX download — browser fixture. +Legacy backend push-workflow [CI / Tests и Lint](https://github.com/PROCOLLAB-github/api/actions/runs/36462314549) также PASS. +У Angular нет pull_request workflow: существующие workflows запускаются на push в dev/master и выполняют deploy. +Они намеренно не запускались вручную. Локальная среда Angular — Node 24.18.0; CI Node 20 отдельно не проверялся. + +## Порядок merge и проверка dev пользователем + +1. Сначала backend PR в `dev`. Проверить list `view=case_analytics` и server-side permissions. +2. Затем Angular PR в `dev`. Старый backend оставляет новый export заблокированным; list/export должны выпускаться вместе. +3. Под manager открыть selected / without_case / zero case; сверить counts, leader/submission и презентации. +4. Применить поиск, перейти на страницу 2; сравнить case XLSX с полным bucket, общий XLSX — со всеми кейсами. +5. Проверить missing presentation, отсутствие options, noncompetitive, mobile и Escape/focus return. +6. Прямым API-вызовом проверить отказ для чужой программы/participant; убедиться, что прежние exports и drilldowns работают. + +Migration не требуется. Решение о production принимается после проверки dev пользователем. diff --git a/docs/qa/analytics-case-drilldown/empty-desktop.png b/docs/qa/analytics-case-drilldown/empty-desktop.png new file mode 100644 index 000000000..4f2e13723 Binary files /dev/null and b/docs/qa/analytics-case-drilldown/empty-desktop.png differ diff --git a/docs/qa/analytics-case-drilldown/error-desktop.png b/docs/qa/analytics-case-drilldown/error-desktop.png new file mode 100644 index 000000000..f6f632124 Binary files /dev/null and b/docs/qa/analytics-case-drilldown/error-desktop.png differ diff --git a/docs/qa/analytics-case-drilldown/loading-desktop.png b/docs/qa/analytics-case-drilldown/loading-desktop.png new file mode 100644 index 000000000..626ddad98 Binary files /dev/null and b/docs/qa/analytics-case-drilldown/loading-desktop.png differ diff --git a/docs/qa/analytics-case-drilldown/no-results-desktop.png b/docs/qa/analytics-case-drilldown/no-results-desktop.png new file mode 100644 index 000000000..c2bb9cec8 Binary files /dev/null and b/docs/qa/analytics-case-drilldown/no-results-desktop.png differ diff --git a/docs/qa/analytics-case-drilldown/overview-desktop.png b/docs/qa/analytics-case-drilldown/overview-desktop.png new file mode 100644 index 000000000..6382fe12c Binary files /dev/null and b/docs/qa/analytics-case-drilldown/overview-desktop.png differ diff --git a/docs/qa/analytics-case-drilldown/selected-390.png b/docs/qa/analytics-case-drilldown/selected-390.png new file mode 100644 index 000000000..9ae051107 Binary files /dev/null and b/docs/qa/analytics-case-drilldown/selected-390.png differ diff --git a/docs/qa/analytics-case-drilldown/selected-desktop.png b/docs/qa/analytics-case-drilldown/selected-desktop.png new file mode 100644 index 000000000..5b0ed0171 Binary files /dev/null and b/docs/qa/analytics-case-drilldown/selected-desktop.png differ diff --git a/docs/qa/analytics-case-drilldown/without-desktop.png b/docs/qa/analytics-case-drilldown/without-desktop.png new file mode 100644 index 000000000..a5672b0ce Binary files /dev/null and b/docs/qa/analytics-case-drilldown/without-desktop.png differ diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts index 269407ae0..5e2076f1e 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts @@ -1,4 +1,7 @@ /** @format */ + +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { TestBed } from "@angular/core/testing"; import { of, Subject } from "rxjs"; import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; @@ -46,6 +49,10 @@ describe.each([ assignments.execute.mockClear(); TestBed.configureTestingModule({ providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, ProgramAnalyticsDrilldownService, { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: notSubmitted }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: participants }, diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts index 1dc00e783..11fdbcc3b 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts @@ -1,4 +1,7 @@ /** @format */ + +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { TestBed } from "@angular/core/testing"; import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; @@ -29,6 +32,10 @@ describe("ProgramAnalyticsDrilldownService", () => { scores.execute.mockReset().mockReturnValue(of(ok(scoreDetail()))); TestBed.configureTestingModule({ providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, provideRouter([]), { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: { execute: vi.fn() } }, { diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts index 6aa602192..034299301 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts @@ -2,6 +2,9 @@ import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Subject, takeUntil } from "rxjs"; +import { ProgramCaseProjectsService } from "./program-case-projects.service"; +import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; +import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model"; import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; @@ -28,6 +31,7 @@ export type AnalyticsAttentionView = | "projects-awaiting-evaluation" | "projects-not-submitted"; export type AnalyticsDrilldownView = + | "case" | "assignments" | "scores" | "delayed" @@ -37,6 +41,20 @@ export type AnalyticsDrilldownView = /** Живёт вместе с одной analytics-модалкой, не сохраняет manager-данные между открытиями. */ @Injectable() export class ProgramAnalyticsDrilldownService { + readonly cases = inject(ProgramCaseProjectsService); + + openCase( + programId: number, + selection: ProgramCaseSelection, + metrics: ProgramAnalyticsCaseMetrics, + applicable: boolean, + programName = "program", + ): void { + if (!this.start(programId)) return; + this.view.set("case"); + this.cases.open(programId, selection, metrics, applicable, programName); + } + private readonly getAssignments = inject(GetProgramManagerAssignmentsUseCase); private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase); private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase); @@ -272,6 +290,7 @@ export class ProgramAnalyticsDrilldownService { /** Закрытие и смена программы уничтожают весь локальный контекст и активные запросы. */ close(): void { + this.cases.reset(); this.cancelAssignments.next(); this.cancelScores.next(); this.cancelAttention.next(); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts index 7c0c04d13..0f1058cfd 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts @@ -34,6 +34,7 @@ export class ProgramAnalyticsInfoService { private routeProgramId: number | null = null; private loadedProgram: Program | undefined; readonly programId = signal(null); + readonly programName = computed(() => this.programUI.program()?.name ?? "program"); constructor() { this.route.parent?.paramMap diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts new file mode 100644 index 000000000..125eb9d70 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts @@ -0,0 +1,187 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { of, Subject } from "rxjs"; +import { GetProgramCaseProjectsUseCase } from "@api/program/use-cases/get-program-case-projects.use-case"; +import { ExportProgramCaseProjectsUseCase } from "@api/program/use-cases/export-program-case-projects.use-case"; +import { + casePage, + caseMetrics, + selectedCase, + caseProject, +} from "@domain/program/program-case-analytics.fixture"; +import { fail, ok } from "@domain/shared/result.type"; +import { saveCaseProjects } from "@utils/program-case-analytics"; +import { ProgramCaseProjectsService } from "./program-case-projects.service"; + +vi.mock("@utils/program-case-analytics", () => ({ saveCaseProjects: vi.fn() })); + +describe("Case drilldown: контекст, поиск, страницы и независимый export", () => { + const get = { execute: vi.fn() }; + const download = { execute: vi.fn() }; + let service: ProgramCaseProjectsService; + beforeEach(() => { + get.execute.mockReset().mockReturnValue(of(ok(casePage()))); + download.execute.mockReset().mockReturnValue(of(ok(new Blob(["xlsx"])))); + vi.mocked(saveCaseProjects).mockReset(); + TestBed.configureTestingModule({ + providers: [ + ProgramCaseProjectsService, + { provide: GetProgramCaseProjectsUseCase, useValue: get }, + { provide: ExportProgramCaseProjectsUseCase, useValue: download }, + ], + }); + service = TestBed.inject(ProgramCaseProjectsService); + }); + it("до подтверждения contract export заблокирован; overview-метрики видны при loading", () => { + const pending = new Subject(); + get.execute.mockReturnValue(pending); + service.open(12, selectedCase, caseMetrics, true); + expect(service.pending()).toBe(true); + expect(service.metrics()).toEqual(caseMetrics); + service.download(); + expect(download.execute).not.toHaveBeenCalled(); + pending.next(ok(casePage())); + expect(service.canExport()).toBe(true); + }); + it("search сбрасывает страницу; метрики/export сохраняют полный bucket при no results", () => { + service.open(12, selectedCase); + service.offset.set(25); + service.searchDraft.set(" неизвестный проект "); + get.execute.mockReturnValue(of(ok(casePage({ count: 0, results: [] })))); + service.applySearch(); + expect(get.execute).toHaveBeenLastCalledWith(12, { + selection: selectedCase, + search: "неизвестный проект", + limit: 25, + offset: 0, + }); + expect(service.page()?.count).toBe(0); + expect(service.metrics()).toEqual(caseMetrics); + expect(service.canExport()).toBe(true); + service.download(); + expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, selectedCase); + service.clearSearch(); + expect(get.execute.mock.lastCall?.[1].search).toBe(""); + }); + it("pagination не следует URL next и использует стабильный offset", () => { + get.execute.mockReturnValue( + of( + ok( + casePage({ + count: 26, + next: "https://foreign.invalid", + results: Array.from({ length: 25 }, (_, i) => caseProject({ programProjectId: i + 1 })), + }), + ), + ), + ); + service.open(12, selectedCase); + expect(service.range()).toBe("1–25 из 26"); + get.execute.mockReturnValue(of(ok(casePage({ count: 26, results: [caseProject()] })))); + service.changePage(1); + expect(get.execute.mock.lastCall?.[1].offset).toBe(25); + expect(service.range()).toBe("26–26 из 26"); + expect(service.hasNext()).toBe(false); + service.changePage(1); + expect(get.execute).toHaveBeenCalledTimes(2); + service.changePage(-1); + expect(get.execute.mock.lastCall?.[1].offset).toBe(0); + }); + it("zero configured case не включает export", () => { + get.execute.mockReturnValue( + of( + ok( + casePage({ + count: 0, + results: [], + caseMetrics: { projectsTotal: 0, participantsTotal: 0, submitted: 0, notSubmitted: 0 }, + }), + ), + ), + ); + service.open(12, selectedCase); + expect(service.canExport()).toBe(false); + expect(service.page()?.results).toEqual([]); + }); + it.each(["case", "program", "close", "destroy"] as const)( + "отменяет list и XLSX при %s", + change => { + service.open(12, selectedCase); + const staleList = new Subject(); + const freshList = new Subject(); + const staleFile = new Subject(); + get.execute.mockReturnValue(staleList); + download.execute.mockReturnValue(staleFile); + service.download(); + service.load(); + get.execute.mockReturnValue(freshList); + if (change === "case") service.open(12, { scope: "without_case" }); + if (change === "program") service.open(99, selectedCase); + if (change === "close") service.reset(); + if (change === "destroy") TestBed.resetTestingModule(); + expect(staleList.observed).toBe(false); + staleList.next(ok(casePage())); + expect(service.page()).toBeNull(); + expect(staleFile.observed).toBe(false); + staleFile.next(ok(new Blob(["stale"]))); + expect(saveCaseProjects).not.toHaveBeenCalled(); + if (change === "close" || change === "destroy") { + expect(staleList.observed).toBe(false); + staleList.next(ok(casePage())); + expect(service.page()).toBeNull(); + } + if (change === "case" || change === "program") { + const fresh = casePage({ count: 0, results: [] }); + freshList.next(ok(fresh)); + expect(service.page()).toEqual(fresh); + } + }, + ); + it("поиск отменяет предыдущий list, но не прерывает full-case export", () => { + const first = new Subject(), + next = new Subject(), + file = new Subject(); + service.open(12, selectedCase); + download.execute.mockReturnValue(file); + service.download(); + service.download(); + expect(download.execute).toHaveBeenCalledTimes(1); + get.execute.mockReturnValueOnce(first).mockReturnValueOnce(next); + service.load(); + service.searchDraft.set("Новый"); + service.applySearch(); + expect(first.observed).toBe(false); + expect(file.observed).toBe(true); + file.next(ok(new Blob(["xlsx"]))); + expect(saveCaseProjects).toHaveBeenCalledTimes(1); + next.next(ok(casePage())); + }); + it("ошибка/retry сохраняет контекст, а отказ прав выключает export", () => { + get.execute.mockReturnValue(of(fail({ kind: "network" }))); + service.open(12, selectedCase, caseMetrics); + expect(service.error()?.kind).toBe("network"); + expect(service.pending()).toBe(false); + expect(service.canExport()).toBe(false); + get.execute.mockReturnValue(of(ok(casePage()))); + service.load(); + expect(service.error()).toBeNull(); + download.execute.mockReturnValue(of(fail({ kind: "forbidden" }))); + service.download(); + expect(service.exportError()?.kind).toBe("forbidden"); + expect(service.canExport()).toBe(false); + }); + it("all проверяет одну строку, но выгружает весь scope", () => { + get.execute.mockReturnValue( + of(ok(casePage({ selection: { scope: "all", caseName: null }, caseMetrics: null }))), + ); + service.open(12, { scope: "all" }); + expect(get.execute).toHaveBeenCalledExactlyOnceWith(12, { + selection: { scope: "all" }, + search: "", + offset: 0, + limit: 1, + }); + service.download(); + expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, { scope: "all" }); + }); +}); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts new file mode 100644 index 000000000..d1b0dbd71 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts @@ -0,0 +1,159 @@ +/** @format */ +import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Subject, takeUntil } from "rxjs"; +import { GetProgramCaseProjectsUseCase } from "@api/program/use-cases/get-program-case-projects.use-case"; +import { ExportProgramCaseProjectsUseCase } from "@api/program/use-cases/export-program-case-projects.use-case"; +import { + ProgramCaseError, + ProgramCasePage, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; +import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model"; +import { saveCaseProjects } from "@utils/program-case-analytics"; + +/** Отдельный экземпляр для блока Кейсы и для существующей AnalyticsDrilldown. */ +@Injectable({ providedIn: "root" }) +export class ProgramCaseProjectsService { + private readonly getProjects = inject(GetProgramCaseProjectsUseCase); + private readonly exportProjects = inject(ExportProgramCaseProjectsUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly cancelList = new Subject(); + private readonly cancelExport = new Subject(); + private programId: number | null = null; + private programName = "program"; + readonly selection = signal(null); + readonly page = signal(null); + readonly metrics = signal(null); + readonly submissionApplicable = signal(false); + readonly pending = signal(false); + readonly error = signal(null); + readonly searchDraft = signal(""); + readonly search = signal(""); + readonly offset = signal(0); + readonly limit = 25; + readonly exportPending = signal(false); + readonly exportError = signal(null); + private readonly confirmed = signal(false); + private readonly total = signal(0); + readonly canExport = computed( + () => this.confirmed() && this.total() > 0 && !this.exportPending(), + ); + readonly hasNext = computed(() => { + const page = this.page(); + return !!page && this.offset() + page.results.length < page.count; + }); + readonly range = computed(() => { + const page = this.page(); + return page?.results.length + ? `${this.offset() + 1}–${this.offset() + page.results.length} из ${page.count}` + : `0 из ${page?.count ?? 0}`; + }); + + constructor() { + this.destroyRef.onDestroy(() => this.reset()); + } + + open( + programId: number, + selection: ProgramCaseSelection, + metrics: ProgramAnalyticsCaseMetrics | null = null, + applicable = false, + programName = "program", + ): void { + this.reset(); + if (!Number.isInteger(programId) || programId <= 0) return; + this.programId = programId; + this.programName = programName; + this.selection.set(selection); + this.metrics.set(metrics); + this.submissionApplicable.set(applicable); + this.load(); + } + + load(): void { + const selection = this.selection(); + if (this.programId === null || !selection) return; + this.cancelList.next(); + this.page.set(null); + this.pending.set(true); + this.error.set(null); + this.getProjects + .execute(this.programId, { + selection, + search: this.search(), + limit: selection.scope === "all" ? 1 : this.limit, + offset: this.offset(), + }) + .pipe(takeUntil(this.cancelList), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.pending.set(false); + if (result.ok) { + this.page.set(result.value); + this.metrics.set(result.value.caseMetrics); + this.submissionApplicable.set(result.value.submissionApplicable); + this.total.set(result.value.caseMetrics?.projectsTotal ?? result.value.count); + this.confirmed.set(true); + } else { + this.error.set(result.error); + this.confirmed.set(false); + this.cancelExport.next(); + this.exportPending.set(false); + } + }); + } + + applySearch(): void { + this.search.set(this.searchDraft().trim()); + this.offset.set(0); + this.load(); + } + clearSearch(): void { + this.searchDraft.set(""); + this.applySearch(); + } + changePage(direction: -1 | 1): void { + if (this.pending() || (direction === 1 ? !this.hasNext() : this.offset() === 0)) return; + this.offset.update(offset => Math.max(0, offset + direction * this.limit)); + this.load(); + } + + download(): void { + const selection = this.selection(); + const programId = this.programId; + if (programId === null || !selection || !this.canExport()) return; + this.exportPending.set(true); + this.exportError.set(null); + this.exportProjects + .execute(programId, selection) + .pipe(takeUntil(this.cancelExport), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.exportPending.set(false); + if (result.ok) saveCaseProjects(result.value, this.programName, selection); + else { + this.exportError.set(result.error); + if (result.error.kind !== "network") this.confirmed.set(false); + } + }); + } + + /** Поиск отменяет только list; смена кейса/программы и закрытие отменяют также файл. */ + reset(): void { + this.cancelList.next(); + this.cancelExport.next(); + this.programId = null; + this.selection.set(null); + this.page.set(null); + this.metrics.set(null); + this.submissionApplicable.set(false); + this.pending.set(false); + this.error.set(null); + this.searchDraft.set(""); + this.search.set(""); + this.offset.set(0); + this.exportPending.set(false); + this.exportError.set(null); + this.confirmed.set(false); + this.total.set(0); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts new file mode 100644 index 000000000..778a1af34 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts @@ -0,0 +1,34 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramCaseError, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of, switchMap } from "rxjs"; +import { + caseRequestError, + GetProgramCaseProjectsUseCase, +} from "./get-program-case-projects.use-case"; + +@Injectable({ providedIn: "root" }) +export class ExportProgramCaseProjectsUseCase { + private readonly repository = inject(ProgramRepositoryPort); + private readonly getProjects = inject(GetProgramCaseProjectsUseCase); + + /** Повторная проверка контракта перед файлом; search/offset/limit не попадают в export. */ + execute( + programId: number, + selection: ProgramCaseSelection, + ): Observable> { + return this.getProjects.execute(programId, { selection, limit: 1, offset: 0 }).pipe( + switchMap(result => + result.ok + ? this.repository.exportCaseProjects(programId, selection).pipe(map(blob => ok(blob))) + : of(fail(result.error)), + ), + catchError((error: unknown) => of(fail(caseRequestError(error)))), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-case-projects.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-case-projects.use-case.ts new file mode 100644 index 000000000..93bf1b6c3 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-case-projects.use-case.ts @@ -0,0 +1,47 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + isProgramCasePage, + ProgramCaseError, + ProgramCasePage, + ProgramCaseQuery, +} from "@domain/program/program-case-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +export function caseRequestError(error: unknown): ProgramCaseError { + const status = error instanceof HttpErrorResponse ? error.status : 0; + return { + kind: + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : status === 400 + ? "invalid" + : "network", + }; +} + +@Injectable({ providedIn: "root" }) +export class GetProgramCaseProjectsUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + execute( + programId: number, + query: ProgramCaseQuery, + ): Observable> { + return this.repository.getCaseProjects(programId, query).pipe( + map(value => + isProgramCasePage(value, query.selection) + ? ok(value) + : fail({ kind: "unsupported" }), + ), + catchError((error: unknown) => of(fail(caseRequestError(error)))), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts new file mode 100644 index 000000000..f521f1954 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts @@ -0,0 +1,101 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { casePage, selectedCase } from "@domain/program/program-case-analytics.fixture"; +import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; +import { GetProgramCaseProjectsUseCase } from "./get-program-case-projects.use-case"; +import { ExportProgramCaseProjectsUseCase } from "./export-program-case-projects.use-case"; + +describe("Case analytics: проверка контракта перед list/export", () => { + const repository = { getCaseProjects: vi.fn(), exportCaseProjects: vi.fn() }; + let get: GetProgramCaseProjectsUseCase; + let download: ExportProgramCaseProjectsUseCase; + beforeEach(() => { + repository.getCaseProjects.mockReset().mockReturnValue(of(casePage())); + repository.exportCaseProjects.mockReset().mockReturnValue(of(new Blob(["xlsx"]))); + TestBed.configureTestingModule({ + providers: [{ provide: ProgramRepositoryPort, useValue: repository }], + }); + get = TestBed.inject(GetProgramCaseProjectsUseCase); + download = TestBed.inject(ExportProgramCaseProjectsUseCase); + }); + it("возвращает проверенный DTO и nullable презентацию без detail fetch", async () => { + const result = await firstValueFrom(get.execute(12, { selection: selectedCase })); + expect(result).toEqual({ ok: true, value: casePage() }); + expect(repository.getCaseProjects).toHaveBeenCalledExactlyOnceWith(12, { + selection: selectedCase, + }); + }); + it.each([ + { count: 1, next: null, previous: null, results: [{ id: 1, name: "Legacy" }] }, + casePage({ selection: { scope: "without_case", caseName: null } }), + casePage({ caseMetrics: null }), + casePage({ results: [{ id: 1 } as any] }), + ])("не подтверждает legacy/чужой/неполный контракт и не запрашивает файл", async value => { + repository.getCaseProjects.mockReturnValue(of(value)); + expect(await firstValueFrom(download.execute(12, selectedCase))).toEqual({ + ok: false, + error: { kind: "unsupported" }, + }); + expect(repository.exportCaseProjects).not.toHaveBeenCalled(); + }); + it.each([ + [401, "unauthorized"], + [403, "forbidden"], + [404, "not_found"], + [400, "invalid"], + [500, "network"], + [0, "network"], + ])("контролируемая ошибка %s → %s", async (status, kind) => { + repository.getCaseProjects.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: Number(status) })), + ); + expect(await firstValueFrom(get.execute(12, { selection: selectedCase }))).toEqual({ + ok: false, + error: { kind }, + }); + expect(await firstValueFrom(download.execute(12, selectedCase))).toEqual({ + ok: false, + error: { kind }, + }); + expect(repository.exportCaseProjects).not.toHaveBeenCalled(); + }); + it.each([ + { scope: "all" }, + { scope: "without_case" }, + { scope: "selected", caseName: "Без выбранного кейса" }, + ] as ProgramCaseSelection[])("различает typed scope и настоящий option: %j", async selection => { + const page = casePage({ + selection: { + scope: selection.scope, + caseName: selection.scope === "selected" ? selection.caseName : null, + }, + caseMetrics: + selection.scope === "all" + ? null + : { projectsTotal: 0, participantsTotal: 0, submitted: 0, notSubmitted: 0 }, + casesConfigured: selection.scope === "selected", + count: 0, + results: [], + }); + repository.getCaseProjects.mockReturnValue(of(page)); + expect((await firstValueFrom(download.execute(12, selection))).ok).toBe(true); + expect(repository.getCaseProjects).toHaveBeenCalledExactlyOnceWith(12, { + selection, + limit: 1, + offset: 0, + }); + expect(repository.exportCaseProjects).toHaveBeenCalledExactlyOnceWith(12, selection); + }); + it("ошибка самого XLSX остаётся контролируемой", async () => { + repository.exportCaseProjects.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 403 })), + ); + expect(await firstValueFrom(download.execute(12, selectedCase))).toEqual({ + ok: false, + error: { kind: "forbidden" }, + }); + }); +}); diff --git a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts index 4fca3c076..cba2ba0b5 100644 --- a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts @@ -1,6 +1,10 @@ /** @format */ import { HttpParams } from "@angular/common/http"; +import { + ProgramCaseQuery, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; import { ProgramRoleWidget } from "../program-role-widget.model"; import { Observable } from "rxjs"; import { ApiPagination } from "../../other/api-pagination.model"; @@ -28,6 +32,9 @@ import { /** Порт репозитория программ: список/детали/создание/регистрация, проекты/участники/фильтры. */ export abstract class ProgramRepositoryPort { + abstract getCaseProjects(programId: number, query: ProgramCaseQuery): Observable; + abstract exportCaseProjects(programId: number, selection: ProgramCaseSelection): Observable; + abstract getRoleWidget(programId: number): Observable; abstract getAll( skip: number, diff --git a/projects/social_platform/src/app/domain/program/program-case-analytics.fixture.ts b/projects/social_platform/src/app/domain/program/program-case-analytics.fixture.ts new file mode 100644 index 000000000..616194e47 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-case-analytics.fixture.ts @@ -0,0 +1,58 @@ +/** @format */ +import { + ProgramCasePage, + ProgramCaseProject, + ProgramCaseSelection, +} from "./program-case-analytics.model"; + +export const selectedCase: ProgramCaseSelection = { + scope: "selected", + caseName: "Цифровая трансформация", +}; +export const caseMetrics = { + projectsTotal: 2, + participantsTotal: 5, + submitted: 1, + notSubmitted: 1, +}; +export function caseProject(overrides: Partial = {}): ProgramCaseProject { + return { + programProjectId: 901, + project: { + id: 73, + name: "Умный город", + region: "Москва", + presentationAddress: "https://example.org/deck.pdf", + }, + case: { kind: "selected", name: "Цифровая трансформация" }, + leader: { userId: 14, fullName: "Анна Иванова" }, + teamSize: 3, + linkedAt: "2026-09-01T10:00:00Z", + submitted: true, + submittedAt: "2026-09-20T11:00:00Z", + ...overrides, + }; +} +export function casePage(overrides: Partial = {}): ProgramCasePage { + return { + count: 2, + next: null, + previous: null, + results: [ + caseProject(), + caseProject({ + programProjectId: 902, + project: { id: 74, name: "Второй проект", region: null, presentationAddress: null }, + leader: null, + teamSize: 1, + submitted: false, + submittedAt: null, + }), + ], + selection: { scope: "selected", caseName: "Цифровая трансформация" }, + casesConfigured: true, + submissionApplicable: true, + caseMetrics: { ...caseMetrics }, + ...overrides, + }; +} diff --git a/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts new file mode 100644 index 000000000..8df122723 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts @@ -0,0 +1,99 @@ +/** @format */ +import { ProgramAnalyticsCaseMetrics, ProgramAnalyticsError } from "./program-analytics.model"; + +export type ProgramCaseSelection = + | { scope: "selected"; caseName: string } + | { scope: "all"; caseName?: never } + | { scope: "without_case"; caseName?: never }; +export interface ProgramCaseQuery { + selection: ProgramCaseSelection; + search?: string; + limit?: number; + offset?: number; +} +export interface ProgramCaseProject { + programProjectId: number; + project: { id: number; name: string; region: string | null; presentationAddress: string | null }; + case: { kind: "selected" | "without_case"; name: string | null }; + leader: { userId: number; fullName: string } | null; + teamSize: number; + linkedAt: string; + submitted: boolean; + submittedAt: string | null; +} +export interface ProgramCasePage { + count: number; + next: string | null; + previous: string | null; + results: ProgramCaseProject[]; + selection: { scope: ProgramCaseSelection["scope"]; caseName: string | null }; + casesConfigured: boolean; + submissionApplicable: boolean; + caseMetrics: ProgramAnalyticsCaseMetrics | null; +} +export interface ProgramCaseError { + kind: ProgramAnalyticsError["kind"] | "unsupported" | "invalid"; +} + +const record = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); +const count = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const textOrNull = (value: unknown): boolean => value === null || typeof value === "string"; + +/** Старый backend игнорирует view: проверяем структуру и идентичность bucket, а не только HTTP 200. */ +export function isProgramCasePage( + value: unknown, + selection: ProgramCaseSelection, +): value is ProgramCasePage { + if ( + !record(value) || + !record(value["selection"]) || + value["selection"]["scope"] !== selection["scope"] || + value["selection"]["caseName"] !== + (selection["scope"] === "selected" ? selection["caseName"] : null) || + typeof value["casesConfigured"] !== "boolean" || + typeof value["submissionApplicable"] !== "boolean" || + !count(value["count"]) || + !textOrNull(value["next"]) || + !textOrNull(value["previous"]) || + !Array.isArray(value["results"]) + ) + return false; + const metrics = value["caseMetrics"]; + if ( + selection["scope"] === "all" + ? metrics !== null + : !record(metrics) || + !["projectsTotal", "participantsTotal", "submitted", "notSubmitted"].every(key => + count(metrics[key]), + ) + ) + return false; + return value["results"].every(row => { + if (!record(row) || !record(row["project"]) || !record(row["case"])) return false; + const bucket = row["case"]; + return ( + count(row["programProjectId"]) && + row["programProjectId"] > 0 && + count(row["project"]["id"]) && + row["project"]["id"] > 0 && + typeof row["project"]["name"] === "string" && + textOrNull(row["project"]["presentationAddress"]) && + textOrNull(row["project"]["region"]) && + ((bucket["kind"] === "without_case" && bucket["name"] === null) || + (bucket["kind"] === "selected" && typeof bucket["name"] === "string")) && + (selection["scope"] === "all" || + (bucket["kind"] === selection["scope"] && + bucket["name"] === (selection["scope"] === "selected" ? selection["caseName"] : null))) && + (row["leader"] === null || + (record(row["leader"]) && + count(row["leader"]["userId"]) && + typeof row["leader"]["fullName"] === "string")) && + count(row["teamSize"]) && + typeof row["linkedAt"] === "string" && + typeof row["submitted"] === "boolean" && + textOrNull(row["submittedAt"]) + ); + }); +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-case-http.integration.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-case-http.integration.spec.ts new file mode 100644 index 000000000..57ba13ec8 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-case-http.integration.spec.ts @@ -0,0 +1,82 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; +import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; +import { API_URL, CamelcaseInterceptor } from "@corelib"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramRepository } from "@infrastructure/repository/program/program.repository"; +import { ExportProgramCaseProjectsUseCase } from "@api/program/use-cases/export-program-case-projects.use-case"; +import { firstValueFrom } from "rxjs"; + +describe("Case export: HTTP → camelCase → repository → contract guard → XLSX", () => { + let http: HttpTestingController; + const selection = { scope: "selected", caseName: "Кейс + &=100%" } as const; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: API_URL, useValue: "/api" }, + { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true }, + { provide: ProgramRepositoryPort, useClass: ProgramRepository }, + ], + }); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it("принимает настоящий snake_case DTO и затем запрашивает только полный scope", async () => { + const promise = firstValueFrom( + TestBed.inject(ExportProgramCaseProjectsUseCase).execute(12, selection), + ); + const list = http.expectOne(req => req.url === "/api/programs/12/projects/"); + expect(list.request.params.get("case_name")).toBe("Кейс + &=100%"); + expect(list.request.params.get("view")).toBe("case_analytics"); + list.flush({ + count: 1, + next: null, + previous: null, + cases_configured: true, + submission_applicable: true, + selection: { scope: "selected", case_name: selection.caseName }, + case_metrics: { projects_total: 1, participants_total: 1, submitted: 1, not_submitted: 0 }, + results: [ + { + program_project_id: 901, + project: { + id: 73, + name: "Проект", + presentation_address: "https://example.org/p.pdf", + region: null, + }, + case: { kind: "selected", name: selection.caseName }, + leader: { user_id: 7, full_name: "Анна Иванова" }, + team_size: 2, + linked_at: "2026-09-01T10:00:00Z", + submitted: true, + submitted_at: null, + }, + ], + }); + const file = http.expectOne(req => req.url === "/api/programs/12/export-projects/"); + expect(file.request.responseType).toBe("blob"); + expect(file.request.params.keys().sort()).toEqual(["case_name", "case_scope", "view"]); + const blob = new Blob(["xlsx fixture"]); + file.flush(blob); + expect(await promise).toEqual({ ok: true, value: blob }); + }); + it("legacy HTTP 200 не вызывает export endpoint", async () => { + const promise = firstValueFrom( + TestBed.inject(ExportProgramCaseProjectsUseCase).execute(12, selection), + ); + http + .expectOne(req => req.url === "/api/programs/12/projects/") + .flush({ + count: 1, + next: null, + previous: null, + results: [{ id: 73, name: "Обычный ProjectList" }], + }); + expect(await promise).toEqual({ ok: false, error: { kind: "unsupported" } }); + http.expectNone(req => req.url.includes("export-projects")); + }); +}); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts index 5d0c7ed0c..795ffcb37 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts @@ -17,7 +17,7 @@ describe("ProgramHttpAdapter", () => { let api: any; function setup(): void { - api = { get: vi.fn(), post: vi.fn() }; + api = { get: vi.fn(), post: vi.fn(), getFile: vi.fn() }; TestBed.configureTestingModule({ providers: [ProgramHttpAdapter, { provide: ApiService, useValue: api }], }); @@ -31,6 +31,48 @@ describe("ProgramHttpAdapter", () => { previous: "", }); + it("case list передаёт exact option, opt-in, поиск и страницу", () => { + setup(); + api.get.mockReturnValue(of({})); + adapter + .getCaseProjects(12, { + selection: { scope: "selected", caseName: " Кейс & A " }, + search: " Проект ", + limit: 25, + offset: 50, + }) + .subscribe(); + const [url, params] = api.get.mock.lastCall; + expect(url).toBe("/programs/12/projects/"); + expect(Object.fromEntries(params.keys().map((key: string) => [key, params.get(key)]))).toEqual({ + view: "case_analytics", + case_scope: "selected", + case_name: " Кейс & A ", + search: "Проект", + limit: "25", + offset: "50", + }); + }); + + it.each(["all", "without_case", "selected"] as const)( + "case XLSX %s не принимает search/pagination", + scope => { + setup(); + api.getFile.mockReturnValue(of(new Blob())); + const selection = + scope === "selected" ? { scope, caseName: "Без выбранного кейса" } : { scope }; + adapter.exportCaseProjects(12, selection).subscribe(); + const [url, params] = api.getFile.mock.lastCall; + expect(url).toBe("/programs/12/export-projects/"); + expect(params.keys().sort()).toEqual( + scope === "selected" ? ["case_name", "case_scope", "view"] : ["case_scope", "view"], + ); + expect(params.get("view")).toBe("case_analytics"); + expect(params.get("case_scope")).toBe(scope); + expect(params.get("case_name")).toBe(scope === "selected" ? "Без выбранного кейса" : null); + }, + ); + it("getAll идёт в GET /programs/ c limit/offset и дополнительными фильтрами", () => { setup(); api.get.mockReturnValue(of(pagination())); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts index 8be0062c3..49cbe27ca 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts @@ -5,6 +5,10 @@ import { ProgramRoleWidget } from "@domain/program/program-role-widget.model"; import { ApiService } from "@corelib"; import { Observable } from "rxjs"; import { HttpParams } from "@angular/common/http"; +import { + ProgramCaseQuery, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; import { ApiPagination } from "@domain/other/api-pagination.model"; import { User } from "@domain/auth/user.model"; import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; @@ -31,6 +35,28 @@ import { /** HTTP-адаптер программ: `/programs`, `/auth/public-users` (детали, проекты, участники, фильтры, регистрация). */ @Injectable({ providedIn: "root" }) export class ProgramHttpAdapter { + getCaseProjects(programId: number, query: ProgramCaseQuery): Observable { + let params = this.caseParams(query.selection) + .set("limit", query.limit ?? 25) + .set("offset", query.offset ?? 0); + if (query.search?.trim()) params = params.set("search", query.search.trim()); + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects/`, params); + } + + exportCaseProjects(programId: number, selection: ProgramCaseSelection): Observable { + return this.apiService.getFile( + `${this.PROGRAMS_URL}/${programId}/export-projects/`, + this.caseParams(selection), + ); + } + + private caseParams(selection: ProgramCaseSelection): HttpParams { + const params = new HttpParams() + .set("view", "case_analytics") + .set("case_scope", selection.scope); + return selection.scope === "selected" ? params.set("case_name", selection.caseName) : params; + } + getRoleWidget(programId: number): Observable { return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/analytics-widget/`); } diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts index 5ad0f713e..13755f0b3 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts @@ -1,6 +1,10 @@ /** @format */ import { HttpParams } from "@angular/common/http"; +import { + ProgramCaseQuery, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; import { ProgramRoleWidget } from "@domain/program/program-role-widget.model"; import { inject, Injectable } from "@angular/core"; import { map, Observable, tap, throwError } from "rxjs"; @@ -36,6 +40,14 @@ import { /** Репозиторий программ: `EntityCache` для `getOne`, остальное — passthrough. */ @Injectable({ providedIn: "root" }) export class ProgramRepository implements ProgramRepositoryPort { + getCaseProjects(programId: number, query: ProgramCaseQuery): Observable { + return this.programAdapter.getCaseProjects(programId, query); + } + + exportCaseProjects(programId: number, selection: ProgramCaseSelection): Observable { + return this.programAdapter.exportCaseProjects(programId, selection); + } + getRoleWidget(programId: number): Observable { return this.programAdapter.getRoleWidget(programId); } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html index 81be4423b..f342e6ca7 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html @@ -261,39 +261,74 @@

Кейсы

@if (caseEmptyMessage(); as message) {

{{ message }}

- } @else { + } +
+ Выгрузить все проекты + @if (caseExport.pending()) { +

Проверяем доступность выгрузки…

+ } + @if (caseExport.error() || caseExport.exportError(); as error) { + + @if (caseExport.error()?.kind === "network") { + + } + } +
+ @if (caseRows().length) {
    @for (row of caseRows(); track row.key) { -
  • -
    - {{ row.name }} - - {{ row.projectsTotal }} - {{ row.totalLabel }} - -
    - -
    - @if (overview.cases.submissionApplicable) { - Сдано {{ row.submitted }} - +
  • +
  • }
@@ -608,6 +643,7 @@

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

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 128ebbcc0..1d36873af 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 @@ -308,12 +308,27 @@ } .case-row { + display: block; flex-shrink: 0; + width: 100%; min-width: 0; padding: 10px; + font: inherit; + text-align: left; + cursor: pointer; background: var(--light-gray); + border: 1px solid transparent; border-radius: 8px; + &:hover { + border-color: var(--accent); + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + &--without-case { border: 1px dashed var(--medium-grey-for-outline); } @@ -386,6 +401,26 @@ } } +.cases-card__export { + app-button { + width: min(100%, 220px); + } + + display: grid; + flex: 0 0 auto; + gap: 6px; + justify-items: start; + margin-top: 12px; +} + +.cases-card__retry { + font: inherit; + color: var(--accent-dark); + cursor: pointer; + background: transparent; + border: 0; +} + .funnel { display: flex; flex-direction: column; @@ -499,7 +534,9 @@ background: var(--light-gray); border: 1px solid var(--medium-grey-for-outline); border-radius: 8px; - transition: background-color 0.15s, border-color 0.15s; + transition: + background-color 0.15s, + border-color 0.15s; &:hover:not(:disabled), &:active:not(:disabled) { 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 426939f08..a6163439d 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 @@ -1,5 +1,7 @@ /** @format */ +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -99,6 +101,7 @@ describe("ProgramAnalyticsComponent", () => { const error = signal(null); const analytics = { programId: signal(12), + programName: signal("Тестовая программа"), data, pending, failed, @@ -128,6 +131,10 @@ describe("ProgramAnalyticsComponent", () => { TestBed.configureTestingModule({ imports: [ProgramAnalyticsComponent], providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, provideRouter([]), { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: { execute: vi.fn() } }, { @@ -151,6 +158,10 @@ describe("ProgramAnalyticsComponent", () => { .overrideComponent(ProgramAnalyticsComponent, { set: { providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, { provide: ProgramAnalyticsInfoService, useValue: analytics }, { provide: ExportFileInfoService, useValue: exports }, ], @@ -160,6 +171,22 @@ describe("ProgramAnalyticsComponent", () => { }); describe("cases from manager overview", () => { + it.each([0, 2, 3])("клик строки %s передаёт точный scope, включая нулевой кейс", index => { + const { fixture, rows } = renderCases(); + const drilldown = fixture.debugElement.query( + By.directive(AnalyticsDrilldownComponent), + ).componentInstance; + const open = vi.spyOn(drilldown, "openCase").mockImplementation(() => {}); + rows[index].click(); + expect(open).toHaveBeenCalledWith( + index === 3 + ? { scope: "without_case" } + : { scope: "selected", caseName: index === 0 ? "Case A" : "Case C" }, + expect.objectContaining({ projectsTotal: index === 0 ? 3 : index === 2 ? 0 : 2 }), + true, + rows[index], + ); + }); function renderCases() { const fixture = TestBed.createComponent(ProgramAnalyticsComponent); fixture.detectChanges(); @@ -207,7 +234,11 @@ describe("ProgramAnalyticsComponent", () => { "Case A: 3 проекта, сдано 2, не сдано 1, участников 7", ); expect(rows[3].classList.contains("case-row--without-case")).toBe(true); - expect(card.querySelector("button, a")).toBeNull(); + expect( + rows.every( + row => row.tagName === "BUTTON" && row.getAttribute("aria-haspopup") === "dialog", + ), + ).toBe(true); expect(JSON.stringify(data())).toBe(before); expect(analytics.initialize).toHaveBeenCalledOnce(); const tooltip = fixture.debugElement.query(By.css(".cases-card app-tooltip")) @@ -252,12 +283,13 @@ describe("ProgramAnalyticsComponent", () => { ])("renders controlled empty state when configured=%s", (configured, message) => { const model = overview(); model.cases.configured = configured; - if (configured) model.cases.items = []; + model.cases.items = []; data.set(model); const { card, rows } = renderCases(); - expect(rows).toHaveLength(0); + expect(rows).toHaveLength(1); expect(card.querySelector(".analytics-empty-state.text-body-10")?.textContent).toBe(message); - expect(card.textContent).not.toContain("Без выбранного кейса"); + expect(card.textContent).toContain("Без выбранного кейса"); + expect(rows[0].tagName).toBe("BUTTON"); }); it("keeps all configured zero options, without an empty state or fake bar values", () => { @@ -286,7 +318,9 @@ describe("ProgramAnalyticsComponent", () => { expect(card.textContent).toContain("Участников 7"); expect(card.textContent).not.toMatch(/Сдано|Не сдано/); expect(rows[0].querySelector(".case-row__volume")!.style.width).toBe("100%"); - expect(rows[0].getAttribute("aria-label")).toBe("Case A: 3 проекта, участников 7"); + expect(rows[0].getAttribute("aria-label")).toBe( + "Case A: 3 проекта, участников 7. Открыть проекты кейса", + ); expect(model.cases.items[0].submitted).toBe(2); expect( fixture.nativeElement.querySelector('[data-testid="solution-funnel"]').textContent, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts index 8d50d337d..e2fd84184 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts @@ -4,12 +4,17 @@ import { ChangeDetectionStrategy, Component, computed, + effect, + untracked, inject, OnInit, signal, } from "@angular/core"; import { MatProgressBarModule } from "@angular/material/progress-bar"; import { ExportFileInfoService } from "@api/export-file/facades/export-file-info.service"; +import { ProgramCaseProjectsService } from "@api/program/facades/detail/program-case-projects.service"; +import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; +import { caseErrorMessage } from "@utils/program-case-analytics"; import { ProgramAnalyticsInfoService } from "@api/program/facades/detail/program-analytics-info.service"; import { ProgramAnalyticsActivityPoint, @@ -65,16 +70,37 @@ interface AnalyticsCaseRow extends ProgramAnalyticsCaseMetrics { TooltipComponent, AnalyticsDrilldownComponent, ], - providers: [ProgramAnalyticsInfoService, ExportFileInfoService], + providers: [ProgramAnalyticsInfoService, ExportFileInfoService, ProgramCaseProjectsService], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProgramAnalyticsComponent implements OnInit { private readonly analytics = inject(ProgramAnalyticsInfoService); private readonly exports = inject(ExportFileInfoService); + protected readonly caseExport = inject(ProgramCaseProjectsService); + protected readonly caseErrorMessage = caseErrorMessage; + + constructor() { + effect(() => { + const id = this.programId(); + const overview = this.data(); + const name = this.programName(); + untracked(() => { + if (id !== null && overview) this.caseExport.open(id, { scope: "all" }, null, false, name); + else this.caseExport.reset(); + }); + }); + } + + protected caseSelection(row: AnalyticsCaseRow): ProgramCaseSelection { + return row.isWithoutCase + ? { scope: "without_case" } + : { scope: "selected", caseName: row.name }; + } private readonly pluralize = new PluralizePipe(); protected readonly data = this.analytics.data; protected readonly programId = this.analytics.programId; + protected readonly programName = this.analytics.programName; protected readonly pending = this.analytics.pending; protected readonly failed = this.analytics.failed; protected readonly error = this.analytics.error; @@ -98,7 +124,7 @@ export class ProgramAnalyticsComponent implements OnInit { /** Preserve backend order/counts; only bar percentages and presentation are derived. */ protected readonly caseRows = computed(() => { const cases = this.data()?.cases; - if (!cases?.configured || !cases.items.length) return []; + if (!cases) return []; const rows = cases.items.map((item, index) => ({ ...item, key: `case:${index}`, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts index 82fd0dea9..ce465103a 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts @@ -1,4 +1,7 @@ /** @format */ + +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -25,7 +28,13 @@ import { AnalyticsDrilldownComponent } from "./analytics-drilldown.component"; @Component({ imports: [AnalyticsDrilldownComponent], - providers: [ProgramAnalyticsInfoService], + providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, + ProgramAnalyticsInfoService, + ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` }

{{ title() }}

+ @if (state.view() === "case" && cases.selection()?.scope === "without_case") { +

+ Проекты, для которых кейс не выбран или больше недоступен. +

+ } @if (state.view() === "backlog") {

Ожидающие назначения

} @@ -67,6 +74,197 @@

{{ title() + @if (state.view() === "case") { +
+ @if (cases.metrics(); as metrics) { +
+
+
Проекты
+
{{ metrics.projectsTotal }}
+
+
+
Участники
+
{{ metrics.participantsTotal }}
+
+ @if (cases.submissionApplicable()) { +
+
Сдано
+
{{ metrics.submitted }}
+
+
+
Не сдано
+
{{ metrics.notSubmitted }}
+
+ } +
+ } +
+ Выгрузить проекты кейса +

+ {{ + cases.metrics()?.projectsTotal === 0 + ? "В кейсе пока нет проектов" + : "Все проекты кейса, без учёта поиска" + }} +

+ @if (cases.exportError(); as error) { +

{{ caseErrorMessage(error) }}

+ } +
+ +
+ @if (cases.pending()) { +

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

+ } @else if (cases.error(); as error) { + + @if (error.kind === "network") { + + } + } @else if (cases.page(); as page) { +

Найдено: {{ page.count }}

+ @if (!page.results.length) { +
+ {{ + cases.search() + ? "По запросу ничего не найдено" + : "В этом кейсе пока нет проектов" + }} +

+ {{ + cases.search() + ? "Измените запрос или очистите поиск." + : "Проекты появятся здесь после выбора этого кейса." + }} +

+
+ } @else { + + + + + + + + + + + @for (item of page.results; track item.programProjectId) { + + + + + + + } + +
ПроектЛидер / командаСдача решенияПрезентация
+ {{ item.project.name }} + + {{ item.leader?.fullName || "Лидер не указан" }} + Размер команды: {{ item.teamSize }} + + @if (!page.submissionApplicable) { + Не требуется + } @else { + {{ item.submitted ? "Сдано" : "Не сдано" }} + {{ + item.submittedAt ? (item.submittedAt | date: "dd.MM.yyyy") : "—" + }} + } + + @if (presentationHref(item.project.presentationAddress); as href) { + Открыть презентацию + } @else { + {{ + item.project.presentationAddress + ? "Ссылка недоступна" + : "Не добавлена" + }} + } +
+ } + @if (page.count > cases.limit || cases.offset() > 0) { + + } + } +
+
+ } @if (state.isAttentionView()) {

} } - } @else { + } @else if (state.view() !== "case") { @if (state.view() === "delayed") {
    @for (expert of state.delayedExperts(); track expert.expertId) { 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 cf0cf17d8..cc1372080 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,6 +1,133 @@ /** @format */ @use "styles/responsive"; +// Только case-вариант: shared modal задаёт width своим stylesheet после подключения overlay. +.modal__body.analytics-drilldown-body.analytics-drilldown-body--case { + box-sizing: border-box; + width: min(880px, calc(100% - 24px)); + max-height: calc(100dvh - 32px); + padding: 20px; + overflow-y: auto; +} + +.analytics-case { + &__metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 10px 12px; + margin: 0 0 16px; + background: var(--light-gray); + border-radius: 8px; + + div { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: baseline; + } + + dt { + font-size: 12px; + color: var(--dark-grey); + } + + dd { + margin: 0; + font-weight: 600; + } + + @include responsive.apply-desktop { + display: flex; + + div + div { + padding-left: 12px; + border-left: 1px solid var(--medium-grey-for-outline); + } + } + } + + &__export { + display: grid; + gap: 6px; + margin-bottom: 16px; + font-size: 12px; + + app-button { + width: 100%; + } + + @include responsive.apply-desktop { + justify-items: start; + + app-button { + width: 220px; + } + } + } + + &__loading { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + padding: 48px 12px; + color: var(--dark-grey); + } + + .analytics-drilldown__search-controls { + max-width: 520px; + + input { + flex-basis: 100%; + border-radius: var(--rounded-xxl); + + @include responsive.apply-desktop { + flex-basis: 220px; + } + } + } + + .analytics-case__table { + small { + display: block; + margin-top: 4px; + font-size: 12px; + } + + @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%; + } + } + } +} + // Overlay лежит вне host: единственное глобальное правило ограничено собственным bodyClass. .modal__body.analytics-drilldown-body { box-sizing: border-box; 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 7840bf7ed..2b5a30720 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 @@ -1,4 +1,7 @@ /** @format */ + +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { CdkTrapFocus } from "@angular/cdk/a11y"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -44,6 +47,10 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { await TestBed.configureTestingModule({ imports: [AnalyticsDrilldownComponent], providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, provideRouter([]), { provide: GetProgramManagerProjectsNotSubmittedUseCase, 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 9bb14783e..34613035f 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 @@ -1,5 +1,10 @@ /** @format */ import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; +import { ProgramCaseProjectsService } from "@api/program/facades/detail/program-case-projects.service"; +import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; +import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model"; +import { ButtonComponent, LoaderComponent } from "@ui/primitives"; +import { caseErrorMessage, presentationHref } from "@utils/program-case-analytics"; import { AfterViewInit, afterRenderEffect, @@ -43,16 +48,29 @@ import { selector: "app-analytics-drilldown", templateUrl: "./analytics-drilldown.component.html", styleUrl: "./analytics-drilldown.component.scss", - imports: [A11yModule, ModalComponent, AvatarComponent, NgTemplateOutlet, DatePipe, RouterLink], - providers: [ProgramAnalyticsDrilldownService], + imports: [ + A11yModule, + ModalComponent, + AvatarComponent, + NgTemplateOutlet, + DatePipe, + RouterLink, + ButtonComponent, + LoaderComponent, + ], + providers: [ProgramAnalyticsDrilldownService, ProgramCaseProjectsService], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class AnalyticsDrilldownComponent implements AfterViewInit { readonly programId = input(null); + readonly programName = input("program"); readonly notSubmittedApplicable = input(false); readonly delayedExperts = input({ total: 0, items: [] }); protected readonly state = inject(ProgramAnalyticsDrilldownService); + protected readonly cases = this.state.cases; + protected readonly caseErrorMessage = caseErrorMessage; + protected readonly presentationHref = presentationHref; private readonly destroyRef = inject(DestroyRef); private trigger: HTMLElement | null = null; private focusedView = this.state.view(); @@ -72,6 +90,12 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { protected readonly requestError = analyticsRequestError; protected readonly title = computed(() => { switch (this.state.view()) { + case "case": { + const selection = this.cases.selection(); + return selection?.scope === "selected" + ? `Кейс: ${selection.caseName}` + : "Без выбранного кейса"; + } case "participants-without-team": return "Участники без команды"; case "projects-awaiting-evaluation": @@ -202,6 +226,18 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { this.state.openDelayed(id, this.delayedExperts()); } + openCase( + selection: ProgramCaseSelection, + metrics: ProgramAnalyticsCaseMetrics, + applicable: boolean, + trigger: HTMLElement, + ): void { + const id = this.programId(); + if (id === null || this.state.open() || this.attached()) return; + this.trigger = trigger; + this.state.openCase(id, selection, metrics, applicable, this.programName()); + } + /** Attention root сохраняет конкретную строку-trigger для обычного закрытия. */ openAttention(view: AnalyticsAttentionView, trigger: HTMLElement): void { const id = this.programId(); diff --git a/projects/social_platform/src/app/utils/program-case-analytics.spec.ts b/projects/social_platform/src/app/utils/program-case-analytics.spec.ts new file mode 100644 index 000000000..035f7533e --- /dev/null +++ b/projects/social_platform/src/app/utils/program-case-analytics.spec.ts @@ -0,0 +1,43 @@ +/** @format */ +import { caseErrorMessage, caseExportFilename, presentationHref } from "./program-case-analytics"; +describe("Case analytics: ссылки и имена файлов", () => { + it.each([ + null, + "", + "javascript:alert(1)", + "data:text/html,test", + "/private/file", + "file:///secret", + ])("не делает небезопасный адрес кликабельным: %s", address => { + expect(presentationHref(address)).toBeNull(); + }); + it("сохраняет внешнюю HTTP(S) презентацию", () => { + expect(presentationHref("https://example.org/present?id=42")).toBe( + "https://example.org/present?id=42", + ); + }); + it("scope остаётся явным даже при совпадении option с label", () => { + const date = new Date("2026-09-28T12:00:00Z"); + expect(caseExportFilename("Программа", { scope: "selected", caseName: "A" }, date)).toBe( + "projects_case - A - Программа - 28.09.26.xlsx", + ); + const selected = caseExportFilename( + "Программа", + { scope: "selected", caseName: "Без выбранного кейса" }, + date, + ); + const without = caseExportFilename("Программа", { scope: "without_case" }, date); + expect(selected).not.toBe(without); + expect(selected.endsWith(".xlsx")).toBe(true); + expect(without).toContain("without_case"); + expect( + caseExportFilename("Программа", { scope: "selected", caseName: '../bad:\n"x/\\' }, date), + ).not.toMatch(/[\r\n:/"\\]/); + }); + it.each(["unsupported", "invalid", "forbidden", "unauthorized", "not_found", "network"] as const)( + "контролируемое сообщение %s", + kind => { + expect(caseErrorMessage({ kind })).toBeTruthy(); + }, + ); +}); diff --git a/projects/social_platform/src/app/utils/program-case-analytics.ts b/projects/social_platform/src/app/utils/program-case-analytics.ts new file mode 100644 index 000000000..c6f3295b5 --- /dev/null +++ b/projects/social_platform/src/app/utils/program-case-analytics.ts @@ -0,0 +1,58 @@ +/** @format */ +import { saveAs } from "file-saver"; +import { + ProgramCaseError, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; +import { analyticsRequestError } from "./program-analytics-assignment"; + +export function caseErrorMessage(error: ProgramCaseError | null): string { + if (error?.kind === "unsupported") + return "Детализация кейсов пока недоступна на сервере. Обновите страницу позже."; + if (error?.kind === "invalid") + return "Настройки кейса изменились. Закройте детализацию и обновите аналитику."; + if (error?.kind === "not_found") return "Программа не найдена."; + return analyticsRequestError(error ? { kind: error.kind } : null); +} + +export function presentationHref(address: string | null): string | null { + if (!address) return null; + try { + const url = new URL(address); + return url.protocol === "https:" || url.protocol === "http:" ? url.href : null; + } catch { + return null; + } +} + +/** Тот же base_name и NFKD sanitation, что у backend build_case_projects_export_file. */ +export function caseExportFilename( + programName: string, + selection: ProgramCaseSelection, + date = new Date(), +): string { + const scope = + selection.scope === "all" + ? "all_cases" + : selection.scope === "without_case" + ? "without_case" + : `case - ${selection.caseName}`; + const day = date.toISOString().slice(0, 10).split("-").reverse(); + day[2] = day[2].slice(-2); + const base = `projects_${scope} - ${programName || "program"} - ${day.join(".")}`; + return ( + base + .normalize("NFKD") + .replace(/[^\p{L}\p{N} ._-]/gu, "") + .replace(/\s+/g, " ") + .trim() + ".xlsx" + ); +} + +export function saveCaseProjects( + blob: Blob, + programName: string, + selection: ProgramCaseSelection, +): void { + saveAs(blob, caseExportFilename(programName, selection)); +} diff --git a/vitest.config.ts b/vitest.config.ts index 618c93b62..267a3b056 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,12 @@ export default defineConfig({ ], }, test: { + // Один экземпляр Angular в TestBed и зависимостях: иначе Windows runner теряет platform. + server: { + deps: { + inline: [/@angular\//, /@analogjs\//, /ngx-/, /ng-click-outside/, /@sentry\/angular/], + }, + }, globals: true, environment: "jsdom", testTimeout: 10000,