Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ManagementProjectActionPreview>;
abstract applyProjectAction(
id: number,
body: ManagementProjectActionWrite & { previewToken: string },
): Observable<ManagementProjectActionResult>;
abstract results(programId: number): Observable<ProgramResults>;
abstract exportResults(programId: number, query: ResultsQuery): Observable<Blob>;
abstract criteria(programId: number): Observable<ManagementCriteria>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ManagementProjectActionPreview>(
`${this.root(id)}/projects/actions/preview/`,
body,
);
}
applyProjectAction(id: number, body: ManagementProjectActionWrite & { previewToken: string }) {
return this.api.post<ManagementProjectActionResult>(
`${this.root(id)}/projects/actions/apply/`,
body,
);
}
private readonly api = inject(ApiService);
results(id: number) {
return this.api.get<ProgramResults>(`${this.root(id)}/results/`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ <h3>
projectFiltersOpen() ||
participantFiltersOpen() ||
!!participantRemoval() ||
!!projectActionRequest() ||
!!participantAnswers()
"
[style.left.px]="floatingPosition()?.center"
Expand All @@ -518,7 +519,7 @@ <h3>
>
@if (section() === "projects" && assignmentReason()) {
<p id="project-assignment-reason" class="management__bulk-hint">
{{ assignmentReason() }}
Назначение экспертов: {{ assignmentReason() }}
</p>
}
</div>
Expand All @@ -545,15 +546,25 @@ <h3>
Удалить из программы
</button>
} @else {
<button
appButton="primary"
type="button"
[disabled]="!!assignmentReason()"
[attr.aria-describedby]="assignmentReason() ? 'project-assignment-reason' : null"
(click)="assignSelectedProjects()"
>
Назначить экспертов
</button>
<div class="management__bulk-actions">
<button
appButton="secondary"
type="button"
[disabled]="!state.program()?.isCompetitive"
(click)="openProjectAction('submit')"
>
Сдать решения
</button>
<button
appButton="primary"
type="button"
[disabled]="!!assignmentReason()"
[attr.aria-describedby]="assignmentReason() ? 'project-assignment-reason' : null"
(click)="assignSelectedProjects()"
>
Назначить экспертов
</button>
</div>
}
<button
appButton="icon"
Expand Down Expand Up @@ -1038,6 +1049,13 @@ <h3>Изменятся команды заявок</h3>
</section>
}
</app-modal>
<app-management-project-actions
[request]="projectActionRequest()"
[caseOptions]="state.projects()?.caseOptions ?? []"
(closed)="closeProjectAction()"
(busyChange)="projectActionBusy.set($event)"
(completed)="projectActionCompleted($event)"
/>
<app-modal
[open]="!!participantAnswers()"
labelledBy="participant-answers-heading"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,12 @@
align-items: center;
}

.management__bulk-actions {
display: flex;
gap: 8px;
align-items: center;
}

.management__bulk--projects > button.management__clear-selection {
grid-column: 3;
align-self: center;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<h1>Профиль участника</h1>" })
class ProfileNavigationTarget {}
Expand Down Expand Up @@ -193,6 +194,18 @@ describe("Списки управления программой в DOM", () =>
team: signal<ManagementTeam | null>(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<Blob>>(() => new Subject<Blob>()),
selection: vi.fn(() => of({ ids: Array.from({ length: 41 }, (_, i) => i + 1) })),
previewParticipantRemoval: vi.fn<() => Observable<ParticipantRemovalPreview>>(() =>
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down
Loading