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 @@ -13,6 +13,10 @@ export class ExportFileService {

private readonly apiService = inject(ApiService);

exportProgram(programId: number) {
return this.apiService.getFile(`/programs/${programId}/manager-overview/export/`);
}

exportProgramRates(programId: number) {
return this.apiService.getFile(
`${this.EXPORT_PROGRAM_FILE_URL}${programId}${this.EXPORT_PROGRAM_RATES_URL}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** @format */
import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ApiService, LoggerService } from "@corelib";
import { Subject } from "rxjs";
import { ProgramDetailMainUIInfoService } from "../../program/facades/detail/ui/program-detail-main-ui-info.service";
import { ExportFileInfoService } from "./export-file-info.service";
import { saveFile } from "@utils/export-file";

vi.mock("@utils/export-file", () => ({ saveFile: vi.fn() }));

describe("Consolidated program download", () => {
const program = signal<{ id: number; name: string } | null>({ id: 12, name: "Программа" });
let response: Subject<Blob>;
let state: ExportFileInfoService;
const api = { getFile: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
program.set({ id: 12, name: "Программа" });
response = new Subject<Blob>();
api.getFile.mockReturnValue(response);
TestBed.configureTestingModule({
providers: [
ExportFileInfoService,
{ provide: ApiService, useValue: api },
{ provide: LoggerService, useValue: { error: vi.fn() } },
{ provide: ProgramDetailMainUIInfoService, useValue: { program } },
],
});
state = TestBed.inject(ExportFileInfoService);
});

it("requests the program scoped workbook once and preserves the requested file name", () => {
state.downloadProgram();
state.downloadProgram();
expect(api.getFile).toHaveBeenCalledExactlyOnceWith("/programs/12/manager-overview/export/");
expect(state.loadingExports$().status).toBe("loading");
program.set({ id: 13, name: "Другая программа" });
const blob = new Blob(["workbook"]);
response.next(blob);
response.complete();
expect(saveFile).toHaveBeenCalledExactlyOnceWith(blob, "program", "Программа");
expect(state.loadingExports$().status).toBe("success");
});

it("allows a retry after a download error", () => {
state.downloadProgram();
response.error(new Error("network"));
expect(state.loadingExports$().status).toBe("failure");
expect(saveFile).not.toHaveBeenCalled();
api.getFile.mockReturnValue(new Subject<Blob>());
state.downloadProgram();
expect(api.getFile).toHaveBeenCalledTimes(2);
});

it("does not request before the program is available", () => {
program.set(null);
state.downloadProgram();
expect(api.getFile).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ export class ExportFileInfoService {

readonly loadingExports$ = signal<AsyncState<void>>(initial());

downloadProgram(): void {
const program = this.program();
if (!program || this.loadingExports$().status === "loading") return;
this.loadingExports$.set(loading());
this.exportFileService
.exportProgram(program.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: blob => {
saveFile(blob, "program", program.name);
this.loadingExports$.set(success(undefined));
},
error: () => this.loadingExports$.set(failure("program_export_failed")),
});
}

downloadProjects(): void {
this.loadingExports$.set(loading());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ export class ProgramAnalyticsDrilldownService {
: selection.ids.length;
});

readonly allFoundSelected = computed(
() => this.selectedCount() > 0 && this.selectedCount() === this.attentionCount(),
);
readonly someFoundSelected = computed(() => this.selectedCount() > 0 && !this.allFoundSelected());

toggleAllFound(): void {
if (this.allFoundSelected()) this.clearSelection();
else this.selectAllFound();
}

isSelected(id: number): boolean {
const selection = this.selection();
return selection.mode === "all"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ describe("Attention bulk actions", () => {
expect(actions.send).toHaveBeenCalledTimes(1);
});

it("header selection switches between mixed, all found and cleared states", () => {
expect(state.allFoundSelected()).toBe(false);
expect(state.someFoundSelected()).toBe(false);
state.toggleSelection(123);
expect(state.someFoundSelected()).toBe(true);
state.toggleAllFound();
expect(state.allFoundSelected()).toBe(true);
expect(state.someFoundSelected()).toBe(false);
state.toggleSelection(123);
expect(state.someFoundSelected()).toBe(true);
state.toggleAllFound();
expect(state.allFoundSelected()).toBe(true);
state.toggleAllFound();
expect(state.selectedCount()).toBe(0);
});

it("does not claim emails were queued when every recipient became unavailable", () => {
state.toggleSelection(123);
state.previewReminder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,70 +2,43 @@

<main class="analytics" aria-labelledby="program-analytics-title">
<header class="analytics__header">
<div>
<p class="analytics__eyebrow text-body-10">Управление программой</p>
<div class="analytics__title-row">
<h1 id="program-analytics-title" class="analytics__title text-heading-3">Аналитика</h1>
<p class="analytics__subtitle text-body-10">
Сводка пути участников от регистрации до экспертной оценки.
</p>
@if (data()) {
<button
appButton="icon"
type="button"
class="regions__export analytics__export-trigger"
[cdkMenuTriggerFor]="analyticsExports"
[disabled]="loadingExports()"
[attr.aria-busy]="loadingExports()"
aria-label="Выгрузить данные программы"
title="Выгрузить данные программы"
>
<i appIcon icon="download" appSquare="18" aria-hidden="true"></i>
</button>
<ng-template #analyticsExports>
<div cdkMenu class="analytics-export-menu" aria-label="Выгрузки программы">
<button cdkMenuItem type="button" (click)="downloadProgram()">Вся программа</button>
<button cdkMenuItem type="button" (click)="downloadProjects()">Все проекты</button>
<button cdkMenuItem type="button" (click)="downloadSubmittedProjects()">
Сданные проекты
</button>
<button cdkMenuItem type="button" (click)="downloadRates()">Оценки проектов</button>
</div>
</ng-template>
}
</div>

@if (data()) {
<section class="exports" aria-labelledby="analytics-exports-title">
<div class="analytics__heading-row">
<h2 id="analytics-exports-title" class="text-body-12">Выгрузки</h2>
<app-tooltip
data-testid="exports-tooltip"
text="Скачайте проекты программы или выставленные проектные оценки."
[isVisible]="isTooltipVisible('exports')"
[useOverlay]="true"
position="left"
(click)="toggleTooltip('exports')"
(show)="showTooltip('exports')"
(hide)="hideTooltip('exports')"
></app-tooltip>
</div>
<div class="exports__actions">
<app-button
appearance="outline"
size="big"
[loader]="loadingExports()"
[disabled]="loadingExports()"
customTypographyClass="text-body-10"
(click)="downloadProjects()"
>
Все проекты
</app-button>
<app-button
appearance="outline"
size="big"
[loader]="loadingExports()"
[disabled]="loadingExports()"
customTypographyClass="text-body-10"
(click)="downloadSubmittedProjects()"
>
Сданные проекты
</app-button>
<app-button
appearance="outline"
size="big"
[loader]="loadingExports()"
[disabled]="loadingExports()"
customTypographyClass="text-body-10"
(click)="downloadRates()"
>
Оценки проектов
</app-button>
</div>
@if (exportFailed()) {
<app-state
class="exports__error text-body-10"
[compact]="true"
kind="error"
title="Не удалось подготовить выгрузку. Повторите попытку."
/>
}
</section>
@if (loadingExports()) {
<p class="text-body-10" role="status">Подготавливаем Excel…</p>
}
@if (exportFailed()) {
<app-state
class="exports__error text-body-10"
[compact]="true"
kind="error"
title="Не удалось подготовить выгрузку. Повторите попытку."
/>
}
</header>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,6 @@
background: var(--light-white);
border: 1px solid var(--medium-grey-for-outline);
border-radius: 15px;

@include responsive.apply-desktop {
flex-direction: row;
align-items: flex-start;
}
}

&__eyebrow {
Expand All @@ -46,6 +41,14 @@
margin: 0;
}

&__title-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
width: 100%;
}

&__subtitle {
max-width: 600px;
margin-top: 8px;
Expand Down Expand Up @@ -289,6 +292,8 @@
}

.program-deadlines {
display: flex;
flex-direction: column;
align-self: start;
min-height: 0;

Expand Down Expand Up @@ -372,6 +377,49 @@
}
}

.analytics-grid--priority .attention__list,
.analytics-grid--priority .program-deadlines__list {
flex: 1;
grid-auto-rows: minmax(68px, 1fr);
gap: 8px;
align-content: stretch;
margin-top: 16px;
}

.attention__list .attention__action {
height: 100%;
padding-block: 10px;
}

.analytics-export-menu {
display: grid;
min-width: 220px;
padding: 6px;
background: var(--light-white);
border: 1px solid var(--medium-grey-for-outline);
border-radius: 10px;
box-shadow: 0 8px 24px rgb(51 51 51 / 12%);

button {
padding: 10px 12px;
font: inherit;
font-size: 12px;
color: var(--black);
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 6px;

&:hover,
&:focus-visible {
background: var(--accent-light);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
}
}

.cases-card {
&__title {
font-size: var(--ui-font-h3);
Expand Down Expand Up @@ -587,6 +635,7 @@
flex-direction: column;
align-self: start;
width: 100%;
padding-left: 15px;
background: var(--light-white);
border-color: var(--medium-grey-for-outline);
border-left: 4px solid var(--red);
Expand Down
Loading