From 8848b0c23d5937d8377a35f31336a8d7e780fe24 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 13:27:24 +0300 Subject: [PATCH] feat(analytics): selectively port production actions and exports --- .../src/lib/services/api/api.service.spec.ts | 19 +- .../core/src/lib/services/api/api.service.ts | 4 + .../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 | 185 +++++ .../program-attention-actions.service.spec.ts | 216 ++++++ .../program-attention-actions.use-case.ts | 56 ++ .../program/ports/program.repository.port.ts | 24 + .../program-attention-actions.model.ts | 36 + .../program/program-http.adapter.spec.ts | 27 +- .../adapters/program/program-http.adapter.ts | 39 ++ .../repository/program/program.repository.ts | 30 + .../detail/analytics/analytics.component.html | 97 +-- .../detail/analytics/analytics.component.scss | 52 +- .../analytics/analytics.component.spec.ts | 62 +- .../detail/analytics/analytics.component.ts | 8 + .../analytics-attention.component.spec.ts | 105 ++- .../drilldown/analytics-attention.scss | 233 +++++++ .../analytics-drilldown.component.html | 655 +++++++++++------- .../analytics-drilldown.component.scss | 10 + .../analytics-drilldown.component.spec.ts | 19 +- .../analytics-drilldown.component.ts | 19 +- .../src/app/utils/export-file.ts | 10 +- .../src/app/utils/program-deadlines.spec.ts | 6 +- .../src/app/utils/program-deadlines.ts | 2 +- 26 files changed, 1628 insertions(+), 367 deletions(-) create mode 100644 projects/social_platform/src/app/api/export-file/facades/export-file-info.service.spec.ts create mode 100644 projects/social_platform/src/app/api/program/facades/detail/program-attention-actions.service.spec.ts create mode 100644 projects/social_platform/src/app/api/program/use-cases/program-attention-actions.use-case.ts create mode 100644 projects/social_platform/src/app/domain/program/program-attention-actions.model.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.scss diff --git a/projects/core/src/lib/services/api/api.service.spec.ts b/projects/core/src/lib/services/api/api.service.spec.ts index 73f9d8cab..a21e89e04 100644 --- a/projects/core/src/lib/services/api/api.service.spec.ts +++ b/projects/core/src/lib/services/api/api.service.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from "@angular/core/testing"; import { ApiService } from "./api.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { API_URL } from "../../providers"; describe("ApiService", () => { @@ -20,4 +20,21 @@ describe("ApiService", () => { it("should be created", () => { expect(service).toBeTruthy(); }); + + it("posts a full file selection and returns the original XLSX Blob", () => { + const body = { selection: { mode: "all", excludedIds: [7] } }; + const blob = new Blob(["xlsx"], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + let result: Blob | undefined; + service.postFile("/attention/export/", body).subscribe(value => (result = value)); + const http = TestBed.inject(HttpTestingController); + const request = http.expectOne("/attention/export/"); + expect(request.request.method).toBe("POST"); + expect(request.request.body).toEqual(body); + expect(request.request.responseType).toBe("blob"); + request.flush(blob); + expect(result).toBe(blob); + http.verify(); + }); }); 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/export-file/export-file.service.ts b/projects/social_platform/src/app/api/export-file/export-file.service.ts index e6951d2ef..21fe498c5 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}/project-analytics/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..7e7767981 --- /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/project-analytics/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 74db7de95..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 @@ -2,6 +2,13 @@ import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Subject, takeUntil } from "rxjs"; +import { saveAs } from "file-saver"; +import { ProgramAttentionActionsUseCase } from "@api/program/use-cases/program-attention-actions.use-case"; +import { + ProgramAttentionCategory, + ProgramAttentionSelection, + ProgramAttentionPreview, +} from "@domain/program/program-attention-actions.model"; import { ProgramCaseProjectsService } from "./program-case-projects.service"; import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model"; @@ -43,6 +50,181 @@ export type AnalyticsDrilldownView = /** Живёт вместе с одной analytics-модалкой, не сохраняет manager-данные между открытиями. */ @Injectable() export class ProgramAnalyticsDrilldownService { + private readonly actions = inject(ProgramAttentionActionsUseCase); + private readonly cancelActions = new Subject(); + readonly selection = signal({ + mode: "selected", + ids: [], + excludedIds: [], + }); + private readonly allSelectionCount = signal(0); + readonly preview = signal(null); + readonly actionPending = signal<"preview" | "send" | "export" | null>(null); + readonly actionError = signal(""); + readonly actionSuccess = signal(""); + readonly supportsActions = computed( + () => this.isAttentionView() && this.view() !== "projects-not-submitted", + ); + readonly selectedCount = computed(() => { + const selection = this.selection(); + return selection.mode === "all" + ? Math.max(0, this.allSelectionCount() - selection.excludedIds.length) + : 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" + ? !selection.excludedIds.includes(id) + : selection.ids.includes(id); + } + + toggleSelection(id: number): void { + if (this.actionPending()) return; + this.preview.set(null); + this.actionError.set(""); + this.actionSuccess.set(""); + const current = this.selection(); + const field = current.mode === "all" ? "excludedIds" : "ids"; + const ids = current[field]; + this.selection.set({ + ...current, + [field]: ids.includes(id) ? ids.filter(value => value !== id) : [...ids, id], + }); + } + + selectAllFound(): void { + if (this.actionPending() || this.attentionPending() || !this.attentionCount()) return; + this.allSelectionCount.set(this.attentionCount()); + this.selection.set({ mode: "all", ids: [], excludedIds: [] }); + this.preview.set(null); + this.actionError.set(""); + this.actionSuccess.set(""); + } + + clearSelection(): void { + if (this.actionPending()) return; + this.resetActions(); + } + + backToAttentionList(): void { + if (this.actionPending()) return; + this.preview.set(null); + this.actionError.set(""); + } + + previewReminder(): void { + const category = this.actionCategory(); + if ( + this.programId === null || + !category || + !this.selectedCount() || + this.actionPending() || + this.attentionPending() || + this.attentionError() + ) + return; + this.actionPending.set("preview"); + this.actionError.set(""); + this.actionSuccess.set(""); + this.actions + .preview(this.programId, category, { + search: this.appliedSearch(), + selection: this.selection(), + }) + .pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.actionPending.set(null); + if (result.ok) this.preview.set(result.value); + else this.setActionError(result.error.kind); + }); + } + + sendReminder(): void { + const category = this.actionCategory(); + const token = this.preview()?.token; + if (this.programId === null || !category || !token || this.actionPending()) return; + this.actionPending.set("send"); + this.actionError.set(""); + this.actions + .send(this.programId, category, token) + .pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.actionPending.set(null); + if (result.ok) { + this.resetActions(); + const message = result.value.createdCount + ? `Напоминания созданы: ${result.value.createdCount}. Письма поставлены в очередь.` + : "Нет доступных получателей для напоминания."; + this.actionSuccess.set( + `${message}${result.value.skippedCount ? ` Пропущено после повторной проверки: ${result.value.skippedCount}.` : ""}`, + ); + } else this.setActionError(result.error.kind); + }); + } + + exportAttention(): void { + const category = this.actionCategory(); + if ( + this.programId === null || + !category || + this.actionPending() || + this.attentionPending() || + this.attentionError() || + !this.attentionCount() + ) + return; + this.actionPending.set("export"); + this.actionError.set(""); + this.actions + .export(this.programId, category, { + search: this.appliedSearch(), + selection: this.selectedCount() + ? this.selection() + : { mode: "all", ids: [], excludedIds: [] }, + }) + .pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.actionPending.set(null); + if (result.ok) saveAs(result.value, `Требует внимания-${category}-${this.programId}.xlsx`); + else this.setActionError(result.error.kind); + }); + } + + private actionCategory(): ProgramAttentionCategory | null { + return this.open() && this.supportsActions() ? (this.view() as ProgramAttentionCategory) : null; + } + + private setActionError(kind: string): void { + this.actionError.set( + kind === "forbidden" || kind === "unauthorized" + ? "Нет доступа к действию. Обновите страницу и проверьте права." + : kind === "validation" + ? "Выбор или предпросмотр устарел либо превышает 10 000 строк. Уточните поиск и проверьте получателей еще раз." + : "Не удалось выполнить действие. Попробуйте еще раз.", + ); + if (kind === "validation") this.preview.set(null); + } + + private resetActions(): void { + this.cancelActions.next(); + this.selection.set({ mode: "selected", ids: [], excludedIds: [] }); + this.allSelectionCount.set(0); + this.preview.set(null); + this.actionPending.set(null); + this.actionError.set(""); + this.actionSuccess.set(""); + } readonly cases = inject(ProgramCaseProjectsService); openCase( @@ -140,6 +322,8 @@ export class ProgramAnalyticsDrilldownService { /** Query остаётся черновиком до Enter; сервер получает trimmed search с первой страницы. */ applyAttentionSearch(): void { if (!this.open() || !this.isAttentionView()) return; + if (this.actionPending()) return; + this.resetActions(); this.appliedSearch.set(this.searchDraft().trim()); this.attentionOffset.set(0); this.loadAttention(); @@ -299,6 +483,7 @@ export class ProgramAnalyticsDrilldownService { /** Закрытие и смена программы уничтожают весь локальный контекст и активные запросы. */ close(): void { + this.resetActions(); this.cases.reset(); this.cancelAssignments.next(); this.cancelScores.next(); 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 new file mode 100644 index 000000000..e32de4465 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-attention-actions.service.spec.ts @@ -0,0 +1,216 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { of, Subject } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramAttentionActionsUseCase } from "@api/program/use-cases/program-attention-actions.use-case"; +import { ProgramAnalyticsDrilldownService } from "./program-analytics-drilldown.service"; +import { + participantsPage, + attentionParticipant, +} from "@domain/program/program-analytics-attention.fixture"; +import { fail, ok } from "@domain/shared/result.type"; +import { ProgramAttentionPreview } from "@domain/program/program-attention-actions.model"; + +vi.mock("file-saver", () => ({ saveAs: vi.fn() })); +import { saveAs } from "file-saver"; + +const preview: ProgramAttentionPreview = { + token: "signed-preview", + selectedCount: 1, + recipientCount: 1, + withoutAssignmentsCount: 0, + unavailableCount: 0, + previewText: "Анна, здравствуйте!", + expiresIn: 900, + recipients: [{ userId: 123, fullName: "Анна" }], +}; + +describe("Attention bulk actions", () => { + let state: ProgramAnalyticsDrilldownService; + const actions = { preview: vi.fn(), send: vi.fn(), export: vi.fn() }; + const repo = { getManagerParticipantsWithoutTeam: vi.fn(), getCaseProjects: vi.fn() }; + beforeEach(() => { + vi.clearAllMocks(); + repo.getManagerParticipantsWithoutTeam.mockReturnValue(of(participantsPage({ count: 51 }))); + actions.preview.mockReturnValue(of(ok(preview))); + actions.send.mockReturnValue(of(ok({ createdCount: 1, skippedCount: 0 }))); + actions.export.mockReturnValue(of(ok(new Blob(["xlsx"])))); + TestBed.configureTestingModule({ + providers: [ + ProgramAnalyticsDrilldownService, + { provide: ProgramRepositoryPort, useValue: repo }, + { provide: ProgramAttentionActionsUseCase, useValue: actions }, + ], + }); + state = TestBed.inject(ProgramAnalyticsDrilldownService); + state.openAttention(12, "participants-without-team"); + }); + + it("selects all matching pages with exclusions and preserves explicit row selection on pagination", () => { + state.selectAllFound(); + expect(state.selectedCount()).toBe(51); + state.toggleSelection(123); + expect(state.selectedCount()).toBe(50); + state.changeAttentionPage(1); + expect(state.isSelected(124)).toBe(true); + expect(state.isSelected(123)).toBe(false); + state.previewReminder(); + expect(actions.preview).toHaveBeenCalledWith(12, "participants-without-team", { + search: "", + selection: { mode: "all", ids: [], excludedIds: [123] }, + }); + }); + + it("resets selection and preview when applying search", () => { + state.toggleSelection(123); + state.previewReminder(); + expect(state.preview()).toEqual(preview); + state.searchDraft.set(" Анна "); + state.applyAttentionSearch(); + expect(state.selectedCount()).toBe(0); + expect(state.preview()).toBeNull(); + expect(state.appliedSearch()).toBe("Анна"); + }); + + it("does not send during preview and uses the exact reviewed token", () => { + state.toggleSelection(123); + state.previewReminder(); + expect(actions.send).not.toHaveBeenCalled(); + state.sendReminder(); + expect(actions.send).toHaveBeenCalledExactlyOnceWith( + 12, + "participants-without-team", + "signed-preview", + ); + expect(state.actionSuccess()).toContain("Напоминания созданы: 1"); + expect(state.selectedCount()).toBe(0); + state.sendReminder(); + 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(); + actions.send.mockReturnValue(of(ok({ createdCount: 0, skippedCount: 1 }))); + state.sendReminder(); + expect(state.actionSuccess()).toContain("Нет доступных получателей"); + expect(state.actionSuccess()).toContain("Пропущено после повторной проверки: 1"); + expect(state.actionSuccess()).not.toContain("Письма"); + }); + + it("disables duplicate submissions and keeps selection stable while sending", () => { + const pending = new Subject(); + actions.send.mockReturnValue(pending); + state.toggleSelection(123); + state.previewReminder(); + state.sendReminder(); + state.sendReminder(); + state.toggleSelection(124); + state.clearSelection(); + expect(actions.send).toHaveBeenCalledTimes(1); + expect(state.selectedCount()).toBe(1); + expect(state.actionPending()).toBe("send"); + pending.next(ok({ createdCount: 1, skippedCount: 0 })); + expect(state.actionPending()).toBeNull(); + }); + + it("keeps reviewed token after a network error for an idempotent retry", () => { + state.toggleSelection(123); + state.previewReminder(); + actions.send.mockReturnValue(of(fail({ kind: "network" }))); + state.sendReminder(); + expect(state.preview()?.token).toBe("signed-preview"); + actions.send.mockReturnValue(of(ok({ createdCount: 1, skippedCount: 0 }))); + state.sendReminder(); + expect(actions.send.mock.calls.map(call => call[2])).toEqual([ + "signed-preview", + "signed-preview", + ]); + }); + + it("expired previews require reviewing recipients again", () => { + state.toggleSelection(123); + state.previewReminder(); + actions.send.mockReturnValue(of(fail({ kind: "validation" }))); + state.sendReminder(); + expect(state.preview()).toBeNull(); + expect(state.actionError()).toContain("устарел"); + expect(state.selectedCount()).toBe(1); + }); + + it("no recipient means export remains available and sending is blocked", () => { + state.toggleSelection(123); + actions.preview.mockReturnValue( + of(ok({ ...preview, token: null, recipientCount: 0, recipients: [] })), + ); + state.previewReminder(); + state.sendReminder(); + expect(actions.send).not.toHaveBeenCalled(); + state.exportAttention(); + expect(actions.export).toHaveBeenCalled(); + }); + + it("exports all found without selection and only selected when rows are checked", () => { + state.exportAttention(); + expect(actions.export).toHaveBeenLastCalledWith(12, "participants-without-team", { + search: "", + selection: { mode: "all", ids: [], excludedIds: [] }, + }); + state.toggleSelection(123); + state.exportAttention(); + expect(actions.export).toHaveBeenLastCalledWith(12, "participants-without-team", { + search: "", + selection: { mode: "selected", ids: [123], excludedIds: [] }, + }); + expect(saveAs).toHaveBeenCalledWith( + expect.any(Blob), + "Требует внимания-participants-without-team-12.xlsx", + ); + }); + + it("late responses after closing cannot affect a different program", () => { + const pending = new Subject(); + actions.preview.mockReturnValue(pending); + state.toggleSelection(123); + state.previewReminder(); + state.close(); + state.openAttention(24, "participants-without-team"); + pending.next(ok(preview)); + expect(state.preview()).toBeNull(); + expect(state.selectedCount()).toBe(0); + }); + + it("list failure disables reminder and export actions", () => { + state.toggleSelection(123); + state.attentionError.set({ kind: "forbidden" }); + state.previewReminder(); + state.exportAttention(); + expect(actions.preview).not.toHaveBeenCalled(); + expect(actions.export).not.toHaveBeenCalled(); + }); + + it("clear selection also clears validation and success messages", () => { + state.selectAllFound(); + state.actionSuccess.set("done"); + state.clearSelection(); + expect(state.selectedCount()).toBe(0); + expect(state.actionSuccess()).toBe(""); + expect(state.isSelected(attentionParticipant().userId)).toBe(false); + }); +}); diff --git a/projects/social_platform/src/app/api/program/use-cases/program-attention-actions.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-attention-actions.use-case.ts new file mode 100644 index 000000000..0cf0c176b --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-attention-actions.use-case.ts @@ -0,0 +1,56 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAttentionActionError, + ProgramAttentionActionQuery, + ProgramAttentionCategory, +} from "@domain/program/program-attention-actions.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +@Injectable({ providedIn: "root" }) +export class ProgramAttentionActionsUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + preview( + programId: number, + category: ProgramAttentionCategory, + query: ProgramAttentionActionQuery, + ) { + return this.safe(this.repository.previewAttentionReminder(programId, category, query)); + } + + send(programId: number, category: ProgramAttentionCategory, token: string) { + return this.safe(this.repository.sendAttentionReminder(programId, category, token)); + } + + export( + programId: number, + category: ProgramAttentionCategory, + query: ProgramAttentionActionQuery, + ) { + return this.safe(this.repository.exportAttention(programId, category, query)); + } + + private safe(request: Observable): Observable> { + return request.pipe( + map(value => ok(value)), + catchError((error: unknown) => { + const status = error instanceof HttpErrorResponse ? error.status : 0; + const kind: ProgramAttentionActionError["kind"] = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : status === 400 + ? "validation" + : "network"; + return of(fail({ kind })); + }), + ); + } +} diff --git a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts index 4c4044624..d49a749af 100644 --- a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts @@ -1,5 +1,13 @@ /** @format */ +import { + ProgramAttentionCategory, + ProgramAttentionActionQuery, + ProgramAttentionPreview, + ProgramAttentionSendResult, +} from "@domain/program/program-attention-actions.model"; +/** @format */ + import { HttpParams } from "@angular/common/http"; import { ProgramCaseQuery, @@ -33,6 +41,22 @@ import { /** Порт репозитория программ: список/детали/создание/регистрация, проекты/участники/фильтры. */ export abstract class ProgramRepositoryPort { + abstract previewAttentionReminder( + programId: number, + category: ProgramAttentionCategory, + query: ProgramAttentionActionQuery, + ): Observable; + abstract sendAttentionReminder( + programId: number, + category: ProgramAttentionCategory, + token: string, + ): Observable; + abstract exportAttention( + programId: number, + category: ProgramAttentionCategory, + query: ProgramAttentionActionQuery, + ): Observable; + abstract getCaseProjects(programId: number, query: ProgramCaseQuery): Observable; abstract exportCaseProjects( programId: number, diff --git a/projects/social_platform/src/app/domain/program/program-attention-actions.model.ts b/projects/social_platform/src/app/domain/program/program-attention-actions.model.ts new file mode 100644 index 000000000..98128efe4 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-attention-actions.model.ts @@ -0,0 +1,36 @@ +/** @format */ +export type ProgramAttentionCategory = + | "participants-without-visit" + | "participants-without-team" + | "projects-awaiting-evaluation"; + +export interface ProgramAttentionSelection { + mode: "selected" | "all"; + ids: number[]; + excludedIds: number[]; +} + +export interface ProgramAttentionActionQuery { + search: string; + selection: ProgramAttentionSelection; +} + +export interface ProgramAttentionPreview { + token: string | null; + selectedCount: number; + recipientCount: number; + withoutAssignmentsCount: number; + unavailableCount: number; + previewText: string; + recipients: { userId: number; fullName: string }[]; + expiresIn: number; +} + +export interface ProgramAttentionSendResult { + createdCount: number; + skippedCount: number; +} + +export interface ProgramAttentionActionError { + kind: "unauthorized" | "forbidden" | "not_found" | "validation" | "network"; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts index 21396ead6..0e92899e3 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts @@ -17,7 +17,7 @@ describe("ProgramHttpAdapter", () => { let api: any; function setup(): void { - api = { get: vi.fn(), post: vi.fn(), getFile: vi.fn() }; + api = { get: vi.fn(), post: vi.fn(), getFile: vi.fn(), postFile: vi.fn() }; TestBed.configureTestingModule({ providers: [ProgramHttpAdapter, { provide: ApiService, useValue: api }], }); @@ -31,6 +31,31 @@ describe("ProgramHttpAdapter", () => { previous: "", }); + it("attention preview keeps full selection, export posts a Blob query, send uses reviewed token", () => { + setup(); + api.post.mockReturnValue(of({})); + api.postFile.mockReturnValue(of(new Blob())); + const query = { + search: "Анна", + selection: { mode: "all" as const, ids: [], excludedIds: [7] }, + }; + adapter.previewAttentionReminder(12, "participants-without-team", query).subscribe(); + expect(api.post).toHaveBeenLastCalledWith( + "/programs/12/project-analytics/attention/participants-without-team/reminder-preview/", + query, + ); + adapter.exportAttention(12, "participants-without-team", query).subscribe(); + expect(api.postFile).toHaveBeenCalledExactlyOnceWith( + "/programs/12/project-analytics/attention/participants-without-team/export/", + query, + ); + adapter.sendAttentionReminder(12, "participants-without-team", "reviewed-token").subscribe(); + expect(api.post).toHaveBeenLastCalledWith( + "/programs/12/project-analytics/attention/participants-without-team/reminders/", + { token: "reviewed-token" }, + ); + }); + it("case list передаёт exact option, opt-in, поиск и страницу", () => { setup(); api.get.mockReturnValue(of({})); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts index 55f7747d4..648d279dc 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts @@ -1,5 +1,13 @@ /** @format */ +import { + ProgramAttentionCategory, + ProgramAttentionActionQuery, + ProgramAttentionPreview, + ProgramAttentionSendResult, +} from "@domain/program/program-attention-actions.model"; +/** @format */ + import { inject, Injectable } from "@angular/core"; import { ProgramRoleWidget } from "@domain/program/program-role-widget.model"; import { ApiService } from "@corelib"; @@ -36,6 +44,37 @@ import { /** HTTP-адаптер программ: `/programs`, `/auth/public-users` (детали, проекты, участники, фильтры, регистрация). */ @Injectable({ providedIn: "root" }) export class ProgramHttpAdapter { + previewAttentionReminder( + programId: number, + category: ProgramAttentionCategory, + query: ProgramAttentionActionQuery, + ): Observable { + return this.apiService.post( + `/programs/${programId}/project-analytics/attention/${category}/reminder-preview/`, + query, + ); + } + sendAttentionReminder( + programId: number, + category: ProgramAttentionCategory, + token: string, + ): Observable { + return this.apiService.post( + `/programs/${programId}/project-analytics/attention/${category}/reminders/`, + { token }, + ); + } + exportAttention( + programId: number, + category: ProgramAttentionCategory, + query: ProgramAttentionActionQuery, + ): Observable { + return this.apiService.postFile( + `/programs/${programId}/project-analytics/attention/${category}/export/`, + query, + ); + } + getCaseProjects(programId: number, query: ProgramCaseQuery): Observable { const params = this.caseParams(query.selection, query) .set("limit", query.limit ?? 25) diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts index 638b9e57a..828290f0e 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts @@ -1,5 +1,13 @@ /** @format */ +import { + ProgramAttentionCategory, + ProgramAttentionActionQuery, + ProgramAttentionPreview, + ProgramAttentionSendResult, +} from "@domain/program/program-attention-actions.model"; +/** @format */ + import { HttpParams } from "@angular/common/http"; import { ProgramCaseQuery, @@ -41,6 +49,28 @@ import { /** Репозиторий программ: `EntityCache` для `getOne`, остальное — passthrough. */ @Injectable({ providedIn: "root" }) export class ProgramRepository implements ProgramRepositoryPort { + previewAttentionReminder( + programId: number, + category: ProgramAttentionCategory, + query: ProgramAttentionActionQuery, + ): Observable { + return this.programAdapter.previewAttentionReminder(programId, category, query); + } + sendAttentionReminder( + programId: number, + category: ProgramAttentionCategory, + token: string, + ): Observable { + return this.programAdapter.sendAttentionReminder(programId, category, token); + } + exportAttention( + programId: number, + category: ProgramAttentionCategory, + query: ProgramAttentionActionQuery, + ): Observable { + return this.programAdapter.exportAttention(programId, category, query); + } + getCaseProjects(programId: number, query: ProgramCaseQuery): Observable { return this.programAdapter.getCaseProjects(programId, query); } 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 5caa4e23b..55ab95fc5 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 0fcebc122..0e15d95d9 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 @@ -18,11 +18,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 { @@ -852,3 +847,50 @@ } } } + +.analytics__title-row { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.analytics-grid--priority .attention__list, +.analytics-grid--priority .program-deadlines__list { + grid-auto-rows: minmax(68px, 1fr); + gap: 8px; + margin-top: 16px; +} + +.attention { padding-left: 15px; } +.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; + } + } +} 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 60a59fa60..86f765f59 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); @@ -1101,7 +1103,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(); @@ -1133,7 +1135,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(); @@ -1238,30 +1242,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 3eeddcebf..aa37de4a7 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 */ @@ -63,6 +66,7 @@ interface AnalyticsCaseRow extends ProgramAnalyticsCaseMetrics { templateUrl: "./analytics.component.html", styleUrl: "./analytics.component.scss", imports: [ + CdkMenuModule, StateComponent, ButtonDirective, ButtonComponent, @@ -460,6 +464,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 f6319cde2..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 @@ -1,6 +1,7 @@ /** @format */ import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramAttentionActionsUseCase } from "@api/program/use-cases/program-attention-actions.use-case"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -40,6 +41,7 @@ describe("Attention views: real overlay", () => { const visit = { execute: vi.fn() }; const projects = { execute: vi.fn() }; const notSubmitted = { execute: vi.fn() }; + const actions = { preview: vi.fn(), send: vi.fn(), export: vi.fn() }; const dialog = () => document.querySelector('[role="dialog"]')!; const button = (text: string) => Array.from(dialog().querySelectorAll("button")).find( @@ -47,6 +49,22 @@ describe("Attention views: real overlay", () => { )!; beforeEach(async () => { + actions.preview.mockReset().mockReturnValue( + of( + ok({ + token: "preview", + selectedCount: 1, + recipientCount: 1, + withoutAssignmentsCount: 0, + unavailableCount: 0, + previewText: "Анна, здравствуйте!", + expiresIn: 900, + recipients: [{ userId: 123, fullName: "Анна Петрова" }], + }), + ), + ); + actions.send.mockReset().mockReturnValue(of(ok({ createdCount: 1, skippedCount: 0 }))); + actions.export.mockReset(); participants.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); visit.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); projects.execute.mockReset().mockReturnValue(of(ok(projectsPage()))); @@ -54,6 +72,7 @@ describe("Attention views: real overlay", () => { await TestBed.configureTestingModule({ imports: [AnalyticsDrilldownComponent], providers: [ + { provide: ProgramAttentionActionsUseCase, useValue: actions }, { provide: ProgramRepositoryPort, useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, @@ -137,7 +156,7 @@ describe("Attention views: real overlay", () => { it("visit opens the participant list with server search and pagination", async () => { visit.execute.mockReturnValue(of(ok(participantsPage({ count: 30 })))); await open("participants-without-visit"); - expect(dialog().textContent).toContain("Участники ещё не заходили на платформу"); + expect(dialog().textContent).toContain("Участники еще не заходили на платформу"); expect(dialog().textContent).toContain("Анна Петрова"); expect(dialog().querySelector("input")?.placeholder).toBe("Поиск по имени"); expect(dialog().querySelector("a")?.getAttribute("href")).toBe("/office/profile/123"); @@ -197,6 +216,15 @@ describe("Attention views: real overlay", () => { }); it("distributed: одна работа на строку, причины, прогресс, nullable leader/date", async () => { + const rawProject = attentionProject({ + programProjectId: 72, + project: { id: 56, name: "Ёлка" }, + leader: { userId: 124, fullName: "Алёна Петрова", avatar: null }, + status: "awaiting_evaluation", + reason: "no_completed_evaluations", + reasonLabel: "Нет завершённых оценок", + assignmentsCompleted: 0, + }); projects.execute.mockReturnValue( of( ok( @@ -213,13 +241,7 @@ describe("Attention views: real overlay", () => { assignmentsTotal: 0, assignmentsCompleted: 0, }), - attentionProject({ - programProjectId: 72, - status: "awaiting_evaluation", - reason: "no_completed_evaluations", - reasonLabel: "Нет завершённых оценок", - assignmentsCompleted: 0, - }), + rawProject, ], }), ), @@ -234,13 +256,19 @@ describe("Attention views: real overlay", () => { "Не указан", "Дата сдачи неизвестна", "Нет назначений", - "Нет завершённых оценок", + "Нет завершенных оценок", + "Ёлка", + "Алёна Петрова", ]) expect(dialog().textContent).toContain(text); expect(dialog().querySelectorAll("tbody tr")).toHaveLength(3); + expect(dialog().querySelector('[data-reason="no_completed_evaluations"]')?.textContent).toBe( + "Нет завершенных оценок", + ); + expect(rawProject.reasonLabel).toBe("Нет завершённых оценок"); expect(dialog().querySelector('[data-reason="no_assignments"]')).not.toBeNull(); expect(dialog().querySelector("a")?.getAttribute("href")).toBe("/office/projects/55"); - expect(dialog().textContent).not.toContain("Ещё не сданы"); + expect(dialog().textContent).not.toContain("Еще не сданы"); }); it.each([true, false, null])( @@ -272,12 +300,11 @@ describe("Attention views: real overlay", () => { ? "Срок сдачи не указан" : openState ? "Сдача открыта до" - : "Срок сдачи завершён", + : "Срок сдачи завершен", ); if (openState !== null) expect(metadata).toMatch(/10\.09\.2026, 23:59/); for (const text of [ "Проекты не сдали решение", - "Проекты программы, которые ещё не отправили решение.", "Проект А", "Анна Петрова", "Не указан", @@ -286,7 +313,7 @@ describe("Attention views: real overlay", () => { ]) expect(dialog().textContent).toContain(text); expect(dialog().querySelector('[data-label="Статус"]')?.textContent).toContain( - openState === true ? "Не сдано" : "Срок сдачи завершён", + openState === true ? "Не сдано" : "Срок сдачи завершен", ); expect( Array.from(dialog().querySelectorAll("tbody tr:first-child td"), cell => @@ -480,6 +507,58 @@ describe("Attention views: real overlay", () => { } }); + it("checkbox selection opens an inline reviewed reminder and returns keyboard focus", async () => { + await open(); + expect(dialog().textContent).not.toContain("Зарегистрированные участники, которые"); + expect(button("Отправить напоминание").disabled).toBe(true); + dialog().querySelector('input[type="checkbox"]')!.click(); + await fixture.whenStable(); + expect(state.selectedCount()).toBe(1); + button("Отправить напоминание").click(); + await fixture.whenStable(); + 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).toBe(dialog().querySelector("h2")); + expect(dialog().querySelector("h2")?.textContent).toBe("Отправить напоминание"); + expect(actions.send).not.toHaveBeenCalled(); + button("Назад").click(); + await fixture.whenStable(); + expect(dialog().querySelector('input[type="checkbox"]')!.checked).toBe(true); + expect(document.activeElement?.tagName).toBe("H2"); + }); + + it("select all found selects beyond the visible page, send requires confirmation", async () => { + participants.execute.mockReturnValue(of(ok(participantsPage({ count: 75 })))); + await open(); + 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("Отправить").click(); + await fixture.whenStable(); + expect(actions.send).toHaveBeenCalledExactlyOnceWith( + 12, + "participants-without-team", + "preview", + ); + expect(dialog().textContent).toContain("Напоминания созданы: 1"); + }); + it.each([ "participants-without-visit", "participants-without-team", diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.scss new file mode 100644 index 000000000..ec940f2c3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.scss @@ -0,0 +1,233 @@ +/** @format */ + +.modal__body.analytics-drilldown-body.analytics-drilldown-body--attention { + box-sizing: border-box; + width: min(1100px, calc(100vw - 24px)); + max-height: calc(100dvh - 32px); + padding: 20px; + overflow-y: auto; +} + +.analytics-drilldown__table th.attention-check-cell, +.analytics-drilldown__table td.attention-check-cell { + width: 44px; + padding: 0; + + &::before { + display: none; + } +} + +.attention-check { + display: grid; + place-items: center; + width: 44px; + height: 44px; + cursor: pointer; + + input { + width: 18px; + height: 18px; + margin: 0; + cursor: inherit; + accent-color: var(--accent); + } + + input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + } +} + +.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)); +} + +.attention-reminder { + margin: 20px 0; + + h3 { + margin-bottom: 16px; + font-size: 18px; + font-weight: 600; + } + + h4 { + margin: 24px 0 8px; + font-size: 13px; + font-weight: 600; + } + + &__message { + padding: 16px; + margin: 0 0 16px; + font-size: 14px; + line-height: 1.6; + white-space: pre-line; + background: var(--light-gray); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 10px; + } +} + +.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; +} + +.attention-actions { + position: sticky; + bottom: -20px; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 16px 0 20px; + margin-top: 16px; + background: var(--light-white); + border-top: 1px solid var(--medium-grey-for-outline); + + &__buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + &__count { + font-size: 12px; + font-weight: 600; + } + + &__error, + &__success { + flex-basis: 100%; + margin: 0; + font-size: 12px; + } + + &__error { + color: var(--red-dark); + } + + &__success { + color: var(--green-dark); + } + + .ui-button.ui-button.attention-actions__send { + min-height: 40px; + padding: 9px 16px; + color: var(--light-white); + background: var(--accent); + border: 1px solid var(--accent); + border-radius: 8px; + + &:disabled { + color: var(--grey-for-text); + background: var(--light-gray); + border-color: var(--medium-grey-for-outline); + } + } +} + +@media (max-width: 599px) { + .attention-actions__buttons { + flex-basis: 100%; + width: 100%; + + .ui-button.ui-button { + flex: 1 1 100%; + min-height: 44px; + } + } +} 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 e6d313aa2..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 @@ -6,7 +6,9 @@ ? 'analytics-drilldown-body analytics-drilldown-body--assignments' : state.view() === 'case' ? 'analytics-drilldown-body analytics-drilldown-body--case' - : 'analytics-drilldown-body' + : state.supportsActions() + ? 'analytics-drilldown-body analytics-drilldown-body--attention' + : 'analytics-drilldown-body' " (openChange)="onOpenChange($event)" > @@ -39,26 +41,12 @@

{{ title() @if (state.view() === "backlog") {

Ожидающие назначения

} - @if (state.view() === "participants-without-team") { -

- Зарегистрированные участники, которые пока не состоят в команде проекта этой - программы. -

- } - @if (state.view() === "projects-awaiting-evaluation") { -

- Сданные работы, оценивание которых ещё не завершено. -

- } @if (state.view() === "projects-not-submitted") { -

- Проекты программы, которые ещё не отправили решение. -

@if (state.notSubmittedPage(); as page) { @if (page.applicable) {

{{ title() {{ cases.page()?.count === 0 ? "Нет проектов для выгрузки" - : "Все найденные проекты с учётом поиска и фильтров" + : "Все найденные проекты с учетом поиска и фильтров" }}

@if (cases.exportError(); as error) { @@ -300,228 +288,408 @@

{{ title() } @if (state.isAttentionView()) { - - - @if (state.attentionPending()) { - - } @else if (state.attentionError()) { - - } @else if (state.attentionPage()) { -

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

- @if (!state.attentionPage()?.results?.length) { -

{{ attentionEmptyMessage() }}

- } @else { - @if (state.participantsPage(); as page) { - - - - - - - - - - - @for (item of page.results; track item.userId) { - - - - - + @for (item of page.results; track item.programProjectId) { + + + + + + + + + + } + +
УчастникГородДата регистрацииДействие
- {{ - item.fullName - }} - {{ item.city || "Не указано" }} - {{ - item.registeredAt - ? (item.registeredAt | date: "dd.MM.yyyy") - : "Нет данных" - }} - - Открыть профиль +

{{ attentionRequestError() }}

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

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

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

{{ attentionEmptyMessage() }}

+ } @else { + @if (state.participantsPage(); as page) { + + + + + + + + - } - -
+ - + УчастникГородДата регистрацииДействие
- } - @if (state.projectsPage(); as page) { - - - - - - - - - - - - - @for (item of page.results; track item.programProjectId) { - - - + @for (item of page.results; track item.userId) { + + + - - - - + + + + } + +
ПроектРуководительДата сдачиПричинаПрогрессДействие
{{ item.project.name }} - @if (item.leader; as leader) { + +
+ + {{ - leader.fullName + >{{ + item.fullName }} - } @else { - Не указан - } - - {{ - item.submittedAt - ? (item.submittedAt | date: "dd.MM.yyyy") - : "Дата сдачи неизвестна" - }} - - {{ - item.reasonLabel - }} - - @if (page.mode === "open") { - — - } @else if (item.assignmentsTotal === 0) { - Нет назначений - } @else if ( - item.assignmentsTotal !== null && item.assignmentsCompleted !== null - ) { - Завершили: {{ item.assignmentsCompleted }} из {{ item.assignmentsTotal }} - } @else { - Нет данных - } - - Открыть проект + {{ item.city || "Не указано" }} + {{ + item.registeredAt + ? (item.registeredAt | date: "dd.MM.yyyy") + : "Нет данных" + }} + + Открыть профиль +
+ } + @if (state.projectsPage(); as page) { + + + + + + + + + + - } - -
+ - + ПроектРуководительДата сдачиПричинаПрогрессДействие
+ +
+ + {{ item.project.name }} + @if (item.leader; as leader) { + {{ + leader.fullName + }} + } @else { + Не указан + } + + {{ + item.submittedAt + ? (item.submittedAt | date: "dd.MM.yyyy") + : "Дата сдачи неизвестна" + }} + + {{ attentionReasonLabel(item.reasonLabel) }} + + @if (page.mode === "open") { + — + } @else if (item.assignmentsTotal === 0) { + Нет назначений + } @else if ( + item.assignmentsTotal !== null && item.assignmentsCompleted !== null + ) { + Завершили: {{ item.assignmentsCompleted }} из + {{ item.assignmentsTotal }} + } @else { + Нет данных + } + + Открыть проект +
+ } } - } - @if (state.notSubmittedPage(); as page) { - @if (page.applicable && page.results.length) { - - - - - - - - - - - - @for (item of page.results; track item.programProjectId) { - - - - - - + @if (state.notSubmittedPage(); as page) { + @if (page.applicable && page.results.length) { +
ПроектРуководительВ программе сСтатусДействие
{{ item.project.name }} - @if (item.leader; as leader) { - - - {{ leader.fullName }} - - } @else { - Не указан - } - {{ item.linkedAt | date: "dd.MM.yyyy" }} - {{ - page.submissionOpen ? "Не сдано" : "Срок сдачи завершён" - }} - - Открыть проект -
+ + + + + + + - } - -
ПроектРуководительВ программе сСтатусДействие
+ + + @for (item of page.results; track item.programProjectId) { + + {{ item.project.name }} + + @if (item.leader; as leader) { + + + {{ leader.fullName }} + + } @else { + Не указан + } + + {{ item.linkedAt | date: "dd.MM.yyyy" }} + + {{ + page.submissionOpen ? "Не сдано" : "Срок сдачи завершен" + }} + + + Открыть проект + + + } + + + } } + {{ state.attentionRange() }} } - {{ state.attentionRange() }} + } + @if (state.supportsActions()) { +
+ @if (state.actionError()) { + + } + @if (state.actionSuccess()) { +

{{ state.actionSuccess() }}

+ } + @if (!state.preview()) { + Выбрано: {{ state.selectedCount() }} + } +
+ @if (state.preview(); as preview) { + + + } @else { + + } +
+
} } @else if (state.view() === "scores") { @if (state.scoreDetailPending()) { @@ -595,7 +763,7 @@

{{ criterion.name }}

[class.analytics-drilldown__badge--pending]="expert.severity === 'warning'" >{{ expert.severity === "critical" - ? "Дедлайн оценивания прошёл" + ? "Дедлайн оценивания прошел" : "До дедлайна ≤ 5 дней" }} @@ -651,7 +819,7 @@

{{ criterion.name }}

/> } @if (state.notReadyBacklog().length) { -

Ещё не сданы

+

Еще не сданы

@@ -674,6 +842,7 @@

Ещё не сданы

Эксперт Проект Статус + Оценка Ожидание @@ -694,21 +863,23 @@

Ещё не сданы

[class]="'analytics-drilldown__badge analytics-drilldown__badge--' + item.status" >{{ statusLabel(item.status) }} - @if (item.status === "completed") { - - } + + @if (item.status === "completed") { + + } + {{ waiting(item.waitingSeconds, item.status) }} } 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 519a42722..417a07957 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 @@ -645,3 +645,13 @@ } } } + +@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%; } + } +} 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 a7184a3ae..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( "Посмотреть оценку: Проект А, Иван Иванов", ); } @@ -277,13 +278,13 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { await attached; await fixture.whenStable(); const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); - expect(dialog().textContent).toContain("Дедлайн оценивания прошёл"); + expect(dialog().textContent).toContain("Дедлайн оценивания прошел"); expect(dialog().textContent).toContain("Выполнено 3 из 8"); expect(dialog().textContent).toContain("2 д 4 ч"); button("Посмотреть назначения").click(); await fixture.whenStable(); expect(dialog().querySelector('[data-assignment-id="3"]')).toBeNull(); - expect(dialog().textContent).toContain("Ещё не сданы"); + expect(dialog().textContent).toContain("Еще не сданы"); expect(dialog().querySelector(".analytics-drilldown__table--assignments")).toBeNull(); expect(dialog().closest(".analytics-drilldown-body--assignments")).toBeNull(); expect(dialog().querySelector('[data-label="Прогресс"]')).toBeNull(); @@ -323,7 +324,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { it.each([ ["all", "Назначений экспертов пока нет"], - ["completed", "Завершённых оценок пока нет"], + ["completed", "Завершенных оценок пока нет"], ["pending", "Все назначения выполнены"], ] as const)("scope %s имеет понятный empty state", async (scope, message) => { assignments.execute.mockReturnValue(of(ok([]))); @@ -332,7 +333,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { expect(dialog().querySelector("table")).toBeNull(); }); - it("loading и score error сохраняют dialog; Escape из detail закрывает всё", async () => { + it("loading и score error сохраняют dialog; Escape из detail закрывает все", async () => { const response = new Subject[]>>>(); assignments.execute.mockReturnValue(response); await open(); @@ -365,7 +366,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { await attached; await fixture.whenStable(); expect(dialog().textContent).toContain("До дедлайна ≤ 5 дней"); - expect(dialog().textContent).not.toContain("Дедлайн оценивания прошёл"); + expect(dialog().textContent).not.toContain("Дедлайн оценивания прошел"); button("Посмотреть назначения").click(); await fixture.whenStable(); expect(dialog().textContent).toContain("У эксперта нет ожидающих назначений"); 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 a09ee281e..e6fa9216b 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, @@ -57,7 +57,7 @@ import { @Component({ selector: "app-analytics-drilldown", templateUrl: "./analytics-drilldown.component.html", - styleUrl: "./analytics-drilldown.component.scss", + styleUrls: ["./analytics-drilldown.component.scss", "./analytics-attention.scss"], imports: [ StateComponent, PaginationComponent, @@ -72,6 +72,7 @@ import { DatePipe, RouterLink, ButtonComponent, + IconComponent, LoaderComponent, ], providers: [ProgramAnalyticsDrilldownService, ProgramCaseProjectsService], @@ -90,6 +91,7 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { private readonly destroyRef = inject(DestroyRef); private trigger: HTMLElement | null = null; private focusedView = this.state.view(); + private focusedPreview = false; private previousProgramId: number | null = null; protected readonly attached = signal(false); @@ -104,7 +106,10 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { protected readonly waiting = formatAssignmentWaiting; protected readonly criterionValue = assignmentCriterionValue; protected readonly requestError = analyticsRequestError; + 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(); @@ -115,7 +120,7 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { case "participants-without-team": return "Участники без команды"; case "participants-without-visit": - return "Участники ещё не заходили на платформу"; + return "Участники еще не заходили на платформу"; case "projects-awaiting-evaluation": return "Работы ожидают оценивания"; case "projects-not-submitted": @@ -138,7 +143,7 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { () => ({ all: "Назначений экспертов пока нет", - completed: "Завершённых оценок пока нет", + completed: "Завершенных оценок пока нет", pending: "Все назначения выполнены", })[this.state.scope()], ); @@ -177,6 +182,11 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { // Angular render lifecycle, не ожидание DOM: один постоянный heading меняет текст/id. afterRenderEffect(() => { const view = this.state.view(); + const preview = !!this.state.preview(); + if (this.attached() && this.state.open() && preview !== this.focusedPreview) { + this.focusedPreview = preview; + this.heading.nativeElement.focus(); + } if (this.attached() && this.state.open() && view !== this.focusedView) { this.focusedView = view; this.heading.nativeElement.focus(); @@ -201,6 +211,7 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { } this.attached.set(true); this.focusedView = this.state.view(); + this.focusedPreview = !!this.state.preview(); // Portal уже в DOM. AutoCapture выключен, чтобы CDK не восстанавливал disconnected focus. this.trap.focusTrap.attachAnchors(); this.trap.enabled = true; 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 0e8f16c9e..ca3ac9aac 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 дня", ]); @@ -28,7 +28,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], @@ -61,7 +61,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 8693f5e79..d654903d6 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 "Завтра";