From f5abe5c30c5b9d9b663084800d40fc2c7407f2c5 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 02:05:02 +0300 Subject: [PATCH 1/2] feat(analytics): refine controls, reminders and program exports --- .../api/export-file/export-file.service.ts | 4 + .../facades/export-file-info.service.spec.ts | 61 +++++ .../facades/export-file-info.service.ts | 16 ++ .../program-analytics-drilldown.service.ts | 10 + .../program-attention-actions.service.spec.ts | 16 ++ .../detail/analytics/analytics.component.html | 97 +++----- .../detail/analytics/analytics.component.scss | 54 +++++ .../analytics/analytics.component.spec.ts | 62 ++++-- .../detail/analytics/analytics.component.ts | 8 + .../analytics-attention.component.spec.ts | 22 +- .../analytics-drilldown.component.html | 210 ++++++++---------- .../analytics-drilldown.component.scss | 103 +++++++++ .../analytics-drilldown.component.spec.ts | 9 +- .../analytics-drilldown.component.ts | 7 +- .../src/app/utils/export-file.ts | 10 +- .../src/app/utils/program-deadlines.spec.ts | 6 +- .../src/app/utils/program-deadlines.ts | 2 +- 17 files changed, 475 insertions(+), 222 deletions(-) create mode 100644 projects/social_platform/src/app/api/export-file/facades/export-file-info.service.spec.ts diff --git a/projects/social_platform/src/app/api/export-file/export-file.service.ts b/projects/social_platform/src/app/api/export-file/export-file.service.ts index e6951d2ef..cd56b4488 100644 --- a/projects/social_platform/src/app/api/export-file/export-file.service.ts +++ b/projects/social_platform/src/app/api/export-file/export-file.service.ts @@ -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}`, diff --git a/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.spec.ts b/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.spec.ts new file mode 100644 index 000000000..bdc4b48a3 --- /dev/null +++ b/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.spec.ts @@ -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; + let state: ExportFileInfoService; + const api = { getFile: vi.fn() }; + beforeEach(() => { + vi.clearAllMocks(); + program.set({ id: 12, name: "Программа" }); + response = new Subject(); + 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()); + 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(); + }); +}); diff --git a/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.ts b/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.ts index ee87ba544..de7664ef7 100644 --- a/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.ts +++ b/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.ts @@ -21,6 +21,22 @@ export class ExportFileInfoService { readonly loadingExports$ = signal>(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()); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts index f76b62321..aa77b3d77 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts @@ -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" diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-attention-actions.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-attention-actions.service.spec.ts index 001ed1fd4..e32de4465 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-attention-actions.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-attention-actions.service.spec.ts @@ -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(); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html index 8b52b44f8..0c7d248a7 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html @@ -2,70 +2,43 @@
-
-

Управление программой

+

Аналитика

-

- Сводка пути участников от регистрации до экспертной оценки. -

+ @if (data()) { + + +
+ + + + +
+
+ }
- - @if (data()) { -
-
-

Выгрузки

- -
-
- - Все проекты - - - Сданные проекты - - - Оценки проектов - -
- @if (exportFailed()) { - - } -
+ @if (loadingExports()) { +

Подготавливаем Excel…

+ } + @if (exportFailed()) { + }
diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss index 43fa6bcba..79fbcae1d 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss @@ -46,6 +46,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; @@ -289,6 +297,8 @@ } .program-deadlines { + display: flex; + flex-direction: column; align-self: start; min-height: 0; @@ -372,6 +382,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); @@ -587,6 +640,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); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts index 025f0adb9..a9b4c9187 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts @@ -135,6 +135,7 @@ describe("ProgramAnalyticsComponent", () => { }; const exports = { loadingExports$: signal(initial()), + downloadProgram: vi.fn(), downloadProjects: vi.fn(), downloadSubmittedProjects: vi.fn(), downloadRates: vi.fn(), @@ -157,6 +158,7 @@ describe("ProgramAnalyticsComponent", () => { analytics.initialize.mockClear(); analytics.retry.mockClear(); exports.downloadProjects.mockClear(); + exports.downloadProgram.mockClear(); exports.downloadSubmittedProjects.mockClear(); exports.downloadRates.mockClear(); @@ -401,7 +403,7 @@ describe("ProgramAnalyticsComponent", () => { const root = fixture.nativeElement as HTMLElement; expect(analytics.initialize).toHaveBeenCalledOnce(); - expect(root.textContent).toContain( + expect(root.textContent).not.toContain( "Сводка пути участников от регистрации до экспертной оценки.", ); expect(root.textContent?.toLowerCase()).not.toContain("обезличенная"); @@ -521,7 +523,7 @@ describe("ProgramAnalyticsComponent", () => { expect(row.textContent).toContain("Сегодня"); vi.advanceTimersByTime(60_000); fixture.detectChanges(); - expect(row.textContent).toContain("Срок завершен"); + expect(row.textContent).toContain("Этап завершен"); fixture.destroy(); vi.runOnlyPendingTimers(); expect(vi.getTimerCount()).toBe(0); @@ -1077,7 +1079,7 @@ describe("ProgramAnalyticsComponent", () => { const fixture = TestBed.createComponent(ProgramAnalyticsComponent); fixture.detectChanges(); const buttons = fixture.nativeElement.querySelectorAll( - ".regions__export", + ".regions__export:not(.analytics__export-trigger)", ) as NodeListOf; expect(buttons).toHaveLength(2); expect(exportRegions).not.toHaveBeenCalled(); @@ -1109,7 +1111,9 @@ describe("ProgramAnalyticsComponent", () => { const fixture = TestBed.createComponent(ProgramAnalyticsComponent); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; - const buttons = root.querySelectorAll(".regions__export"); + const buttons = root.querySelectorAll( + ".regions__export:not(.analytics__export-trigger)", + ); expect(buttons[0].disabled).toBe(true); expect(buttons[0].title).toBe("Нет данных для выгрузки"); buttons[0].click(); @@ -1214,30 +1218,40 @@ describe("ProgramAnalyticsComponent", () => { expect(fixture.nativeElement.textContent).toContain("Повторить"); }); - it("сохраняет существующие действия выгрузки", () => { + it("открывает четыре выгрузки одним меню и возвращает фокус после Escape", async () => { const fixture = TestBed.createComponent(ProgramAnalyticsComponent); fixture.detectChanges(); - const buttons = Array.from( - (fixture.nativeElement as HTMLElement).querySelectorAll(".exports__actions app-button"), - ); - - expect(buttons.length).toBe(3); - expect( - buttons.every(button => button.querySelector("button")?.classList.contains("button--big")), - ).toBe(true); - - buttons - .find(button => button.textContent?.includes("Все проекты")) - ?.dispatchEvent(new Event("click")); - buttons - .find(button => button.textContent?.includes("Сданные проекты")) - ?.dispatchEvent(new Event("click")); - buttons - .find(button => button.textContent?.includes("Оценки проектов")) - ?.dispatchEvent(new Event("click")); - + const trigger = fixture.nativeElement.querySelector( + ".analytics__export-trigger", + ) as HTMLButtonElement; + expect(trigger.getAttribute("aria-label")).toBe("Выгрузить данные программы"); + expect(fixture.nativeElement.textContent).not.toContain("Управление программой"); + expect(document.querySelector('[role="menu"]')).toBeNull(); + for (const label of ["Вся программа", "Все проекты", "Сданные проекты", "Оценки проектов"]) { + trigger.click(); + await fixture.whenStable(); + const items = Array.from(document.querySelectorAll('[role="menuitem"]')); + expect(items.map(item => item.textContent?.trim())).toEqual([ + "Вся программа", + "Все проекты", + "Сданные проекты", + "Оценки проектов", + ]); + items.find(item => item.textContent?.trim() === label)!.click(); + await fixture.whenStable(); + expect(document.querySelector('[role="menu"]')).toBeNull(); + } + expect(exports.downloadProgram).toHaveBeenCalledOnce(); expect(exports.downloadProjects).toHaveBeenCalledOnce(); expect(exports.downloadSubmittedProjects).toHaveBeenCalledOnce(); expect(exports.downloadRates).toHaveBeenCalledOnce(); + trigger.click(); + await fixture.whenStable(); + document + .querySelector('[role="menuitem"]')! + .dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", keyCode: 27, bubbles: true })); + await fixture.whenStable(); + expect(document.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); }); }); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts index f3b29f106..ab7f23263 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts @@ -1,5 +1,8 @@ /** @format */ +import { CdkMenuModule } from "@angular/cdk/menu"; +/** @format */ + import { ButtonDirective, StateComponent } from "@uilib"; /** @format */ @@ -62,6 +65,7 @@ interface AnalyticsCaseRow extends ProgramAnalyticsCaseMetrics { templateUrl: "./analytics.component.html", styleUrl: "./analytics.component.scss", imports: [ + CdkMenuModule, StateComponent, ButtonDirective, ButtonComponent, @@ -397,6 +401,10 @@ export class ProgramAnalyticsComponent implements OnInit { return metrics.some(item => (item.value ?? 0) > 0); } + protected downloadProgram(): void { + this.exports.downloadProgram(); + } + protected downloadProjects(): void { this.exports.downloadProjects(); } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts index f5d76336f..062c15945 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts @@ -516,12 +516,21 @@ describe("Attention views: real overlay", () => { expect(state.selectedCount()).toBe(1); button("Отправить напоминание").click(); await fixture.whenStable(); - expect(dialog().textContent).toContain("Уведомление на платформе + письмо на почту"); + expect(dialog().textContent).toContain("Получателей: 1"); + expect(dialog().textContent).toContain("Пример уведомления"); + expect(dialog().textContent).toContain("Пример письма на почту"); + expect(dialog().textContent).toContain("Открыть на платформе"); + expect(dialog().textContent).not.toContain("Выбрано:"); + expect(dialog().querySelector(".attention-list-tools__export")).toBeNull(); + expect( + Array.from(dialog().querySelectorAll("footer button"), item => item.textContent?.trim()), + ).toEqual(["Назад", "Отправить"]); expect(dialog().textContent).toContain("Анна, здравствуйте!"); expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); - expect(document.activeElement?.id).toBe("attention-reminder-heading"); + expect(document.activeElement).toBe(dialog().querySelector("h2")); + expect(dialog().querySelector("h2")?.textContent).toBe("Отправить напоминание"); expect(actions.send).not.toHaveBeenCalled(); - button("Назад к списку").click(); + button("Назад").click(); await fixture.whenStable(); expect(dialog().querySelector('input[type="checkbox"]')!.checked).toBe(true); expect(document.activeElement?.tagName).toBe("H2"); @@ -530,14 +539,17 @@ describe("Attention views: real overlay", () => { it("select all found selects beyond the visible page, send requires confirmation", async () => { participants.execute.mockReturnValue(of(ok(participantsPage({ count: 75 })))); await open(); - button("Выбрать все найденные (75)").click(); + const header = dialog().querySelector('thead input[type="checkbox"]')!; + expect(header.getAttribute("aria-label")).toBe("Выбрать все строки таблицы"); + expect(dialog().textContent).not.toContain("Выбрать все найденные"); + header.click(); await fixture.whenStable(); expect(state.selectedCount()).toBe(75); button("Отправить напоминание").click(); await fixture.whenStable(); expect(actions.preview.mock.lastCall[2].selection.mode).toBe("all"); expect(actions.send).not.toHaveBeenCalled(); - button("Отправить (1)").click(); + button("Отправить").click(); await fixture.whenStable(); expect(actions.send).toHaveBeenCalledExactlyOnceWith( 12, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html index 78bce4013..785ba9753 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html @@ -291,42 +291,22 @@

{{ title() @if (state.preview(); as preview) {
-

- Проверить напоминание -

-
-
-
Выбрано строк
-
{{ preview.selectedCount }}
-
-
-
Получателей
-
{{ preview.recipientCount }}
-
-
-

Уведомление на платформе + письмо на почту

- @if (preview.recipients.length) { -
- Получатели ({{ preview.recipientCount }}) -
    - @for (recipient of preview.recipients; track recipient.userId) { -
  • {{ recipient.fullName }}
  • - } -
- @if (preview.recipientCount > preview.recipients.length) { -

И еще {{ preview.recipientCount - preview.recipients.length }} получателей

- } -
- } +

+ Получателей: {{ preview.recipientCount }} +

@if (preview.previewText) { -

Пример персонального сообщения

+

Пример уведомления

{{ preview.previewText }}

+

Пример письма на почту

+ } @if (preview.withoutAssignmentsCount) {

@@ -378,35 +358,6 @@

Пример персонального сообщения

- @if ( - state.supportsActions() && - state.attentionPage() && - !state.attentionPending() && - !state.attentionError() - ) { -
- - @if (state.selectedCount()) { - - } -
- } @if (state.attentionPending()) { Пример персонального сообщения

} @else if (state.attentionPage()) { -

- Найдено: {{ state.attentionCount() }} -

+
+

+ Найдено: {{ state.attentionCount() }} +

+ @if (state.supportsActions()) { + + } +
@if (!state.attentionPage()?.results?.length) {

{{ attentionEmptyMessage() }}

} @else { @if (state.participantsPage(); as page) { - +
@@ -498,7 +487,20 @@

Пример персонального сообщения

@@ -641,9 +643,11 @@

Пример персонального сообщения

@if (state.actionSuccess()) {

{{ state.actionSuccess() }}

} - Выбрано: {{ state.selectedCount() }} + @if (!state.preview()) { + Выбрано: {{ state.selectedCount() }} + }
@if (state.preview(); as preview) { - } @else { -
+ @@ -891,21 +863,23 @@

Еще не сданы

[class]="'analytics-drilldown__badge analytics-drilldown__badge--' + item.status" >{{ statusLabel(item.status) }} - @if (item.status === "completed") { - - } + } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss index bcce0f586..f6a04036f 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss @@ -714,6 +714,42 @@ } } +.attention-check__label { + display: none; +} + +@media (max-width: 999px) { + .analytics-drilldown__table:has(th.attention-check-cell) { + thead { + display: block; + } + + thead tr { + padding: 0; + border: 0; + } + + thead th:not(.attention-check-cell) { + display: none; + } + + th.attention-check-cell { + width: auto; + } + + thead .attention-check { + display: flex; + gap: 12px; + width: auto; + font-size: 12px; + } + + .attention-check__label { + display: inline; + } + } +} + .analytics-drilldown__table tr.attention-selected-row { background: color-mix(in srgb, var(--accent-light) 35%, var(--light-white)); } @@ -794,6 +830,73 @@ } } +.attention-reminder__recipient-count { + margin: 0 0 20px; + font-size: 14px; +} + +.attention-reminder__email { + display: grid; + gap: 16px; + padding: 20px; + background: var(--light-white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 10px; + + p { + margin: 0; + font-size: 14px; + line-height: 1.6; + white-space: pre-line; + } + + small { + color: var(--grey-for-text); + } +} + +.attention-reminder__email-button { + justify-self: start; + padding: 12px 18px; + color: var(--light-white); + background: var(--accent); + border-radius: 8px; +} + +.attention-list-tools { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + + .analytics-drilldown__count { + margin: 0; + } +} + +.analytics-drilldown .ui-button.ui-button.attention-list-tools__export { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + min-height: 32px; + padding: 0; + color: var(--accent); + background: transparent; + border: 0; +} + +@media (min-width: 1000px) { + .analytics-drilldown__table--assignments th { + &:first-child { width: 24%; } + &:nth-child(2) { width: 27%; } + &:nth-child(3) { width: 18%; } + &:nth-child(4) { width: 19%; } + &:last-child { width: 12%; } + } +} + .attention-actions { position: sticky; bottom: -20px; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts index 4c0e615c8..b622be851 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts @@ -104,7 +104,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { expect(assignments.execute).toHaveBeenCalledWith(12, scope); expect(dialog().closest(".analytics-drilldown-body--assignments")).not.toBeNull(); expect(dialog().querySelectorAll(".analytics-drilldown__table--assignments")).toHaveLength(1); - expect(dialog().querySelectorAll('th[scope="col"]')).toHaveLength(4); + expect(dialog().querySelectorAll('th[scope="col"]')).toHaveLength(5); }); it.each([ @@ -129,11 +129,11 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { ["pending", "in_progress", 187200, "В процессе", "2 д 4 ч"], ["completed", "completed", null, "Выполнено", "—"], ] as const)( - "%s / %s: четыре колонки без прогресса сохраняют статус, ожидание и действие", + "%s / %s: отдельная колонка оценки сохраняет статус и ожидание", async (scope, status, waitingSeconds, statusText, waitingText) => { assignments.execute.mockReturnValue(of(ok([assignment({ status, waitingSeconds })]))); await open(scope); - const labels = ["Эксперт", "Проект", "Статус", "Ожидание"]; + const labels = ["Эксперт", "Проект", "Статус", "Оценка", "Ожидание"]; expect( Array.from(dialog().querySelectorAll("thead th"), cell => cell.textContent?.trim()), ).toEqual(labels); @@ -146,6 +146,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { expect(row.querySelector('[data-label="Эксперт"]')?.textContent).toContain("Иван Иванов"); expect(row.querySelector('[data-label="Проект"]')?.textContent).toContain("Проект А"); expect(row.querySelector('[data-label="Статус"]')?.textContent).toContain(statusText); + expect(row.querySelector('[data-label="Статус"] button')).toBeNull(); expect(row.querySelector('[data-label="Ожидание"]')?.textContent?.trim()).toBe(waitingText); expect(!!row.querySelector('button[aria-label^="Посмотреть оценку"]')).toBe( status === "completed", @@ -155,7 +156,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { statusText, ); if (status === "completed") { - expect(statusCell.querySelector("button")?.getAttribute("aria-label")).toBe( + expect(row.querySelector('[data-label="Оценка"] button')?.getAttribute("aria-label")).toBe( "Посмотреть оценку: Проект А, Иван Иванов", ); } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts index 85120a85d..59541d77e 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts @@ -13,7 +13,7 @@ import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; import { ProgramCaseProjectsService } from "@api/program/facades/detail/program-case-projects.service"; import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model"; -import { ButtonComponent, LoaderComponent } from "@ui/primitives"; +import { ButtonComponent, IconComponent, LoaderComponent } from "@ui/primitives"; import { caseErrorMessage, presentationHref } from "@utils/program-case-analytics"; import { AfterViewInit, @@ -72,6 +72,7 @@ import { DatePipe, RouterLink, ButtonComponent, + IconComponent, LoaderComponent, ], providers: [ProgramAnalyticsDrilldownService, ProgramCaseProjectsService], @@ -99,7 +100,6 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { @ViewChild(CdkTrapFocus) private trap!: CdkTrapFocus; @ViewChild("closeButton") private closeButton!: ElementRef; @ViewChild("heading") private heading!: ElementRef; - @ViewChild("reminderHeading") private reminderHeading?: ElementRef; protected readonly statusLabel = (status: ProgramAnalyticsAssignmentStatus): string => assignmentStatusLabels[status]; @@ -109,6 +109,7 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { protected readonly attentionReasonLabel = (label: string): string => label.replace(/ё/g, "е").replace(/Ё/g, "Е"); protected readonly title = computed(() => { + if (this.state.preview()) return "Отправить напоминание"; switch (this.state.view()) { case "case": { const selection = this.cases.selection(); @@ -184,7 +185,7 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { const preview = !!this.state.preview(); if (this.attached() && this.state.open() && preview !== this.focusedPreview) { this.focusedPreview = preview; - (preview ? this.reminderHeading : this.heading)?.nativeElement.focus(); + this.heading.nativeElement.focus(); } if (this.attached() && this.state.open() && view !== this.focusedView) { this.focusedView = view; diff --git a/projects/social_platform/src/app/utils/export-file.ts b/projects/social_platform/src/app/utils/export-file.ts index 3ba333ffc..33c5affbb 100644 --- a/projects/social_platform/src/app/utils/export-file.ts +++ b/projects/social_platform/src/app/utils/export-file.ts @@ -9,11 +9,17 @@ import { saveAs } from "file-saver"; export const saveFile = ( blob: Blob, - type: "all" | "submitted" | "rates" | "cv", + type: "all" | "submitted" | "rates" | "cv" | "program", name?: string, ): void => { const prefixFileName = - type === "all" ? "projects" : type === "rates" ? "scores" : "projects_review"; + type === "program" + ? "program" + : type === "all" + ? "projects" + : type === "rates" + ? "scores" + : "projects_review"; const todayDate = new Date().toLocaleDateString("ru-RU"); const fullName = (type !== "cv" ? prefixFileName + "-" : "") + diff --git a/projects/social_platform/src/app/utils/program-deadlines.spec.ts b/projects/social_platform/src/app/utils/program-deadlines.spec.ts index 9d9a3a688..9bea2e265 100644 --- a/projects/social_platform/src/app/utils/program-deadlines.spec.ts +++ b/projects/social_platform/src/app/utils/program-deadlines.spec.ts @@ -16,7 +16,7 @@ describe("programDeadlines", () => { const rows = programDeadlines({ ...dates, datetimeEvaluationEnds: at(16) }, true, now); expect(rows.map(row => row.key)).toEqual(["registration", "submission", "evaluation"]); expect(rows.map(row => row.status)).toEqual([ - "Срок завершен", + "Этап завершен", "Осталось 3 дня", "Осталось 2 дня", ]); @@ -29,7 +29,7 @@ describe("programDeadlines", () => { }); it.each([ - [at(14, 11, 59), "Срок завершен", true], + [at(14, 11, 59), "Этап завершен", true], [at(14, 12), "Сегодня", false], [at(14, 23, 59), "Сегодня", false], [at(15, 0), "Завтра", false], @@ -62,7 +62,7 @@ describe("programDeadlines", () => { ); expect(rows[1].datetime).toBe(rows[0].datetime); expect(rows[1].usesRegistrationDeadline).toBe(true); - expect(rows[1].status).toBe("Срок завершен"); + expect(rows[1].status).toBe("Этап завершен"); }, ); diff --git a/projects/social_platform/src/app/utils/program-deadlines.ts b/projects/social_platform/src/app/utils/program-deadlines.ts index 95ccf110d..678ef19cc 100644 --- a/projects/social_platform/src/app/utils/program-deadlines.ts +++ b/projects/social_platform/src/app/utils/program-deadlines.ts @@ -32,7 +32,7 @@ function calendarDay(date: Date): number { } function deadlineStatus(date: Date, now: Date): string { - if (date.getTime() < now.getTime()) return "Срок завершен"; + if (date.getTime() < now.getTime()) return "Этап завершен"; const days = calendarDay(date) - calendarDay(now); if (days === 0) return "Сегодня"; if (days === 1) return "Завтра"; From 38f1fc55fca8072cde11cefb25218b7af55974aa Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 02:17:13 +0300 Subject: [PATCH 2/2] fix(analytics): keep export feedback below the header --- .../pages/program/detail/analytics/analytics.component.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss index 79fbcae1d..77ca541f8 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss @@ -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 {
- Выбрать участника + Участник Город
- Выбрать работу + Проект РуководительЭксперт Проект СтатусОценка Ожидание
+ @if (item.status === "completed") { + + } + {{ waiting(item.waitingSeconds, item.status) }}