From 83c582901ef96d7379a3fcadb2e360ec818c37c3 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 01:24:36 +0300 Subject: [PATCH 1/7] feat(programs): add creation preparation preview and publication flow --- .../src/app/api/paths/app-routes.ts | 4 + .../program-detail-main-info.service.ts | 5 + .../detail/program-role-widget.service.ts | 2 + .../ui/program-detail-main-ui-info.service.ts | 4 +- .../use-cases/program-launch.use-case.ts | 28 +++ .../app/api/solution/solution-navigation.ts | 2 + .../ports/program-launch.repository.port.ts | 19 ++ .../domain/program/program-launch.model.ts | 61 +++++ .../program/program-management.model.ts | 8 +- .../program/program-registration.model.ts | 1 + .../src/app/domain/program/program.model.ts | 6 + .../program-launch-http.adapter.spec.ts | 68 ++++++ .../program/program-launch-http.adapter.ts | 42 ++++ .../program/create/create.component.html | 82 +++++++ .../program/create/create.component.scss | 133 +++++++++++ .../program/create/create.component.spec.ts | 90 +++++++ .../pages/program/create/create.component.ts | 145 +++++++++++ .../program/detail/main/main.component.html | 7 +- .../program/detail/main/main.component.scss | 7 + .../program/detail/main/main.component.ts | 9 + .../program-role-widget.component.html | 6 +- .../program/launch/launch.component.html | 182 ++++++++++++++ .../program/launch/launch.component.scss | 226 ++++++++++++++++++ .../pages/program/launch/launch.component.ts | 144 +++++++++++ .../launch/preview-toolbar.component.scss | 77 ++++++ .../launch/preview-toolbar.component.ts | 123 ++++++++++ .../pages/program/launch/preview.resolver.ts | 21 ++ .../program/managed/managed.component.html | 59 +++++ .../program/managed/managed.component.scss | 80 +++++++ .../program/managed/managed.component.ts | 109 +++++++++ .../management/management.component.html | 6 + .../management/management.component.ts | 9 + .../registration/registration.component.html | 37 ++- .../registration/registration.component.ts | 17 +- .../ui/pages/program/program.component.html | 30 ++- .../ui/pages/program/program.component.scss | 48 ++++ .../app/ui/pages/program/program.component.ts | 47 +++- .../app/ui/routes/program/program.routes.ts | 64 ++++- .../ui/widgets/detail/detail.component.html | 5 + .../app/ui/widgets/detail/detail.component.ts | 2 + .../program/detail-program-info.service.ts | 1 + .../registration-preview-content.component.ts | 87 +++++++ 42 files changed, 2053 insertions(+), 50 deletions(-) create mode 100644 projects/social_platform/src/app/api/program/use-cases/program-launch.use-case.ts create mode 100644 projects/social_platform/src/app/domain/program/ports/program-launch.repository.port.ts create mode 100644 projects/social_platform/src/app/domain/program/program-launch.model.ts create mode 100644 projects/social_platform/src/app/infrastructure/adapters/program/program-launch-http.adapter.spec.ts create mode 100644 projects/social_platform/src/app/infrastructure/adapters/program/program-launch-http.adapter.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/create/create.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/create/create.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/create/create.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/create/create.component.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/launch/launch.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/launch/launch.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/launch/launch.component.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/launch/preview-toolbar.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/launch/preview-toolbar.component.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/launch/preview.resolver.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/managed/managed.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/managed/managed.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/managed/managed.component.ts create mode 100644 projects/social_platform/src/app/ui/widgets/registration-fields/registration-preview-content.component.ts 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.ts b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts index 4df92007b..17d720a73 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 @@ -52,7 +52,9 @@ export class ProgramDetailMainUIInfoService { this.registerDateExpired.set(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/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..43a481ed3 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-launch.use-case.ts @@ -0,0 +1,28 @@ +/** @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 { 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); + readonly readiness = this.repository.readiness.bind(this.repository); + publish(id: number, revision: string) { + return this.repository.publish(id, revision).pipe( + tap(state => { + if (!state.program.draft) + this.events.emit({ + type: "ProgramUpdated", + payload: { programId: id }, + occurredAt: new Date(), + }); + }), + ); + } + readonly preview = this.repository.preview.bind(this.repository); +} 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 7c0b25469..6f364a4b8 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; 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 c6f790408..b8ba7e369 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 @@ -24,7 +24,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..a6fa13927 --- /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 { + 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..2b3bf4a9b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/launch/launch.component.ts @@ -0,0 +1,144 @@ +/** @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 (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/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) {