From 3d232432a04db550036e9f1b11e1132d423d2cc2 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 00:13:38 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=20=D0=B8=20=D0=BC=D0=B0=D1=81=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B2=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=BE?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/src/lib/services/api/api.service.ts | 4 + ...am-project-assignment-selection.service.ts | 16 + .../program/use-cases/evaluation.use-case.ts | 5 +- .../use-cases/program-management.use-case.ts | 12 + .../program/evaluation-workspace.model.ts | 5 + .../ports/evaluation.repository.port.ts | 2 + .../program-management.repository.port.ts | 17 + .../program/program-management.model.ts | 19 + .../program/evaluation-http.adapter.ts | 4 + .../program-management-http.adapter.spec.ts | 45 ++- .../program-management-http.adapter.ts | 36 +- .../program/evaluation.repository.ts | 4 + .../program/program-management.repository.ts | 12 + .../management/experts/experts.component.html | 218 +++++++----- .../management/experts/experts.component.scss | 135 +++++++- .../experts/experts.component.spec.ts | 51 ++- .../management/experts/experts.component.ts | 187 ++++++++-- .../management/management.component.html | 327 ++++++++++++------ .../management/management.component.scss | 132 ++++++- .../management/management.component.spec.ts | 121 +++++-- .../management/management.component.ts | 272 ++++++++++++++- .../organizers/organizers.component.html | 91 +---- .../organizers/organizers.component.scss | 47 ++- .../organizers/organizers.component.spec.ts | 16 +- .../organizers/organizers.component.ts | 39 ++- .../row-actions/row-actions.component.html | 68 ++++ .../row-actions/row-actions.component.scss | 84 +++++ .../row-actions/row-actions.component.spec.ts | 57 +++ .../row-actions/row-actions.component.ts | 51 +++ 29 files changed, 1711 insertions(+), 366 deletions(-) create mode 100644 projects/social_platform/src/app/api/program/facades/detail/program-project-assignment-selection.service.ts create mode 100644 projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.html create mode 100644 projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.scss create mode 100644 projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.ts diff --git a/projects/core/src/lib/services/api/api.service.ts b/projects/core/src/lib/services/api/api.service.ts index c5e4319b6..f6a22ce02 100644 --- a/projects/core/src/lib/services/api/api.service.ts +++ b/projects/core/src/lib/services/api/api.service.ts @@ -58,6 +58,10 @@ export class ApiService { .pipe(retry(exponentialBackoff(this.RETRY_COUNT)), first()) as Observable; } + postFile(path: string, body: object): Observable { + return this.http.post(this.apiUrl + path, body, { responseType: "blob" }).pipe(first()); + } + /** * Выполняет PUT запрос к API (полное обновление ресурса) * @param path - Относительный путь к ресурсу diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-project-assignment-selection.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-project-assignment-selection.service.ts new file mode 100644 index 000000000..4ad538668 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-project-assignment-selection.service.ts @@ -0,0 +1,16 @@ +/** @format */ +import { Injectable } from "@angular/core"; + +/** Короткоживущий переход между таблицей проектов и существующим редактором назначений. */ +@Injectable({ providedIn: "root" }) +export class ProgramProjectAssignmentSelection { + private value: { programId: number; projectIds: number[] } | null = null; + set(programId: number, projectIds: number[]): void { + this.value = { programId, projectIds }; + } + take(programId: number): number[] | null { + const value = this.value; + this.value = null; + return value?.programId === programId ? value.projectIds : null; + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/evaluation.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/evaluation.use-case.ts index 0635349cd..7b6dee839 100644 --- a/projects/social_platform/src/app/api/program/use-cases/evaluation.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/evaluation.use-case.ts @@ -3,7 +3,7 @@ import { inject, Injectable } from "@angular/core"; import { EvaluationRepositoryPort } from "@domain/program/ports/evaluation.repository.port"; import { EventBus } from "@domain/shared/event-bus"; import { ProgramUpdated } from "@domain/program/program-updated.event"; -import { ExpertRegistration } from "@domain/program/evaluation-workspace.model"; +import { ExpertRegistration, ExpertsBulkWrite } from "@domain/program/evaluation-workspace.model"; import { tap } from "rxjs"; @Injectable({ providedIn: "root" }) export class EvaluationUseCase { @@ -18,6 +18,9 @@ export class EvaluationUseCase { } readonly programs = this.repository.programs.bind(this.repository); readonly workspace = this.repository.workspace.bind(this.repository); + bulkExperts(id: number, data: ExpertsBulkWrite) { + return this.repository.bulkExperts(id, data); + } readonly saveCaseScope = this.repository.saveCaseScope.bind(this.repository); readonly saveInvitationCaseScope = this.repository.saveInvitationCaseScope.bind(this.repository); readonly saveProjectExperts = this.repository.saveProjectExperts.bind(this.repository); 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 88744669b..7d3bdce69 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 @@ -42,6 +42,18 @@ export class ProgramManagementUseCase { exportParticipants(id: number, q: ManagementQuery) { return this.repository.exportParticipants(id, q); } + selection(id: number, kind: "participants" | "projects", q: ManagementQuery) { + return this.repository.selection(id, kind, q); + } + exportSelectedParticipants(id: number, ids: number[]) { + return this.repository.exportSelectedParticipants(id, ids); + } + previewParticipantRemoval(id: number, ids: number[]) { + return this.repository.previewParticipantRemoval(id, ids); + } + removeParticipants(id: number, ids: number[], previewToken: string) { + return this.repository.removeParticipants(id, ids, previewToken); + } projects(id: number, q: ManagementQuery) { return this.repository.projects(id, q); } diff --git a/projects/social_platform/src/app/domain/program/evaluation-workspace.model.ts b/projects/social_platform/src/app/domain/program/evaluation-workspace.model.ts index 4858170c1..a4780d2f6 100644 --- a/projects/social_platform/src/app/domain/program/evaluation-workspace.model.ts +++ b/projects/social_platform/src/app/domain/program/evaluation-workspace.model.ts @@ -46,6 +46,11 @@ export interface ExpertCaseScopeWrite { allCases: boolean; caseNames: (string | null)[]; } +export interface ExpertsBulkWrite extends ExpertCaseScopeWrite { + action: "cases" | "resend" | "revoke"; + expertIds: number[]; + invitationIds: number[]; +} export interface ProjectExpertsWrite { revision: string; projectIds: number[]; diff --git a/projects/social_platform/src/app/domain/program/ports/evaluation.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/evaluation.repository.port.ts index 00eed30c7..352611600 100644 --- a/projects/social_platform/src/app/domain/program/ports/evaluation.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/evaluation.repository.port.ts @@ -13,10 +13,12 @@ import { ExpertRegistered, ExpertCaseScopeWrite, ProjectExpertsWrite, + ExpertsBulkWrite, } from "@domain/program/evaluation-workspace.model"; export abstract class EvaluationRepositoryPort { abstract programs(): Observable; abstract workspace(id: number): Observable; + abstract bulkExperts(id: number, data: ExpertsBulkWrite): Observable; abstract saveCaseScope( id: number, expertId: number, 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 5db1cc3bb..c7355a5e3 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 @@ -14,6 +14,8 @@ import { ManagementSettingsWrite, ManagementTeam, ProgramManagement, + ManagementSelection, + ParticipantRemovalPreview, } from "../program-management.model"; export abstract class ProgramManagementRepositoryPort { @@ -35,6 +37,21 @@ export abstract class ProgramManagementRepositoryPort { query: ManagementQuery, ): Observable; abstract exportParticipants(programId: number, query: ManagementQuery): Observable; + abstract selection( + programId: number, + kind: "participants" | "projects", + query: ManagementQuery, + ): Observable; + abstract exportSelectedParticipants(programId: number, ids: number[]): Observable; + abstract previewParticipantRemoval( + programId: number, + ids: number[], + ): Observable; + abstract removeParticipants( + programId: number, + ids: number[], + previewToken: string, + ): Observable<{ removed: number }>; abstract projects(programId: number, query: ManagementQuery): Observable; abstract team(programId: number, linkId: number): Observable; abstract evaluation(programId: number, linkId: 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 14ce36dbc..9029e71be 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 @@ -75,6 +75,7 @@ export interface ManagementProject { name: string; leader: { id: number; name: string }; submitted: boolean; + draft?: boolean; evaluationStatus: ManagementEvaluationStatus; assignmentsTotal: number | null; assignmentsCompleted: number | null; @@ -126,6 +127,7 @@ export interface ManagementQuery { offset: number; limit: number; submission: "all" | "submitted" | "not_submitted"; + evaluation?: "all" | "evaluated" | "not_evaluated"; caseScope: "all" | "selected" | "without_case"; caseName?: string; regionScope?: "all" | "selected" | "without_region"; @@ -134,6 +136,23 @@ export interface ManagementQuery { activityFrom?: string; activityTo?: string; } +export interface ManagementSelectionProject { + id: number; + projectId: number; + submitted: boolean; + caseName: string | null; + draft: boolean; +} +export interface ManagementSelection { + ids: number[]; + projects?: ManagementSelectionProject[]; +} +export interface ParticipantRemovalPreview { + previewToken: string; + participantCount: number; + applications: { id: number; projectId: number; name: string; submitted: boolean }[]; + assignmentsCount: number; +} export interface ManagementSettingsWrite { expectedUpdatedAt: string; confirmReopen?: boolean; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.ts index 7fe9c89a6..62812d614 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.ts @@ -15,6 +15,7 @@ import { ExpertRegistered, ExpertCaseScopeWrite, ProjectExpertsWrite, + ExpertsBulkWrite, } from "@domain/program/evaluation-workspace.model"; import { EvaluationRepositoryPort } from "@domain/program/ports/evaluation.repository.port"; @Injectable({ providedIn: "root" }) @@ -29,6 +30,9 @@ export class EvaluationHttpAdapter implements EvaluationRepositoryPort { workspace(id: number): Observable { return this.api.get(`${this.root(id)}/experts/`); } + bulkExperts(id: number, data: ExpertsBulkWrite): Observable { + return this.api.post(`${this.root(id)}/experts/bulk/`, data); + } saveCaseScope( id: number, expertId: number, 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 b26f39fcb..43d322403 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 @@ -34,8 +34,9 @@ describe("Контракт HTTP панели организатора", () => { adapter .projects(4, { search: "Город", - offset: 25, - limit: 25, + offset: 20, + limit: 20, + evaluation: "not_evaluated", submission: "submitted", caseScope: "selected", caseName: "Экология", @@ -44,7 +45,8 @@ describe("Контракт HTTP панели организатора", () => { const request = http.expectOne(r => r.url.endsWith("/programs/4/management/projects/")); expect(request.request.params.get("case_scope")).toBe("selected"); expect(request.request.params.get("case_name")).toBe("Экология"); - expect(request.request.params.get("offset")).toBe("25"); + expect(request.request.params.get("offset")).toBe("20"); + expect(request.request.params.get("evaluation")).toBe("not_evaluated"); request.flush({ count: 0, results: [], case_options: ["Экология"], is_competitive: true }); }); it("выгрузка участников передаёт те же фильтры региона и периода, что и список", () => { @@ -89,6 +91,43 @@ describe("Контракт HTTP панели организатора", () => { }); request.flush({ updated_at: version }); }); + it("выбор всех проектов использует те же фильтры и сохраняет оба вида ID", () => { + adapter + .selection(4, "projects", { + search: "Город", + offset: 20, + limit: 20, + submission: "submitted", + caseScope: "selected", + caseName: "Экология", + evaluation: "not_evaluated", + }) + .subscribe(result => expect(result.projects?.[0].projectId).toBe(91)); + const request = http.expectOne(r => r.url.endsWith("/projects/selection/")); + expect(request.request.params.get("evaluation")).toBe("not_evaluated"); + expect(request.request.params.get("case_name")).toBe("Экология"); + request.flush({ ids: [11], projects: [{ id: 11, project_id: 91 }] }); + }); + it("выбранная выгрузка использует POST Blob, удаление передает подписанное подтверждение без retry", () => { + adapter.exportSelectedParticipants(4, [1, 21]).subscribe(); + const file = http.expectOne(r => r.url.endsWith("/participants/export/")); + expect(file.request.method).toBe("POST"); + expect(file.request.body).toEqual({ ids: [1, 21] }); + expect(file.request.responseType).toBe("blob"); + file.flush(new Blob(["xlsx"])); + adapter + .previewParticipantRemoval(4, [1, 21]) + .subscribe(result => expect(result.previewToken).toBe("proof")); + const preview = http.expectOne(r => r.url.endsWith("/remove-preview/")); + preview.flush({ preview_token: "proof", participant_count: 2, applications: [] }); + const failed = vi.fn(); + adapter.removeParticipants(4, [1, 21], "proof").subscribe({ error: failed }); + const removal = http.expectOne(r => r.url.endsWith("/participants/remove/")); + expect(removal.request.body).toEqual({ ids: [1, 21], preview_token: "proof" }); + removal.flush({ detail: "Изменено" }, { status: 409, statusText: "Conflict" }); + expect(failed).toHaveBeenCalledOnce(); + http.expectNone(r => r.url.endsWith("/participants/remove/")); + }); it("удаление материала передаёт версию в query и не вызывает удаление UserFile", () => { adapter .deleteMaterial(4, { id: 9, updatedAt: "2026-10-17T15:00:00+03:00" } as ManagementMaterial) 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 787fee18f..913c528f4 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 @@ -23,6 +23,8 @@ import { ManagementSettingsWrite, ManagementTeam, ProgramManagement, + ManagementSelection, + ParticipantRemovalPreview, } from "@domain/program/program-management.model"; @Injectable() @@ -67,6 +69,27 @@ export class ProgramManagementHttpAdapter implements ProgramManagementRepository exportParticipants(id: number, q: ManagementQuery) { return this.api.getFile(`${this.root(id)}/participants/export/`, this.participantParams(q)); } + selection(id: number, kind: "participants" | "projects", q: ManagementQuery) { + return this.api.get( + `${this.root(id)}/${kind}/selection/`, + kind === "participants" ? this.participantParams(q) : this.projectParams(q), + ); + } + exportSelectedParticipants(id: number, ids: number[]) { + return this.api.postFile(`${this.root(id)}/participants/export/`, { ids }); + } + previewParticipantRemoval(id: number, ids: number[]) { + return this.api.post( + `${this.root(id)}/participants/remove-preview/`, + { ids }, + ); + } + removeParticipants(id: number, ids: number[], previewToken: string) { + return this.api.post<{ removed: number }>(`${this.root(id)}/participants/remove/`, { + ids, + previewToken, + }); + } private participantParams(q: ManagementQuery): HttpParams { let params = this.params(q) .set("region_scope", q.regionScope ?? "all") @@ -79,9 +102,18 @@ export class ProgramManagementHttpAdapter implements ProgramManagementRepository return params; } projects(id: number, q: ManagementQuery) { - let params = this.params(q).set("submission", q.submission).set("case_scope", q.caseScope); + return this.api.get( + `${this.root(id)}/projects/`, + this.projectParams(q), + ); + } + private projectParams(q: ManagementQuery): HttpParams { + let params = this.params(q) + .set("submission", q.submission) + .set("case_scope", q.caseScope) + .set("evaluation", q.evaluation ?? "all"); if (q.caseScope === "selected" && q.caseName) params = params.set("case_name", q.caseName); - return this.api.get(`${this.root(id)}/projects/`, params); + return params; } team(id: number, linkId: number) { return this.api.get(`${this.root(id)}/projects/${linkId}/team/`); diff --git a/projects/social_platform/src/app/infrastructure/repository/program/evaluation.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/evaluation.repository.ts index 7b52103b3..5a1a457dd 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/evaluation.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/evaluation.repository.ts @@ -2,11 +2,15 @@ import { inject, Injectable } from "@angular/core"; import { EvaluationHttpAdapter } from "../../adapters/program/evaluation-http.adapter"; import { EvaluationRepositoryPort } from "@domain/program/ports/evaluation.repository.port"; +import { ExpertsBulkWrite } from "@domain/program/evaluation-workspace.model"; @Injectable({ providedIn: "root" }) export class EvaluationRepository implements EvaluationRepositoryPort { private readonly adapter = inject(EvaluationHttpAdapter); readonly programs = this.adapter.programs.bind(this.adapter); readonly workspace = this.adapter.workspace.bind(this.adapter); + bulkExperts(id: number, data: ExpertsBulkWrite) { + return this.adapter.bulkExperts(id, data); + } readonly saveCaseScope = this.adapter.saveCaseScope.bind(this.adapter); readonly saveInvitationCaseScope = this.adapter.saveInvitationCaseScope.bind(this.adapter); readonly saveProjectExperts = this.adapter.saveProjectExperts.bind(this.adapter); 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 57c21a0cb..167d5376b 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 @@ -47,6 +47,18 @@ export class ProgramManagementRepository implements ProgramManagementRepositoryP exportParticipants(id: number, q: ManagementQuery) { return this.adapter.exportParticipants(id, q); } + selection(id: number, kind: "participants" | "projects", q: ManagementQuery) { + return this.adapter.selection(id, kind, q); + } + exportSelectedParticipants(id: number, ids: number[]) { + return this.adapter.exportSelectedParticipants(id, ids); + } + previewParticipantRemoval(id: number, ids: number[]) { + return this.adapter.previewParticipantRemoval(id, ids); + } + removeParticipants(id: number, ids: number[], previewToken: string) { + return this.adapter.removeParticipants(id, ids, previewToken).pipe(tap(() => this.changed(id))); + } projects(id: number, q: ManagementQuery) { return this.adapter.projects(id, q); } diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html index 06a33a532..255585ee1 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html @@ -57,83 +57,59 @@

Эксперты программы

} - @if (selected(); as expert) { -
- {{ - expert.name || expert.email - }} -
- @if (expert.status === "joined") { - - - } @else { - - - @if (expert.status !== "revoked") { - - } - } - -
-
- } @if (loading() && !snapshot()) {

Загружаем экспертов…

} +
+ +
+ + @for (expert of visible(); track expert.id) { + + } @empty { -
+ + Эксперт Кейсы Статус Назначено ОцененоДействия
+ +
Эксперты программы
{{ expert.assigned ?? "—" }} {{ expert.completed ?? "—" }} + +
+ {{ search.value || status.value !== "all" ? "Эксперты не найдены. Измените поиск или статус." @@ -276,6 +260,16 @@

Проекты на оценку

} } +
+ +
@@ -283,7 +277,7 @@

Проекты на оценку

Проекты на оценку @for (project of distributionPage(); track project.id) { } @empty { @@ -385,7 +376,49 @@

Проекты на оценку

} } -@if (tab() === "distribution" && selection().length) { +@if (tab() === "experts" && selectedExpertRows().length && !modal()) { +
+ Выбрано экспертов: {{ selectedExpertRows().length }} +
+ + + + +
+
+} +@if (tab() === "distribution" && selection().length && !modal()) {
Проекты на оценку Выбрано {{ selection().length }} {{ selection().length | pluralize: ["проект", "проекта", "проектов"] }} · - {{ caseLabel(selection()[0].caseName) }}
-
+ @if (mixedProjectCases()) { + Для ручного назначения выберите проекты одного кейса. + }
} {{ dialogTitle() }} } @case ("revoke") { -

Отозвать приглашение для {{ selected()?.email }}? Эта ссылка перестанет действовать.

+

+ {{ + bulkRevoke() + ? "Отозвать выбранные приглашения: " + selectedExpertRows().length + "?" + : "Отозвать приглашение для " + selected()?.email + "?" + }} + Ссылки в отозванных приглашениях перестанут действовать. +

- @if (selected.submitted && program.isCompetitive) { - Перейти к оценке - } @else { - - - } - @if (count()) { -

- Выберите один проект, чтобы посмотреть команду или перейти к оценке. -

- } + } + } @if (state.pending()) {

Обновляем список…

@@ -269,18 +235,46 @@

} } @else if (section() === "participants") { +
+ +

- + {{ project.name }} @@ -334,14 +327,12 @@

Проекты на оценку

{{ completed(project) }} из {{ state.requiredEvaluators }}
- + (action)="openProject(project)" + />
+ - + + @@ -289,25 +283,25 @@

track person.id; let index = $index ) { -

+ + - + }
Участники программы
+ + № Участник Почта РегионВ программе сДата регистрации Дата последней активностиДействия
+ + {{ offset() + index + 1 }} - @if (person.registrationAnswers?.length) { - - } @else { - {{ person.name }} - } + {{ person.name }} {{ person.email || "Не указана" }} {{ person.region || "—" }} + {{ person.registeredAt | date: "dd.MM.yyyy" : "+0300" }} @@ -319,14 +313,38 @@

— }

+ +
} @else { +
+ +
@@ -337,10 +355,21 @@

+ - + @@ -348,6 +377,7 @@

+ @@ -356,22 +386,16 @@

track project.id; let index = $index ) { -

+ @@ -405,6 +429,14 @@

Не добавлена } +

} @@ -419,6 +451,70 @@

>{{ offset() + 1 }}–{{ rangeEnd() }} из {{ count() }} } + @if (tableError()) { +

{{ tableError() }}

+ } + @if (tableMessage()) { +

{{ tableMessage() }}

+ } + @if (selectedRows().size) { +
+ Выбрано {{ section() === "participants" ? "участников" : "проектов" }}: + {{ selectedRows().size }} + @if (section() === "participants") { + + + } @else { + + @if (assignmentReason()) { + {{ assignmentReason() }} + } + } + +
+ } } @else if (section() === "analytics") { @@ -693,23 +789,13 @@

: material.url }}

-
- -
+ } @empty {
@@ -724,6 +810,51 @@

В программе пока нет материалов

} } + + @if (participantRemoval(); as removal) { +
+

Удалить участников из программы?

+

Участников: {{ removal.preview.participantCount }}. Аккаунты на платформе сохранятся.

+

Участники останутся в командах проектов на платформе.

+ @if (removal.preview.applications.length) { +
+ Среди выбранных есть руководители проектов +

+ Из этой программы будут удалены их заявки: {{ removal.preview.applications.length }}. + Эти заявки перестанут участвовать в оценивании и рейтинге. Сами проекты на платформе + сохранятся. +

+
+
    + @for (application of removal.preview.applications; track application.id) { +
  • {{ application.name }}
  • + } +
+ } +
+ +
+
+ } +
button:not(.management__clear-selection) { + grid-column: 1 / -1; + } + + .management__bulk .management__clear-selection { + grid-column: 2; + } +} 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 5e25ca2ac..a60833f93 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 @@ -17,6 +17,7 @@ import { ProgramManagement, } from "@domain/program/program-management.model"; import { ProgramManagementComponent } from "./management.component"; +import { ProgramProjectAssignmentSelection } from "@api/program/facades/detail/program-project-assignment-selection.service"; const program = { id: 1, @@ -158,6 +159,8 @@ describe("Списки управления программой в DOM", () => results: [ { id: 1, + userId: 51, + registeredAt: "2026-10-01T12:00:00Z", name: "Анна", email: "anna@example.com", lastActivity: null as string | null | undefined, @@ -181,7 +184,16 @@ describe("Списки управления программой в DOM", () => teamError: signal(null), team: signal(null), context: null as ManagementContext | null, - api: {}, + api: { + selection: vi.fn(() => of({ ids: Array.from({ length: 41 }, (_, i) => i + 1) })), + previewParticipantRemoval: vi.fn(() => + of({ previewToken: "proof", participantCount: 1, applications: [], assignmentsCount: 0 }), + ), + removeParticipants: vi.fn(() => of({ removed: 1 })), + }, + retry: vi.fn(), + openTeam: vi.fn(() => state.teamOpen.set(true)), + closeTeam: vi.fn(() => state.teamOpen.set(false)), load: vi.fn((ctx: ManagementContext) => { state.context = ctx; }), @@ -307,8 +319,9 @@ describe("Списки управления программой в DOM", () => }); harness.detectChanges(); const headings = element().querySelectorAll(".management__participants th"); - expect(headings).toHaveLength(6); - expect(headings[5].textContent).toContain("Дата последней активности"); + expect(headings).toHaveLength(8); + expect(headings[6].textContent).toContain("Дата последней активности"); + expect(headings[5].textContent).toContain("Дата регистрации"); const cells = element().querySelectorAll('td[data-label="Последняя активность"]'); expect(Array.from(cells, cell => cell.textContent?.trim())).toEqual([ "17.10.2026 01:30", @@ -494,39 +507,85 @@ describe("Списки управления программой в DOM", () => expect(component.caseFilter.value).toBe("case:Кейс №1"); }); - it("повторный клик по кружку или строке снимает выбор, ссылка не меняет выбор", async () => { - const { harness, state, element } = await setup("projects"); - const radio = () => element().querySelector('input[type="radio"]')!; - radio().click(); + it("чекбокс выбирает несколько строк, меню проекта работает без одиночной плашки", async () => { + const { harness, component, state, element } = await setup("projects"); + const checkbox = () => + element().querySelector('tbody input[type="checkbox"]')!; + checkbox().click(); harness.detectChanges(); - expect(state.selected()?.id).toBe(11); - radio().click(); + expect([...component.selectedRows()]).toEqual([11]); + checkbox().click(); harness.detectChanges(); - expect(state.selected()).toBeNull(); - expect(radio().checked).toBe(false); - radio().dispatchEvent( - new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }), - ); + expect(component.selectedRows().size).toBe(0); + const menu = element().querySelector("app-row-actions button")!; + const row = element().querySelector("tbody tr"); + menu.click(); harness.detectChanges(); - expect(state.selected()?.id).toBe(11); - radio().dispatchEvent( - new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }), - ); - harness.detectChanges(); - expect(state.selected()).toBeNull(); - const rowCell = () => - element().querySelector("tbody td[data-label=Руководитель]")!; - rowCell().click(); + expect(element().querySelector(".management__selection")).toBeNull(); + expect(element().querySelector("tbody tr")).toBe(row); + const team = Array.from(document.querySelectorAll('[role="menuitem"]')).find( + n => n.textContent?.trim() === "Посмотреть команду", + )!; + team.click(); harness.detectChanges(); + expect(state.openTeam).toHaveBeenCalledOnce(); expect(state.selected()?.id).toBe(11); - rowCell().click(); - harness.detectChanges(); - expect(state.selected()).toBeNull(); - radio().click(); - harness.detectChanges(); - vi.spyOn(TestBed.inject(Router), "navigateByUrl").mockResolvedValue(true); - element().querySelector("tbody a")!.click(); + }); + it("имя участника ведет в его профиль, а анкета остается отдельной командой", async () => { + const { component, element } = await setup(); + const link = element().querySelector('td[data-label="Участник"] a')!; + expect(link.getAttribute("href")).toBe("/office/profile/51"); + expect(component.state.context?.query.limit).toBe(20); + }); + it("выбор всех охватывает другие страницы, сохраняется при пагинации и очищается при смене фильтра", async () => { + const { component, state, harness } = await setup(); + state.participants.update(p => ({ ...p, count: 41 })); harness.detectChanges(); - expect(state.selected()?.id).toBe(11); + component.selectAllRows(); + expect(component.selectedRows().size).toBe(41); + expect(state.api.selection).toHaveBeenCalledWith( + 1, + "participants", + expect.objectContaining({ limit: 20 }), + ); + component.page(1); + await vi.waitFor(() => expect(state.context?.query.offset).toBe(20)); + expect(component.selectedRows().size).toBe(41); + await component.changeQuery({ search: "другой" }); + expect(component.selectedRows().size).toBe(0); + }); + it("фильтр оценивания восстанавливается из URL и передается в серверный запрос", async () => { + const { component, state } = await setup("projects", "?evaluation=evaluated"); + expect(component.evaluationFilter.value).toBe("evaluated"); + expect(state.context?.query.evaluation).toBe("evaluated"); + }); + it("поздний ответ выбора всех после смены фильтра не восстанавливает старый выбор", async () => { + const { component, state } = await setup(); + const delayed = new Subject<{ ids: number[] }>(); + state.api.selection.mockReturnValue(delayed); + component.selectAllRows(); + await component.changeQuery({ search: "новая выборка" }); + delayed.next({ ids: [1, 2, 3] }); + delayed.complete(); + expect(component.selectedRows().size).toBe(0); + expect(component.selectingRows()).toBe(false); + }); + it("передает редактору назначений глобальный ID проекта вместо ID заявки", async () => { + const { component, project } = await setup("projects"); + component.toggleRow({ ...project, submitted: true }); + component.assignSelectedProjects(); + expect(TestBed.inject(ProgramProjectAssignmentSelection).take(1)).toEqual([91]); + expect(TestBed.inject(ProgramProjectAssignmentSelection).take(1)).toBeNull(); + }); + it("удаление запрашивает предварительный просмотр и отправляет его подписанное подтверждение", async () => { + const { component, state } = await setup(); + component.selectedRows.set(new Set([1])); + component.previewParticipantRemoval([1]); + expect(state.api.removeParticipants).not.toHaveBeenCalled(); + expect(component.participantRemoval()?.preview.previewToken).toBe("proof"); + component.removeParticipants(); + expect(state.api.removeParticipants).toHaveBeenCalledWith(1, [1], "proof"); + expect(component.selectedRows().size).toBe(0); + expect(state.retry).toHaveBeenCalledOnce(); }); }); 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 4139b7695..be2c88896 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 @@ -43,14 +43,22 @@ import { ManagementSection, ManagementSettingsWrite, SettingsSection, + ManagementSelectionProject, + ParticipantRemovalPreview, } 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"; import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; import { ProgramManagementService } from "@api/program/facades/detail/program-management.service"; +import { ProgramProjectAssignmentSelection } from "@api/program/facades/detail/program-project-assignment-selection.service"; import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; import { AppRoutes } from "@api/paths/app-routes"; import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { + RowAction, + RowActionEvent, + RowActionsComponent, +} from "@ui/primitives/row-actions/row-actions.component"; import { ProgramAnalyticsComponent } from "../detail/analytics/analytics.component"; import { ProgramBrandingPreviewComponent } from "./branding-preview/branding-preview.component"; import { ManagementProjectFormComponent } from "./project-form/project-form.component"; @@ -82,6 +90,7 @@ import { ProgramImageFraming, ProgramImageKey } from "@domain/program/program-im IconComponent, AvatarComponent, ModalComponent, + RowActionsComponent, ProgramAnalyticsComponent, ProgramBrandingPreviewComponent, ManagementProjectFormComponent, @@ -103,6 +112,7 @@ import { ProgramImageFraming, ProgramImageKey } from "@domain/program/program-im }) export class ProgramManagementComponent implements OnInit { readonly state = inject(ProgramManagementService); + private readonly assignmentSelection = inject(ProgramProjectAssignmentSelection); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly fb = inject(FormBuilder).nonNullable; @@ -118,6 +128,7 @@ export class ProgramManagementComponent implements OnInit { readonly search = this.fb.control(""); readonly caseFilter = this.fb.control("all"); readonly submissionFilter = this.fb.control("all"); + readonly evaluationFilter = this.fb.control("all"); readonly regionFilter = this.fb.control("all"); readonly activityFilter = this.fb.control("all"); readonly activityFrom = this.fb.control(""); @@ -127,6 +138,32 @@ export class ProgramManagementComponent implements OnInit { readonly participantExportError = signal(false); readonly participantExportSuccess = signal(false); readonly participantAnswers = signal(null); + readonly selectedRows = signal(new Set()); + readonly selectedRowIds = computed(() => [...this.selectedRows()]); + readonly selectedProjects = signal(new Map()); + readonly selectingRows = signal(false); + readonly participantRemoval = signal<{ + ids: number[]; + preview: ParticipantRemovalPreview; + } | null>(null); + readonly removingParticipants = signal(false); + readonly tableError = signal(null); + readonly tableMessage = signal(null); + private selectionKey = ""; + private selectionSequence = 0; + readonly allRowsSelected = computed( + () => this.count() > 0 && this.selectedRows().size === this.count(), + ); + readonly assignmentReason = computed(() => { + if (!this.state.program()?.isCompetitive) return "В программе нет оценивания."; + const projects = [...this.selectedProjects().values()]; + if (projects.length !== this.selectedRows().size) return "Обновите выбор проектов."; + if (projects.some(p => !p.submitted || p.draft)) + return "Выберите сданные и опубликованные проекты."; + if (new Set(projects.map(p => p.caseName)).size > 1) + return "Для ручного назначения выберите проекты одного кейса."; + return null; + }); readonly hasListFilters = computed(() => { const q = this.state.context?.query; // Depend on the list signals: context is updated before the request resolves. @@ -137,7 +174,7 @@ export class ProgramManagementComponent implements OnInit { (!!q.search || (this.section() === "participants" ? (q.regionScope ?? "all") !== "all" || (q.activity ?? "all") !== "all" - : q.caseScope !== "all" || q.submission !== "all")) + : q.caseScope !== "all" || q.submission !== "all" || (q.evaluation ?? "all") !== "all")) ); }); private searchNavigation: { value: string } | null = null; @@ -276,7 +313,8 @@ export class ProgramManagementComponent implements OnInit { ? (this.state.context?.query.offset ?? 0) : 0, ); - readonly rangeEnd = computed(() => Math.min(this.offset() + 25, this.count())); + readonly pageSize = 20; + readonly rangeEnd = computed(() => Math.min(this.offset() + this.pageSize, this.count())); readonly modalOpen = computed(() => !!this.confirmation() || !!this.editingMaterial()); readonly requestError = computed(() => this.errorMessage(this.state.error())); @@ -366,6 +404,21 @@ export class ProgramManagementComponent implements OnInit { : "basic", ); this.section.set(section); + const selectionKey = JSON.stringify([ + section, + this.route.parent?.snapshot.paramMap.get("programId"), + q.keys + .filter(key => key !== "offset" && key !== "selected") + .sort() + .map(key => [key, q.getAll(key)]), + ]); + if (selectionKey !== this.selectionKey) { + this.clearRows(); + this.participantRemoval.set(null); + this.tableError.set(null); + this.tableMessage.set(null); + this.selectionKey = selectionKey; + } this.formError.set(null); this.saved.set(false); const offset = Number(q.get("offset") ?? 0), @@ -390,6 +443,7 @@ export class ProgramManagementComponent implements OnInit { { emitEvent: false }, ); this.submissionFilter.setValue(q.get("submission") ?? "all", { emitEvent: false }); + this.evaluationFilter.setValue(q.get("evaluation") ?? "all", { emitEvent: false }); this.regionFilter.setValue( q.get("region_scope") === "selected" ? `region:${q.get("region") ?? ""}` @@ -411,7 +465,13 @@ export class ProgramManagementComponent implements OnInit { query: { search: (q.get("search") ?? "").slice(0, 200), offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0, - limit: 25, + limit: this.pageSize, + evaluation: + q.get("evaluation") === "evaluated" + ? "evaluated" + : q.get("evaluation") === "not_evaluated" + ? "not_evaluated" + : "all", submission: q.get("submission") === "submitted" ? "submitted" @@ -459,7 +519,7 @@ export class ProgramManagementComponent implements OnInit { }); } page(delta: number): void { - void this.changeQuery({ offset: Math.max(0, this.offset() + delta * 25) }); + void this.changeQuery({ offset: Math.max(0, this.offset() + delta * this.pageSize) }); } changeParticipantFilter(field: "region" | "activity" | "from" | "to", value: string): void { const control = { @@ -504,6 +564,7 @@ export class ProgramManagementComponent implements OnInit { case_scope: null, case_name: null, submission: null, + evaluation: null, region_scope: null, region: null, activity: null, @@ -544,6 +605,149 @@ export class ProgramManagementComponent implements OnInit { if (this.state.selected()?.id === project.id) this.state.clearSelection(); else this.state.select(project); } + clearRows(): void { + ++this.selectionSequence; + this.selectingRows.set(false); + this.selectedRows.set(new Set()); + this.selectedProjects.set(new Map()); + } + toggleRow(row: ManagementParticipant | ManagementProject): void { + if (this.state.pending() || this.selectingRows() || this.removingParticipants()) return; + const ids = new Set(this.selectedRows()), + projects = new Map(this.selectedProjects()); + if (ids.has(row.id)) { + ids.delete(row.id); + projects.delete(row.id); + } else { + ids.add(row.id); + if ("projectId" in row) + projects.set(row.id, { + id: row.id, + projectId: row.projectId, + submitted: row.submitted, + caseName: row.caseName, + draft: !!row.draft, + }); + } + this.selectedRows.set(ids); + this.selectedProjects.set(projects); + } + selectAllRows(): void { + const ctx = this.state.context, + kind = this.section(); + if ( + !ctx || + (kind !== "participants" && kind !== "projects") || + this.selectingRows() || + this.state.pending() + ) + return; + if (this.allRowsSelected()) { + this.clearRows(); + return; + } + const key = this.selectionKey, + seq = ++this.selectionSequence; + this.selectingRows.set(true); + this.tableError.set(null); + this.state.api + .selection(ctx.programId, kind, ctx.query) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => { + if (seq === this.selectionSequence) this.selectingRows.set(false); + }), + ) + .subscribe({ + next: result => { + if (key !== this.selectionKey || seq !== this.selectionSequence) return; + this.selectedRows.set(new Set(result.ids)); + this.selectedProjects.set(new Map((result.projects ?? []).map(p => [p.id, p]))); + }, + error: () => { + if (seq === this.selectionSequence) + this.tableError.set("Не удалось выбрать все записи. Повторите попытку."); + }, + }); + } + assignSelectedProjects(): void { + if (!this.selectedRows().size || this.assignmentReason()) return; + const id = this.state.program()!.id; + this.assignmentSelection.set( + id, + [...this.selectedProjects().values()].map(p => p.projectId), + ); + void this.router.navigateByUrl(this.paths.program.management(id, "experts")); + } + exportSelectedParticipants(): void { + const ctx = this.state.context; + if (!ctx || !this.selectedRows().size || this.exportingParticipants()) return; + this.exportingParticipants.set(true); + this.tableError.set(null); + this.state.api + .exportSelectedParticipants(ctx.programId, [...this.selectedRows()]) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.exportingParticipants.set(false)), + ) + .subscribe({ + next: file => saveAs(file, `program-${ctx.programId}-participants.xlsx`), + error: () => + this.tableError.set( + "Не удалось выгрузить выбранных участников. Обновите список и повторите попытку.", + ), + }); + } + previewParticipantRemoval(ids: number[]): void { + const ctx = this.state.context; + if (!ctx || !ids.length || this.removingParticipants()) return; + const key = this.selectionKey; + this.removingParticipants.set(true); + this.tableError.set(null); + this.state.api + .previewParticipantRemoval(ctx.programId, ids) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.removingParticipants.set(false)), + ) + .subscribe({ + next: preview => { + if (key === this.selectionKey) this.participantRemoval.set({ ids, preview }); + }, + error: e => { + if (key === this.selectionKey) this.tableError.set(this.errorMessage(e)); + }, + }); + } + removeParticipants(): void { + const removal = this.participantRemoval(), + ctx = this.state.context; + if (!removal || !ctx || this.removingParticipants()) return; + this.removingParticipants.set(true); + this.tableError.set(null); + this.state.api + .removeParticipants(ctx.programId, removal.ids, removal.preview.previewToken) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.removingParticipants.set(false)), + ) + .subscribe({ + next: result => { + this.participantRemoval.set(null); + this.clearRows(); + this.tableMessage.set(`Удалено участников из программы: ${result.removed}.`); + const lastOffset = + Math.max(0, Math.ceil((this.count() - result.removed) / this.pageSize) - 1) * + this.pageSize; + if (this.offset() > lastOffset) void this.changeQuery({ offset: lastOffset }); + else this.state.retry(); + }, + error: e => { + this.participantRemoval.set(null); + this.tableError.set(this.errorMessage(e)); + }, + }); + } selectFromRow(event: MouseEvent, project: ManagementProject): void { if (!(event.target as HTMLElement).closest("a,button,input,label")) this.select(project); } @@ -560,8 +764,63 @@ export class ProgramManagementComponent implements OnInit { { injector: this.injector }, ); } - openTeam(event: Event): void { - this.teamTrigger = event.currentTarget as HTMLElement; + projectActions(project: ManagementProject): RowAction[] { + const competitive = this.state.program()?.isCompetitive; + const actions: RowAction[] = [ + { id: "team", label: "Посмотреть команду" }, + { + id: "evaluation", + label: "Перейти к оценке", + routerLink: this.evaluationPath(project.id), + queryParams: { ...this.returnQuery(), selected: project.id }, + disabled: !competitive || !project.submitted, + reason: !competitive + ? "В программе нет оценивания" + : !project.submitted + ? "Решение еще не сдано" + : undefined, + }, + ]; + const presentation = this.href(project.presentationUrl); + if (presentation) + actions.push({ id: "presentation", label: "Открыть презентацию", href: presentation }); + return actions; + } + projectAction(project: ManagementProject, event: RowActionEvent): void { + if (event.id === "team") { + this.state.select(project); + this.openTeam(event.trigger); + } + } + materialActions(): RowAction[] { + return [ + { id: "edit", label: "Изменить" }, + { id: "delete", label: "Удалить", destructive: true }, + ]; + } + materialAction(material: ManagementMaterial, event: RowActionEvent): void { + if (event.id === "edit") this.editMaterial(material); + else if (event.id === "delete") void this.deleteMaterial(material); + } + participantActions(person: ManagementParticipant): RowAction[] { + const actions: RowAction[] = [ + { + id: "profile", + label: "Открыть профиль", + routerLink: this.paths.profile.detail(person.userId), + }, + ]; + if (person.registrationAnswers?.length) + actions.push({ id: "answers", label: "Посмотреть анкету" }); + actions.push({ id: "remove", label: "Удалить из программы", destructive: true }); + return actions; + } + participantAction(person: ManagementParticipant, event: RowActionEvent): void { + if (event.id === "answers") this.participantAnswers.set(person); + else if (event.id === "remove") this.previewParticipantRemoval([person.id]); + } + openTeam(event: Event | HTMLElement): void { + this.teamTrigger = event instanceof HTMLElement ? event : (event.currentTarget as HTMLElement); this.state.openTeam(); afterNextRender(() => this.document.getElementById("management-team-heading")?.focus(), { injector: this.injector, @@ -597,6 +856,7 @@ export class ProgramManagementComponent implements OnInit { async canLeave(): Promise { if ( this.saving() || + this.removingParticipants() || this.uploading() || this.projectFormStatus().busy || this.criteriaStatus().busy || diff --git a/projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.html b/projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.html index 9397d3daa..c76e93b51 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.html @@ -62,32 +62,15 @@

Организаторы

+ @for (row of visible(); track row.id) { - + + } @empty { -
- Проекты программы. Можно выбрать один проект. + Проекты программы.
Выбор проекта + + № Проект РуководительОценивание Кейс ПрезентацияДействия
{{ offset() + index + 1 }} + +
Организатор Email СтатусДействия
- Организаторы >{{ label(row) }}
+ +
+ {{ loading() ? "Загружаем список…" @@ -147,64 +138,6 @@

Организаторы

} - @if (selected(); as row) { -
- {{ row.name || row.email }} - @if (row.isSelf) { - · вы - } - -
- @if (row.status === "joined") { - - } @else { - - } - -
- @if (row.status === "joined" && !row.canRemove) { -

- Сначала пригласите коллегу и дождитесь принятия. -

- } -
- } { - it("переключает выбор повторным нажатием и не запрашивает список при выборе", () => { + it("меню своей строки открывается без плашки и без повторного запроса списка", () => { const { fixture, component, api } = setup(); const calls = api.list.mock.calls.length; const row = fixture.nativeElement.querySelector( 'tr[data-organizer-id="member:2"]', ) as HTMLElement; - row.click(); + (row.querySelector("app-row-actions button") as HTMLButtonElement).click(); fixture.detectChanges(); - expect(component.selected()?.userId).toBe(2); - expect(fixture.nativeElement.querySelector(".organizers__floating")).not.toBeNull(); - row.click(); + expect(fixture.nativeElement.querySelector(".organizers__floating")).toBeNull(); + const remove = Array.from(document.querySelectorAll("[role=menuitem]")).find( + n => n.textContent?.trim() === "Убрать доступ", + )!; + remove.click(); fixture.detectChanges(); - expect(component.selected()).toBeNull(); + expect(component.selected()?.userId).toBe(2); + expect(component.modal()).toBe("remove"); expect(api.list).toHaveBeenCalledTimes(calls); }); + it("оставляет ввод и фокус поиска при обновлении данных", () => { const { fixture, component } = setup(); const search = fixture.nativeElement.querySelector("input[type=search]") as HTMLInputElement; diff --git a/projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.ts b/projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.ts index 4457c8ac2..19ec2e15c 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.ts @@ -32,6 +32,11 @@ import { ProgramOrganizers, } from "@domain/program/program-organizers.model"; import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { + RowAction, + RowActionEvent, + RowActionsComponent, +} from "@ui/primitives/row-actions/row-actions.component"; import { evaluationError } from "@utils/evaluation-workspace"; import { presentationHref } from "@utils/program-case-analytics"; @@ -45,6 +50,7 @@ import { presentationHref } from "@utils/program-case-analytics"; ChoiceDirective, FieldDirective, ModalComponent, + RowActionsComponent, ], templateUrl: "./organizers.component.html", styleUrl: "./organizers.component.scss", @@ -76,7 +82,7 @@ export class ManagementOrganizersComponent { private readonly query = signal(""); private readonly statusValue = signal("all"); readonly page = signal(0); - readonly pageSize = 25; + readonly pageSize = 20; readonly selectedId = signal(null); readonly selected = computed( () => this.snapshot()?.organizers.find(r => r.id === this.selectedId()) ?? null, @@ -285,9 +291,36 @@ export class ManagementOrganizersComponent { ? "Ожидает отправки" : "Приглашен"; } - open(mode: "invite" | "remove" | "revoke", event?: Event): void { + rowActions(row: OrganizerRow): RowAction[] { + return row.status === "joined" + ? [ + { + id: "remove", + label: row.isSelf ? "Выйти из организаторов" : "Убрать доступ", + destructive: true, + disabled: !row.canRemove, + reason: !row.canRemove ? "Сначала пригласите коллегу и дождитесь принятия." : undefined, + }, + ] + : [ + { id: "resend", label: "Отправить повторно" }, + { id: "revoke", label: "Отозвать приглашение", destructive: true }, + ]; + } + rowAction(row: OrganizerRow, event: RowActionEvent): void { + if (this.busy()) return; + this.selectedId.set(row.id); + this.trigger = event.trigger; + if (event.id === "resend") this.invitationAction("resend"); + else if (event.id === "remove" && row.canRemove) this.open("remove", event.trigger); + else if (event.id === "revoke") this.open("revoke", event.trigger); + } + open(mode: "invite" | "remove" | "revoke", event?: Event | HTMLElement): void { if (this.busy()) return; - this.trigger = (event?.currentTarget as HTMLElement) ?? (document.activeElement as HTMLElement); + this.trigger = + event instanceof HTMLElement + ? event + : ((event?.currentTarget as HTMLElement) ?? (document.activeElement as HTMLElement)); this.dialogError.set(null); this.email.reset(""); this.dirty.set(false); diff --git a/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.html b/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.html new file mode 100644 index 000000000..d6cd23376 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.html @@ -0,0 +1,68 @@ + + + +
+ @for (item of actions(); track item.id) { + @if (item.routerLink) { + {{ item.label }} + @if (item.disabled && item.reason) { + {{ item.reason }} + } + + } @else if (item.href) { + {{ item.label }} + @if (item.disabled && item.reason) { + {{ item.reason }} + } + + } @else { + + } + } +
+
diff --git a/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.scss b/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.scss new file mode 100644 index 000000000..57ef7f9a2 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.scss @@ -0,0 +1,84 @@ +/** @format */ +:host { + display: inline-flex; + vertical-align: middle; +} + +.row-actions { + &__trigger { + width: 36px; + min-width: 36px; + min-height: 32px; + padding: 6px; + color: var(--accent); + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + @media (pointer: coarse) { + width: 44px; + min-width: 44px; + min-height: 44px; + } + } + + &__menu { + display: grid; + width: 250px; + max-width: calc(100vw - 32px); + max-height: calc(100dvh - 32px); + padding: 6px; + overflow-y: auto; + background: var(--white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + box-shadow: 0 4px 12px rgb(0 0 0 / 12%); + } + + &__item { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 36px; + padding: 8px 12px; + font: inherit; + font-size: 12px; + line-height: 20px; + color: var(--black); + text-align: left; + overflow-wrap: anywhere; + cursor: pointer; + background: transparent; + border: 0; + border-radius: 4px; + + &:hover, + &:focus-visible { + background: var(--accent-light); + outline: 0; + } + + &[aria-disabled="true"] { + color: var(--grey-for-text); + cursor: default; + background: transparent; + } + + &--destructive { + color: var(--red); + } + + @media (pointer: coarse) { + min-height: 44px; + } + } + + &__reason { + display: block; + font-size: 11px; + line-height: 16px; + } +} diff --git a/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.spec.ts b/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.spec.ts new file mode 100644 index 000000000..6e8c58b6d --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.spec.ts @@ -0,0 +1,57 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { OverlayContainer } from "@angular/cdk/overlay"; +import { RowActionsComponent } from "./row-actions.component"; + +describe("Меню действий строки", () => { + function setup() { + TestBed.configureTestingModule({ providers: [provideRouter([])] }); + const fixture = TestBed.createComponent(RowActionsComponent); + fixture.componentRef.setInput("label", "Действия проекта: Финплан"); + fixture.componentRef.setInput("actions", [ + { id: "team", label: "Посмотреть команду" }, + { id: "rate", label: "Перейти к оценке", disabled: true }, + ]); + const chosen = vi.fn(); + fixture.componentInstance.action.subscribe(chosen); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector("button") as HTMLButtonElement; + const overlay = TestBed.inject(OverlayContainer).getContainerElement(); + return { fixture, trigger, overlay, chosen }; + } + it("открывает меню в отдельном слое и сообщает действие вместе с триггером строки", () => { + const { fixture, trigger, overlay, chosen } = setup(); + fixture.nativeElement.style.overflow = "hidden"; + trigger.click(); + fixture.detectChanges(); + const menu = overlay.querySelector('[role="menu"]')!; + expect(menu).not.toBeNull(); + expect(fixture.nativeElement.contains(menu)).toBe(false); + (menu.querySelector("button") as HTMLButtonElement).click(); + expect(chosen).toHaveBeenCalledWith({ id: "team", trigger }); + }); + it("не запускает запрещенное действие", () => { + const { fixture, trigger, overlay, chosen } = setup(); + trigger.click(); + fixture.detectChanges(); + const disabled = overlay.querySelector('[aria-disabled="true"]') as HTMLButtonElement; + expect(disabled.textContent).toContain("Перейти к оценке"); + disabled.click(); + expect(chosen).not.toHaveBeenCalled(); + }); + it("закрывается по Escape и возвращает фокус кнопке своей строки", () => { + const { fixture, trigger, overlay } = setup(); + trigger.focus(); + trigger.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", keyCode: 40, bubbles: true }), + ); + fixture.detectChanges(); + const menu = overlay.querySelector('[role="menu"]') as HTMLElement; + expect(menu).not.toBeNull(); + menu.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", keyCode: 27, bubbles: true })); + fixture.detectChanges(); + expect(overlay.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); +}); diff --git a/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.ts b/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.ts new file mode 100644 index 000000000..f89b90662 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/row-actions/row-actions.component.ts @@ -0,0 +1,51 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + input, + output, + viewChild, +} from "@angular/core"; +import { CdkMenuModule } from "@angular/cdk/menu"; +import { ConnectedPosition } from "@angular/cdk/overlay"; +import { Params, RouterLink } from "@angular/router"; +import { ButtonDirective, IconComponent } from "@uilib"; + +export interface RowAction { + id: string; + label: string; + disabled?: boolean; + reason?: string; + destructive?: boolean; + routerLink?: string; + queryParams?: Params; + href?: string; +} +export interface RowActionEvent { + id: string; + trigger: HTMLElement; +} + +/** Командное меню строки: CDK управляет фокусом, клавиатурой и слоем overlay. */ +@Component({ + selector: "app-row-actions", + imports: [CdkMenuModule, RouterLink, ButtonDirective, IconComponent], + templateUrl: "./row-actions.component.html", + styleUrl: "./row-actions.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RowActionsComponent { + readonly label = input.required(); + readonly actions = input.required(); + readonly disabled = input(false); + readonly action = output(); + readonly trigger = viewChild.required>("trigger"); + readonly positions: ConnectedPosition[] = [ + { originX: "end", originY: "bottom", overlayX: "end", overlayY: "top", offsetY: 4 }, + { originX: "end", originY: "top", overlayX: "end", overlayY: "bottom", offsetY: -4 }, + ]; + choose(item: RowAction): void { + if (!item.disabled) this.action.emit({ id: item.id, trigger: this.trigger().nativeElement }); + } +} From c6d9d67a2cba2ccfd5aabecae283480c15b6e84e Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 00:17:50 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82=D0=B8=D1=8F=20=D1=87?= =?UTF-8?q?=D0=B5=D0=BA=D0=B1=D0=BE=D0=BA=D1=81=D0=BE=D0=B2=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=81=D0=BF=D0=B5=D1=80=D1=82=D0=BE=D0=B2=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../program/management/experts/experts.component.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.scss b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.scss index f0e23a530..c208f5a1a 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.scss @@ -823,7 +823,7 @@ padding-inline: 4px !important; } -.experts__pick-target { +.experts .experts__pick-target { display: inline-flex; align-items: center; justify-content: center; @@ -854,7 +854,7 @@ .experts__table--people td:nth-child(2) { grid-column: 1 / -1; - padding-inline: 40px; + padding-inline: 48px; } .experts__table--people td:nth-child(n + 3) { @@ -863,9 +863,9 @@ .experts__table--people td.experts__pick { position: absolute; - top: 16px; + top: 8px; left: 0; - width: 32px; + width: 44px; } .experts__table td.experts__row-actions,