diff --git a/docs/contracts/program-creation-publication.md b/docs/contracts/program-creation-publication.md new file mode 100644 index 000000000..3dc6031cf --- /dev/null +++ b/docs/contracts/program-creation-publication.md @@ -0,0 +1,77 @@ + + +# Program creation and first publication — API contract + +Version: 2026-10-27. Target: dev. Keys below use server snake_case; the existing Angular ApiService camel-cases responses and snake-cases requests. + +## Authorization + +All new endpoints require an authenticated active account. Creation additionally requires Django permission `partner_programs.add_partnerprogram`, assigned through the existing user/group admin. An ordinary staff account or an existing program manager does not automatically receive creation permission. Django superuser semantics remain unchanged. + +Program management, preview and publication use existing `can_manage_program`: program managers or staff/superuser. Removing creation permission does not remove existing program management access. + +Drafts are excluded from the public catalog. Program-scoped routes return 404 to outsiders, including registration/news/solutions/published readers. Draft reads/hidden responses and management responses are private/no-store. Unsafe participant actions on a draft return 404 even for a manager. Existing authorized management/news editing remains available. + +## Create and list + +- `GET /programs/capabilities/` → `{ "can_create_program": true }`. +- `POST /programs/drafts/` accepts exactly `name` (trimmed, nonblank, max255), `format` (`case_championship|hackathon|noncompetitive`) and `idempotency_key` (UUID). +- New object: 201; same creator/key/input retry: 200 with the original object; reuse with different input: 409 `creation_key_reused`. Permission is checked before creation and again under the creator lock. +- Creator becomes a manager, without enrollment. Dates remain null. No emails or publication side effects occur. +- Competitive formats use the existing solution template/storage and opt in only the new program. Noncompetitive uses the existing noncompetitive legacy-project mode. +- `GET /programs/managed/?search=&limit=10&offset=0` → `{count,next,previous,results}`. Limit1–100, offset≥0, search≤255. Managers see their own objects; staff/superusers retain existing all-program management scope. Sort: newest first. + +Summary object: `id`, `name`, `draft`, `program_format` (nullable for legacy), `solution_storage_mode`, `updated_at`, `datetime_started`, `datetime_finished`, `datetime_registration_ends` (dates nullable for draft). + +Legacy `POST /programs/` also requires the explicit creation permission and assigns creator/manager. Its serializer payload remains compatible and model-default draft status is retained. + +## Existing editors + +Basic/dates/registration editing continues through existing management endpoints and optimistic-lock contracts. Draft dates can be partial/null; published programs reject null required dates. Migration0040 also enforces nonnull dates when `draft=false`. + +Registration settings add `program_draft`. For a draft, `POST .../management/registration/publish/` validates/saves the questionnaire but leaves its public version unset. Angular labels this action “Сохранить анкету”. The program publication transaction opens both program and registration. For existing published programs, questionnaire publication behavior stays unchanged. + +## Readiness + +`GET /programs/{id}/management/launch/` returns: + +- `program`: summary above; +- `revision`: SHA256 of saved launch-affecting state; +- `checks`: three `{code,title,section,ready,detail}` objects; codes `page|dates|registration`, sections `basic|dates|registration`; +- `blockers`: `{code,section,detail}` for unmet checks; +- `warnings`: optional work that does not prevent enrollment; +- `can_publish`: draft with no blockers; +- `links`: `{program,registration}`; registration null until publication. + +Minimum: nonblank name/description; all3dates; finish after start; registration deadline in the future; saved valid registration schema. An empty additional questionnaire is valid because account requirements are handled by existing enrollment. External mode additionally requires administrator connection and a valid HTTP(S) URL. For first publication, the registration deadline may be after the program starts but must be no later than the program finish (equality is valid). A deadline after finish returns a dates blocker and publication HTTP400 without changing program/questionnaire state. Existing published-program date editing is unchanged. + +URLs use existing `EXPERT_WORKSPACE_FRONTEND_URL`; set it to the dev frontend origin in dev. Native registration URL remains `/program-registration/{id}`. External registration uses the connected URL. + +## Preview + +`GET /programs/{id}/management/preview/` uses the existing participant detail representation, with `preview=true`, explicit draft status and `preview_widget` for the current empty participant state. It grants no participant permissions. It creates no enrollment/view/email, suppresses welcome/news/certificate side effects, and disables participant actions in Angular. + +The preview questionnaire uses existing registration settings, saved `draft.fields` and the same `RegistrationPreviewContentComponent` as settings. Account fields are outside this existing preview dialog. Native controls/required marks/help/order/source selections are unchanged. + +## First publication + +`POST /programs/{id}/management/publish/` accepts `{ "revision": "64 lowercase hex characters" }`. + +- Program and registration are locked in one transaction. +- Current permission, revision, dates and schema are rechecked. +- 409 `stale_program_launch` if saved launch state changed. +- 400 `{code:"program_not_ready",detail,blockers}` if requirements are unmet. Neither object is partially published. +- Success200 returns readiness state with `program.draft=false`, public links and `can_publish=false`. +- A retry after success returns current published state without incrementing questionnaire version again. + +New competitive program details for registered participants add `solution_action_available` and `solution_action_blocked_reason`, using the existing canonical solution context. An existing solution remains accessible even if creation is disabled. Before form setup, `form_not_configured` disables creation and supplies a clear explanation. Form setup later enables the existing flow without another rollout command. Existing programs’ operator rollback remains effective. + +## Migration and rollout + +Apply migration0040 before deploying this API, then deploy Angular. Existing format/creator/key fields remain null; no inferred classification, fake dates or legacy-answer migration occurs. Published dates retain a DB constraint. + +Safe operational rollback: revoke creation permission/hide new Angular entry, retain schema and API draft privacy. Do not reverse nullable fields while incomplete drafts exist, and do not restore the old publicly readable draft API over new draft data. + +Verification and AC audit are in `docs/feature-specs/program-creation-publication.md`. Live dev acceptance follows API→Angular deployment and is separate from local real-API browser QA. + +New program managers receive the full page representation on the normal detail route without changing is_user_member or creating enrollment. Legacy program representation stays unchanged. Recovery from a lost publication response confirms saved state via GET; Angular clears the obsolete error and invalidates the scoped public-detail cache before opening the program. diff --git a/docs/feature-specs/program-creation-publication.md b/docs/feature-specs/program-creation-publication.md new file mode 100644 index 000000000..80f10cb08 --- /dev/null +++ b/docs/feature-specs/program-creation-publication.md @@ -0,0 +1,148 @@ + + +# Создание программы и первая публикация + +Дата: 27.10.2026. Целевая среда: dev, Angular + API. +Статус: реализовано в отдельных feature-ветках; публикация в dev через связанные PR. +Пользователь разрешил внедрение после аудита и исправлений макетов. + +Макеты: [27 · Создание программы · черновик и публикация](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=1109-3285). +11 desktop-экранов и 3 мобильных варианта. Existing controls сверены с реальным кодом. +Runtime использует Mont; Figma использует Inter как технический preview, поскольку connector не предоставляет Mont. + +## Задача и подтвержденные решения + +Организатор должен самостоятельно создать программу, вернуться к черновику, +подготовить страницу, сроки и регистрацию, проверить результат и открыть прием. + +- Право создания отдельно выдает администратор. +- Первая публикация требует страницы, сроков и регистрации. Форма решения, критерии и эксперты могут быть настроены позже. +- Форматы: кейс-чемпионат, хакатон, неконкурсная программа. +- Существующие оболочка, элементы, плотность и порядок разделов сохраняются. +- Реализация и PR в dev входят в scope. Production release не входит. + +## Источники и совместимость + +Исходные dev revisions: API `625517e615aa595ad0fe7ad90550317e959c5c3b`, Angular `72f7aa9837e18367239f1db3781c436dbf57d618`. +Перед PR включены новые dev commits: API `7b6a0c4f00d67d4acf35461a23e32900659634d3`, Angular `43f70c10`. +Они добавляют публичный reader опубликованных legacy-проектов и уточняют действующий UI решений. + +Источники: `partner_programs/models.py`, `views.py`, `services/management.py`, +`services/native_registration.py`, `solution_api.py`; Angular `ui/widgets/detail`, +`ui/pages/program/detail/main`, `ui/pages/program/management`, +`ui/pages/program/management/registration`, `ui/widgets/registration-fields`, +`styles/_program-management-controls.scss`, `_ui-foundation.scss`. + +Каталог по-прежнему показывает опубликованные программы. Существующие редакторы +страницы, дат, материалов, формы решения, экспертов и регистрации используются напрямую. +Старые программы не классифицируются автоматически: `program_format=NULL`. +Их технические режимы и флаг операторского отключения создания решений сохранены. + +## Сценарий + +1. На действующей странице программ появляется вкладка «Я организую» и заметная кнопка «Создать программу» для пользователя с правом создания. +2. Создание спрашивает название и один из трех форматов. Отмена не создает объект. +3. Создается приватный черновик без фиктивных дат; автор становится организатором, без регистрации участником. +4. Подготовка показывает три проверки: страница, сроки, регистрация. Первый незавершенный пункт получает primary CTA. +5. Настройки открывают существующие редакторы. Для черновика есть возврат к подготовке. Сохраняются только реальные изменения. +6. Предпросмотр использует действующую страницу зарегистрированного участника; создание решения, регистрация, просмотры и письма не запускаются. +7. Предпросмотр анкеты из настроек и из подготовки использует один компонент, сохраненную схему и disabled controls. +8. Подтверждение публикации повторно проверяет серверное состояние и атомарно публикует программу с текущей анкетой. +9. Успех показывает ссылки, основное действие «Открыть программу» и второстепенные настройки. Повторный запрос не публикует вторую версию. + +## Правила + +- Право создания: `user.has_perm('partner_programs.add_partnerprogram')`. Оно доступно в существующем Django admin пользователя/группы. Обычный staff и менеджер существующей программы не получают его автоматически; Django superuser сохраняет штатную семантику прав. +- Управление и публикация: существующий `can_manage_program`. Отзыв права создания не удаляет управление уже созданной программой. +- Черновики и вложенные program endpoints возвращают 404 посторонним. Ответы черновиков и management имеют `private, no-store`. +- Обязательные данные страницы: непустые название и описание. Обложка, логотип, город, тег и материалы необязательны. +- Для первой публикации нужны начало, окончание программы и дедлайн регистрации; окончание позже начала, регистрация еще открыта. +- Подтверждено пользователем 27.10.2026: регистрация может продолжаться после старта, но завершается не позже окончания программы. Равенство дедлайна регистрации окончанию программы допустимо. Проверка действует при первой публикации; существующие опубликованные программы и редакторы дат не меняются. +- Встроенная анкета может не иметь дополнительных полей: обязательные данные аккаунта запрашивает существующая регистрация. +- Для черновика «Сохранить анкету» валидирует и сохраняет схему. Регистрация открывается вместе с программой. Для опубликованной программы существующая публикация формы остается прежней. +- Внешняя форма требует подключения администратором и валидного HTTP(S) URL. Неподключенная форма блокирует запуск. +- Новые конкурсные программы включают штатный режим/шаблон решения. Создание решения по-прежнему требует членства, открытых сроков и настроенной формы. До настройки формы участник видит объяснение и недоступное действие. +- Неконкурсная программа не требует формы конкурсного решения, критериев или экспертов. +- Создание сериализуется по автору и UUID; публикация блокирует программу и анкету, проверяет revision и актуальные данные. Ошибка откатывает обе записи. + +## UI и continuity + +- Оболочка Angular, sidebar, реальная карточка программы, редакторы, селекты, radio, модалки и иконки сохранены. +- Управление: Участники → Заявки/Проекты → Эксперты → Результаты → Аналитика → Настройки → Сертификаты. Подготовка не добавлена восьмой вкладкой. +- Карточки 20/16 px, заголовки 16/24, компактные действия 32 px, существующие скругления и tokens. +- Главный CTA заполнен фиолетовым; вторичные действия outline. Desktop submit справа. Mobile сохраняет DOM/focus order. +- В readiness нет фиктивного «Сохранить». На успехе primary ведет к программе. +- В существующем редакторе регистрации для draft изменены только lifecycle-статус, пояснение и название действия. +- Все 14 Figma frames проверены. Экран 05 повторяет реальную participant page; экран 08 использует ту же анкету, что экран 11. + +## API contract + +См. `docs/contracts/program-creation-publication.md` в API и Angular PR. + +| Метод | URL | Назначение | +| ----- | ---------------------------------------------- | -------------------------------------------- | +| GET | `/programs/capabilities/` | `can_create_program` | +| POST | `/programs/drafts/` | `name`, `format`, `idempotency_key`; 201/200 | +| GET | `/programs/managed/?search=&limit=10&offset=0` | Scoped список с пагинацией | +| GET | `/programs/{id}/management/launch/` | checks, blockers, warnings, revision, links | +| GET | `/programs/{id}/management/preview/` | Read-only participant representation | +| POST | `/programs/{id}/management/publish/` | ожидаемая revision; 400 blockers, 409 stale | + +Старый POST каталога также проверяет право создания и назначает автора организатором. +Nullable даты допускаются только у draft; DB constraint и существующий published serializer не допускают их у публичной программы. + +## Acceptance criteria и audit + +| AC | Требование | Evidence | Verdict | +| ---- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------- | +| AC01 | Создание только по отдельному праву | API capabilities/create/legacy POST negatives; browser no-access | PASS | +| AC02 | Автор управляет, без автоматического участия | Creation API tests + real browser editors | PASS | +| AC03 | Черновик скрыт в каталоге и по прямым URL | API negatives, all program route boundary, no-store tests | PASS | +| AC04 | Собственный список с поиском/пагинацией и возвратом | Scoped API tests; browser error/retry/search; URL query source | PASS | +| AC05 | Только имя/формат, без фиктивных дат | API three formats/null dates; browser create/checklist | PASS | +| AC06 | Server blockers без частичной публикации | API validation/rollback/concurrency tests | PASS | +| AC07 | Публикация до формы решения/оценки/экспертов | API tests; real Angular/API full flow and participant enrollment | PASS | +| AC08 | Внешняя регистрация ждет подключения | API connected/not-connected; browser readiness disabled and explanation | PASS | +| AC09 | Изменение настроек требует обновления revision | API stale page/schema and concurrent publication tests | PASS | +| AC10 | Повтор create/publish не создает дубликаты | API concurrency/idempotency; component lost-response retry | PASS | +| AC11 | Preview без участия, просмотров и писем | API no-enrollment/no-view; browser shared preview; source no writes | PASS | +| AC12 | Поздняя настройка формы без ручного rollout | API canonical solution context before/after config and operator rollback | PASS | +| AC13 | Неконкурсный запуск без конкурсных требований | API publication; real Angular/API full noncompetitive flow | PASS | +| AC14 | Desktop/390/360, длинное имя, empty/errors | Browser no overflow, 255 chars, denied/retry/no-results; keyboard focus source/test | PASS | +| AC15 | Один renderer/схема предпросмотра анкеты | Shared component; browser saved fields order/help/disabled; registration tests | PASS | +| AC16 | Existing UI совпадает с реальной платформой | Source continuity + native Mont browser screenshots + Figma audit | PASS | +| AC17 | Первая публикация: регистрация после старта разрешена, дедлайн не позже окончания включительно | 29 launch tests: PostgreSQL PASS; SQLite PASS (2 DB feature skips); negative publish без изменений | PASS | + +## Verification и границы evidence + +- API: 1045 program tests и 135 project tests прошли на настоящем PostgreSQL 18 с Unicode ICU collation. После обновления dev дополнительно прошли 30 launch/public-project tests. +- Локальный harness исключает 4 существующих native PDF tests: WeasyPrint/Pango завершает Python с Windows access violation. В репозитории тесты не пропущены; полный Linux PostgreSQL CI остается обязательным. +- Django check, makemigrations --check, Black и flake8 проходят. Миграция применена на пустой локальной базе. +- Angular: полный `npm run test:ci` — 496 files / 3056 tests плюс solution contract types. После обновления dev — 37 targeted tests, build:pr и browser integration. +- Browser: installed Edge/Playwright, реальный Angular build и Django API с локальной PostgreSQL базой. Тестовые учетные записи и данные изолированы. Внешние API и изображения заблокированы; поэтому remote fallback avatars на скриншотах могут быть недоступны. +- В локальном static QA server изменяется только API origin при отдаче build; файлы окружений и исходный код не переключаются на fake API. +- Проверены case-championship и noncompetitive full flow, создание трех форматов, реальная регистрация участника, readiness, обе анкеты preview, error/retry, no access, no results, mobile 360/390, CTA text color и отсутствие page errors. +- Live dev: отдельная сессия возвращает на login. Авторизованное deployed состояние не проверено; локальные проверки не выдаются за dev acceptance. +- Figma: 11 desktop + 3 mobile, editable layers и source instances. Mont недоступен connector; не заявляется pixel-perfect совпадение метрик Inter/Mont или полный accessibility audit. + +## Review / rollout + +Requirement audit выполнен по исходным AC. Найденные permission/privacy, query-budget, +DI, пустое уведомление, порядок focus и цвет текста CTA исправлены и перепроверены. +Открытых BLOCKER/MAJOR по локальному evidence нет. Linux CI и live dev acceptance отслеживаются отдельно. + +Порядок: API PR → миграция 0040 и API dev deployment → Angular PR → dev smoke. +Оба PR направляются в dev; merge выполняет пользователь по project workflow. +До успешного API deployment создание не следует включать в доступном Angular UI. +Администратор выдает право выбранным пользователям через Django admin. + +Rollback: убрать право создания и откатить Angular UI; сохранить API privacy boundary и новую схему. +Обратная миграция nullable → NOT NULL требует явного решения по черновикам без дат; +автоматически удалять их или подставлять даты нельзя. Старый API без защиты draft откатывать поверх новых данных нельзя. + +Out of scope: снятие с публикации/архивирование, копирование программ, новый общий +редактор, миграция legacy ответов, React, production release. Подключение внешней формы остается административным. + +Дополнительная verification: восстановление после потерянного POST подтверждено реальным браузером: GET проверяет публикацию, устаревшая ошибка убирается, EntityCache инвалидируется и карточка запрашивается заново. Новые организаторы получают полные данные страницы без автоматической регистрации участником; 27 launch API tests на PostgreSQL и отдельный browser scenario прошли. Обычный Linux CI на предыдущей ревизии: 1879 tests, OK (75 feature skips), включая native PDF renderer. После финального исправления полного доступа организатора CI на новой ревизии требуется повторно. + +Уточнение AC17 (27.10.2026): организатор публикует подготовленный черновик с регистрацией после старта и до окончания программы включительно → успех; дедлайн регистрации позже окончания хотя бы на секунду → dates blocker и HTTP 400 без публикации программы/анкеты. Проверка: API tests readiness + publish, граничное равенство. Реализация: program_launch.\_date_problem, response detail отображается существующим Angular checklist без изменения wire schema, ролей и legacy редакторов. Verification AC17: 29 launch tests на PostgreSQL PASS; SQLite PASS с 2 DB-feature skips; Black/flake8 PASS. Figma states 09 обновлен, screenshot без overflow. Angular runtime source не менялся: server detail отображается существующим checklist. Полный CI нового head отслеживается в PR отдельно. diff --git a/projects/social_platform/src/app/api/paths/app-routes.ts b/projects/social_platform/src/app/api/paths/app-routes.ts index 857e99042..34f050b89 100644 --- a/projects/social_platform/src/app/api/paths/app-routes.ts +++ b/projects/social_platform/src/app/api/paths/app-routes.ts @@ -42,6 +42,10 @@ export const AppRoutes = { }, program: { + create: () => "/office/program/create", + managed: () => "/office/program/managed", + launch: (programId: number | string) => `/office/program/${programId}/launch`, + preview: (programId: number | string) => `/office/program/${programId}/preview`, publishedSolutions: (programId: number | string) => `/office/program/${programId}/published-solutions`, publishedSolution: (programId: number | string, solutionId: number | string) => diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts index 489db28a9..8bb5815e7 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts @@ -49,6 +49,10 @@ export class ProgramDetailMainService { readonly programId = this.programDetailMainUIInfoService.programId; initializationProgramDetailMain(descEl: ElementRef | undefined): void { + if (this.programDetailMainUIInfoService.program()?.preview) { + this.newsInfoService.applySetNews({ results: [], count: 0 }); + return; + } this.initializationProgramId(); this.initializationProgramQueryParams(); this.initializationProgram(descEl); @@ -141,6 +145,7 @@ export class ProgramDetailMainService { } initScroll(target: HTMLElement, descEl: ElementRef | undefined): void { + if (this.programDetailMainUIInfoService.program()?.preview) return; this.checkDescriptionTimeout(descEl); fromEvent(target, "scroll") diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.ts index 715dc8a43..1962a3637 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-role-widget.service.ts @@ -41,6 +41,7 @@ export class ProgramRoleWidgetService { if (!this.auth.profile()?.id || this.auth.profile() === this.loggedOutProfile()) return failure("unauthorized"); const program = this.programUI.program(); + if (program?.preview && program.previewWidget) return success(program.previewWidget); if (program && !programWidgetRole(program)) return failure("forbidden"); return this.key() && this.loadedKey() === this.key() ? this.rawState() @@ -82,6 +83,7 @@ export class ProgramRoleWidgetService { this.loadedKey.set(key); this.rawState.set(loading()); if (!key) return; + if (this.programUI.program()?.preview) return; const programId = this.programId(); this.activeRequest = this.request.execute(programId).subscribe(result => { if (version !== this.version || key !== this.key()) return; diff --git a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts index c26bee506..0b76e972d 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts @@ -69,6 +69,23 @@ describe("ProgramDetailMainUIInfoService", () => { expect(service.evaluationDateExpired()).toBe(expired); } }); + + it("does not carry an expired registration into a draft without invented dates", () => { + const service = new ProgramDetailMainUIInfoService(); + service.applyFormatingProgramData({ ...createProgram(1), datetimeRegistrationEnds: past }); + expect(service.registerDateExpired()).toBe(true); + service.applyFormatingProgramData({ + ...createProgram(2), + preview: true, + draft: true, + datetimeRegistrationEnds: null, + datetimeStarted: null, + datetimeFinished: null, + }); + expect(service.registerDateExpired()).toBe(false); + expect(service.program()?.datetimeRegistrationEnds).toBeNull(); + expect(service.registeredProgramModal()).toBe(false); + }); }); it("uses null as the required default current application", () => { diff --git a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts index 4df92007b..ee276ec00 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts @@ -49,10 +49,17 @@ export class ProgramDetailMainUIInfoService { applyFormatingProgramData(program: Program): void { this.program.set(program); - this.registerDateExpired.set(Date.now() > Date.parse(program.datetimeRegistrationEnds)); + this.registerDateExpired.set( + Boolean( + program.datetimeRegistrationEnds && + Date.now() > Date.parse(program.datetimeRegistrationEnds), + ), + ); const evaluationEnds = Date.parse(program.datetimeEvaluationEnds ?? ""); this.evaluationDateExpired.set(Number.isFinite(evaluationEnds) && Date.now() > evaluationEnds); - this.registeredProgramModal.set(program.isUserMember && program.welcomeAcknowledgedAt === null); + this.registeredProgramModal.set( + !program.preview && program.isUserMember && program.welcomeAcknowledgedAt === null, + ); } applyProgramOpenModal(type: "access" | "error"): void { diff --git a/projects/social_platform/src/app/api/program/facades/program-shell-info.service.ts b/projects/social_platform/src/app/api/program/facades/program-shell-info.service.ts index cc4540ce6..048ce7d05 100644 --- a/projects/social_platform/src/app/api/program/facades/program-shell-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/program-shell-info.service.ts @@ -54,7 +54,9 @@ export class ProgramShellInfoService { readonly actualPrograms = computed(() => { return this.programs() - .filter(p => Date.now() < Date.parse(p.datetimeRegistrationEnds)) + .filter( + p => p.datetimeRegistrationEnds && Date.now() < Date.parse(p.datetimeRegistrationEnds), + ) .slice(0, 3); }); diff --git a/projects/social_platform/src/app/api/program/use-cases/program-launch.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/program-launch.use-case.spec.ts new file mode 100644 index 000000000..caaef3071 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-launch.use-case.spec.ts @@ -0,0 +1,71 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { ProgramLaunchUseCase } from "./program-launch.use-case"; +import { ProgramLaunchRepositoryPort } from "@domain/program/ports/program-launch.repository.port"; +import { ProgramLaunch } from "@domain/program/program-launch.model"; +import { Program } from "@domain/program/program.model"; +import { ProgramRepository } from "@infrastructure/repository/program/program.repository"; +import { ProgramHttpAdapter } from "@infrastructure/adapters/program/program-http.adapter"; + +describe("Published program cache after launch", () => { + function setup(draft: boolean) { + TestBed.resetTestingModule(); + const state = { program: { id: 42, draft } } as ProgramLaunch; + const port = { + capabilities: vi.fn(), + create: vi.fn(), + managed: vi.fn(), + preview: vi.fn(), + readiness: vi.fn(() => of(state)), + publish: vi.fn(() => of(state)), + }; + const old = { id: 42, draft: true, description: "Старое описание" } as Program; + const fresh = { id: 42, draft: false, description: "Описание после публикации" } as Program; + const http = { getOne: vi.fn().mockReturnValueOnce(of(old)).mockReturnValue(of(fresh)) }; + TestBed.configureTestingModule({ + providers: [ + ProgramLaunchUseCase, + ProgramRepository, + { provide: ProgramLaunchRepositoryPort, useValue: port }, + { provide: ProgramHttpAdapter, useValue: http }, + ], + }); + return { + launch: TestBed.inject(ProgramLaunchUseCase), + detail: TestBed.inject(ProgramRepository), + port, + http, + old, + fresh, + }; + } + + it("refreshes cached detail when GET confirms a publication whose POST response was lost", async () => { + const { launch, detail, port, http, old, fresh } = setup(false); + expect(await firstValueFrom(detail.getOne(42))).toBe(old); + port.publish.mockImplementation(() => throwError(() => new Error("Lost response"))); + await expect(firstValueFrom(launch.publish(42, "revision"))).rejects.toThrow("Lost response"); + expect(await firstValueFrom(detail.getOne(42))).toBe(old); + expect(http.getOne).toHaveBeenCalledTimes(1); + await firstValueFrom(launch.readiness(42)); + expect(await firstValueFrom(detail.getOne(42))).toBe(fresh); + expect(http.getOne).toHaveBeenCalledTimes(2); + }); + + it("keeps cached detail when readiness still reports a draft", async () => { + const { launch, detail, http, old } = setup(true); + await firstValueFrom(detail.getOne(42)); + await firstValueFrom(launch.readiness(42)); + expect(await firstValueFrom(detail.getOne(42))).toBe(old); + expect(http.getOne).toHaveBeenCalledTimes(1); + }); + + it("refreshes cached detail after the normal successful publication response", async () => { + const { launch, detail, http, fresh } = setup(false); + await firstValueFrom(detail.getOne(42)); + await firstValueFrom(launch.publish(42, "revision")); + expect(await firstValueFrom(detail.getOne(42))).toBe(fresh); + expect(http.getOne).toHaveBeenCalledTimes(2); + }); +}); diff --git a/projects/social_platform/src/app/api/program/use-cases/program-launch.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-launch.use-case.ts new file mode 100644 index 000000000..f0f676f39 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-launch.use-case.ts @@ -0,0 +1,32 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ProgramLaunchRepositoryPort } from "@domain/program/ports/program-launch.repository.port"; +import { EventBus } from "@domain/shared/event-bus"; +import { ProgramUpdated } from "@domain/program/program-updated.event"; +import { ProgramLaunch } from "@domain/program/program-launch.model"; +import { tap } from "rxjs"; +@Injectable() +export class ProgramLaunchUseCase { + private readonly repository = inject(ProgramLaunchRepositoryPort); + private readonly events = inject(EventBus); + readonly capabilities = this.repository.capabilities.bind(this.repository); + readonly create = this.repository.create.bind(this.repository); + readonly managed = this.repository.managed.bind(this.repository); + readiness(id: number) { + return this.repository.readiness(id).pipe(tap(state => this.invalidatePublished(id, state))); + } + publish(id: number, revision: string) { + return this.repository + .publish(id, revision) + .pipe(tap(state => this.invalidatePublished(id, state))); + } + readonly preview = this.repository.preview.bind(this.repository); + private invalidatePublished(id: number, state: ProgramLaunch) { + if (!state.program.draft) + this.events.emit({ + type: "ProgramUpdated", + payload: { programId: id }, + occurredAt: new Date(), + }); + } +} diff --git a/projects/social_platform/src/app/api/solution/solution-navigation.ts b/projects/social_platform/src/app/api/solution/solution-navigation.ts index 9ce43dc05..ab1ba82a9 100644 --- a/projects/social_platform/src/app/api/solution/solution-navigation.ts +++ b/projects/social_platform/src/app/api/solution/solution-navigation.ts @@ -21,6 +21,8 @@ export function solutionApplicationRoute(application: SolutionApplicationRef) { } export function solutionCreationBlockedMessage(reason: string | null): string { + if (reason === "form_not_configured") + return "Организатор еще настраивает форму решения. Подать решение можно будет позже."; return reason === "solution_creation_disabled" ? "Создание новых решений временно приостановлено. Ранее созданные заявки доступны." : "Создание заявки сейчас недоступно. Проверьте регистрацию и сроки программы."; diff --git a/projects/social_platform/src/app/domain/program/ports/program-launch.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-launch.repository.port.ts new file mode 100644 index 000000000..5c159836a --- /dev/null +++ b/projects/social_platform/src/app/domain/program/ports/program-launch.repository.port.ts @@ -0,0 +1,19 @@ +/** @format */ +import { Observable } from "rxjs"; +import { + ManagedProgram, + ManagedProgramsPage, + ManagedProgramsQuery, + ProgramCreation, + ProgramLaunch, + ProgramPreview, +} from "../program-launch.model"; + +export abstract class ProgramLaunchRepositoryPort { + abstract capabilities(): Observable<{ canCreateProgram: boolean }>; + abstract create(body: ProgramCreation): Observable; + abstract managed(query: ManagedProgramsQuery): Observable; + abstract readiness(id: number): Observable; + abstract publish(id: number, revision: string): Observable; + abstract preview(id: number): Observable; +} diff --git a/projects/social_platform/src/app/domain/program/program-launch.model.ts b/projects/social_platform/src/app/domain/program/program-launch.model.ts new file mode 100644 index 000000000..b42a99a9f --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-launch.model.ts @@ -0,0 +1,61 @@ +/** @format */ +import { Program } from "./program.model"; +import { ManagementPage } from "./program-management.model"; + +export type ProgramFormat = "case_championship" | "hackathon" | "noncompetitive"; +export const PROGRAM_FORMATS: { id: ProgramFormat; label: string; description: string }[] = [ + { + id: "case_championship", + label: "Кейс-чемпионат", + description: "Команды решают кейс и сдают решение на оценку.", + }, + { + id: "hackathon", + label: "Хакатон", + description: "Команды создают продукт. В форме предусмотрены репозиторий и демо.", + }, + { + id: "noncompetitive", + label: "Неконкурсная программа", + description: "Регистрация, материалы и работа с участниками без конкурсной сдачи.", + }, +]; +export interface ManagedProgram { + id: number; + name: string; + draft: boolean; + programFormat: ProgramFormat | null; + solutionStorageMode?: "legacy_project" | "solution"; + updatedAt: string; + datetimeStarted: string | null; + datetimeFinished: string | null; + datetimeRegistrationEnds: string | null; +} +export interface ProgramCreation { + name: string; + format: ProgramFormat; + idempotencyKey: string; +} +export interface LaunchCheck { + code: "page" | "dates" | "registration"; + title: string; + ready: boolean; + detail: string; + section: "basic" | "dates" | "registration"; +} +export interface ProgramLaunch { + program: ManagedProgram; + revision: string; + checks: LaunchCheck[]; + canPublish: boolean; + blockers: { code: string; section: string; detail: string }[]; + warnings: string[]; + links: { program: string; registration: string | null }; +} +export type ManagedProgramsPage = ManagementPage; +export interface ManagedProgramsQuery { + search: string; + limit: number; + offset: number; +} +export type ProgramPreview = Program & { preview: true }; diff --git a/projects/social_platform/src/app/domain/program/program-management.model.ts b/projects/social_platform/src/app/domain/program/program-management.model.ts index 1c8d75466..6b0569776 100644 --- a/projects/social_platform/src/app/domain/program/program-management.model.ts +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -42,13 +42,15 @@ export interface ManagementBasic { presentationAddress: string | null; } export interface ManagementDates { - datetimeStarted: string; - datetimeRegistrationEnds: string; + datetimeStarted: string | null; + datetimeRegistrationEnds: string | null; datetimeProjectSubmissionEnds: string | null; datetimeEvaluationEnds: string | null; - datetimeFinished: string; + datetimeFinished: string | null; } export interface ProgramManagement extends ManagementBasic, ManagementDates { + draft?: boolean; + programFormat?: "case_championship" | "hackathon" | "noncompetitive" | null; solutionStorageMode?: "legacy_project" | "solution"; id: number; updatedAt: string; diff --git a/projects/social_platform/src/app/domain/program/program-registration.model.ts b/projects/social_platform/src/app/domain/program/program-registration.model.ts index d1de81737..0ead2ad0d 100644 --- a/projects/social_platform/src/app/domain/program/program-registration.model.ts +++ b/projects/social_platform/src/app/domain/program/program-registration.model.ts @@ -15,6 +15,7 @@ export interface RegistrationSchema { fields: RegistrationField[]; } export interface RegistrationSettings { + programDraft?: boolean; draft: RegistrationSchema; published: RegistrationSchema | null; revision: number; diff --git a/projects/social_platform/src/app/domain/program/program.model.ts b/projects/social_platform/src/app/domain/program/program.model.ts index e82b97497..a002dbdd1 100644 --- a/projects/social_platform/src/app/domain/program/program.model.ts +++ b/projects/social_platform/src/app/domain/program/program.model.ts @@ -1,6 +1,7 @@ /** @format */ import { ProgramImageFraming } from "./program-image-framing.model"; +import type { ProgramRoleWidget } from "./program-role-widget.model"; export interface ProgramCurrentApplication { projectId: number; @@ -10,6 +11,11 @@ export interface ProgramCurrentApplication { /** Основная модель программы */ export class Program { + preview?: boolean; + previewWidget?: ProgramRoleWidget; + draft?: boolean; + solutionActionAvailable?: boolean; + solutionActionBlockedReason?: string | null; id!: number; imageAddress!: string; coverImageAddress!: string; @@ -27,9 +33,9 @@ export class Program { registrationManaged?: boolean; materials!: { title: string; url: string }[]; shortDescription!: string; - datetimeRegistrationEnds!: string; - datetimeStarted!: string; - datetimeFinished!: string; + datetimeRegistrationEnds!: string | null; + datetimeStarted!: string | null; + datetimeFinished!: string | null; datetimeProjectSubmissionEnds!: string; datetimeEvaluationEnds!: string; viewsCount!: number; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-launch-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-launch-http.adapter.spec.ts new file mode 100644 index 000000000..a6155a5be --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-launch-http.adapter.spec.ts @@ -0,0 +1,68 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; +import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; +import { CamelcaseInterceptor } from "@core/lib/interceptors/camelcase.interceptor"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { API_URL } from "@core/lib/providers"; +import { ProgramLaunchHttpAdapter } from "./program-launch-http.adapter"; + +describe("Создание и первая публикация программы: HTTP contract", () => { + let adapter: ProgramLaunchHttpAdapter; + let http: HttpTestingController; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ProgramLaunchHttpAdapter, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: API_URL, useValue: "https://local.test" }, + { provide: LoggerService, useValue: { warn: vi.fn() } }, + { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true }, + ], + }); + adapter = TestBed.inject(ProgramLaunchHttpAdapter); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it("uses the explicit capability, not a global user role", () => { + adapter.capabilities().subscribe(data => expect(data.canCreateProgram).toBe(false)); + http + .expectOne("https://local.test/programs/capabilities/") + .flush({ can_create_program: false }); + }); + it("preserves the idempotency key and does not submit invented dates or permissions", () => { + const key = "12345678-1234-4234-8234-123456789012"; + adapter.create({ name: "Хакатон", format: "hackathon", idempotencyKey: key }).subscribe(); + const request = http.expectOne("https://local.test/programs/drafts/"); + expect(request.request.method).toBe("POST"); + expect(request.request.body).toEqual({ + name: "Хакатон", + format: "hackathon", + idempotency_key: key, + }); + request.flush({ id: 7, draft: true, datetime_started: null }); + }); + it("keeps organizer scope and pagination separate from participation filtering", () => { + adapter.managed({ search: "Город", limit: 10, offset: 20 }).subscribe(); + const request = http.expectOne(r => r.url === "https://local.test/programs/managed/"); + expect(request.request.params.get("offset")).toBe("20"); + expect(request.request.params.get("search")).toBe("Город"); + expect(request.request.params.has("participating")).toBe(false); + request.flush({ count: 0, results: [], next: null, previous: null }); + }); + it("publishes the program atomically through the launch endpoint with the server revision", () => { + const revision = "b".repeat(64); + adapter.publish(7, revision).subscribe(); + const request = http.expectOne("https://local.test/programs/7/management/publish/"); + expect(request.request.method).toBe("POST"); + expect(request.request.body).toEqual({ revision }); + request.flush({ program: { id: 7, draft: false } }); + }); + it("preview is read-only and never calls enrollment or view endpoints", () => { + adapter.preview(7).subscribe(data => expect(data.preview).toBe(true)); + const request = http.expectOne("https://local.test/programs/7/management/preview/"); + expect(request.request.method).toBe("GET"); + request.flush({ id: 7, preview: true }); + }); +}); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-launch-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-launch-http.adapter.ts new file mode 100644 index 000000000..390ae625e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-launch-http.adapter.ts @@ -0,0 +1,42 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpParams } from "@angular/common/http"; +import { ApiService } from "@corelib"; +import { ProgramLaunchRepositoryPort } from "@domain/program/ports/program-launch.repository.port"; +import { + ManagedProgram, + ManagedProgramsPage, + ManagedProgramsQuery, + ProgramCreation, + ProgramLaunch, + ProgramPreview, +} from "@domain/program/program-launch.model"; + +@Injectable() +export class ProgramLaunchHttpAdapter implements ProgramLaunchRepositoryPort { + private readonly api = inject(ApiService); + capabilities() { + return this.api.get<{ canCreateProgram: boolean }>("/programs/capabilities/"); + } + create(body: ProgramCreation) { + return this.api.post("/programs/drafts/", body); + } + managed(query: ManagedProgramsQuery) { + return this.api.get( + "/programs/managed/", + new HttpParams() + .set("search", query.search) + .set("limit", query.limit) + .set("offset", query.offset), + ); + } + readiness(id: number) { + return this.api.get(`/programs/${id}/management/launch/`); + } + publish(id: number, revision: string) { + return this.api.post(`/programs/${id}/management/publish/`, { revision }); + } + preview(id: number) { + return this.api.get(`/programs/${id}/management/preview/`); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/create/create.component.html b/projects/social_platform/src/app/ui/pages/program/create/create.component.html new file mode 100644 index 000000000..8f28591f0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/create/create.component.html @@ -0,0 +1,82 @@ + +
+ К программам +
+

