diff --git a/docs/prod-project-card-port.md b/docs/prod-project-card-port.md
new file mode 100644
index 000000000..042b7c860
--- /dev/null
+++ b/docs/prod-project-card-port.md
@@ -0,0 +1,79 @@
+
+
+# Выборочный перенос финальных карточек проектов на PROD
+
+База master: `3890ee6ccb84bf92d215ea32b4316e3f52f1d404`.
+Финальный DEV source: `683da4b9b30334bab4bd6c7a1afea3ddc91fe9ad`.
+
+| DEV PR | Merge SHA | Назначение |
+| -------------------------------------------------------------- | ------------------------------------------ | ------------------------------- |
+| [#357](https://github.com/PROCOLLAB-github/procollab/pull/357) | `918e30d54b3240078f8dda43c0fbc5761c011ae6` | Карточка и lifecycle |
+| [#358](https://github.com/PROCOLLAB-github/procollab/pull/358) | `1b92bf0e8e05f1edd906195770ca8fa46ab8d95a` | Общие поверхности списков |
+| [#359](https://github.com/PROCOLLAB-github/procollab/pull/359) | `ec822d13b4f55b0718335ac7f0f9e64d69ed05da` | Роль и доступ |
+| [#360](https://github.com/PROCOLLAB-github/procollab/pull/360) | `358e818c7ebd8b7d5916e56167a402e2848177d6` | Единый CTA |
+| [#361](https://github.com/PROCOLLAB-github/procollab/pull/361) | `69b37d12000b22fd09b77b5a35d95812bf6fbda5` | Независимость lifecycle от роли |
+| [#362](https://github.com/PROCOLLAB-github/procollab/pull/362) | `683da4b9b30334bab4bd6c7a1afea3ddc91fe9ad` | Защита неполного контракта |
+
+## Состав переноса
+
+Переносятся конечные изменения InfoCard, Dashboard/DashboardItem, ProjectsList,
+адаптивной сетки и project-card контейнера ProgramList. Для этих 20 файлов
+сравнены PROD master и DEV-база до #357 (`42cbdd65117cc56beaa6069bcc061192df7733ce`):
+различие только в порядке двух SCSS-свойств. Поэтому итоговое содержимое выбранных
+файлов совместимо с PROD без переноса остальных DEV-изменений.
+
+Архивные макеты, промежуточные варианты и DEV merge commits не переносятся.
+PROD editor, detail resolver, оценивание, прочие сервисы, React, workflows,
+dependencies и test harness остаются на master-реализации.
+
+## Инварианты
+
+- Карточка 156 × 180 px, аватар 70 px; название и описание по центру, до двух строк.
+- Lifecycle: submitted > draft > program > published, независимо от пользователя.
+- Роль берётся из `Project.leader` и `ProfileInfoService.profile()?.id`.
+- Редактирование обозначается только лидеру без связи или с явным `isSubmitted === false`.
+ Неизвестный submission state даёт «только просмотр».
+- CTA «Открыть» ведёт в detail; одна ссылка охватывает карточку и CTA.
+ Подписка — отдельная кнопка вне ссылки; двойной навигации нет.
+- Dashboard и полный список используют my/subs/base; all/subscriptions показывают
+ отрасль и не получают lifecycle/роль собственного проекта.
+- Приглашения сохраняют принятие/отклонение и идентичность по inviteId.
+- Черновик серый, опубликованный зелёный, программа lilac/accent, сданный gold.
+
+## Контракт и приёмка
+
+Нужен backend port DEV #748: `partner_program` содержит `id`, `name`,
+`program_link_id`, `program_id`, `is_submitted` той же min-PK связи, что в detail
+для лидера/участника. Backend следует развернуть первым после отдельного разрешения.
+
+Интеграционный тест передаёт сокращённый snake_case HTTP payload через настоящий
+CamelcaseInterceptor, ApiService, adapter, repository и use case в InfoCard.
+Проверяются сданный проект, неизвестное состояние, восемь сочетаний lifecycle/роли,
+контексты Dashboard/List, маршруты, подписки и приглашения.
+
+Проверки выполняются на PROD-ветке: targeted, полный `npm run test:ci`, format,
+lint, scoped Stylelint, `build:prod`, diff-check. В master уже установлен `pool: forks`;
+порт не меняет эту настройку и не подавляет ошибки.
+
+Браузерный smoke использует локальную сборку настоящих компонентов с фикстурами,
+без обращений к DEV/PROD и пользовательским данным. Он не заменяет авторизованную
+live-проверку после отдельно разрешённого deploy. Здесь создаётся только Draft PR.
+
+## Локальный visual smoke
+
+Измерены reference, Dashboard и полные my/subscriptions/all при viewport 1440 × 900,
+а также my с длинными строками при 390 × 844 и 320 × 844 (DPR 1).
+Во всех случаях карточки 156 × 180 px, аватары 70 × 70 px; горизонтального overflow
+и вложенных интерактивных элементов в project link нет. Восьми сочетаниям
+lifecycle/роли соответствуют одинаковые подписи в Dashboard и полном списке.
+
+Tab выделяет следующую ссылку рамкой 2 px; Enter показывает detail-placeholder
+проекта 102. Это локальный TestBed-маршрут, не live detail. Console: ошибок нет,
+есть предупреждение NG0912 о двух IconComponent в preview. Подписки и приглашения
+дополнительно проверены компонентными тестами с настоящими DOM-действиями.
+
+Замеры: [geometry.json](prod-project-cards/geometry.json).
+
+
+
+
diff --git a/docs/prod-project-cards/eight-states.png b/docs/prod-project-cards/eight-states.png
new file mode 100644
index 000000000..4ca9bd010
Binary files /dev/null and b/docs/prod-project-cards/eight-states.png differ
diff --git a/docs/prod-project-cards/geometry.json b/docs/prod-project-cards/geometry.json
new file mode 100644
index 000000000..37ba49442
--- /dev/null
+++ b/docs/prod-project-cards/geometry.json
@@ -0,0 +1,958 @@
+{
+ "reference": {
+ "cards": [
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/101",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/102",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/103",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/104",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/107",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/108",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/105",
+ "nested": 0,
+ "role": "Участник",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/106",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ }
+ ],
+ "overflow": false,
+ "viewport": {
+ "dpr": 1,
+ "height": 900,
+ "width": 1440
+ }
+ },
+ "dashboard": {
+ "cards": [
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/101",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/102",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/103",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/104",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/107",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/108",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/105",
+ "nested": 0,
+ "role": "Участник",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/106",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/201",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/202",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/203",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/204",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/201",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/202",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/203",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/204",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ }
+ ],
+ "overflow": false,
+ "viewport": {
+ "dpr": 1,
+ "height": 900,
+ "width": 1440
+ }
+ },
+ "myDesktop": {
+ "cards": [
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/101",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/102",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/103",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/104",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/107",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/108",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/105",
+ "nested": 0,
+ "role": "Участник",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/106",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ }
+ ],
+ "overflow": false,
+ "viewport": {
+ "dpr": 1,
+ "height": 900,
+ "width": 1440
+ }
+ },
+ "subscriptions": {
+ "cards": [
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/201",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/202",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/203",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/204",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ }
+ ],
+ "overflow": false,
+ "viewport": {
+ "dpr": 1,
+ "height": 900,
+ "width": 1440
+ }
+ },
+ "all": {
+ "cards": [
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/201",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/202",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/203",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ },
+ {
+ "access": null,
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/204",
+ "nested": 0,
+ "role": null,
+ "status": null,
+ "statusBackground": null,
+ "width": 156
+ }
+ ],
+ "overflow": false,
+ "viewport": {
+ "dpr": 1,
+ "height": 900,
+ "width": 1440
+ }
+ },
+ "myMobileLong": {
+ "cards": [
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/101",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/102",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/103",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/104",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/107",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/108",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/105",
+ "nested": 0,
+ "role": "Участник",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/106",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ }
+ ],
+ "overflow": false,
+ "viewport": {
+ "dpr": 1,
+ "height": 844,
+ "width": 390
+ }
+ },
+ "my320": {
+ "cards": [
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/101",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/102",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "можно редактировать",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/103",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/104",
+ "nested": 0,
+ "role": "Лидер",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/107",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Черновик",
+ "statusBackground": "rgb(249, 249, 249)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/108",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Опубликован",
+ "statusBackground": "rgb(227, 240, 232)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/105",
+ "nested": 0,
+ "role": "Участник",
+ "status": "В программе",
+ "statusBackground": "rgb(232, 224, 250)",
+ "width": 156
+ },
+ {
+ "access": "только просмотр",
+ "avatar": {
+ "height": 70,
+ "width": 70
+ },
+ "cta": "Открыть",
+ "height": 180,
+ "href": "/office/projects/106",
+ "nested": 0,
+ "role": "Участник",
+ "status": "Сдан в программу",
+ "statusBackground": "rgb(253, 248, 240)",
+ "width": 156
+ }
+ ],
+ "overflow": false,
+ "viewport": {
+ "dpr": 1,
+ "height": 844,
+ "width": 320
+ }
+ },
+ "keyboardFocus": {
+ "href": "/office/projects/102",
+ "outline": "rgb(123, 99, 179) solid 2px",
+ "tag": "A"
+ },
+ "keyboardRoute": "http://127.0.0.1:4307/office/projects/my?long",
+ "console": [
+ {
+ "level": "warn",
+ "message": "NG0912: Component ID generation collision detected. Components '_IconComponent' and '_IconComponent2' with selector '[appIcon]' generated the same component ID. To fix this, you can change the selector of one of those components or add an extra host attribute to force a different ID. Find more at https://v20.angular.dev/errors/NG0912",
+ "timestamp": "2026-09-21T21:13:25.507Z",
+ "url": "http://127.0.0.1:4307/main.js"
+ },
+ {
+ "level": "warn",
+ "message": "NG0912: Component ID generation collision detected. Components '_IconComponent' and '_IconComponent2' with selector '[appIcon]' generated the same component ID. To fix this, you can change the selector of one of those components or add an extra host attribute to force a different ID. Find more at https://v20.angular.dev/errors/NG0912",
+ "timestamp": "2026-09-21T21:14:10.706Z",
+ "url": "http://127.0.0.1:4307/main.js"
+ },
+ {
+ "level": "warn",
+ "message": "NG0912: Component ID generation collision detected. Components '_IconComponent' and '_IconComponent2' with selector '[appIcon]' generated the same component ID. To fix this, you can change the selector of one of those components or add an extra host attribute to force a different ID. Find more at https://v20.angular.dev/errors/NG0912",
+ "timestamp": "2026-09-21T21:14:11.895Z",
+ "url": "http://127.0.0.1:4307/main.js"
+ },
+ {
+ "level": "warn",
+ "message": "NG0912: Component ID generation collision detected. Components '_IconComponent' and '_IconComponent2' with selector '[appIcon]' generated the same component ID. To fix this, you can change the selector of one of those components or add an extra host attribute to force a different ID. Find more at https://v20.angular.dev/errors/NG0912",
+ "timestamp": "2026-09-21T21:14:24.941Z",
+ "url": "http://127.0.0.1:4307/main.js"
+ },
+ {
+ "level": "warn",
+ "message": "NG0912: Component ID generation collision detected. Components '_IconComponent' and '_IconComponent2' with selector '[appIcon]' generated the same component ID. To fix this, you can change the selector of one of those components or add an extra host attribute to force a different ID. Find more at https://v20.angular.dev/errors/NG0912",
+ "timestamp": "2026-09-21T21:14:25.54Z",
+ "url": "http://127.0.0.1:4307/main.js"
+ },
+ {
+ "level": "warn",
+ "message": "NG0912: Component ID generation collision detected. Components '_IconComponent' and '_IconComponent2' with selector '[appIcon]' generated the same component ID. To fix this, you can change the selector of one of those components or add an extra host attribute to force a different ID. Find more at https://v20.angular.dev/errors/NG0912",
+ "timestamp": "2026-09-21T21:14:26.098Z",
+ "url": "http://127.0.0.1:4307/main.js"
+ }
+ ],
+ "keyboardDetailRendered": "Тестовый маршрут: office/projects/:id / 102 /",
+ "keyboardScope": "Локальный TestBed preview: маршрут подтверждён отрисованным detail-placeholder; URL browser API остаётся URL входа."
+}
diff --git a/docs/prod-project-cards/my-mobile-long.png b/docs/prod-project-cards/my-mobile-long.png
new file mode 100644
index 000000000..b3713b8f4
Binary files /dev/null and b/docs/prod-project-cards/my-mobile-long.png differ
diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html
index c07bb3312..cc700166d 100644
--- a/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html
+++ b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html
@@ -87,7 +87,15 @@
@for (listItem of searchedList(); track listItem.id) {
-
- @if (listType() === "projects" || listType() === "members") {
+ @if (listType() === "projects") {
+
+ } @else if (listType() === "members") {
{
let component: ProgramListComponent;
let fixture: ComponentFixture;
+ const projects = signal([]);
beforeEach(async () => {
+ projects.set([]);
const programDetailListInfoServiceSpy = {
initializeSearchForm: vi.fn(),
initializationListData: vi.fn(),
@@ -30,7 +39,7 @@ describe("ProgramListComponent", () => {
const programDetailListUIInfoServiceSpy = {
searchForm: fb.group({ search: [""] }),
listType: signal("projects"),
- searchedList: signal([]),
+ searchedList: projects,
profileProjSubsIds: signal([]),
routerLink: vi.fn(),
applySetAvailableFilters: vi.fn(),
@@ -70,7 +79,18 @@ describe("ProgramListComponent", () => {
await TestBed.configureTestingModule({
imports: [ProgramListComponent],
- providers: [provideRouter([])],
+ providers: [
+ provideRouter([]),
+ { provide: IndustryRepositoryPort, useValue: { getOne: () => ({ name: "Образование" }) } },
+ {
+ provide: AddProjectSubscriptionUseCase,
+ useValue: { execute: vi.fn(() => of(ok(undefined))) },
+ },
+ {
+ provide: DeleteProjectSubscriptionUseCase,
+ useValue: { execute: vi.fn(() => of(ok(undefined))) },
+ },
+ ],
})
.overrideComponent(ProgramListComponent, {
remove: {
@@ -112,4 +132,26 @@ describe("ProgramListComponent", () => {
it("should create", () => {
expect(component).toBeTruthy();
});
+ it("проект программы открывается одной ссылкой без вложенной кнопки подписки", () => {
+ projects.set([projectCardFixture()]);
+ fixture.detectChanges();
+ const card = fixture.nativeElement.querySelector("app-info-card") as HTMLElement;
+ const link = card.querySelector(".card__project-link")!;
+ expect(card.closest("a")).toBeNull();
+ expect(card.querySelector("a a, a button, a [tabindex]")).toBeNull();
+ expect(link.getAttribute("href")).toBe("/office/projects/101");
+ expect(link.querySelector(".card__project-action")?.textContent?.trim()).toBe("Открыть");
+ expect(card.querySelector(".card__subscription-action")?.closest("a")).toBeNull();
+ const router = TestBed.inject(Router);
+ const navigate = vi.spyOn(router, "navigateByUrl").mockResolvedValue(true);
+ try {
+ link.querySelector(".card__project-action")!.click();
+ expect(navigate).toHaveBeenCalledOnce();
+ expect(router.serializeUrl(navigate.mock.calls[0][0] as UrlTree)).toBe(
+ "/office/projects/101",
+ );
+ } finally {
+ navigate.mockRestore();
+ }
+ });
});
diff --git a/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.scss b/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.scss
index fb4ab018f..cc0d02ee1 100644
--- a/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.scss
+++ b/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.scss
@@ -51,4 +51,22 @@
}
}
}
+
+ @media (max-width: 999px) {
+ // Перенос вкладок не даёт навигации растягивать сетку фиксированных карточек.
+ &__tabs {
+ flex-wrap: wrap;
+ gap: 4px;
+ }
+
+ &__link {
+ flex: 1 1 140px;
+ min-width: 0;
+ }
+
+ &__item {
+ justify-content: center;
+ padding: 5px 8px;
+ }
+ }
}
diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.html b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.html
index 1e4d077d4..f0dc49b88 100644
--- a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.html
+++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.html
@@ -7,7 +7,8 @@
[title]="dashboardItem.title"
[iconName]="dashboardItem.iconName"
[arrayItems]="dashboardItem.arrayItems"
- [profileProjSubsIds]="dashboardItem.sectionName === 'all' ? profileProjSubsIds() : []"
+ [profileProjSubsIds]="profileProjSubsIds()"
+ [loggedUserId]="profile()?.id"
(addProjectClick)="onAddProject()"
>
}
diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.spec.ts
new file mode 100644
index 000000000..5e8882dbf
--- /dev/null
+++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.spec.ts
@@ -0,0 +1,162 @@
+/** @format */
+
+import { signal } from "@angular/core";
+import { TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { provideRouter, Router, RouterLink } from "@angular/router";
+import { of } from "rxjs";
+import { DashboardProjectsComponent } from "./dashboard.component";
+import { DashboardItemComponent } from "./dashboardItem/dashboardItem.component";
+import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component";
+import {
+ myProjectCardFixtures,
+ publicProjectCardFixtures,
+} from "@ui/widgets/info-card/info-card.fixture";
+import { ProjectsDashboardInfoService } from "@api/project/facades/dashboard/projects-dashboard-info.service";
+import { ProjectsDashboardUIInfoService } from "@api/project/facades/dashboard/ui/projects-dashboard-ui-info.service";
+import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service";
+import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port";
+import { AddProjectSubscriptionUseCase } from "@api/project/use-cases/add-project-subscription.use-case";
+import { DeleteProjectSubscriptionUseCase } from "@api/project/use-cases/delete-project-subscription.use-case";
+import { ProfileInfoService } from "@api/profile/facades/profile-info.service";
+import { ok } from "@domain/shared/result.type";
+import { DashboardItem } from "@utils/dashboardItemBuilder";
+
+/** Дашборд использует те же карточки и контексты, что и полный список. */
+describe("ProjectsDashboard: контексты карточек", () => {
+ const items = signal([]);
+ const dashboard = { initializationDashboardItems: vi.fn(), addProject: vi.fn() };
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ items.set([
+ {
+ sectionName: "my",
+ title: "мои проекты",
+ iconName: "main",
+ arrayItems: myProjectCardFixtures.map(x => x.project),
+ },
+ {
+ sectionName: "subscriptions",
+ title: "мои подписки",
+ iconName: "favourities",
+ arrayItems: publicProjectCardFixtures,
+ },
+ {
+ sectionName: "all",
+ title: "витрина проектов",
+ iconName: "folders",
+ arrayItems: publicProjectCardFixtures,
+ },
+ ]);
+ await TestBed.configureTestingModule({
+ imports: [DashboardProjectsComponent, DashboardItemComponent],
+ providers: [
+ { provide: ProfileInfoService, useValue: { profile: signal({ id: 7 }) } },
+ provideRouter([]),
+ {
+ provide: IndustryRepositoryPort,
+ useValue: { getOne: (id: number) => ({ id, name: "Отрасль " + id }) },
+ },
+ {
+ provide: AddProjectSubscriptionUseCase,
+ useValue: { execute: vi.fn(() => of(ok(undefined))) },
+ },
+ {
+ provide: DeleteProjectSubscriptionUseCase,
+ useValue: { execute: vi.fn(() => of(ok(undefined))) },
+ },
+ ],
+ })
+ .overrideComponent(DashboardProjectsComponent, {
+ set: {
+ providers: [
+ { provide: ProjectsDashboardInfoService, useValue: dashboard },
+ { provide: ProjectsDashboardUIInfoService, useValue: { dashboardItems: items } },
+ {
+ provide: ProgramDetailListUIInfoService,
+ useValue: { profileProjSubsIds: signal(publicProjectCardFixtures.map(x => x.id)) },
+ },
+ ],
+ },
+ })
+ .compileComponents();
+ });
+
+ it("передаёт my/subs/base, ID и подписки; показывает нужный контекст каждого раздела", () => {
+ const fixture = TestBed.createComponent(DashboardProjectsComponent);
+ fixture.detectChanges();
+ const sections = fixture.debugElement.queryAll(By.directive(DashboardItemComponent));
+ expect(sections).toHaveLength(3);
+ for (const [index, section] of sections.entries()) {
+ const appearance = ["my", "subs", "base"][index];
+ const cards = section.queryAll(By.directive(InfoCardComponent));
+ expect(cards).toHaveLength(index === 0 ? myProjectCardFixtures.length : 4);
+ cards.forEach((el, i) => {
+ const c = el.componentInstance as InfoCardComponent;
+ expect(c.appereance()).toBe(appearance);
+ expect(c.profileId()).toBe(c.info().id);
+ expect(c.loggedUserId()).toBe(7);
+ expect(el.nativeElement.closest("a")).toBeNull();
+ expect(el.nativeElement.querySelector("a a, a button, a [tabindex]")).toBeNull();
+ expect(el.nativeElement.querySelector(".card__project-action").textContent.trim()).toBe(
+ "Открыть",
+ );
+ const actionLink = el.query(By.directive(RouterLink)).injector.get(RouterLink);
+ expect(TestBed.inject(Router).serializeUrl(actionLink.urlTree!)).toBe(
+ "/office/projects/" + c.info().id,
+ );
+ expect(actionLink.urlTree!.queryParams).toEqual({});
+ if (index === 0) {
+ expect(el.nativeElement.querySelector(".card__status").textContent.trim()).toBe(
+ myProjectCardFixtures[i].label,
+ );
+ expect(el.nativeElement.querySelector(".card__context--industry")).toBeNull();
+ expect(el.nativeElement.querySelector(".card__role").textContent).toBe(
+ myProjectCardFixtures[i].role,
+ );
+ expect(el.nativeElement.querySelector(".card__access-label").textContent).toBe(
+ myProjectCardFixtures[i].access,
+ );
+ } else {
+ expect(c.isSubscribed).toBe(true);
+ expect(el.nativeElement.querySelector(".card__status")).toBeNull();
+ expect(!!el.nativeElement.querySelector(".card__context--industry")).toBe(i !== 3);
+ expect(
+ el.nativeElement.querySelector(".card__project-action-label").textContent.trim(),
+ ).toBe("Открыть");
+ }
+ });
+ const sectionLink = section.query(By.css(".dashboard__link")).injector.get(RouterLink);
+ expect(TestBed.inject(Router).serializeUrl(sectionLink.urlTree!)).toBe(
+ "/office/projects/" + ["my", "subscriptions", "all"][index],
+ );
+ }
+ expect(dashboard.initializationDashboardItems).toHaveBeenCalledOnce();
+ });
+
+ it("определяет контекст по разделу, даже если декоративная иконка другая", () => {
+ const fixture = TestBed.createComponent(DashboardItemComponent);
+ fixture.componentRef.setInput("title", "Раздел");
+ fixture.componentRef.setInput("iconName", "folders");
+ fixture.componentRef.setInput("sectionName", "my");
+ fixture.componentRef.setInput("arrayItems", [myProjectCardFixtures[0].project]);
+ fixture.detectChanges();
+ const card = () =>
+ fixture.debugElement.query(By.directive(InfoCardComponent))
+ .componentInstance as InfoCardComponent;
+ expect(card().appereance()).toBe("my");
+ fixture.componentRef.setInput("sectionName", "subscriptions");
+ fixture.detectChanges();
+ expect(card().appereance()).toBe("subs");
+ });
+
+ it("пустой раздел сохраняет действие создания", () => {
+ items.set([{ sectionName: "my", title: "мои проекты", iconName: "main", arrayItems: [] }]);
+ const fixture = TestBed.createComponent(DashboardProjectsComponent);
+ fixture.detectChanges();
+ const card = fixture.debugElement.query(By.directive(InfoCardComponent));
+ expect(card.componentInstance.appereance()).toBe("empty");
+ card.nativeElement.querySelector(".card__body").click();
+ expect(dashboard.addProject).toHaveBeenCalledOnce();
+ });
+});
diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts
index aa6f8960c..d8e5bee09 100644
--- a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts
+++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts
@@ -6,6 +6,7 @@ import { DashboardItemComponent } from "./dashboardItem/dashboardItem.component"
import { ProjectsDashboardInfoService } from "@api/project/facades/dashboard/projects-dashboard-info.service";
import { ProjectsDashboardUIInfoService } from "@api/project/facades/dashboard/ui/projects-dashboard-ui-info.service";
import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service";
+import { ProfileInfoService } from "@api/profile/facades/profile-info.service";
/** Дашборд проектов пользователя. */
@Component({
@@ -26,6 +27,7 @@ export class DashboardProjectsComponent implements OnInit {
private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService);
protected readonly dashboardItems = this.projectsDashboardUIInfoService.dashboardItems;
+ protected readonly profile = inject(ProfileInfoService).profile;
protected readonly profileProjSubsIds = this.programDetailListUIInfoService.profileProjSubsIds;
ngOnInit(): void {
diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.html b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.html
index 276f76950..cb2419cdc 100644
--- a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.html
+++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.html
@@ -9,13 +9,16 @@
@if (arrayItems().length) {
@for (project of arrayItems(); track project.id) {
-
+ -
-
+
}
} @else {
diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.scss b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.scss
index b2b495b4e..1ffb5ee0c 100644
--- a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.scss
+++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.scss
@@ -18,14 +18,12 @@
&__list {
display: grid;
flex-grow: 1;
- grid-template-columns: 1fr;
+ grid-template-columns: repeat(auto-fill, 156px);
+ row-gap: 50px;
column-gap: 20px;
align-items: flex-start;
+ justify-content: center;
margin-top: 50px;
-
- @include responsive.apply-desktop {
- grid-template-columns: repeat(4, 2fr);
- }
}
&__link {
diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.ts b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.ts
index 4d4505499..f6c92e586 100644
--- a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.ts
+++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.ts
@@ -1,16 +1,7 @@
/** @format */
import { CommonModule } from "@angular/common";
-import {
- ChangeDetectionStrategy,
- Component,
- EventEmitter,
- input,
- Input,
- OnInit,
- output,
- Output,
-} from "@angular/core";
+import { ChangeDetectionStrategy, Component, computed, input, output } from "@angular/core";
import { IconComponent } from "@uilib";
import { RouterLink } from "@angular/router";
import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component";
@@ -25,30 +16,19 @@ import { AppRoutes } from "@api/paths/app-routes";
imports: [CommonModule, IconComponent, RouterLink, InfoCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class DashboardItemComponent implements OnInit {
+export class DashboardItemComponent {
readonly title = input.required();
readonly arrayItems = input.required();
readonly iconName = input.required();
readonly sectionName = input.required();
readonly profileProjSubsIds = input();
+ readonly loggedUserId = input();
readonly addProjectClick = output();
- appereance: "base" | "subs" | "my" = "base";
+ /** Раздел задаёт контекст независимо от декоративной иконки и обновляется вместе с input. */
+ readonly appereance = computed(() =>
+ this.sectionName() === "my" ? "my" : this.sectionName() === "subscriptions" ? "subs" : "base",
+ );
protected readonly AppRoutes = AppRoutes;
-
- ngOnInit(): void {
- switch (this.iconName()) {
- case "favourities":
- this.appereance = "subs";
- break;
-
- case "main":
- this.appereance = "my";
- break;
-
- default:
- break;
- }
- }
}
diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.html b/projects/social_platform/src/app/ui/pages/projects/list/list.component.html
index c50769bf4..2f1067d1d 100644
--- a/projects/social_platform/src/app/ui/pages/projects/list/list.component.html
+++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.html
@@ -8,19 +8,29 @@
}
diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.scss b/projects/social_platform/src/app/ui/pages/projects/list/list.component.scss
index 85d685865..0301fbf68 100644
--- a/projects/social_platform/src/app/ui/pages/projects/list/list.component.scss
+++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.scss
@@ -29,14 +29,16 @@
&__list {
display: grid;
flex-grow: 1;
- grid-template-columns: 1fr;
+
+ // Число колонок зависит от доступного места рядом с сайдбаром, а не от ширины окна.
+ grid-template-columns: repeat(auto-fill, 156px);
row-gap: 50px;
column-gap: 20px;
align-items: flex-start;
-
- @include responsive.apply-desktop {
- grid-template-columns: repeat(4, 2fr);
- }
+ justify-content: center;
+ width: 100%;
+ min-width: 0;
+ padding-top: 35px;
}
}
diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts
index b9df54f06..f81eff230 100644
--- a/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts
+++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts
@@ -1,117 +1,173 @@
/** @format */
-import { ComponentFixture, TestBed } from "@angular/core/testing";
-
-import { ProjectsListComponent } from "./list.component";
-import { of } from "rxjs";
-import { AuthRepository } from "@infrastructure/repository/auth/auth.repository";
-import { HttpClientTestingModule } from "@angular/common/http/testing";
-import { provideRouter } from "@angular/router";
-import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port";
import { signal } from "@angular/core";
-import { initial } from "@domain/shared/async-state";
+import { TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { provideRouter, Router, UrlTree } from "@angular/router";
+import { RouterTestingHarness } from "@angular/router/testing";
+import { of } from "rxjs";
+import { ProjectsListComponent } from "./list.component";
+import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component";
+import { myProjectCardFixtures, projectCardFixture } from "@ui/widgets/info-card/info-card.fixture";
import { ProjectsListInfoService } from "@api/project/facades/list/projects-list-info.service";
import { ProjectsInfoService } from "@api/project/facades/projects-info.service";
-import { ProgramDetailListInfoService } from "@api/program/facades/detail/program-detail-list-info.service";
import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service";
import { OfficeInfoService } from "@api/office/facades/office-info.service";
-import { OfficeUIInfoService } from "@api/office/facades/ui/office-ui-info.service";
import { SwipeService } from "@api/swipe/swipe.service";
+import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port";
+import { AddProjectSubscriptionUseCase } from "@api/project/use-cases/add-project-subscription.use-case";
+import { DeleteProjectSubscriptionUseCase } from "@api/project/use-cases/delete-project-subscription.use-case";
+import { ProfileInfoService } from "@api/profile/facades/profile-info.service";
+import { ok } from "@domain/shared/result.type";
-describe("ProjectsListComponent", () => {
- let component: ProjectsListComponent;
- let fixture: ComponentFixture;
+/** Проверяем реальную разметку списка и идентичность карточек при обновлении массива. */
+describe("ProjectsListComponent: контекст и track", () => {
+ const first = projectCardFixture({ id: 101, inviteId: 0 });
+ const second = projectCardFixture({ id: 102, inviteId: 0 });
+ const projects = signal([first, second]);
+ const office = { onAcceptInvite: vi.fn(), onRejectInvite: vi.fn() };
+ let harness: RouterTestingHarness;
beforeEach(async () => {
- const authSpy = {
- profile: of({}),
- };
- const authPortSpy = {
- login: of({} as any),
- logout: of(undefined),
- fetchProfile: of({} as any),
- fetchUserRoles: of([]),
- fetchChangeableRoles: of([]),
- fetchLeaderProjects: of({} as any),
- };
-
- const projectsListInfoServiceSpy = {
- initializationProjectsList: vi.fn(),
- initScroll: vi.fn(),
- destroy: vi.fn(),
- projects: signal([]),
- };
-
- const projectsInfoServiceSpy = {
- isAll: signal(false),
- isMy: signal(false),
- isSubs: signal(false),
- isInvites: signal(false),
- };
-
- const programDetailListUIInfoServiceSpy = {
- profileProjSubsIds: signal([]),
- };
-
- const officeInfoServiceSpy = { onAcceptInvite: vi.fn(), onRejectInvite: vi.fn() };
-
- const officeUIInfoServiceSpy = {};
-
- const swipeServiceSpy = {
- isFilterOpen: signal(false),
- onSwipeStart: vi.fn(),
- onSwipeMove: vi.fn(),
- onSwipeEnd: vi.fn(),
- closeFilter: vi.fn(),
- };
-
- const programDetailListInfoServiceSpy = { init: vi.fn(), destroy: vi.fn() };
-
+ vi.clearAllMocks();
+ projects.set([first, second]);
+ const routeIs = (section: string) => TestBed.inject(Router).url.endsWith("/" + section);
await TestBed.configureTestingModule({
- imports: [HttpClientTestingModule, ProjectsListComponent],
+ imports: [ProjectsListComponent],
providers: [
- provideRouter([]),
- { provide: AuthRepository, useValue: authSpy },
- { provide: AuthRepositoryPort, useValue: authPortSpy },
+ { provide: ProfileInfoService, useValue: { profile: signal({ id: 7 }) } },
+ provideRouter([{ path: "office/projects/:section", component: ProjectsListComponent }]),
+ {
+ provide: IndustryRepositoryPort,
+ useValue: { getOne: () => ({ id: 1, name: "Образование" }) },
+ },
+ {
+ provide: AddProjectSubscriptionUseCase,
+ useValue: { execute: vi.fn(() => of(ok(undefined))) },
+ },
+ {
+ provide: DeleteProjectSubscriptionUseCase,
+ useValue: { execute: vi.fn(() => of(ok(undefined))) },
+ },
],
})
.overrideComponent(ProjectsListComponent, {
- remove: {
- providers: [
- ProjectsListInfoService,
- ProjectsInfoService,
- ProgramDetailListInfoService,
- ProgramDetailListUIInfoService,
- OfficeInfoService,
- OfficeUIInfoService,
- SwipeService,
- ],
- },
- add: {
+ set: {
providers: [
- { provide: ProjectsListInfoService, useValue: projectsListInfoServiceSpy },
- { provide: ProjectsInfoService, useValue: projectsInfoServiceSpy },
- { provide: ProgramDetailListInfoService, useValue: programDetailListInfoServiceSpy },
+ {
+ provide: ProjectsListInfoService,
+ useValue: { projects, initializationProjectsList: vi.fn(), initScroll: vi.fn() },
+ },
+ {
+ provide: ProjectsInfoService,
+ useValue: {
+ isMy: () => routeIs("my"),
+ isSubs: () => routeIs("subscriptions"),
+ isAll: () => routeIs("all"),
+ isInvites: () => routeIs("invites"),
+ },
+ },
{
provide: ProgramDetailListUIInfoService,
- useValue: programDetailListUIInfoServiceSpy,
+ useValue: { profileProjSubsIds: signal([101]) },
},
- { provide: OfficeInfoService, useValue: officeInfoServiceSpy },
- { provide: OfficeUIInfoService, useValue: officeUIInfoServiceSpy },
- { provide: SwipeService, useValue: swipeServiceSpy },
+ { provide: OfficeInfoService, useValue: office },
+ { provide: SwipeService, useValue: { isFilterOpen: signal(false) } },
],
},
})
.compileComponents();
+ harness = await RouterTestingHarness.create();
+ });
+
+ const cards = () => harness.routeDebugElement!.queryAll(By.directive(InfoCardComponent));
+
+ it.each(["my", "subscriptions", "all"])(
+ "/%s: CTA выполняет один переход в detail",
+ async route => {
+ await harness.navigateByUrl("/office/projects/" + route, ProjectsListComponent);
+ harness.detectChanges();
+ const router = TestBed.inject(Router);
+ const navigate = vi.spyOn(router, "navigateByUrl").mockResolvedValue(true);
+ try {
+ cards()[0].nativeElement.querySelector(".card__project-action").click();
+ expect(navigate).toHaveBeenCalledOnce();
+ expect(router.serializeUrl(navigate.mock.calls[0][0] as UrlTree)).toBe(
+ "/office/projects/101",
+ );
+ } finally {
+ navigate.mockRestore();
+ }
+ },
+ );
+
+ it.each([
+ ["my", "my"],
+ ["subscriptions", "subs"],
+ ["all", "base"],
+ ] as const)("/%s явно передаёт %s и сохраняет ID действия", async (route, appearance) => {
+ await harness.navigateByUrl("/office/projects/" + route, ProjectsListComponent);
+ harness.detectChanges();
+ expect(cards()).toHaveLength(2);
+ for (const [index, element] of cards().entries()) {
+ const card = element.componentInstance as InfoCardComponent;
+ expect(card.appereance()).toBe(appearance);
+ expect(card.type()).toBe("projects");
+ expect(card.profileId()).toBe(projects()[index].id);
+ expect(card.loggedUserId()).toBe(7);
+ expect(element.nativeElement.closest("a")).toBeNull();
+ expect(element.nativeElement.querySelector("a a, a button, a [tabindex]")).toBeNull();
+ expect(card.showSubscriptionAction()).toBe(route !== "my");
+ }
+ });
+
+ it("полный список сохраняет независимые lifecycle/роль/доступ во всех восьми сочетаниях", async () => {
+ projects.set(myProjectCardFixtures.map(item => item.project));
+ await harness.navigateByUrl("/office/projects/my", ProjectsListComponent);
+ harness.detectChanges();
+ expect(cards()).toHaveLength(8);
+ cards().forEach((element, index) => {
+ const item = myProjectCardFixtures[index];
+ const card = element.nativeElement as HTMLElement;
+ expect(card.querySelector(".card__status")?.textContent?.trim()).toBe(item.label);
+ expect(card.querySelector(".card__role")?.textContent).toBe(item.role);
+ expect(card.querySelector(".card__access-label")?.textContent).toBe(item.access);
+ expect(card.querySelector(".card__project-action")?.textContent?.trim()).toBe("Открыть");
+ expect(card.querySelector(".card__project-link")?.getAttribute("href")).toBe(
+ "/office/projects/" + item.project.id,
+ );
+ });
});
- beforeEach(() => {
- fixture = TestBed.createComponent(ProjectsListComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ it("обычные проекты с одинаковым inviteId сохраняют DOM по project.id", async () => {
+ await harness.navigateByUrl("/office/projects/my", ProjectsListComponent);
+ harness.detectChanges();
+ const before = cards().map(c => c.nativeElement);
+ projects.set([{ ...second, name: "Обновлённый второй" }, { ...first }]);
+ harness.detectChanges();
+ expect(cards()[0].nativeElement).toBe(before[1]);
+ expect(cards()[1].nativeElement).toBe(before[0]);
+ expect(cards()[0].nativeElement.textContent).toContain("Обновлённый второй");
});
- it("should create", () => {
- expect(component).toBeTruthy();
+ it("приглашения одного проекта используют inviteId и прежние действия", async () => {
+ const invite1 = { ...first, inviteId: 51 };
+ const invite2 = { ...first, inviteId: 52 };
+ projects.set([invite1, invite2]);
+ await harness.navigateByUrl("/office/projects/invites", ProjectsListComponent);
+ harness.detectChanges();
+ const before = cards().map(c => c.nativeElement);
+ expect(cards().every(c => c.componentInstance.type() === "invite")).toBe(true);
+ expect(cards().every(c => !c.componentInstance.showSubscriptionAction())).toBe(true);
+ projects.set([{ ...invite2 }, { ...invite1 }]);
+ harness.detectChanges();
+ expect(cards()[0].nativeElement).toBe(before[1]);
+ expect(cards()[1].nativeElement).toBe(before[0]);
+ const buttons = cards()[0].nativeElement.querySelectorAll(".card__invite-actions button");
+ buttons[0].click();
+ buttons[1].click();
+ expect(office.onAcceptInvite).toHaveBeenCalledWith(52);
+ expect(office.onRejectInvite).toHaveBeenCalledWith(52);
+ expect(TestBed.inject(Router).url).toBe("/office/projects/invites");
});
});
diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts b/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts
index fed041abf..ea2aa9be4 100644
--- a/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts
+++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts
@@ -10,6 +10,7 @@ import {
viewChild,
} from "@angular/core";
import { RouterLink } from "@angular/router";
+import { NgTemplateOutlet } from "@angular/common";
import { IconComponent } from "@ui/primitives";
import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component";
import { ProjectsListInfoService } from "@api/project/facades/list/projects-list-info.service";
@@ -20,13 +21,14 @@ import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/p
import { ProgramDetailListInfoService } from "@api/program/facades/detail/program-detail-list-info.service";
import { OfficeUIInfoService } from "@api/office/facades/ui/office-ui-info.service";
import { AppRoutes } from "@api/paths/app-routes";
+import { ProfileInfoService } from "@api/profile/facades/profile-info.service";
/** Отображает список проектов с поиском, фильтрацией и бесконечной прокруткой. */
@Component({
selector: "app-list",
templateUrl: "./list.component.html",
styleUrl: "./list.component.scss",
- imports: [IconComponent, RouterLink, InfoCardComponent],
+ imports: [IconComponent, RouterLink, InfoCardComponent, NgTemplateOutlet],
providers: [
ProjectsListInfoService,
ProjectsInfoService,
@@ -51,6 +53,7 @@ export class ProjectsListComponent implements OnInit, AfterViewInit {
protected readonly isFilterOpen = this.swipeService.isFilterOpen;
protected readonly projects = this.projectsListInfoService.projects;
+ protected readonly profile = inject(ProfileInfoService).profile;
protected readonly profileProjSubsIds = this.programDetailListUIInfoService.profileProjSubsIds;
protected readonly isAll = this.projectsInfoService.isAll;
diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.component.scss b/projects/social_platform/src/app/ui/pages/projects/projects.component.scss
index d60f60ce0..be2a2ea6e 100644
--- a/projects/social_platform/src/app/ui/pages/projects/projects.component.scss
+++ b/projects/social_platform/src/app/ui/pages/projects/projects.component.scss
@@ -5,10 +5,15 @@
&__info {
display: flex;
+ flex-direction: column;
gap: 20px;
justify-content: space-between;
padding-bottom: 100px;
+ @include responsive.apply-desktop {
+ flex-direction: row;
+ }
+
form {
width: 100%;
}
@@ -20,6 +25,7 @@
flex-grow: 1;
gap: 20px;
width: 100%;
+ min-width: 0;
}
&__create {
diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts
new file mode 100644
index 000000000..dfe6d84e8
--- /dev/null
+++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts
@@ -0,0 +1,136 @@
+/** @format */
+
+import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http";
+import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { provideRouter, Router, RouterLink } from "@angular/router";
+import { firstValueFrom, of } from "rxjs";
+import { API_URL, CamelcaseInterceptor } from "@corelib";
+import { GetMyProjectsUseCase } from "@api/project/use-cases/get-my-projects.use-case";
+import { AddProjectSubscriptionUseCase } from "@api/project/use-cases/add-project-subscription.use-case";
+import { DeleteProjectSubscriptionUseCase } from "@api/project/use-cases/delete-project-subscription.use-case";
+import { AppRoutes } from "@api/paths/app-routes";
+import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port";
+import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port";
+import { Project } from "@domain/project/project.model";
+import { ok } from "@domain/shared/result.type";
+import { ProjectRepository } from "@infrastructure/repository/project/project.repository";
+import { InfoCardComponent } from "./info-card.component";
+
+describe("Мои проекты: HTTP list → преобразование контракта → карточка", () => {
+ let fixture: ComponentFixture;
+ let http: HttpTestingController;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [InfoCardComponent],
+ providers: [
+ provideHttpClient(withInterceptorsFromDi()),
+ provideHttpClientTesting(),
+ { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true },
+ { provide: API_URL, useValue: "/api" },
+ { provide: ProjectRepositoryPort, useClass: ProjectRepository },
+ { provide: IndustryRepositoryPort, useValue: { getOne: () => undefined } },
+ { provide: AddProjectSubscriptionUseCase, useValue: { execute: () => of(ok(undefined)) } },
+ {
+ provide: DeleteProjectSubscriptionUseCase,
+ useValue: { execute: () => of(ok(undefined)) },
+ },
+ provideRouter([]),
+ ],
+ }).compileComponents();
+ http = TestBed.inject(HttpTestingController);
+ fixture = TestBed.createComponent(InfoCardComponent);
+ fixture.componentRef.setInput("appereance", "my");
+ fixture.componentRef.setInput("loggedUserId", 7);
+ });
+
+ afterEach(() => http.verify());
+
+ /**
+ * Передаёт настоящий сокращённый snake_case payload через HTTP/interceptor,
+ * адаптер, репозиторий и use case. Готовая camelCase-фикстура скрыла бы дефект.
+ */
+ async function loadProject(submitted: boolean | undefined, draft = false): Promise {
+ const resultPromise = firstValueFrom(TestBed.inject(GetMyProjectsUseCase).execute());
+ const request = http.expectOne("/api/auth/users/projects/");
+ expect(request.request.method).toBe("GET");
+ request.flush({
+ count: 1,
+ next: null,
+ previous: null,
+ results: [
+ {
+ id: 101,
+ name: "Проект программы",
+ leader: 7,
+ short_description: "Описание проекта",
+ image_address: "/assets/images/projects/shared/idea.svg",
+ industry: 1,
+ views_count: 0,
+ draft,
+ is_company: false,
+ partner_program: {
+ id: 12,
+ name: "Программа",
+ ...(submitted === undefined
+ ? {}
+ : { program_link_id: 120, program_id: 12, is_submitted: submitted }),
+ },
+ },
+ ],
+ });
+ const result = await resultPromise;
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("Список проектов не загружен");
+ const project = result.value.results[0];
+ expect(project).toBeInstanceOf(Project);
+ expect(project.shortDescription).toBe("Описание проекта");
+ expect(project.partnerProgram?.isSubmitted).toBe(submitted);
+ if (submitted !== undefined) {
+ expect(project.partnerProgram?.programLinkId).toBe(120);
+ expect(project.partnerProgram?.programId).toBe(12);
+ }
+ fixture.componentRef.setInput("info", project);
+ fixture.detectChanges();
+ }
+
+ const text = (selector: string): string =>
+ fixture.nativeElement.querySelector(selector)?.textContent?.trim();
+
+ it.each([
+ { submitted: true, draft: false, status: "Сдан в программу" },
+ { submitted: true, draft: true, status: "Сдан в программу" },
+ { submitted: false, draft: false, status: "В программе" },
+ { submitted: false, draft: true, status: "Черновик" },
+ { submitted: undefined, draft: false, status: "В программе" },
+ { submitted: undefined, draft: true, status: "Черновик" },
+ ])("is_submitted=$submitted, draft=$draft: lifecycle и доступ независимы", async item => {
+ await loadProject(item.submitted, item.draft);
+ for (const userId of [7, 99, undefined]) {
+ fixture.componentRef.setInput("loggedUserId", userId);
+ fixture.detectChanges();
+ expect(text(".card__status")).toBe(item.status);
+ expect(text(".card__role")).toBe(userId === 7 ? "Лидер" : "Участник");
+ expect(text(".card__access-label")).toBe(
+ userId === 7 && item.submitted === false ? "можно редактировать" : "только просмотр",
+ );
+ expect(text(".card__project-action")).toBe("Открыть");
+ const link = fixture.debugElement.query(By.directive(RouterLink)).injector.get(RouterLink);
+ expect(TestBed.inject(Router).serializeUrl(link.urlTree!)).toBe(
+ AppRoutes.projects.detail(101),
+ );
+ expect(link.urlTree!.queryParams).toEqual({});
+ }
+ });
+
+ it("обновляет доступ после получения состояния и не сохраняет его для неполного ответа", async () => {
+ for (const submitted of [undefined, false, true, undefined]) {
+ await loadProject(submitted);
+ expect(text(".card__access-label")).toBe(
+ submitted === false ? "можно редактировать" : "только просмотр",
+ );
+ }
+ });
+});
diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html
index b341cbd5a..a873de976 100644
--- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html
+++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html
@@ -2,6 +2,8 @@
- @if (shouldShowProjectInfo()) {
-
- }
@if (shouldShowSubscriptionBadge()) {
-
+
+
}
-
+ @if (isProjectCard()) {
+
+
+
+
+ } @else {
+
+ }
-
-
-
- @if (appereance() === "empty") {
-
- } @else {
-
- }
-
-
+
+
+
- @if (appereance() !== "empty") {
-
- }
+
+
-
+
{{ info()?.name }}
@@ -86,7 +113,9 @@
- @if (type() === "projects" || type() === "invite") {
+ @if (isProjectCard()) {
+ {{ info().name }}
+ } @else if (type() === "projects" || type() === "invite") {
@if (info().name) {
{{ info().name | truncate: 12 }}
}
@@ -116,7 +145,7 @@
@if (type() === "invite") {
@@ -143,67 +172,12 @@
- @if (type() === "projects") {
-
- @if (info().partnerProgram || info().draft) {
- @if (info().partnerProgram && info().draft) {
-
-
-
-
- @if (programProjectHovered) {
-
-
- проект привязан к программе {{ info().partnerProgram.name }}
-
-
- }
- }
-
-
- проект
-
-
-
-
-
-
- @if (iconHovered) {
-
-
- @if (info().draft) {
- проект пока еще не опубликовали.
- } @else {
- проект привязан к программе
- {{ info().partnerProgram.name }}
- }
-
-
- }
- } @else {
-
- проект
-
- }
-
+ @if (isProjectCard()) {
+
+
+ Открыть
+
+
} @else if (type() === "members") {
@if (leaderId() === loggedUserId() && loggedUserId() !== info()?.userId) {
diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss
index b34779d7a..9a96740fb 100644
--- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss
+++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss
@@ -27,63 +27,72 @@
align-items: center;
justify-content: space-evenly;
- &--vacancies,
- &--collaborators {
- display: flex;
- gap: 4px;
- align-items: center;
- color: var(--grey-for-text);
- }
-
&--program {
display: flex;
gap: 5px;
align-items: center;
justify-content: center;
-
- &-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 16px;
- height: 12px;
- margin-top: 6px;
- border: 0.5px solid var(--medium-grey-for-outline);
- border-radius: var(--rounded-lg);
-
- i {
- color: var(--accent);
- }
- }
}
+ }
- &--project-partner {
- position: absolute;
- top: 63%;
- left: 30%;
- z-index: 10000;
- padding: 3px 5px;
- background-color: var(--light-white);
- border: 0.5px solid var(--medium-grey-for-outline);
- border-radius: var(--rounded-lg);
+ // Ссылка занимает прежнее место контента; CTA внутри не создаёт второй tab stop.
+ &__project-link {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-height: 0;
+ border-radius: inherit;
- p {
- color: var(--black);
- }
+ &:focus-visible {
+ outline: 2px solid var(--accent-dark);
+ outline-offset: 2px;
}
}
- &__badge {
- position: absolute;
- top: 30px;
- left: 0;
- z-index: 1;
+ // Сохраняем геометрию прежнего ButtonComponent для навигационного CTA.
+ &__project-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 70px;
+ padding: 2px 10px;
+ color: var(--white);
+ background: var(--accent);
+ border: 0.5px solid transparent;
+ border-radius: var(--rounded-xxl);
+ transition: all 0.2s;
+
+ &:hover {
+ background-color: var(--accent-medium);
+ box-shadow: -2px 3px 3px rgb(51 51 51 / 20%);
+ }
}
- &__subscribe-badge {
- display: block;
- color: var(--accent);
+ &__subscription-action {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ color: var(--accent-dark);
cursor: pointer;
+ background: var(--light-white);
+ border: 0;
+ border-radius: var(--rounded-lg);
+
+ &:hover {
+ background: var(--accent-light);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--accent-dark);
+ outline-offset: 2px;
+ }
}
&__photo {
@@ -243,6 +252,201 @@
&__skill {
width: 100px;
}
+
+ // Общая геометрия проектов не затрагивает приглашения, участников и пустые карточки.
+ &--project {
+ .card__content {
+ padding: 27px 12px 0;
+ }
+
+ .card__head {
+ width: 100%;
+ height: 30px;
+ margin-bottom: 6px;
+
+ > div {
+ width: 100%;
+ min-width: 0;
+ }
+ }
+
+ .card__name {
+ /* stylelint-disable-next-line value-no-vendor-prefix -- Префикс нужен для двухстрочного ellipsis. */
+ display: -webkit-box;
+ max-height: 30px;
+ margin: 0;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 15px;
+ overflow-wrap: anywhere;
+ white-space: normal;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ }
+
+ .card__description {
+ flex: 0 0 26px;
+ width: 100%;
+ height: 26px;
+ min-height: 26px;
+
+ p {
+ /* stylelint-disable-next-line value-no-vendor-prefix -- Префикс сохраняет двухстрочное сокращение описания. */
+ display: -webkit-box;
+ overflow: hidden;
+ font-size: 10px;
+ line-height: 13px;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ }
+ }
+
+ .card__project-action-label {
+ font-size: 9px;
+ font-weight: 600;
+ line-height: inherit;
+ white-space: nowrap;
+ }
+ }
+
+ // Нижний блок имеет постоянную высоту: длинный текст не сдвигает роль и CTA.
+ &--my-project {
+ .card__body {
+ padding-bottom: 8px;
+ }
+
+ .card__content {
+ padding: 22px 10px 0;
+ }
+
+ .card__context {
+ margin-bottom: 3px;
+ }
+
+ .card__head {
+ height: 28px;
+ margin-bottom: 2px;
+ }
+
+ .card__name {
+ max-height: 28px;
+ font-size: 11px;
+ line-height: 14px;
+ text-align: center;
+ }
+
+ .card__description {
+ flex-basis: 24px;
+ height: 24px;
+ min-height: 24px;
+ color: var(--grey-for-text);
+
+ p {
+ font-size: 9px;
+ line-height: 12px;
+ }
+ }
+
+ .card__footer {
+ flex-direction: column;
+ gap: 4px;
+ align-items: stretch;
+ width: 100%;
+ }
+
+ .card__info,
+ .card__info--text {
+ width: 100%;
+ }
+
+ .card__project-action {
+ width: 100%;
+ padding: 4px 24px;
+ }
+
+ .card__project-action-label {
+ line-height: 11px;
+ }
+ }
+
+ &__access {
+ flex-shrink: 0;
+ padding-top: 4px;
+ text-align: center;
+ border-top: 1px solid var(--medium-grey-for-outline);
+ }
+
+ &__role {
+ font-size: 9px;
+ font-weight: 600;
+ line-height: 11px;
+ color: var(--black);
+ }
+
+ &__access-label {
+ font-size: 8px;
+ line-height: 10px;
+ color: var(--grey-for-text);
+ }
+
+ &__context {
+ display: inline-flex;
+ flex-shrink: 0;
+ gap: 5px;
+ align-items: center;
+ max-width: 100%;
+ height: 20px;
+ padding: 3px 8px;
+ margin-bottom: 6px;
+ font-size: 9px;
+ font-weight: 600;
+ line-height: 14px;
+ color: var(--dark-grey);
+ white-space: nowrap;
+ background: var(--light-gray);
+ border-radius: 11px;
+
+ &--industry {
+ color: var(--accent-dark);
+ background: var(--accent-light);
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--accent-dark);
+ outline-offset: 2px;
+ }
+ }
+ }
+
+ &__status {
+ &::before {
+ flex: 0 0 6px;
+ width: 6px;
+ height: 6px;
+ content: "";
+ background: currentcolor;
+ border-radius: 50%;
+ }
+
+ &--published {
+ color: var(--green-dark);
+ background: var(--green-light);
+ }
+
+ &--program {
+ color: var(--accent-dark);
+ background: var(--accent-light);
+ }
+
+ &--submitted {
+ color: var(--gold-dark);
+ background: var(--gold-light);
+ }
+ }
}
.message-modal {
diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts
index 0fc33a848..321a713dc 100644
--- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts
+++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts
@@ -1,50 +1,346 @@
/** @format */
+import { OverlayContainer } from "@angular/cdk/overlay";
import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { provideRouter, Router, RouterLink, UrlTree } from "@angular/router";
import { of } from "rxjs";
import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port";
+import { AddProjectSubscriptionUseCase } from "@api/project/use-cases/add-project-subscription.use-case";
+import { DeleteProjectSubscriptionUseCase } from "@api/project/use-cases/delete-project-subscription.use-case";
+import { AppRoutes } from "@api/paths/app-routes";
+import { ok } from "@domain/shared/result.type";
+import { AvatarComponent } from "@ui/primitives/avatar/avatar.component";
+import { IconComponent } from "@ui/primitives";
import { InfoCardComponent } from "./info-card.component";
-import { provideRouter } from "@angular/router";
-import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port";
-import { EventBus } from "@domain/shared/event-bus";
+import { myProjectCardFixtures, projectCardFixture, projectCardProgram } from "./info-card.fixture";
-describe("ProjectCardComponent", () => {
- let component: InfoCardComponent;
+describe("InfoCardComponent: статусы моих проектов", () => {
let fixture: ComponentFixture
;
+ let component: InfoCardComponent;
+ const addSubscription = { execute: vi.fn(() => of(ok(undefined))) };
+ const deleteSubscription = { execute: vi.fn(() => of(ok(undefined))) };
beforeEach(async () => {
- const industrySpy = { industries: () => of([]), getOne: () => of({ name: "Test Industry" }) };
-
+ vi.clearAllMocks();
await TestBed.configureTestingModule({
imports: [InfoCardComponent],
providers: [
- { provide: IndustryRepositoryPort, useValue: industrySpy },
{
- provide: ProjectSubscriptionRepositoryPort,
- useValue: { addSubscription: of({}), deleteSubscription: of({}) },
+ provide: IndustryRepositoryPort,
+ useValue: { industries: () => [], getOne: () => ({ id: 1, name: "Образование" }) },
},
- { provide: EventBus, useValue: { emit: () => {} } },
+ { provide: AddProjectSubscriptionUseCase, useValue: addSubscription },
+ { provide: DeleteProjectSubscriptionUseCase, useValue: deleteSubscription },
provideRouter([]),
],
}).compileComponents();
- });
-
- beforeEach(() => {
fixture = TestBed.createComponent(InfoCardComponent);
component = fixture.componentInstance;
- fixture.componentRef.setInput("info", {
- id: 1,
- name: "Test Project",
- description: "",
- skills: [],
- status: "active",
- isCompetitive: false,
- });
+ fixture.componentRef.setInput("info", projectCardFixture());
fixture.componentRef.setInput("type", "projects");
+ fixture.componentRef.setInput("appereance", "my");
+ fixture.componentRef.setInput("loggedUserId", 7);
+ });
+
+ afterEach(async () => {
+ // CDK прикрепляет/открепляет portal в следующем такте; завершаем его до teardown.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ vi.restoreAllMocks();
+ });
+ const card = (): HTMLElement => fixture.nativeElement.querySelector(".card__body");
+
+ it.each(myProjectCardFixtures)(
+ "$key / $role: статус, доступ и единственный корректный маршрут",
+ async item => {
+ const original = JSON.stringify(item.project);
+ fixture.componentRef.setInput("info", item.project);
+ fixture.detectChanges();
+ expect(card().querySelectorAll(".card__status")).toHaveLength(1);
+ expect(
+ card()
+ .querySelector(".card__status")
+ ?.classList.contains("card__status--" + item.key),
+ ).toBe(true);
+ expect(card().querySelector(".card__status")?.textContent?.trim()).toBe(item.label);
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe(
+ item.action,
+ );
+ expect(card().querySelector(".card__name")?.textContent).toBe(item.project.name);
+ expect(
+ card().querySelector(
+ ".card__info--vacancies, .card__info--collaborators, .card__info--program-icon, .card__industry, .card__info--project-partner",
+ ),
+ ).toBeNull();
+ expect(fixture.debugElement.queryAll(By.directive(IconComponent))).toHaveLength(0);
+ const avatar = fixture.debugElement.query(By.directive(AvatarComponent)).componentInstance;
+ expect(avatar.size()).toBe(70);
+ expect(avatar.url()).toBe(item.project.imageAddress);
+ const router = TestBed.inject(Router);
+ const link = fixture.debugElement.query(By.directive(RouterLink)).injector.get(RouterLink);
+ expect(card().querySelector(".card__role")?.textContent).toBe(item.role);
+ expect(card().querySelector(".card__access-label")?.textContent).toBe(item.access);
+ const expectedRoute = AppRoutes.projects.detail(item.project.id);
+ expect(router.serializeUrl(link.urlTree!)).toBe(expectedRoute);
+ expect(link.urlTree!.queryParams).toEqual({});
+ expect(card().querySelectorAll("a")).toHaveLength(1);
+ expect(card().querySelector("a a, a button, a [tabindex]")).toBeNull();
+ const navigate = vi.spyOn(router, "navigateByUrl").mockResolvedValue(true);
+ card().querySelector(".card__project-action")!.click();
+ await fixture.whenStable();
+ expect(navigate).toHaveBeenCalledOnce();
+ expect(router.serializeUrl(navigate.mock.calls[0][0] as UrlTree)).toBe(expectedRoute);
+ navigate.mockClear();
+ card().querySelector(".card__name")!.click();
+ expect(navigate).toHaveBeenCalledOnce();
+ expect(router.serializeUrl(navigate.mock.calls[0][0] as UrlTree)).toBe(expectedRoute);
+ expect(JSON.stringify(item.project)).toBe(original);
+ expect(addSubscription.execute).not.toHaveBeenCalled();
+ expect(deleteSubscription.execute).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([7, 99])("сдача имеет приоритет над draft для пользователя %s", userId => {
+ fixture.componentRef.setInput("loggedUserId", userId);
+ fixture.componentRef.setInput(
+ "info",
+ projectCardFixture({ draft: true, partnerProgram: projectCardProgram(true) }),
+ );
fixture.detectChanges();
+ expect(card().querySelector(".card__status")?.textContent?.trim()).toBe("Сдан в программу");
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe(
+ "Открыть",
+ );
+ expect(card().querySelector(".card__role")?.textContent).toBe(
+ userId === 7 ? "Лидер" : "Участник",
+ );
+ expect(card().querySelector(".card__access-label")?.textContent).toBe("только просмотр");
+ const link = fixture.debugElement.query(By.directive(RouterLink)).injector.get(RouterLink);
+ expect(link.urlTree!.queryParams).toEqual({});
+ expect(TestBed.inject(Router).serializeUrl(link.urlTree!)).toBe(AppRoutes.projects.detail(101));
});
- it("should create", () => {
- expect(component).toBeTruthy();
+ it.each(myProjectCardFixtures.slice(0, 4))(
+ "$key: смена роли или отсутствие профиля не меняет lifecycle и CTA",
+ item => {
+ fixture.componentRef.setInput("info", item.project);
+ for (const userId of [7, 99, undefined]) {
+ fixture.componentRef.setInput("loggedUserId", userId);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")?.textContent?.trim()).toBe(item.label);
+ expect(card().querySelector(".card__role")?.textContent).toBe(
+ userId === 7 ? "Лидер" : "Участник",
+ );
+ expect(card().querySelector(".card__access-label")?.textContent).toBe(
+ userId === 7 ? item.access : "только просмотр",
+ );
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe("Открыть");
+ const link = fixture.debugElement.query(By.directive(RouterLink)).injector.get(RouterLink);
+ expect(TestBed.inject(Router).serializeUrl(link.urlTree!)).toBe(
+ AppRoutes.projects.detail(item.project.id),
+ );
+ expect(link.urlTree!.queryParams).toEqual({});
+ }
+ },
+ );
+
+ it("отсутствующие ID безопасны, а загрузка/смена пользователя обновляет доступ", () => {
+ fixture.componentRef.setInput("info", projectCardFixture({ leader: undefined }));
+ fixture.componentRef.setInput("loggedUserId", undefined);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__role")?.textContent).toBe("Участник");
+ expect(card().querySelector(".card__access-label")?.textContent).toBe("только просмотр");
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe(
+ "Открыть",
+ );
+ fixture.componentRef.setInput("info", projectCardFixture({ leader: 7 }));
+ fixture.detectChanges();
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe(
+ "Открыть",
+ );
+ fixture.componentRef.setInput("loggedUserId", 7);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__role")?.textContent).toBe("Лидер");
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe(
+ "Открыть",
+ );
+ fixture.componentRef.setInput("loggedUserId", undefined);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe(
+ "Открыть",
+ );
+ });
+
+ it("canSubmit не подменяет факт сдачи", () => {
+ fixture.componentRef.setInput(
+ "info",
+ projectCardFixture({
+ partnerProgram: { ...projectCardProgram(false), canSubmit: false },
+ }),
+ );
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")?.textContent?.trim()).toBe("В программе");
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe(
+ "Открыть",
+ );
+ });
+
+ it("draft имеет приоритет над обычной связью с программой", () => {
+ fixture.componentRef.setInput(
+ "info",
+ projectCardFixture({ draft: true, partnerProgram: projectCardProgram(false) }),
+ );
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")?.textContent?.trim()).toBe("Черновик");
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe(
+ "Открыть",
+ );
+ fixture.detectChanges();
+ expect(card().querySelector(".card__info--project-partner")).toBeNull();
+ expect(card().textContent).not.toContain("привязан к программе");
+ });
+
+ it("отсутствующая связь не превращает опубликованный проект в программный", () => {
+ fixture.componentRef.setInput("info", projectCardFixture({ partnerProgram: undefined }));
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")?.textContent?.trim()).toBe("Опубликован");
+ });
+
+ it("обновляет статус при замене данных и убирает его при смене appearance", () => {
+ fixture.componentRef.setInput("info", myProjectCardFixtures[0].project);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")?.textContent?.trim()).toBe("Черновик");
+ fixture.componentRef.setInput("info", myProjectCardFixtures[3].project);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")?.textContent?.trim()).toBe("Сдан в программу");
+ fixture.componentRef.setInput("appereance", "subs");
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")).toBeNull();
+ expect(fixture.nativeElement.querySelector(".card--project")).not.toBeNull();
+ });
+
+ it("сохраняет полное название и описание в DOM для двухстрочного CSS-ограничения", () => {
+ const project = projectCardFixture({
+ name: "ОченьДлинноеНазваниеБезПробелов".repeat(4),
+ shortDescription: "Очень длинное описание проекта. ".repeat(20),
+ });
+ fixture.componentRef.setInput("info", project);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__name")?.textContent).toBe(project.name);
+ expect(card().querySelector(".card__description")?.textContent?.trim()).toBe(
+ project.shortDescription.trim(),
+ );
+ });
+
+ it.each(["base", "subs"] as const)(
+ "%s: отрасль, полный текст, Открыть и отсутствие lifecycle/шума",
+ appearance => {
+ const project = projectCardFixture({ draft: true, partnerProgram: projectCardProgram(true) });
+ fixture.componentRef.setInput("info", project);
+ fixture.componentRef.setInput("appereance", appearance);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")).toBeNull();
+ expect(card().querySelector(".card__access")).toBeNull();
+ expect(fixture.nativeElement.querySelector(".card--project")).not.toBeNull();
+ expect(card().querySelector(".card__name")?.textContent).toBe(project.name);
+ expect(card().querySelector(".card__context--industry")?.textContent?.trim()).toBe(
+ "Образование",
+ );
+ expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe(
+ "Открыть",
+ );
+ expect(
+ card().querySelector(
+ ".card__info--vacancies, .card__info--collaborators, .card__info--program-icon, .card__industry, .card__info--project-partner",
+ ),
+ ).toBeNull();
+ const router = TestBed.inject(Router);
+ const link = fixture.debugElement.query(By.directive(RouterLink)).injector.get(RouterLink);
+ expect(router.serializeUrl(link.urlTree!)).toBe(AppRoutes.projects.detail(project.id));
+ },
+ );
+
+ it.each(["base", "subs"] as const)("%s без отрасли не резервирует плашку", appearance => {
+ fixture.componentRef.setInput("info", projectCardFixture({ industry: undefined }));
+ fixture.componentRef.setInput("appereance", appearance);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__context")).toBeNull();
+ expect(card().querySelector(".card__name")?.textContent).toBe(projectCardFixture().name);
+ });
+
+ it("длинная отрасль доступна целиком в подсказке и с клавиатуры", () => {
+ const name = "Очень длинное название отрасли проекта";
+ vi.spyOn(TestBed.inject(IndustryRepositoryPort), "getOne").mockReturnValue({ id: 1, name });
+ fixture.componentRef.setInput("appereance", "subs");
+ fixture.detectChanges();
+ const context = card().querySelector(".card__context--industry")!;
+ expect(context.title).toBe(name);
+ expect(context.textContent?.trim()).toBe(name);
+ const link = card().querySelector(".card__project-link")!;
+ expect(link.title).toBe(name);
+ expect(link.tabIndex).toBe(0);
+ });
+
+ it.each(["base", "subs"] as const)(
+ "%s: отдельное действие сохраняет сценарий отписки",
+ async appearance => {
+ fixture.componentRef.setInput("appereance", appearance);
+ fixture.componentRef.setInput("showSubscriptionAction", true);
+ fixture.componentRef.setInput("profileId", 101);
+ fixture.componentRef.setInput("isSubscribed", true);
+ fixture.detectChanges();
+ const action = card().querySelector(".card__subscription-action")!;
+ expect(action.getAttribute("aria-label")).toBe("Отписаться от проекта");
+ expect(action.type).toBe("button");
+ expect(action.closest(".card__content, a")).toBeNull();
+ const navigate = vi.spyOn(TestBed.inject(Router), "navigateByUrl").mockResolvedValue(true);
+ action.click();
+ fixture.detectChanges();
+ expect(component.isUnsubscribeModalOpen).toBe(true);
+ expect(deleteSubscription.execute).not.toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ await fixture.whenStable();
+ TestBed.inject(OverlayContainer)
+ .getContainerElement()
+ .querySelector(".unsubscribe-modal__buttons button")!
+ .click();
+ fixture.detectChanges();
+ expect(deleteSubscription.execute).toHaveBeenCalledExactlyOnceWith(101);
+ expect(component.isSubscribed).toBe(false);
+ expect(component.isUnsubscribeModalOpen).toBe(false);
+ },
+ );
+
+ it("подписка отправляет существующий use case с ID проекта", () => {
+ fixture.componentRef.setInput("appereance", "base");
+ fixture.componentRef.setInput("showSubscriptionAction", true);
+ fixture.componentRef.setInput("profileId", 101);
+ fixture.detectChanges();
+ card().querySelector(".card__subscription-action")!.click();
+ fixture.detectChanges();
+ expect(addSubscription.execute).toHaveBeenCalledExactlyOnceWith(101);
+ expect(component.isSubscribed).toBe(true);
+ });
+
+ it.each(["invite", "members", "rating"] as const)("не оформляет %s как мой проект", type => {
+ fixture.componentRef.setInput("showSubscriptionAction", true);
+ fixture.componentRef.setInput("type", type);
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")).toBeNull();
+ expect(card().querySelector(".card__access")).toBeNull();
+ expect(fixture.nativeElement.querySelector(".card--project")).toBeNull();
+ });
+
+ it("сохраняет пустую карточку и действие создания", () => {
+ fixture.componentRef.setInput("appereance", "empty");
+ fixture.detectChanges();
+ expect(card().querySelector(".card__status")).toBeNull();
+ expect(fixture.nativeElement.querySelector(".card__empty")).not.toBeNull();
+ expect(card().textContent).toContain("Создайте первый проект");
+ const create = vi.fn();
+ component.onCreate.subscribe(create);
+ card().click();
+ expect(create).toHaveBeenCalledOnce();
});
});
diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts
index 12a9fedf7..15007d28a 100644
--- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts
+++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts
@@ -3,6 +3,7 @@
import {
ChangeDetectionStrategy,
Component,
+ computed,
DestroyRef,
inject,
Input,
@@ -23,6 +24,16 @@ import { AddProjectSubscriptionUseCase } from "@api/project/use-cases/add-projec
import { DeleteProjectSubscriptionUseCase } from "@api/project/use-cases/delete-project-subscription.use-case";
import { AppRoutes } from "@api/paths/app-routes";
import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port";
+import { Project } from "@domain/project/project.model";
+
+interface MyProjectPresentation {
+ lifecycle: "submitted" | "draft" | "program" | "published";
+ statusLabel: "Черновик" | "Опубликован" | "В программе" | "Сдан в программу";
+ role: "leader" | "participant";
+ roleLabel: "Лидер" | "Участник";
+ accessLabel: "можно редактировать" | "только просмотр";
+ canEdit: boolean;
+}
/**
* Компонент карточки информации с разным наполнением, в зависимости от контекста
@@ -64,6 +75,65 @@ export class InfoCardComponent {
readonly profileId = input();
readonly leaderId = input();
readonly loggedUserId = input();
+ readonly showSubscriptionAction = input(false);
+
+ /** Общая геометрия применяется только к заполненным карточкам проектов. */
+ protected readonly isProjectCard = computed(
+ () => this.type() === "projects" && this.appereance() !== "empty",
+ );
+
+ /** Отрасль берётся из уже загруженного справочника; пустая плашка не занимает строку. */
+ protected readonly projectIndustry = computed(() => {
+ if (!this.isProjectCard() || this.appereance() === "my") return null;
+ const industryId = this.info()?.industry;
+ return industryId == null
+ ? null
+ : this.industryRepository.getOne(industryId)?.name?.trim() || null;
+ });
+
+ /**
+ * Lifecycle зависит только от проекта: submitted > draft > program > published.
+ * Роль определяется отдельно по текущему профилю. Отсутствующие ID не делают
+ * пользователя лидером; после сдачи даже лидер видит «только просмотр».
+ * Единственный признак сдачи — isSubmitted: canSubmit описывает возможность
+ * действия, например открытый срок. Пока состояние программной связи неизвестно,
+ * не обещаем редактирование: отсутствие isSubmitted не равнозначно false.
+ * Это представление готовых данных,
+ * а не изменение guard или серверных прав. CTA от этих значений не зависит.
+ */
+ protected readonly myProjectPresentation = computed(() => {
+ if (this.type() !== "projects" || this.appereance() !== "my") return null;
+ const project: Project | undefined = this.info();
+ if (!project) return null;
+
+ const isSubmitted = project.partnerProgram?.isSubmitted === true;
+ const lifecycle = isSubmitted
+ ? "submitted"
+ : project.draft === true
+ ? "draft"
+ : project.partnerProgram != null
+ ? "program"
+ : "published";
+ const labels: Record =
+ {
+ submitted: "Сдан в программу",
+ draft: "Черновик",
+ program: "В программе",
+ published: "Опубликован",
+ };
+ const userId = this.loggedUserId();
+ const isLeader = userId != null && project.leader === userId;
+ const canEdit =
+ isLeader && (project.partnerProgram == null || project.partnerProgram.isSubmitted === false);
+ return {
+ lifecycle,
+ statusLabel: labels[lifecycle],
+ role: isLeader ? "leader" : "participant",
+ roleLabel: isLeader ? "Лидер" : "Участник",
+ accessLabel: canEdit ? "можно редактировать" : "только просмотр",
+ canEdit,
+ };
+ });
readonly onAcceptingInvite = output();
readonly onRejectingInvite = output();
@@ -73,35 +143,20 @@ export class InfoCardComponent {
// Состояние компонента
isUnsubscribeModalOpen = false;
inviteErrorModal = false;
- haveBadge = this.calculateHaveBadge();
-
- programProjectHovered = false;
- iconHovered = false;
- draftProjectHovered = false;
removeCollaboratorFromProject(userId: number): void {
this.onRemoveCollaborator.emit(userId);
}
/**
- * Определяет, нужно ли показывать информацию о проекте
- */
- shouldShowProjectInfo(): boolean {
- return (
- this.type() === "projects" && this.appereance() !== "subs" && this.appereance() !== "empty"
- );
- }
-
- /**
- * Определяет, нужно ли показывать бейдж подписки
+ * Контейнер явно разрешает действие подписки; URL не определяет контекст карточки.
+ * Приглашения, участники, пустые и собственные проекты не получают это действие.
*/
shouldShowSubscriptionBadge(): boolean {
return (
- this.appereance() !== "empty" &&
- this.haveBadge &&
- this.appereance() === "base" &&
- this.type() !== "invite" &&
- this.type() !== "members"
+ this.showSubscriptionAction() &&
+ this.isProjectCard() &&
+ (this.appereance() === "base" || this.appereance() === "subs")
);
}
@@ -244,15 +299,4 @@ export class InfoCardComponent {
.navigateByUrl(AppRoutes.projects.all())
.then(() => this.logger.debug("Route change from ProjectsComponent"));
}
-
- /**
- * Вычисление флага haveBadge
- */
- private calculateHaveBadge(): boolean {
- return (
- location.href.includes("/subscriptions") ||
- location.href.includes("/all") ||
- location.href.includes("/projects")
- );
- }
}
diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.fixture.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.fixture.ts
new file mode 100644
index 000000000..97fe1564b
--- /dev/null
+++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.fixture.ts
@@ -0,0 +1,159 @@
+/** @format */
+
+import { PartnerProgramInfo, Project } from "@domain/project/project.model";
+
+/** Связь из list-контракта: имя программы для отображения статуса не требуется. */
+export function projectCardProgram(isSubmitted: boolean): PartnerProgramInfo {
+ return {
+ id: 12,
+ programId: 12,
+ programLinkId: 120,
+ isSubmitted,
+ canSubmit: !isSubmitted,
+ programFields: [],
+ programFieldValues: [],
+ };
+}
+
+/** Полная модель исключает зависимость UI-тестов от случайно пропущенных полей API. */
+export function projectCardFixture(overrides: Partial = {}): Project {
+ return {
+ ...Project.default(),
+ id: 101,
+ leader: 7,
+ name: "Кейс для Ростовской области",
+ shortDescription: "Проект для комфортной жизни и совместной работы студентов.",
+ imageAddress: "/assets/images/projects/shared/idea.svg",
+ industry: 1,
+ ...overrides,
+ };
+}
+
+/** Lifecycle и роль проверяются на одних данных в component tests и visual smoke. */
+export const myProjectCardFixtures = [
+ {
+ key: "draft",
+ label: "Черновик",
+ action: "Открыть",
+ role: "Лидер",
+ access: "можно редактировать",
+ canEdit: true,
+ project: projectCardFixture({
+ id: 101,
+ draft: true,
+ name: "Тестирование пути пользователя",
+ shortDescription: "Удобный сервис для организации учебного процесса и студенческой жизни.",
+ }),
+ },
+ {
+ key: "published",
+ label: "Опубликован",
+ action: "Открыть",
+ role: "Лидер",
+ access: "можно редактировать",
+ canEdit: true,
+ project: projectCardFixture({
+ id: 102,
+ name: "Тест ленты",
+ shortDescription: "Инициативы для более экологичного и комфортного университета.",
+ }),
+ },
+ {
+ key: "program",
+ label: "В программе",
+ action: "Открыть",
+ role: "Лидер",
+ access: "можно редактировать",
+ canEdit: true,
+ project: projectCardFixture({
+ id: 103,
+ name: "TEST VALIDATION",
+ shortDescription: "Интеллектуальный ассистент для студентов и преподавателей.",
+ partnerProgram: projectCardProgram(false),
+ }),
+ },
+ {
+ key: "submitted",
+ label: "Сдан в программу",
+ action: "Открыть",
+ role: "Лидер",
+ access: "только просмотр",
+ canEdit: false,
+ project: projectCardFixture({
+ id: 104,
+ name: "Анализ результатов исследования",
+ shortDescription: "Решения для создания инклюзивной и комфортной образовательной среды.",
+ partnerProgram: projectCardProgram(true),
+ }),
+ },
+ {
+ key: "program",
+ label: "В программе",
+ action: "Открыть",
+ role: "Участник",
+ access: "только просмотр",
+ canEdit: false,
+ project: projectCardFixture({
+ id: 105,
+ leader: 8,
+ name: "TEST VALIDATION & SEVERAL PROCESSES",
+ shortDescription: "info",
+ partnerProgram: projectCardProgram(false),
+ }),
+ },
+ {
+ key: "submitted",
+ label: "Сдан в программу",
+ action: "Открыть",
+ role: "Участник",
+ access: "только просмотр",
+ canEdit: false,
+ project: projectCardFixture({
+ id: 106,
+ leader: 8,
+ name: "Командное исследование",
+ shortDescription: "Сданный проект участника команды.",
+ partnerProgram: projectCardProgram(true),
+ }),
+ },
+ {
+ key: "draft",
+ label: "Черновик",
+ action: "Открыть",
+ role: "Участник",
+ access: "только просмотр",
+ canEdit: false,
+ project: projectCardFixture({
+ id: 107,
+ leader: 8,
+ draft: true,
+ partnerProgram: null,
+ name: "Черновик команды",
+ shortDescription: "Совместная работа над идеей нового проекта.",
+ }),
+ },
+ {
+ key: "published",
+ label: "Опубликован",
+ action: "Открыть",
+ role: "Участник",
+ access: "только просмотр",
+ canEdit: false,
+ project: projectCardFixture({
+ id: 108,
+ leader: 8,
+ draft: false,
+ partnerProgram: null,
+ name: "Открытая инициатива",
+ shortDescription: "Опубликованный проект участника команды.",
+ }),
+ },
+] as const;
+
+/** Отрасли и их отсутствие проверяются на одних данных в подписках и витрине. */
+export const publicProjectCardFixtures = [
+ projectCardFixture({ id: 201, name: "Учимся вместе", industry: 1 }),
+ projectCardFixture({ id: 202, name: "Открытая лаборатория технологий", industry: 2 }),
+ projectCardFixture({ id: 203, name: "Сообщество добрых дел", industry: 3 }),
+ projectCardFixture({ id: 204, name: "Проект без указанной отрасли", industry: undefined }),
+];