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
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@angular/router": "^20.3.23",
"@sentry/angular": "^10.55.0",
"camelcase-keys": "^8.0.1",
"chart.js": "4.5.1",
"class-transformer": "^0.5.1",
"core-js": "^3.23.4",
"dayjs": "^1.11.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.m
import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case";
import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case";
import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case";
import { GetProgramManagerParticipantsWithoutVisitUseCase } from "@api/program/use-cases/get-program-manager-participants-without-visit.use-case";
import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case";
import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case";
import {
Expand All @@ -27,6 +28,7 @@ import {

/** Root-списки внимания используют тот же modal context, не вложенную модалку. */
export type AnalyticsAttentionView =
| "participants-without-visit"
| "participants-without-team"
| "projects-awaiting-evaluation"
| "projects-not-submitted";
Expand Down Expand Up @@ -58,6 +60,7 @@ export class ProgramAnalyticsDrilldownService {
private readonly getAssignments = inject(GetProgramManagerAssignmentsUseCase);
private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase);
private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase);
private readonly getWithoutVisit = inject(GetProgramManagerParticipantsWithoutVisitUseCase);
private readonly getProjects = inject(GetProgramManagerProjectsAwaitingEvaluationUseCase);
private readonly getNotSubmitted = inject(GetProgramManagerProjectsNotSubmittedUseCase);
private readonly destroyRef = inject(DestroyRef);
Expand Down Expand Up @@ -89,14 +92,18 @@ export class ProgramAnalyticsDrilldownService {
readonly notSubmittedPage = signal<ProgramAnalyticsNotSubmittedProjectsPage | null>(null);
readonly attentionPending = signal(false);
readonly attentionError = signal<ProgramAnalyticsError | null>(null);
readonly isParticipantView = computed(
() =>
this.view() === "participants-without-team" || this.view() === "participants-without-visit",
);
readonly isAttentionView = computed(
() =>
this.view() === "participants-without-team" ||
this.isParticipantView() ||
this.view() === "projects-awaiting-evaluation" ||
this.view() === "projects-not-submitted",
);
readonly attentionPage = computed(() =>
this.view() === "participants-without-team"
this.isParticipantView()
? this.participantsPage()
: this.view() === "projects-not-submitted"
? this.notSubmittedPage()
Expand Down Expand Up @@ -167,8 +174,10 @@ export class ProgramAnalyticsDrilldownService {
limit: this.attentionLimit,
offset: this.attentionOffset(),
};
if (this.view() === "participants-without-team") {
this.getParticipants
if (this.isParticipantView()) {
const useCase =
this.view() === "participants-without-visit" ? this.getWithoutVisit : this.getParticipants;
useCase
.execute(this.programId, query)
.pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ describe("ProgramAnalyticsInfoService", () => {

expect(service.error()?.kind).toBe("forbidden");
expect(getOverview.execute).not.toHaveBeenCalled();
expect(service.scheduleDates()).toBeNull();
});

it("reads schedule dates from the authorized resolved program without extra API requests", () => {
const dates = {
datetimeRegistrationEnds: "2026-10-14T18:00:00+03:00",
datetimeProjectSubmissionEnds: "",
datetimeEvaluationEnds: "2026-10-20T18:00:00+03:00",
};
programUI.program.set({ ...Program.default(), id: 12, isUserManager: true, ...dates });
expect(service.scheduleDates()).toBeNull();
service.initialize();
TestBed.tick();
expect(service.scheduleDates()).toEqual(dates);
expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12);
});

it("сохраняет loading до завершения manager overview", () => {
Expand Down Expand Up @@ -129,6 +144,7 @@ describe("ProgramAnalyticsInfoService", () => {
expect(old.observed).toBe(false);
expect(service.programId()).toBeNull();
expect(service.data()).toBeNull();
expect(service.scheduleDates()).toBeNull();
expect(service.pending()).toBe(true);
expect(programUI.program()?.id).toBe(12);
expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12);
Expand All @@ -138,6 +154,11 @@ describe("ProgramAnalyticsInfoService", () => {
programUI.program.set({ ...Program.default(), id: 13, isUserManager: true });
TestBed.tick();
expect(service.programId()).toBe(13);
expect(service.scheduleDates()).toEqual({
datetimeRegistrationEnds: "",
datetimeProjectSubmissionEnds: "",
datetimeEvaluationEnds: "",
});
expect(getOverview.execute).toHaveBeenCalledTimes(2);
expect(getOverview.execute).toHaveBeenLastCalledWith(13);
old.next(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service";
import { distinctUntilChanged, map, Subject, takeUntil } from "rxjs";
import { Program } from "@domain/program/program.model";
import { ProgramScheduleDates } from "@utils/program-deadlines";

/** Состояние и загрузка внутренней вкладки аналитики программы. */
@Injectable()
Expand All @@ -35,6 +36,15 @@ export class ProgramAnalyticsInfoService {
private loadedProgram: Program | undefined;
readonly programId = signal<number | null>(null);
readonly programName = computed(() => this.programUI.program()?.name ?? "program");
readonly scheduleDates = computed<ProgramScheduleDates | null>(() => {
const program = this.programUI.program();
if (!program?.isUserManager || program.id !== this.programId()) return null;
return {
datetimeRegistrationEnds: program.datetimeRegistrationEnds,
datetimeProjectSubmissionEnds: program.datetimeProjectSubmissionEnds,
datetimeEvaluationEnds: program.datetimeEvaluationEnds,
};
});

constructor() {
this.route.parent?.paramMap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ProgramCaseProjectsService } from "./program-case-projects.service";

vi.mock("@utils/program-case-analytics", () => ({ saveCaseProjects: vi.fn() }));

describe("Case drilldown: контекст, поиск, страницы и независимый export", () => {
describe("Case drilldown: контекст, фильтры, страницы и export найденных", () => {
const get = { execute: vi.fn() };
const download = { execute: vi.fn() };
let service: ProgramCaseProjectsService;
Expand Down Expand Up @@ -43,7 +43,7 @@ describe("Case drilldown: контекст, поиск, страницы и не
pending.next(ok(casePage()));
expect(service.canExport()).toBe(true);
});
it("search сбрасывает страницу; метрики/export сохраняют полный bucket при no results", () => {
it("search сбрасывает страницу, сохраняет метрики кейса и запрещает пустую выгрузку", () => {
service.open(12, selectedCase);
service.offset.set(25);
service.searchDraft.set(" неизвестный проект ");
Expand All @@ -52,14 +52,16 @@ describe("Case drilldown: контекст, поиск, страницы и не
expect(get.execute).toHaveBeenLastCalledWith(12, {
selection: selectedCase,
search: "неизвестный проект",
submission: "all",
presentation: "all",
limit: 25,
offset: 0,
});
expect(service.page()?.count).toBe(0);
expect(service.metrics()).toEqual(caseMetrics);
expect(service.canExport()).toBe(true);
expect(service.canExport()).toBe(false);
service.download();
expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, selectedCase);
expect(download.execute).not.toHaveBeenCalled();
service.clearSearch();
expect(get.execute.mock.lastCall?.[1].search).toBe("");
});
Expand Down Expand Up @@ -137,7 +139,7 @@ describe("Case drilldown: контекст, поиск, страницы и не
}
},
);
it("поиск отменяет предыдущий list, но не прерывает full-case export", () => {
it("поиск отменяет предыдущий list и файл с устаревшим отбором", () => {
const first = new Subject<any>(),
next = new Subject<any>(),
file = new Subject<any>();
Expand All @@ -151,9 +153,9 @@ describe("Case drilldown: контекст, поиск, страницы и не
service.searchDraft.set("Новый");
service.applySearch();
expect(first.observed).toBe(false);
expect(file.observed).toBe(true);
expect(file.observed).toBe(false);
file.next(ok(new Blob(["xlsx"])));
expect(saveCaseProjects).toHaveBeenCalledTimes(1);
expect(saveCaseProjects).not.toHaveBeenCalled();
next.next(ok(casePage()));
});
it("ошибка/retry сохраняет контекст, а отказ прав выключает export", () => {
Expand All @@ -170,6 +172,68 @@ describe("Case drilldown: контекст, поиск, страницы и не
expect(service.exportError()?.kind).toBe("forbidden");
expect(service.canExport()).toBe(false);
});
it("combines filters and search, resets pagination, and exports all matching rows", () => {
service.open(12, selectedCase);
service.offset.set(25);
service.searchDraft.set(" Проект ");
service.setSubmission("submitted");
service.setPresentation("missing");
const filters = { search: "Проект", submission: "submitted", presentation: "missing" };
expect(get.execute).toHaveBeenLastCalledWith(12, {
selection: selectedCase,
...filters,
limit: 25,
offset: 0,
});
service.download();
expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, selectedCase, filters);
service.clearFilters();
expect(service.hasFilters()).toBe(false);
expect(get.execute.mock.lastCall?.[1]).toMatchObject({
search: "",
submission: "all",
presentation: "all",
offset: 0,
});
});
it("changing pages preserves a download with the same filters", () => {
get.execute.mockReturnValue(
of(
ok(
casePage({
count: 26,
results: Array.from({ length: 25 }, (_, i) => caseProject({ programProjectId: i + 1 })),
}),
),
),
);
service.open(12, selectedCase);
const file = new Subject<any>();
download.execute.mockReturnValue(file);
service.download();
get.execute.mockReturnValue(of(ok(casePage({ count: 26, results: [caseProject()] }))));
service.changePage(1);
expect(service.offset()).toBe(25);
expect(file.observed).toBe(true);
expect(service.exportPending()).toBe(true);
file.next(ok(new Blob(["xlsx"])));
expect(saveCaseProjects).toHaveBeenCalledTimes(1);
});
it("filter changes cancel stale downloads and keep export disabled while loading", () => {
service.open(12, selectedCase);
const file = new Subject<any>();
const next = new Subject<any>();
download.execute.mockReturnValue(file);
service.download();
get.execute.mockReturnValue(next);
service.setPresentation("present");
expect(file.observed).toBe(false);
expect(service.canExport()).toBe(false);
file.next(ok(new Blob(["stale"])));
expect(saveCaseProjects).not.toHaveBeenCalled();
next.next(ok(casePage()));
expect(service.canExport()).toBe(true);
});
it("all проверяет одну строку, но выгружает весь scope", () => {
get.execute.mockReturnValue(
of(ok(casePage({ selection: { scope: "all", caseName: null }, caseMetrics: null }))),
Expand All @@ -178,10 +242,16 @@ describe("Case drilldown: контекст, поиск, страницы и не
expect(get.execute).toHaveBeenCalledExactlyOnceWith(12, {
selection: { scope: "all" },
search: "",
submission: "all",
presentation: "all",
offset: 0,
limit: 1,
});
service.download();
expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, { scope: "all" });
expect(download.execute).toHaveBeenCalledExactlyOnceWith(
12,
{ scope: "all" },
{ search: "", submission: "all", presentation: "all" },
);
});
});
Loading