Создание программы

+

Начните с названия и формата. Остальное можно заполнить позже.

+
+ @if (error()) { +
+ {{ error() }} + @if (loading() === false && !allowed()) { + + } +
+ } + @if (loading()) { +

Загружаем доступные действия…

+ } @else if (!allowed()) { +
+

Создание программ недоступно

+

Право создания отдельно выдает администратор.

+ К моим программам +
+ } @else { +
+
+ +

+ @if (form.controls.name.touched && form.controls.name.invalid) { + Введите название программы до 255 символов. + } @else { + Название увидят участники после публикации. + } +

+
+ Формат программы * + @for (format of formats; track format.id) { + + } +
+

+ Черновик будет доступен вам и организаторам программы. В каталоге он появится после + публикации. +

+
+ +
+
+
+ } +
diff --git a/projects/social_platform/src/app/ui/pages/program/create/create.component.scss b/projects/social_platform/src/app/ui/pages/program/create/create.component.scss new file mode 100644 index 000000000..10ae3ca9c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/create/create.component.scss @@ -0,0 +1,133 @@ +/** @format */ +@use "styles/ui-foundation"; +@use "styles/program-management-controls"; + +:host { + display: block; + min-width: 0; +} + +.ui-button.ui-button { + @include ui-foundation.button; + @include program-management-controls.compact-action; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; + min-height: 41px; + font-size: 14px; +} + +.ui-choice { + @include ui-foundation.choice; +} + +.program-create { + max-width: 863px; + margin: 24px auto 48px; + font-size: 12px; + color: var(--black); + + > a { + display: inline-block; + margin-bottom: 20px; + color: var(--accent); + } + + header { + margin-bottom: 24px; + } + + h1, + h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; + } + + p { + margin: 8px 0 0; + line-height: 1.5; + color: var(--grey-for-text); + } + + fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; + } + + label { + display: grid; + gap: 8px; + } + + &__card { + padding: 20px; + background: var(--light-white); + border-radius: 15px; + } + + &__formats { + display: grid; + gap: 20px; + margin-block: 24px !important; + + legend { + padding-bottom: 20px; + font-size: 16px; + font-weight: 600; + } + } + + &__format { + padding: 16px; + cursor: pointer; + background: var(--white); + border-radius: 8px; + + span { + display: flex; + gap: 8px; + align-items: center; + } + + p { + margin: 0; + } + } + + &__format--active { + background: var(--accent-light); + } + + &__actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 24px; + } +} + +@media (max-width: 999px) { + .program-create { + margin: 24px 20px 40px; + + &__card { + padding: 16px; + } + + &__actions { + flex-direction: column; + + button { + width: 100%; + margin-block: 6px; + } + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/create/create.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/create/create.component.spec.ts new file mode 100644 index 000000000..dbaf6afed --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/create/create.component.spec.ts @@ -0,0 +1,90 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { provideRouter, Router } from "@angular/router"; +import { HttpErrorResponse } from "@angular/common/http"; +import { of, Subject, throwError } from "rxjs"; +import { ProgramLaunchUseCase } from "@api/program/use-cases/program-launch.use-case"; +import { ProgramCreateComponent } from "./create.component"; + +describe("Создание программы", () => { + let api: { capabilities: ReturnType; create: ReturnType }; + beforeEach(() => { + sessionStorage.removeItem("programCreationIntent:v1"); + api = { capabilities: vi.fn(() => of({ canCreateProgram: true })), create: vi.fn() }; + TestBed.configureTestingModule({ + imports: [ProgramCreateComponent], + providers: [provideRouter([]), { provide: ProgramLaunchUseCase, useValue: api }], + }); + }); + afterEach(() => { + sessionStorage.removeItem("programCreationIntent:v1"); + vi.restoreAllMocks(); + }); + function create() { + const fixture = TestBed.createComponent(ProgramCreateComponent); + fixture.detectChanges(); + return fixture; + } + it("requires a real title and focuses the invalid field without creating an object", () => { + const fixture = create(); + fixture.componentInstance.form.controls.name.setValue(" "); + fixture.componentInstance.submit(); + fixture.detectChanges(); + expect(api.create).not.toHaveBeenCalled(); + expect(document.activeElement).toBe( + fixture.nativeElement.querySelector("input[formControlName=name]"), + ); + }); + it("prevents duplicate submit while pending and navigates to the created draft", () => { + const response = new Subject<{ id: number }>(); + api.create.mockReturnValue(response); + const router = TestBed.inject(Router); + const navigate = vi.spyOn(router, "navigateByUrl").mockResolvedValue(true); + const fixture = create(); + const component = fixture.componentInstance; + component.form.patchValue({ name: "Новая программа", format: "hackathon" }); + component.submit(); + component.submit(); + expect(api.create).toHaveBeenCalledTimes(1); + expect(component.canDeactivate()).toBe(false); + expect(api.create.mock.calls[0][0]).toMatchObject({ + name: "Новая программа", + format: "hackathon", + }); + response.next({ id: 42 }); + response.complete(); + expect(navigate).toHaveBeenCalledWith("/office/program/42/launch"); + expect(component.canDeactivate()).toBe(true); + expect(sessionStorage.getItem("programCreationIntent:v1")).toBe(null); + }); + it("a lost response preserves the input and reuses the same key for retry", () => { + api.create.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 }))); + const fixture = create(); + const component = fixture.componentInstance; + component.form.patchValue({ name: "Повтор", format: "noncompetitive" }); + component.submit(); + component.submit(); + expect(api.create).toHaveBeenCalledTimes(2); + expect(api.create.mock.calls[0][0]).toEqual(api.create.mock.calls[1][0]); + expect(component.form.controls.name.value).toBe("Повтор"); + expect(component.error()).not.toBe(null); + }); + it("permission denial is handled without sending a creation command", () => { + api.capabilities.mockReturnValue(of({ canCreateProgram: false })); + const fixture = create(); + fixture.componentInstance.submit(); + expect(fixture.nativeElement.textContent).toContain( + "Право создания отдельно выдает администратор", + ); + expect(api.create).not.toHaveBeenCalled(); + }); + it("cancel clears a pending intent without creating an empty draft", () => { + const router = TestBed.inject(Router); + const navigate = vi.spyOn(router, "navigateByUrl").mockResolvedValue(true); + const fixture = create(); + fixture.componentInstance.form.controls.name.setValue("Не сохранять"); + fixture.componentInstance.cancel(); + expect(api.create).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith("/office/program/managed"); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/create/create.component.ts b/projects/social_platform/src/app/ui/pages/program/create/create.component.ts new file mode 100644 index 000000000..58ad791ac --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/create/create.component.ts @@ -0,0 +1,145 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + signal, + viewChild, + ElementRef, +} from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { Router, RouterLink } from "@angular/router"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; +import { AlertDirective, ButtonDirective, ChoiceDirective, FieldDirective } from "@uilib"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ProgramLaunchUseCase } from "@api/program/use-cases/program-launch.use-case"; +import { + PROGRAM_FORMATS, + ProgramCreation, + ProgramFormat, +} from "@domain/program/program-launch.model"; +import { evaluationError } from "@utils/evaluation-workspace"; + +const INTENT_KEY = "programCreationIntent:v1"; +@Component({ + selector: "app-program-create", + imports: [ + ReactiveFormsModule, + RouterLink, + AlertDirective, + ButtonDirective, + ChoiceDirective, + FieldDirective, + ], + templateUrl: "./create.component.html", + styleUrl: "./create.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramCreateComponent { + private readonly api = inject(ProgramLaunchUseCase); + private readonly router = inject(Router); + private readonly destroy = inject(DestroyRef); + private readonly fb = inject(FormBuilder).nonNullable; + readonly paths = AppRoutes; + readonly formats = PROGRAM_FORMATS; + readonly pending = signal(false); + readonly loading = signal(true); + readonly allowed = signal(false); + readonly error = signal(null); + readonly nameInput = viewChild>("nameInput"); + readonly form = this.fb.group({ + name: ["", [Validators.required, Validators.pattern(/\S/), Validators.maxLength(255)]], + format: this.fb.control("case_championship"), + }); + private intent: ProgramCreation | null = null; + constructor() { + try { + const saved: unknown = JSON.parse(sessionStorage.getItem(INTENT_KEY) ?? "null"); + if ( + saved && + typeof saved === "object" && + "name" in saved && + typeof saved.name === "string" && + "format" in saved && + PROGRAM_FORMATS.some(f => f.id === saved.format) && + "idempotencyKey" in saved && + typeof saved.idempotencyKey === "string" && + /^[a-f0-9-]{36}$/i.test(saved.idempotencyKey) + ) { + this.intent = saved as ProgramCreation; + this.form.patchValue(this.intent); + } + } catch { + /* An unavailable session store must not prevent creation. */ + } + this.reloadCapability(); + } + reloadCapability() { + this.loading.set(true); + this.error.set(null); + this.api + .capabilities() + .pipe( + finalize(() => this.loading.set(false)), + takeUntilDestroyed(this.destroy), + ) + .subscribe({ + next: value => this.allowed.set(value.canCreateProgram), + error: e => this.error.set(evaluationError(e)), + }); + } + submit() { + if (this.pending() || !this.allowed()) return; + if (this.form.invalid) { + this.form.markAllAsTouched(); + this.nameInput()?.nativeElement.focus(); + return; + } + const value = this.form.getRawValue(); + const name = value.name.trim(); + if (!this.intent || this.intent.name !== name || this.intent.format !== value.format) { + this.intent = { name, format: value.format, idempotencyKey: crypto.randomUUID() }; + } + try { + sessionStorage.setItem(INTENT_KEY, JSON.stringify(this.intent)); + } catch { + /* Retry still uses the in-memory key. */ + } + this.pending.set(true); + this.error.set(null); + this.api + .create(this.intent) + .pipe( + finalize(() => this.pending.set(false)), + takeUntilDestroyed(this.destroy), + ) + .subscribe({ + next: program => { + this.clearIntent(); + void this.router.navigateByUrl(this.paths.program.launch(program.id)); + }, + error: e => { + if (e.status === 403) this.allowed.set(false); + this.error.set(evaluationError(e)); + }, + }); + } + cancel() { + if (this.pending()) return; + this.clearIntent(); + void this.router.navigateByUrl(this.paths.program.managed()); + } + canDeactivate(): boolean { + return !this.pending(); + } + private clearIntent() { + this.intent = null; + try { + sessionStorage.removeItem(INTENT_KEY); + } catch { + /* Optional storage. */ + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html index 278f51956..869422924 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html @@ -29,7 +29,12 @@
- @if (program()!.isUserMember) { + @if (applicationBlockedMessage()) { +

+ {{ applicationBlockedMessage() }} +

+ } + @if (program()!.isUserMember && !program()!.preview) { }
diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.scss index 09e3b1648..8d27a79b4 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.scss @@ -2,6 +2,13 @@ @use "styles/responsive"; @use "styles/typography"; +@use "styles/program-management-controls"; + +.program__launch-notice.ui-alert { + @include program-management-controls.status-notice; + + margin-bottom: 16px; +} .program { padding-bottom: 100px; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts index 8d284287e..74999ac48 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts @@ -32,6 +32,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { finalize } from "rxjs"; import { ProgramLinksComponent } from "@ui/widgets/program-links/program-links.component"; import { MyCertificateComponent } from "./my-certificate.component"; +import { AlertDirective } from "@uilib"; +import { solutionCreationBlockedMessage } from "@api/solution/solution-navigation"; /** Страница основной вкладки программы с описанием, ссылками и новостями. */ @Component({ @@ -53,6 +55,7 @@ import { MyCertificateComponent } from "./my-certificate.component"; NewsCardComponent, ProgramLinksComponent, MyCertificateComponent, + AlertDirective, ], providers: [ProgramDetailMainService, ExpandService], changeDetection: ChangeDetectionStrategy.OnPush, @@ -83,6 +86,12 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { protected readonly news = this.newsInfoService.news; protected readonly newsPending = signal(false); + readonly applicationBlockedMessage = computed(() => { + const program = this.program(); + return program?.solutionActionAvailable === false + ? solutionCreationBlockedMessage(program.solutionActionBlockedReason ?? null) + : null; + }); protected readonly AppRoutes = AppRoutes; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html index 8eed9a691..15bd3610c 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html @@ -181,7 +181,11 @@

аналитика

+
+ } + @if (publishError()) { +
+ {{ publishError() }} + +
+ } + @if (message()) { + {{ + message() + }} + } + @if (state(); as state) { +

{{ state.program.name }}

+ @if (state.program.draft) { +

Черновик · Не виден участникам

+

Подготовка к запуску

+ +
+

Подготовка к запуску

+

+ {{ + state.canPublish + ? "Все обязательные настройки готовы. Можно открыть регистрацию." + : "Завершите три настройки, чтобы открыть регистрацию." + }} +

+ @for (check of state.checks; track check.code) { + + } +
+
+

Можно настроить после публикации

+ @for (warning of state.warnings; track warning) { +

{{ warning }}

+ } @empty { +

Материалы, организаторов и сертификаты можно добавить позже.

+ } +
+
+ Предпросмотр +
+

+ {{ + state.canPublish + ? "Откроются страница программы и регистрация участников." + : "Публикация станет доступна после обязательных настроек." + }} +

+ } @else { +
+ Программа опубликована +

Страница появилась в каталоге.

+
+
+

Пригласите участников

+ + @if (state.links.registration) { + + } + @for (warning of state.warnings; track warning) { +

{{ warning }}

+ } + +
+ } + } + + +
+
+

Опубликовать программу?

+ +
+

+ Программа появится в каталоге, и откроется регистрация участников. + @if (state()?.program?.solutionStorageMode === "solution") { + Форму решения и оценивание можно настроить позже. + } +

+
+ +
+
+
diff --git a/projects/social_platform/src/app/ui/pages/program/launch/launch.component.scss b/projects/social_platform/src/app/ui/pages/program/launch/launch.component.scss new file mode 100644 index 000000000..0bd5922f6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/launch/launch.component.scss @@ -0,0 +1,226 @@ +/** @format */ +@use "styles/ui-foundation"; +@use "styles/program-management-controls"; + +:host { + display: block; + min-width: 0; +} + +.ui-button.ui-button { + @include ui-foundation.button; + @include program-management-controls.compact-action; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; + font-size: 12px; +} + +.ui-alert { + @include program-management-controls.status-notice; +} + +.launch { + display: grid; + gap: 20px; + min-width: 0; + max-width: 863px; + margin: 24px auto 48px; + font-size: 12px; + color: var(--black); + + h1, + h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; + } + + h3 { + margin: 0; + font-size: 12px; + font-weight: 600; + line-height: 20px; + } + + p { + margin: 0; + line-height: 1.5; + color: var(--grey-for-text); + } + + a:not(.ui-button) { + color: var(--accent); + text-decoration: none; + } + + &__name { + margin-top: -16px !important; + overflow-wrap: anywhere; + } + + &__status { + margin-top: -12px !important; + } + + &__label { + color: var(--accent) !important; + } + + &__tabs { + display: flex; + gap: 12px; + + a { + display: flex; + flex: 0 1 157px; + align-items: center; + justify-content: center; + min-height: 32px; + border: 1px solid var(--accent); + border-radius: 30px; + } + } + + &__card { + display: grid; + gap: 20px; + min-width: 0; + padding: 20px; + background: var(--light-white); + border-radius: 15px; + } + + &__check { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding-block: 0 20px; + border-bottom: 1px solid var(--medium-grey-for-outline); + + p { + margin-top: 6px; + } + } + + &__check-action { + display: grid; + gap: 8px; + align-content: start; + min-width: 140px; + + span { + font-size: 12px; + } + } + + &__later { + display: grid; + gap: 8px; + } + + &__actions { + display: flex; + gap: 12px; + justify-content: flex-end; + } + + &__link { + display: flex; + gap: 12px; + align-items: center; + margin-top: 8px; + + input { + flex: 1; + min-width: 0; + } + + button { + flex-shrink: 0; + } + } + + &__dialog { + display: grid; + gap: 20px; + width: min(520px, calc(100vw - 72px)); + font-size: 14px; + color: var(--black); + + h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; + } + + p { + margin: 0; + line-height: 1.5; + } + + header { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + + button { + width: 32px; + padding: 5px; + } + } + } +} + +@media (max-width: 999px) { + .launch { + margin: 24px 20px 40px; + + &__card { + padding: 16px; + } + + &__tabs { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px 12px; + + a { + grid-column: span 2; + margin-block: 6px; + } + } + + &__check { + grid-template-columns: minmax(0, 1fr); + } + + &__check-action { + justify-items: start; + } + + &__actions { + flex-direction: column; + + > * { + width: 100%; + margin-block: 6px; + } + } + + &__link { + display: grid; + + button { + justify-self: start; + margin-block: 6px; + } + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/launch/launch.component.ts b/projects/social_platform/src/app/ui/pages/program/launch/launch.component.ts new file mode 100644 index 000000000..ddcdc988c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/launch/launch.component.ts @@ -0,0 +1,147 @@ +/** @format */ +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from "@angular/core"; +import { ActivatedRoute, RouterLink } from "@angular/router"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + BehaviorSubject, + catchError, + combineLatest, + defer, + EMPTY, + finalize, + switchMap, +} from "rxjs"; +import { AlertDirective, ButtonDirective, FieldDirective, IconComponent } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { TransientNoticeComponent } from "@ui/primitives/transient-notice/transient-notice.component"; +import { ProgramLaunchUseCase } from "@api/program/use-cases/program-launch.use-case"; +import { ProgramLaunch } from "@domain/program/program-launch.model"; +import { AppRoutes } from "@api/paths/app-routes"; +import { evaluationError } from "@utils/evaluation-workspace"; + +@Component({ + selector: "app-program-launch", + imports: [ + RouterLink, + AlertDirective, + ButtonDirective, + FieldDirective, + IconComponent, + ModalComponent, + TransientNoticeComponent, + ], + templateUrl: "./launch.component.html", + styleUrl: "./launch.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramLaunchComponent { + private readonly api = inject(ProgramLaunchUseCase); + private readonly route = inject(ActivatedRoute); + private readonly destroy = inject(DestroyRef); + private readonly refresh = new BehaviorSubject(0); + private contextId = 0; + private trigger: HTMLElement | null = null; + readonly paths = AppRoutes; + readonly state = signal(null); + readonly loading = signal(false); + readonly busy = signal(false); + readonly error = signal(null); + readonly publishError = signal(null); + readonly message = signal(null, { equal: () => false }); + readonly confirmOpen = signal(false); + readonly tabs = [ + { id: "participants", label: "Участники" }, + { id: "projects", label: "Проекты" }, + { id: "experts", label: "Эксперты" }, + { id: "results", label: "Результаты" }, + { id: "analytics", label: "Аналитика" }, + { id: "settings", label: "Настройки" }, + { id: "certificates", label: "Сертификаты" }, + ]; + constructor() { + combineLatest([this.route.paramMap, this.refresh]) + .pipe( + switchMap(([params]) => + defer(() => { + this.contextId = Number(params.get("programId")); + this.state.set(null); + this.loading.set(true); + this.error.set(null); + this.confirmOpen.set(false); + return this.api.readiness(this.contextId).pipe( + catchError(e => { + this.error.set(evaluationError(e)); + return EMPTY; + }), + finalize(() => this.loading.set(false)), + ); + }), + ), + takeUntilDestroyed(this.destroy), + ) + .subscribe(state => { + this.state.set(state); + if (!state.program.draft) this.publishError.set(null); + }); + if (this.route.snapshot.queryParamMap.has("previewError")) + this.publishError.set( + "Предпросмотр недоступен. Проверьте доступ к программе и обновите данные.", + ); + } + reload() { + if (!this.busy()) this.refresh.next(this.refresh.value + 1); + } + firstMissing(code: string): boolean { + return this.state()?.checks.find(c => !c.ready)?.code === code; + } + confirm(event: Event) { + if (!this.state()?.canPublish || this.busy()) return; + this.trigger = event.currentTarget as HTMLElement; + this.publishError.set(null); + this.confirmOpen.set(true); + } + closeConfirm() { + if (this.busy()) return; + this.confirmOpen.set(false); + this.trigger?.focus(); + } + publish() { + const state = this.state(); + if (!state?.canPublish || this.busy()) return; + const id = state.program.id; + this.busy.set(true); + this.publishError.set(null); + this.api + .publish(id, state.revision) + .pipe( + finalize(() => this.busy.set(false)), + takeUntilDestroyed(this.destroy), + ) + .subscribe({ + next: value => { + if (this.contextId !== id) return; + this.state.set(value); + this.confirmOpen.set(false); + }, + error: e => { + if (this.contextId !== id) return; + this.publishError.set(evaluationError(e)); + this.confirmOpen.set(false); + // GET recovery also handles a successful publication whose response was lost. + this.refresh.next(this.refresh.value + 1); + }, + }); + } + async copy(link: string | null) { + if (!link) return; + try { + await navigator.clipboard.writeText(link); + this.message.set("Ссылка скопирована."); + } catch { + this.publishError.set("Не удалось скопировать. Выделите ссылку и скопируйте вручную."); + } + } + canDeactivate(): boolean { + return !this.busy(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/launch/preview-toolbar.component.scss b/projects/social_platform/src/app/ui/pages/program/launch/preview-toolbar.component.scss new file mode 100644 index 000000000..b01bba477 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/launch/preview-toolbar.component.scss @@ -0,0 +1,77 @@ +/** @format */ +@use "styles/ui-foundation"; +@use "styles/program-management-controls"; + +.ui-button.ui-button { + @include ui-foundation.button; + @include program-management-controls.compact-action; +} + +.preview-toolbar { + display: grid; + gap: 12px; + margin-bottom: 20px; + font-size: 12px; + color: var(--black); + + p { + margin: 0; + } + + > div { + display: flex; + flex-wrap: wrap; + gap: 12px; + } +} + +.preview-dialog { + display: flex; + flex-direction: column; + gap: 24px; + width: min(680px, calc(100vw - 32px)); + max-height: min(860px, calc(100dvh - 48px)); + padding: 24px; + font-size: 14px; + + header { + display: flex; + flex-shrink: 0; + gap: 16px; + align-items: center; + justify-content: space-between; + + button { + width: 32px; + padding: 0; + } + } + + h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; + } + + footer { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + } + + &__scroll { + display: grid; + min-height: 0; + padding: 2px; + overflow: auto; + overscroll-behavior: contain; + } +} + +::ng-deep .modal .modal__body.program-registration-preview { + width: 680px; + max-width: calc(100vw - 24px); + padding: 0; + overflow: hidden; +} diff --git a/projects/social_platform/src/app/ui/pages/program/launch/preview-toolbar.component.ts b/projects/social_platform/src/app/ui/pages/program/launch/preview-toolbar.component.ts new file mode 100644 index 000000000..a15c2016b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/launch/preview-toolbar.component.ts @@ -0,0 +1,123 @@ +/** @format */ +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { finalize } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ButtonDirective, IconComponent, AlertDirective } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { ProgramRegistrationUseCase } from "@api/program/use-cases/program-registration.use-case"; +import { ProgramRegistrationRepositoryPort } from "@domain/program/ports/program-registration.repository.port"; +import { ProgramRegistrationHttpAdapter } from "@infrastructure/adapters/program/program-registration-http.adapter"; +import { RegistrationSchema } from "@domain/program/program-registration.model"; +import { registrationControls } from "@ui/widgets/registration-fields/registration-fields.component"; +import { RegistrationPreviewContentComponent } from "@ui/widgets/registration-fields/registration-preview-content.component"; +import { AppRoutes } from "@api/paths/app-routes"; +import { evaluationError } from "@utils/evaluation-workspace"; + +@Component({ + selector: "app-program-preview-toolbar", + imports: [ + RouterLink, + ButtonDirective, + IconComponent, + AlertDirective, + ModalComponent, + RegistrationPreviewContentComponent, + ], + providers: [ + ProgramRegistrationUseCase, + { provide: ProgramRegistrationRepositoryPort, useClass: ProgramRegistrationHttpAdapter }, + ], + template: `@if (program(); as program) { +
+

Предпросмотр программы · зарегистрированный участник

+
+ К подготовке программы +
+

Действия участника в предпросмотре недоступны.

+ @if (error()) { +

{{ error() }}

+ } +
+ +
+
+

Предпросмотр анкеты

+ +
+ @if (schema(); as schema) { +
+ +
+ } +
+
+
+ }`, + styleUrl: "./preview-toolbar.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramPreviewToolbarComponent { + readonly program = inject(ProgramDetailMainUIInfoService).program; + readonly paths = AppRoutes; + private readonly api = inject(ProgramRegistrationUseCase); + private readonly destroy = inject(DestroyRef); + readonly loading = signal(false); + readonly error = signal(null); + readonly open = signal(false); + readonly schema = signal(null); + group = registrationControls([]); + private trigger: HTMLElement | null = null; + preview(event: Event) { + const id = this.program()?.id; + if (!id || this.loading()) return; + this.trigger = event.currentTarget as HTMLElement; + this.loading.set(true); + this.error.set(null); + this.api + .settings(id) + .pipe( + finalize(() => this.loading.set(false)), + takeUntilDestroyed(this.destroy), + ) + .subscribe({ + next: data => { + if (id !== this.program()?.id) return; + if (data.draft.mode === "external") { + this.error.set( + "Предпросмотр встроенной анкеты доступен для регистрации через PROCOLLAB.", + ); + return; + } + this.schema.set(data.draft); + this.group = registrationControls(data.draft.fields); + this.group.disable(); + this.open.set(true); + }, + error: e => { + if (id === this.program()?.id) this.error.set(evaluationError(e)); + }, + }); + } + close() { + this.open.set(false); + this.trigger?.focus(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/launch/preview.resolver.ts b/projects/social_platform/src/app/ui/pages/program/launch/preview.resolver.ts new file mode 100644 index 000000000..1edd7c295 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/launch/preview.resolver.ts @@ -0,0 +1,21 @@ +/** @format */ +import { inject } from "@angular/core"; +import { ResolveFn, Router } from "@angular/router"; +import { catchError, EMPTY, tap } from "rxjs"; +import { ProgramLaunchUseCase } from "@api/program/use-cases/program-launch.use-case"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { Program } from "@domain/program/program.model"; +import { AppRoutes } from "@api/paths/app-routes"; +export const programPreviewResolver: ResolveFn = route => { + const api = inject(ProgramLaunchUseCase); + const ui = inject(ProgramDetailMainUIInfoService); + const router = inject(Router); + const id = Number(route.paramMap.get("programId")); + return api.preview(id).pipe( + tap(program => ui.applyFormatingProgramData(program)), + catchError(() => { + void router.navigate([AppRoutes.program.launch(id)], { queryParams: { previewError: 1 } }); + return EMPTY; + }), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts index 6034b1ab9..de20162b1 100644 --- a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts @@ -25,7 +25,8 @@ export class ProgramCardComponent implements OnInit { readonly program = input.required(); ngOnInit(): void { - this.registerDateExpired = Date.now() > Date.parse(this.program().datetimeRegistrationEnds); + const deadline = this.program().datetimeRegistrationEnds; + this.registerDateExpired = Boolean(deadline && Date.now() > Date.parse(deadline)); } registerDateExpired?: boolean; diff --git a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.html b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.html new file mode 100644 index 000000000..f23f17142 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.html @@ -0,0 +1,59 @@ + +
+ + @if (loading()) { +

Загружаем программы…

+ } @else if (error()) { +
+ {{ error() }} +
+ } @else if (data(); as data) { +
+ @for (program of data.results; track program.id) { + + } @empty { + @if (search.value) { +

Программы не найдены

+

Попробуйте изменить название в поиске.

+ + } @else if (offset()) { +

На этой странице программ нет.

+ + } @else { +

Программы пока не созданы

+

Здесь появятся программы, которыми вы управляете, включая незавершенные черновики.

+ @if (canCreate()) { + Создать программу + } + } + } +
+ @if (data.count > 10 || offset()) { + {{ data.results.length ? offset() + 1 : 0 }}–{{ rangeEnd() }} из + {{ data.count }} + } + } +
diff --git a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.scss b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.scss new file mode 100644 index 000000000..7342229d8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.scss @@ -0,0 +1,80 @@ +/** @format */ +@use "styles/ui-foundation"; +@use "styles/program-management-controls"; + +:host { + display: block; + min-width: 0; +} + +.ui-button.ui-button { + @include ui-foundation.button; + @include program-management-controls.compact-action; +} + +.managed { + font-size: 12px; + color: var(--black); + + h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; + overflow-wrap: anywhere; + } + + p { + margin: 8px 0 0; + line-height: 1.5; + color: var(--grey-for-text); + } + + &__search { + display: grid; + gap: 8px; + max-width: 480px; + margin-bottom: 24px; + } + + &__list { + display: grid; + gap: 24px; + padding: 20px; + background: var(--light-white); + border-radius: 15px; + } + + &__row { + display: flex; + gap: 24px; + align-items: center; + justify-content: space-between; + + > div { + min-width: 0; + } + + > a { + flex-shrink: 0; + } + } +} + +@media (max-width: 999px) { + .managed { + &__list { + padding: 16px; + } + + &__row { + display: grid; + gap: 12px; + + > a { + justify-self: start; + margin-block: 6px; + } + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.ts b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.ts new file mode 100644 index 000000000..0aea84bd4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.ts @@ -0,0 +1,109 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + signal, + computed, +} from "@angular/core"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + BehaviorSubject, + catchError, + combineLatest, + debounceTime, + defer, + distinctUntilChanged, + EMPTY, + finalize, + switchMap, +} from "rxjs"; +import { AlertDirective, ButtonDirective, PaginationComponent } from "@uilib"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { ProgramLaunchUseCase } from "@api/program/use-cases/program-launch.use-case"; +import { ManagedProgramsPage } from "@domain/program/program-launch.model"; +import { AppRoutes } from "@api/paths/app-routes"; +import { evaluationError } from "@utils/evaluation-workspace"; + +@Component({ + selector: "app-managed-programs", + imports: [ + RouterLink, + ReactiveFormsModule, + SearchComponent, + AlertDirective, + ButtonDirective, + PaginationComponent, + ], + templateUrl: "./managed.component.html", + styleUrl: "./managed.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManagedProgramsComponent { + private readonly api = inject(ProgramLaunchUseCase); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroy = inject(DestroyRef); + readonly paths = AppRoutes; + readonly search = new FormControl("", { nonNullable: true }); + readonly loading = signal(false); + readonly error = signal(null); + readonly data = signal(null); + readonly canCreate = signal(false); + readonly offset = signal(0); + readonly rangeEnd = computed(() => this.offset() + (this.data()?.results.length ?? 0)); + private readonly refresh = new BehaviorSubject(0); + constructor() { + this.api + .capabilities() + .pipe(takeUntilDestroyed(this.destroy)) + .subscribe({ next: d => this.canCreate.set(d.canCreateProgram), error: () => {} }); + combineLatest([this.route.queryParamMap, this.refresh]) + .pipe( + switchMap(([params]) => + defer(() => { + const raw = Number(params.get("offset") ?? 0); + const offset = Number.isSafeInteger(raw) && raw >= 0 ? raw : 0; + const search = (params.get("search") ?? "").slice(0, 255); + this.offset.set(offset); + this.search.setValue(search, { emitEvent: false }); + this.loading.set(true); + this.error.set(null); + this.data.set(null); + return this.api.managed({ search, offset, limit: 10 }).pipe( + catchError(e => { + this.error.set(evaluationError(e)); + return EMPTY; + }), + finalize(() => this.loading.set(false)), + ); + }), + ), + takeUntilDestroyed(this.destroy), + ) + .subscribe(data => this.data.set(data)); + this.search.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroy)) + .subscribe(search => { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { search: search.trim() || null, offset: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + }); + } + page(delta: number) { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { offset: Math.max(0, this.offset() + delta * 10) || null }, + queryParamsHandling: "merge", + }); + } + reload() { + this.refresh.next(this.refresh.value + 1); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.html b/projects/social_platform/src/app/ui/pages/program/management/management.component.html index 1ac232a7d..83c393718 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.html @@ -15,6 +15,12 @@

Управление программой

{{ state.program()?.name }}

} + @if (state.program()?.draft) { +

Черновик · Не виден участникам

+ К подготовке к запуску + } @if (state.program(); as program) {