diff --git a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts index 21392c7d2..d679d69bf 100644 --- a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts @@ -10,10 +10,17 @@ import { ManagementMaterialWrite, ManagementQuery, ManagementSettingsWrite, + ManagementProjectActionWrite, } from "@domain/program/program-management.model"; @Injectable() export class ProgramManagementUseCase { + previewProjectAction(id: number, body: ManagementProjectActionWrite) { + return this.repository.previewProjectAction(id, body); + } + applyProjectAction(id: number, body: ManagementProjectActionWrite & { previewToken: string }) { + return this.repository.applyProjectAction(id, body); + } private readonly repository = inject(ProgramManagementRepositoryPort); results(id: number) { return this.repository.results(id); diff --git a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts index 41a0b2fbf..780a32f0d 100644 --- a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts @@ -16,9 +16,20 @@ import { ProgramManagement, ManagementSelection, ParticipantRemovalPreview, + ManagementProjectActionWrite, + ManagementProjectActionPreview, + ManagementProjectActionResult, } from "../program-management.model"; export abstract class ProgramManagementRepositoryPort { + abstract previewProjectAction( + id: number, + body: ManagementProjectActionWrite, + ): Observable; + abstract applyProjectAction( + id: number, + body: ManagementProjectActionWrite & { previewToken: string }, + ): Observable; abstract results(programId: number): Observable; abstract exportResults(programId: number, query: ResultsQuery): Observable; abstract criteria(programId: number): Observable; 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 924889d94..4a1586e0f 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 @@ -152,6 +152,25 @@ export interface ManagementSelection { ids: number[]; projects?: ManagementSelectionProject[]; } +export type ManagementProjectAction = "change_case" | "detach" | "submit"; +export interface ManagementProjectActionWrite { + action: ManagementProjectAction; + ids: number[]; + caseName?: string; +} +export interface ManagementProjectActionPreview { + items: { id: number; name: string; errors: string[] }[]; + readyCount: number; + alreadySubmittedCount: number; + scoreCount: number; + removedAssignmentsCount: number; + canApply: boolean; + previewToken: string | null; +} +export interface ManagementProjectActionResult { + changedCount: number; + alreadySubmittedCount: number; +} export interface ParticipantRemovalPreview { confirmationVersion: 2; previewToken: string; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts index 26c0d9a42..f2d2846b5 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts @@ -11,6 +11,34 @@ import { API_URL } from "@core/lib/providers"; describe("Контракт HTTP панели организатора", () => { let adapter: ProgramManagementHttpAdapter, http: HttpTestingController; + it("действия проекта передают ID связей, целевой кейс и подписанный token", () => { + adapter + .previewProjectAction(4, { action: "change_case", ids: [17], caseName: "Город" }) + .subscribe(value => expect(value.previewToken).toBe("proof")); + const preview = http.expectOne(r => + r.url.endsWith("/programs/4/management/projects/actions/preview/"), + ); + expect(preview.request.body).toEqual({ action: "change_case", ids: [17], case_name: "Город" }); + preview.flush({ preview_token: "proof", can_apply: true, ready_count: 1, items: [] }); + adapter + .applyProjectAction(4, { + action: "change_case", + ids: [17], + caseName: "Город", + previewToken: "proof", + }) + .subscribe(value => expect(value.changedCount).toBe(1)); + const apply = http.expectOne(r => + r.url.endsWith("/programs/4/management/projects/actions/apply/"), + ); + expect(apply.request.body).toEqual({ + action: "change_case", + ids: [17], + case_name: "Город", + preview_token: "proof", + }); + apply.flush({ changed_count: 1, already_submitted_count: 0 }); + }); beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts index 1959cbaa1..d8c6d8a73 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts @@ -24,11 +24,26 @@ import { ManagementTeam, ProgramManagement, ManagementSelection, + ManagementProjectActionWrite, + ManagementProjectActionPreview, + ManagementProjectActionResult, ParticipantRemovalPreview, } from "@domain/program/program-management.model"; @Injectable() export class ProgramManagementHttpAdapter implements ProgramManagementRepositoryPort { + previewProjectAction(id: number, body: ManagementProjectActionWrite) { + return this.api.post( + `${this.root(id)}/projects/actions/preview/`, + body, + ); + } + applyProjectAction(id: number, body: ManagementProjectActionWrite & { previewToken: string }) { + return this.api.post( + `${this.root(id)}/projects/actions/apply/`, + body, + ); + } private readonly api = inject(ApiService); results(id: number) { return this.api.get(`${this.root(id)}/results/`); diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts index 5c28322be..71df45b62 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts @@ -13,11 +13,18 @@ import { ManagementMaterialWrite, ManagementQuery, ManagementSettingsWrite, + ManagementProjectActionWrite, } from "@domain/program/program-management.model"; /** Приватные manager-данные не попадают в общий EntityCache программы. */ @Injectable() export class ProgramManagementRepository implements ProgramManagementRepositoryPort { + previewProjectAction(id: number, body: ManagementProjectActionWrite) { + return this.adapter.previewProjectAction(id, body); + } + applyProjectAction(id: number, body: ManagementProjectActionWrite & { previewToken: string }) { + return this.adapter.applyProjectAction(id, body).pipe(tap(() => this.changed(id))); + } private readonly adapter = inject(ProgramManagementHttpAdapter); results(id: number) { return this.adapter.results(id); 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 421161893..f4fc846d4 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 @@ -504,6 +504,7 @@

projectFiltersOpen() || participantFiltersOpen() || !!participantRemoval() || + !!projectActionRequest() || !!participantAnswers() " [style.left.px]="floatingPosition()?.center" @@ -518,7 +519,7 @@

> @if (section() === "projects" && assignmentReason()) {

- {{ assignmentReason() }} + Назначение экспертов: {{ assignmentReason() }}

} @@ -545,15 +546,25 @@

Удалить из программы } @else { - +
+ + +
}

} + button.management__clear-selection { grid-column: 3; align-self: center; @@ -1203,6 +1209,14 @@ .management__evaluation > h3 { margin: 0; } @media (max-width: 999px) { + .management--lists:has(.management__projects) { padding-bottom: 260px; } + + .management__bulk-actions { + display: grid; + grid-row: 2; + grid-column: 1 / -1; + } + .management__bulk--projects { grid-template-columns: minmax(0, 1fr) 32px; } .management__bulk--projects > .management__bulk-summary { diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts index e54118b1d..a894755d7 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts @@ -21,6 +21,7 @@ import { } from "@domain/program/program-management.model"; import { ProgramManagementComponent } from "./management.component"; import { ProgramProjectAssignmentSelection } from "@api/program/facades/detail/program-project-assignment-selection.service"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; @Component({ template: "

Профиль участника

" }) class ProfileNavigationTarget {} @@ -193,6 +194,18 @@ describe("Списки управления программой в DOM", () => team: signal(null), context: null as ManagementContext | null, api: { + previewProjectAction: vi.fn(() => + of({ + items: [], + readyCount: 1, + alreadySubmittedCount: 0, + scoreCount: 0, + removedAssignmentsCount: 0, + canApply: true, + previewToken: "proof", + }), + ), + applyProjectAction: vi.fn(() => of({ changedCount: 1, alreadySubmittedCount: 0 })), exportProjects: vi.fn<() => Observable>(() => new Subject()), selection: vi.fn(() => of({ ids: Array.from({ length: 41 }, (_, i) => i + 1) })), previewParticipantRemoval: vi.fn<() => Observable>(() => @@ -231,7 +244,12 @@ describe("Списки управления программой в DOM", () => { provide: FileService, useValue: { uploadFile } }, ], }).overrideComponent(ProgramManagementComponent, { - set: { providers: [{ provide: ProgramManagementService, useValue: state }] }, + set: { + providers: [ + { provide: ProgramManagementService, useValue: state }, + { provide: ProgramManagementUseCase, useValue: state.api }, + ], + }, }); const harness = await RouterTestingHarness.create(); const component = await harness.navigateByUrl( @@ -242,6 +260,31 @@ describe("Списки управления программой в DOM", () => return { harness, component, state, project, element, uploadFile, uploaded }; } + it("действия строки используют ID связи, а массовая сдача включает другие страницы", async () => { + const { component, project } = await setup("projects"); + expect(component.projectActions(project).map(a => a.id)).toEqual( + expect.arrayContaining(["change_case", "detach", "submit"]), + ); + component.openProjectAction("change_case", [project.id], project.name); + expect(component.projectActionRequest()?.ids).toEqual([project.id]); + component.selectedRows.set(new Set(Array.from({ length: 41 }, (_, i) => i + 1))); + component.openProjectAction("submit"); + expect(component.projectActionRequest()?.ids).toHaveLength(41); + }); + it("после открепления обновляет список и очищает выбор", async () => { + const { component, state } = await setup("projects"); + component.selectedRows.set(new Set([1])); + component.projectActionCompleted({ + programId: 1, + action: "detach", + changedCount: 1, + alreadySubmittedCount: 0, + }); + expect(component.selectedRows().size).toBe(0); + expect(component.projectActionRequest()).toBeNull(); + expect(state.retry).toHaveBeenCalledOnce(); + expect(component.tableMessage()).toBe("Проект откреплен от программы."); + }); it("при401 показывает рабочую ссылку Войти с возвратом в текущую программу", async () => { const { state, harness, element } = await setup( "participants", diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts index 9dccf531e..e9185fd68 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts @@ -48,6 +48,7 @@ import { SettingsSection, ManagementSelectionProject, ParticipantRemovalPreview, + ManagementProjectAction, } from "@domain/program/program-management.model"; import { ProgramManagementHttpAdapter } from "@infrastructure/adapters/program/program-management-http.adapter"; import { ProgramManagementRepository } from "@infrastructure/repository/program/program-management.repository"; @@ -64,6 +65,11 @@ import { } from "@ui/primitives/row-actions/row-actions.component"; import { ProgramAnalyticsComponent } from "../detail/analytics/analytics.component"; import { ProgramBrandingPreviewComponent } from "./branding-preview/branding-preview.component"; +import { + ManagementProjectActionsComponent, + ProjectActionRequest, + ProjectActionDone, +} from "./project-actions/project-actions.component"; import { ManagementProjectFormComponent } from "./project-form/project-form.component"; import { ManagementCriteriaComponent } from "./criteria/criteria.component"; import { ManagementExpertsComponent } from "./experts/experts.component"; @@ -98,6 +104,7 @@ import { ProgramImageFraming, ProgramImageKey } from "@domain/program/program-im RowActionsComponent, ProgramAnalyticsComponent, ProgramBrandingPreviewComponent, + ManagementProjectActionsComponent, ManagementProjectFormComponent, ManagementCriteriaComponent, ManagementExpertsComponent, @@ -180,6 +187,9 @@ export class ProgramManagementComponent implements OnInit { readonly selectedRows = signal(new Set()); readonly selectedRowIds = computed(() => [...this.selectedRows()]); readonly selectedProjects = signal(new Map()); + readonly projectActionRequest = signal(null); + readonly projectActionBusy = signal(false); + private projectActionTrigger: HTMLElement | null = null; readonly selectingRows = signal(false); readonly participantRemoval = signal<{ ids: number[]; @@ -396,6 +406,7 @@ export class ProgramManagementComponent implements OnInit { this.dirty() || this.saving() || this.uploading() || + this.projectActionBusy() || this.projectFormStatus().busy || this.criteriaStatus().busy || this.expertsStatus().busy || @@ -454,6 +465,7 @@ export class ProgramManagementComponent implements OnInit { ]); if (selectionKey !== this.selectionKey) { this.clearRows(); + this.projectActionRequest.set(null); this.participantRemoval.set(null); this.tableError.set(null); this.tableMessage.set(null); @@ -924,14 +936,89 @@ export class ProgramManagementComponent implements OnInit { const presentation = this.href(project.presentationUrl); if (presentation) actions.push({ id: "presentation", label: "Открыть презентацию", href: presentation }); + actions.push( + { + id: "change_case", + label: "Сменить кейс", + disabled: !this.state.projects()?.caseOptions.length, + reason: "В программе не настроены кейсы", + }, + { + id: "submit", + label: "Сдать решение", + disabled: !competitive || project.submitted, + reason: project.submitted ? "Решение уже сдано" : "В программе нет сдачи решений", + }, + { id: "detach", label: "Открепить от программы", destructive: true }, + ); return actions; } projectAction(project: ManagementProject, event: RowActionEvent): void { if (event.id === "team") { this.state.select(project); this.openTeam(event.trigger); + } else if (["change_case", "detach", "submit"].includes(event.id)) { + this.openProjectAction( + event.id as ManagementProjectAction, + [project.id], + project.name, + event.trigger, + ); } } + openProjectAction( + action: ManagementProjectAction, + ids = this.selectedRowIds(), + projectName?: string, + trigger?: HTMLElement, + ): void { + const context = this.state.context; + if (!context || !ids.length || this.projectActionBusy()) return; + this.projectActionTrigger = + trigger ?? + (this.managementLayout()?.nativeElement.ownerDocument.activeElement as HTMLElement | null); + this.projectActionRequest.set({ + programId: context.programId, + action, + ids: [...ids], + projectName, + }); + } + projectActionCompleted(result: ProjectActionDone): void { + if (this.state.context?.programId !== result.programId || this.section() !== "projects") return; + this.projectActionRequest.set(null); + this.clearRows(); + this.tableMessage.set( + result.action === "change_case" + ? "Кейс проекта изменен." + : result.action === "detach" + ? "Проект откреплен от программы." + : `Сдано решений: ${result.changedCount}.`, + ); + const lastOffset = + Math.max( + 0, + Math.ceil( + (this.count() - (result.action === "detach" ? result.changedCount : 0)) / this.pageSize, + ) - 1, + ) * this.pageSize; + if (this.offset() > lastOffset) void this.changeQuery({ offset: lastOffset }); + else this.state.retry(); + } + closeProjectAction(): void { + if (this.projectActionBusy()) return; + const trigger = this.projectActionTrigger; + this.projectActionTrigger = null; + this.projectActionRequest.set(null); + afterNextRender( + () => { + setTimeout(() => { + if (trigger?.isConnected) trigger.focus({ preventScroll: true }); + }); + }, + { injector: this.injector }, + ); + } materialActions(): RowAction[] { return [ { id: "edit", label: "Изменить" }, @@ -997,6 +1084,7 @@ export class ProgramManagementComponent implements OnInit { if ( this.saving() || this.removingParticipants() || + this.projectActionBusy() || this.uploading() || this.projectFormStatus().busy || this.criteriaStatus().busy || diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.html b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.html new file mode 100644 index 000000000..46f9ab9ff --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.html @@ -0,0 +1,119 @@ + + + + @if (request(); as action) { +
+
+

{{ title() }}

+ +
+

+ {{ action.projectName || "Выбрано проектов: " + action.ids.length }} +

+ @if (action.action === "change_case") { + +

+ Сохраненные оценки перейдут в рейтинг нового кейса. Оценки и назначения оценивших + экспертов сохранятся. +

+ } @else if (action.action === "detach") { +

+ Заявка исчезнет из этой программы и ее рейтинга. Назначения экспертов этой программы будут + сняты. Сам проект, его команда и аккаунты участников сохранятся. +

+ } @else { +

+ Сдадим текущую заявку с сохраненными материалами, в том числе после срока сдачи. Перед + сдачей проверим обязательные поля и состав команды. +

+ } + @if (loading()) { +

Проверяем проекты…

+ } + @if (preview(); as checked) { + @if (checked.scoreCount && action.action !== "submit") { +

+ У проекта есть сохраненные оценки. Результаты рейтинга изменятся. +

+ } + @if (action.action === "change_case" && checked.removedAssignmentsCount) { +

+ Будет снято назначений экспертов без оценки: {{ checked.removedAssignmentsCount }}. Их + доступ ограничен другим кейсом. +

+ } + @if (action.action === "submit") { +

+ Готовы к сдаче: {{ checked.readyCount }}. Уже сданы: + {{ checked.alreadySubmittedCount }}. +

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

Сдача не выполнена. Исправьте перечисленные заявки или исключите их из выбора.

+ } + } + @if (blocked().length) { +
    + @for (item of blocked().slice(0, 100); track item.id) { +
  • + {{ item.name }} +
      + @for (error of item.errors; track $index) { +
    • {{ error }}
    • + } +
    +
  • + } +
+ @if (blocked().length > 100) { +

Показаны первые 100 заявок с ошибками из {{ blocked().length }}.

+ } + } + } + @if (error()) { +

{{ error() }}

+ + } +
+ + +
+
+ } +
diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.scss b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.scss new file mode 100644 index 000000000..5363aab5b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.scss @@ -0,0 +1,85 @@ +/** @format */ +@use "styles/ui-foundation"; +@use "styles/program-management-controls"; + +.ui-button.ui-button { + @include ui-foundation.button; + @include program-management-controls.compact-action; +} + +.ui-field { + @include ui-foundation.control; + + box-sizing: border-box; + width: 100%; + min-width: 0; +} + +.ui-alert { @include program-management-controls.status-notice; } + +.project-action { + display: grid; + gap: 20px; + width: 432px; + min-width: 0; + max-width: 100%; + font-size: 12px; + line-height: 1.5; + color: var(--black); + + header, + footer { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + } + + h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; + } + + p { margin: 0; } + + label { + display: grid; + gap: 6px; + min-width: 0; + } + + footer { justify-content: flex-end; } +} + +.project-action__name { overflow-wrap: anywhere; } + +.project-action__errors { + display: grid; + gap: 12px; + max-height: 25dvh; + padding: 12px 12px 12px 28px; + margin: 0; + overflow: auto; + overflow-wrap: anywhere; + background: var(--red-light); + border-radius: 8px; +} + +.project-action__errors ul { padding-left: 16px; } + +.ui-button.project-action__danger { + color: var(--black); + background: var(--red-light); + border-color: var(--red-light); +} + +@media (max-width: 599px) { + .project-action footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .project-action footer .ui-button { padding-inline: 8px; } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.spec.ts new file mode 100644 index 000000000..c95b32eb7 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.spec.ts @@ -0,0 +1,158 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; +import { of, Subject, throwError } from "rxjs"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { + ManagementProjectActionPreview, + ManagementProjectActionResult, +} from "@domain/program/program-management.model"; +import { + ManagementProjectActionsComponent, + ProjectActionRequest, +} from "./project-actions.component"; + +describe("Действия с проектами программы", () => { + const ready: ManagementProjectActionPreview = { + items: [{ id: 7, name: "Проект", errors: [] }], + readyCount: 1, + alreadySubmittedCount: 0, + scoreCount: 2, + removedAssignmentsCount: 1, + canApply: true, + previewToken: "proof", + }; + const request: ProjectActionRequest = { programId: 4, ids: [7], action: "submit" }; + const setup = (action = request) => { + const api = { + previewProjectAction: vi.fn(() => of(ready)), + applyProjectAction: vi.fn(() => of({ changedCount: 1, alreadySubmittedCount: 0 })), + }; + TestBed.configureTestingModule({ + imports: [ManagementProjectActionsComponent], + providers: [{ provide: ProgramManagementUseCase, useValue: api }], + }); + const fixture = TestBed.createComponent(ManagementProjectActionsComponent); + fixture.componentRef.setInput("request", action); + fixture.componentRef.setInput("caseOptions", ["Банк", "Город"]); + fixture.detectChanges(); + return { fixture, component: fixture.componentInstance, api }; + }; + + it("сначала проверяет весь набор, отправляет подтвержденные ID и token только по нажатию", () => { + const { component, api } = setup({ ...request, ids: [7, 8] }); + expect(api.previewProjectAction).toHaveBeenCalledWith(4, { action: "submit", ids: [7, 8] }); + expect(api.applyProjectAction).not.toHaveBeenCalled(); + const done = vi.fn(); + component.completed.subscribe(done); + component.confirm(); + expect(api.applyProjectAction).toHaveBeenCalledWith(4, { + action: "submit", + ids: [7, 8], + previewToken: "proof", + }); + expect(done).toHaveBeenCalledWith({ + programId: 4, + action: "submit", + changedCount: 1, + alreadySubmittedCount: 0, + }); + }); + + it("выбор кейса проверяется заново, старый ответ не заменяет текущий", () => { + const { component, api } = setup({ ...request, action: "change_case" }); + expect(api.previewProjectAction).not.toHaveBeenCalled(); + const old = new Subject(), + current = new Subject(); + api.previewProjectAction.mockReturnValueOnce(old).mockReturnValueOnce(current); + component.caseControl.setValue("Банк"); + component.caseControl.setValue("Город"); + old.next({ ...ready, previewToken: "old" }); + expect(component.preview()).toBeNull(); + current.next({ ...ready, previewToken: "new" }); + current.complete(); + component.confirm(); + expect(api.applyProjectAction).toHaveBeenCalledWith(4, { + action: "change_case", + ids: [7], + caseName: "Город", + previewToken: "new", + }); + }); + + it("блокеры и отмена не приводят к сдаче", () => { + const { component, api } = setup(); + api.previewProjectAction.mockReturnValue( + of({ + ...ready, + canApply: false, + previewToken: null, + items: [{ id: 7, name: "Проект", errors: ["Заполните презентацию"] }], + }), + ); + component.loadPreview(); + component.confirm(); + expect(api.applyProjectAction).not.toHaveBeenCalled(); + expect(component.blocked()[0].errors).toEqual(["Заполните презентацию"]); + const close = vi.fn(); + component.closed.subscribe(close); + component.close(); + expect(close).toHaveBeenCalledOnce(); + }); + + it("не отправляет повторный apply и не закрывает окно во время сохранения", () => { + const { component, api } = setup({ ...request, action: "detach" }); + const pending = new Subject(); + api.applyProjectAction.mockReturnValue(pending); + const close = vi.fn(), + busy = vi.fn(); + component.closed.subscribe(close); + component.busyChange.subscribe(busy); + component.confirm(); + component.confirm(); + component.close(); + expect(api.applyProjectAction).toHaveBeenCalledOnce(); + expect(close).not.toHaveBeenCalled(); + expect(busy).toHaveBeenCalledWith(true); + pending.next({ changedCount: 1, alreadySubmittedCount: 0 }); + pending.complete(); + expect(component.applying()).toBe(false); + expect(busy).toHaveBeenLastCalledWith(false); + }); + + it("после409 требует новую проверку и явное подтверждение", () => { + const { component, api } = setup(); + api.applyProjectAction.mockReturnValueOnce( + throwError( + () => new HttpErrorResponse({ status: 409, error: { detail: "Данные изменились" } }), + ), + ); + component.confirm(); + expect(component.preview()).toBeNull(); + expect(component.error()).toBe("Данные изменились"); + component.confirm(); + expect(api.applyProjectAction).toHaveBeenCalledOnce(); + component.loadPreview(); + expect(api.applyProjectAction).toHaveBeenCalledOnce(); + component.confirm(); + expect(api.applyProjectAction).toHaveBeenCalledTimes(2); + }); + + it("при смене контекста сбрасывает token и незавершенную проверку", () => { + const { component, fixture, api } = setup(); + const pending = new Subject(); + api.previewProjectAction.mockReturnValueOnce(pending); + component.loadPreview(); + fixture.componentRef.setInput("request", { + ...request, + programId: 9, + ids: [42], + action: "change_case", + }); + fixture.detectChanges(); + pending.next(ready); + expect(component.preview()).toBeNull(); + component.confirm(); + expect(api.applyProjectAction).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.ts b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.ts new file mode 100644 index 000000000..4b7bd3de4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/project-actions/project-actions.component.ts @@ -0,0 +1,199 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal, + untracked, +} from "@angular/core"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { HttpErrorResponse } from "@angular/common/http"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Subject, finalize, takeUntil } from "rxjs"; +import { AlertDirective, ButtonDirective, FieldDirective, IconComponent } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { + ManagementProjectAction, + ManagementProjectActionPreview, + ManagementProjectActionResult, + ManagementProjectActionWrite, +} from "@domain/program/program-management.model"; + +export interface ProjectActionRequest { + programId: number; + action: ManagementProjectAction; + ids: number[]; + projectName?: string; +} +export interface ProjectActionDone extends ManagementProjectActionResult { + programId: number; + action: ManagementProjectAction; +} + +@Component({ + selector: "app-management-project-actions", + templateUrl: "./project-actions.component.html", + styleUrl: "./project-actions.component.scss", + imports: [ + ReactiveFormsModule, + ModalComponent, + AlertDirective, + ButtonDirective, + FieldDirective, + IconComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManagementProjectActionsComponent { + readonly request = input(null); + readonly caseOptions = input([]); + readonly closed = output(); + readonly completed = output(); + readonly busyChange = output(); + readonly caseControl = new FormControl("", { nonNullable: true }); + readonly preview = signal(null); + readonly loading = signal(false); + readonly applying = signal(false); + readonly error = signal(null); + readonly blocked = computed(() => this.preview()?.items.filter(item => item.errors.length) ?? []); + readonly title = computed( + () => + ({ + change_case: "Сменить кейс", + detach: "Открепить проект от программы", + submit: + this.request()?.ids.length === 1 + ? "Сдать решение за участника" + : "Сдать решения за участников", + })[this.request()?.action ?? "submit"], + ); + readonly confirmLabel = computed( + () => + ({ change_case: "Сменить кейс", detach: "Открепить проект", submit: "Сдать решения" })[ + this.request()?.action ?? "submit" + ], + ); + private readonly api = inject(ProgramManagementUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly cancelled = new Subject(); + private sequence = 0; + private previewBody: ManagementProjectActionWrite | null = null; + + constructor() { + effect(() => { + const request = this.request(); + untracked(() => { + this.sequence++; + this.cancelled.next(); + this.preview.set(null); + this.previewBody = null; + this.error.set(null); + this.loading.set(false); + this.caseControl.reset("", { emitEvent: false }); + if (request && request.action !== "change_case") this.loadPreview(); + }); + }); + this.caseControl.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.loadPreview()); + } + + close(): void { + if (this.applying()) return; + this.sequence++; + this.cancelled.next(); + this.closed.emit(); + } + + loadPreview(): void { + const request = this.request(); + if (!request || this.applying()) return; + const sequence = ++this.sequence; + this.cancelled.next(); + this.preview.set(null); + this.previewBody = null; + this.error.set(null); + this.loading.set(false); + if (request.action === "change_case" && !this.caseControl.value) return; + const body: ManagementProjectActionWrite = { action: request.action, ids: [...request.ids] }; + if (request.action === "change_case") body.caseName = this.caseControl.value; + this.loading.set(true); + this.api + .previewProjectAction(request.programId, body) + .pipe( + takeUntil(this.cancelled), + takeUntilDestroyed(this.destroyRef), + finalize(() => { + if (sequence === this.sequence) this.loading.set(false); + }), + ) + .subscribe({ + next: preview => { + if (sequence !== this.sequence) return; + this.previewBody = body; + this.preview.set(preview); + }, + error: (e: HttpErrorResponse) => { + if (sequence === this.sequence) this.error.set(this.errorText(e)); + }, + }); + } + + confirm(): void { + const request = this.request(), + preview = this.preview(), + body = this.previewBody; + if ( + !request || + !body || + !preview?.canApply || + !preview.previewToken || + this.applying() || + this.loading() + ) + return; + this.applying.set(true); + this.busyChange.emit(true); + this.error.set(null); + const sequence = this.sequence; + this.api + .applyProjectAction(request.programId, { ...body, previewToken: preview.previewToken }) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => { + this.applying.set(false); + this.busyChange.emit(false); + }), + ) + .subscribe({ + next: result => { + if (sequence === this.sequence) + this.completed.emit({ + ...result, + action: request.action, + programId: request.programId, + }); + }, + error: (e: HttpErrorResponse) => { + if (sequence !== this.sequence) return; + this.preview.set(null); + this.previewBody = null; + this.error.set(this.errorText(e)); + }, + }); + } + + private errorText(error: HttpErrorResponse): string { + if (error.status === 401 || error.status === 403) + return "Нет доступа к действиям с проектами этой программы."; + if (error.status === 404) return "Проект больше не связан с этой программой. Обновите список."; + if (typeof error.error?.detail === "string") return error.error.detail; + return "Не удалось проверить или выполнить действие. Повторите проверку."; + } +}