From e9b38a63b1cc25f9dd909632756c85851c623e38 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 12 Oct 2026 00:12:32 +0300 Subject: [PATCH 1/2] feat(analytics): add unfinished onboarding attention list --- .../program-analytics-drilldown.service.ts | 22 +++++-- ...program-manager-attention.use-case.spec.ts | 8 +++ ...articipants-without-onboarding.use-case.ts | 44 ++++++++++++++ .../program/ports/program.repository.port.ts | 5 ++ .../program-analytics-attention.model.ts | 2 +- .../domain/program/program-analytics.model.ts | 2 + .../adapters/program/program-http.adapter.ts | 11 ++++ .../repository/program/program.repository.ts | 8 +++ .../detail/analytics/analytics.component.html | 8 ++- .../analytics/analytics.component.spec.ts | 58 +++++++++++++++++++ .../detail/analytics/analytics.component.ts | 13 +++++ .../analytics-attention-route.spec.ts | 5 +- .../analytics-attention.component.spec.ts | 54 +++++++++++++---- .../analytics-drilldown.component.html | 8 +-- .../analytics-drilldown.component.ts | 4 ++ 15 files changed, 226 insertions(+), 26 deletions(-) create mode 100644 projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-onboarding.use-case.ts 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 034299301..3489257a4 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 @@ -8,6 +8,7 @@ import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.m import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerParticipantsWithoutOnboardingUseCase } from "@api/program/use-cases/get-program-manager-participants-without-onboarding.use-case"; import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case"; import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case"; import { @@ -27,6 +28,7 @@ import { /** Root-списки внимания используют тот же modal context, не вложенную модалку. */ export type AnalyticsAttentionView = + | "participants-without-onboarding" | "participants-without-team" | "projects-awaiting-evaluation" | "projects-not-submitted"; @@ -58,6 +60,9 @@ export class ProgramAnalyticsDrilldownService { private readonly getAssignments = inject(GetProgramManagerAssignmentsUseCase); private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase); private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase); + private readonly getWithoutOnboarding = inject( + GetProgramManagerParticipantsWithoutOnboardingUseCase, + ); private readonly getProjects = inject(GetProgramManagerProjectsAwaitingEvaluationUseCase); private readonly getNotSubmitted = inject(GetProgramManagerProjectsNotSubmittedUseCase); private readonly destroyRef = inject(DestroyRef); @@ -89,14 +94,19 @@ export class ProgramAnalyticsDrilldownService { readonly notSubmittedPage = signal(null); readonly attentionPending = signal(false); readonly attentionError = signal(null); - readonly isAttentionView = computed( + readonly isParticipantView = computed( () => this.view() === "participants-without-team" || + this.view() === "participants-without-onboarding", + ); + readonly isAttentionView = computed( + () => + this.isParticipantView() || this.view() === "projects-awaiting-evaluation" || this.view() === "projects-not-submitted", ); readonly attentionPage = computed(() => - this.view() === "participants-without-team" + this.isParticipantView() ? this.participantsPage() : this.view() === "projects-not-submitted" ? this.notSubmittedPage() @@ -167,8 +177,12 @@ export class ProgramAnalyticsDrilldownService { limit: this.attentionLimit, offset: this.attentionOffset(), }; - if (this.view() === "participants-without-team") { - this.getParticipants + if (this.isParticipantView()) { + const useCase = + this.view() === "participants-without-onboarding" + ? this.getWithoutOnboarding + : this.getParticipants; + useCase .execute(this.programId, query) .pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef)) .subscribe(result => { diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts index c8b3d515a..58d4ecece 100644 --- a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts @@ -12,6 +12,7 @@ import { notSubmittedPage, } from "@domain/program/program-analytics-attention.fixture"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "./get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerParticipantsWithoutOnboardingUseCase } from "./get-program-manager-participants-without-onboarding.use-case"; import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "./get-program-manager-projects-awaiting-evaluation.use-case"; import { GetProgramManagerProjectsNotSubmittedUseCase } from "./get-program-manager-projects-not-submitted.use-case"; @@ -28,6 +29,12 @@ describe("Attention: adapter → repository → use cases", () => { }); for (const [method, endpoint, useCase, page] of [ + [ + "getManagerParticipantsWithoutOnboarding", + "participants-without-onboarding", + GetProgramManagerParticipantsWithoutOnboardingUseCase, + participantsPage(), + ], [ "getManagerProjectsNotSubmitted", "projects-not-submitted", @@ -50,6 +57,7 @@ describe("Attention: adapter → repository → use cases", () => { const injectUseCase = () => TestBed.inject< | GetProgramManagerParticipantsWithoutTeamUseCase + | GetProgramManagerParticipantsWithoutOnboardingUseCase | GetProgramManagerProjectsAwaitingEvaluationUseCase | GetProgramManagerProjectsNotSubmittedUseCase >(useCase); diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-onboarding.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-onboarding.use-case.ts new file mode 100644 index 000000000..8d197cce4 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-onboarding.use-case.ts @@ -0,0 +1,44 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; +import { ProgramAnalyticsError } from "@domain/program/program-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +/** Уникальные участники программы с незавершённым онбордингом платформы. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerParticipantsWithoutOnboardingUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + execute( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable< + Result< + ProgramAnalyticsAttentionPage, + ProgramAnalyticsError + > + > { + return this.repository.getManagerParticipantsWithoutOnboarding(programId, query).pipe( + map(value => ok(value)), + catchError((error: unknown) => { + const status = error instanceof HttpErrorResponse ? error.status : 0; + const kind: ProgramAnalyticsError["kind"] = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : "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 cba2ba0b5..daeafeb69 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 @@ -46,6 +46,11 @@ export abstract class ProgramRepositoryPort { abstract getManagerOverview(programId: number): Observable; + abstract getManagerParticipantsWithoutOnboarding( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable>; + /** Уникальные участники без команды текущей программы, серверный поиск/пагинация. */ abstract getManagerParticipantsWithoutTeam( programId: number, diff --git a/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts b/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts index d0f2b27d0..e9b875ef2 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts @@ -15,7 +15,7 @@ export type ProgramAnalyticsAttentionPage = Omit, "next" | " previous: string | null; }; -/** Уникальный участник без команды в этой программе, не признак «ищет команду». +/** Уникальный участник списка внимания программы, не признак «ищет команду». * registeredAt — регистрация в программе, city сохраняет legacy-значения backend. */ export interface ProgramAnalyticsAttentionParticipant { diff --git a/projects/social_platform/src/app/domain/program/program-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-analytics.model.ts index 9b85cc688..d5030f280 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics.model.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics.model.ts @@ -41,6 +41,8 @@ export interface ProgramAnalyticsOverview { }; }; attention: { + /** Absent until the API deployment provides the onboarding attention list. */ + participantsWithoutOnboarding?: number; participantsWithoutTeam: number; projectsAwaitingEvaluation: number; /** Несданные связи: требование сдачи применимо только к конкурсной программе. */ 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 49cbe27ca..e61dd1bc1 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 @@ -87,6 +87,17 @@ export class ProgramHttpAdapter { return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/manager-overview/`); } + /** Без автозагрузки остальных страниц; camelcase преобразует общий interceptor. */ + getManagerParticipantsWithoutOnboarding( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/manager-overview/participants-without-onboarding/`, + this.attentionParams(query), + ); + } + /** Без автозагрузки остальных страниц; camelcase преобразует общий interceptor. */ getManagerParticipantsWithoutTeam( programId: number, 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 13755f0b3..77965b4fa 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 @@ -81,6 +81,14 @@ export class ProgramRepository implements ProgramRepositoryPort { return this.programAdapter.getManagerOverview(programId); } + /** Свежий manager-список при каждом открытии/поиске, без EntityCache. */ + getManagerParticipantsWithoutOnboarding( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.programAdapter.getManagerParticipantsWithoutOnboarding(programId, query); + } + /** Свежий manager-список при каждом открытии/поиске, без EntityCache. */ getManagerParticipantsWithoutTeam( programId: number, 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 e24c09b5e..22e7c4de2 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 @@ -170,9 +170,11 @@

Требует внимания

[disabled]="item.value === 0" (click)=" drilldown.openAttention( - item.key === 'without-team' - ? 'participants-without-team' - : 'projects-awaiting-evaluation', + item.key === 'without-onboarding' + ? 'participants-without-onboarding' + : item.key === 'without-team' + ? 'participants-without-team' + : 'projects-awaiting-evaluation', attentionTrigger ) " 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 e38c9dcb1..70c534453 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 @@ -549,6 +549,64 @@ describe("ProgramAnalyticsComponent", () => { expect(open).toHaveBeenCalledTimes(1); }); + it("onboarding attention uses the API count, opens its list, and disables zero", () => { + const base = overview(); + data.set({ + ...base, + attention: { ...base.attention, participantsWithoutOnboarding: 7 }, + }); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const drilldown = fixture.debugElement.query(By.directive(AnalyticsDrilldownComponent)) + .componentInstance as AnalyticsDrilldownComponent; + const open = vi.spyOn(drilldown, "openAttention").mockImplementation(() => {}); + const trigger = Array.from( + fixture.nativeElement.querySelectorAll(".attention__action") as NodeListOf, + ).find(button => button.textContent?.includes("Участники не завершили онбординг"))!; + expect(trigger.querySelector("strong")?.textContent).toBe("7"); + expect(trigger.disabled).toBe(false); + trigger.click(); + expect(open).toHaveBeenCalledExactlyOnceWith("participants-without-onboarding", trigger); + data.set({ ...base, attention: { ...base.attention, participantsWithoutOnboarding: 0 } }); + fixture.detectChanges(); + expect(trigger.disabled).toBe(true); + trigger.click(); + expect(open).toHaveBeenCalledTimes(1); + }); + + it("does not invent an onboarding attention count when the API field is absent", () => { + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('[data-testid="attention-block"]').textContent, + ).not.toContain("Участники не завершили онбординг"); + }); + + it("unfinished onboarding alone prevents the all-clear attention state", () => { + const base = overview(); + data.set({ + ...base, + attention: { + participantsWithoutOnboarding: 1, + participantsWithoutTeam: 0, + projectsAwaitingEvaluation: 0, + projectsNotSubmitted: { applicable: false, total: 0 }, + delayedExperts: { total: 0, items: [] }, + }, + }); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector(".attention__empty")).toBeNull(); + data.update(current => ({ + ...current!, + attention: { ...current!.attention, participantsWithoutOnboarding: 0 }, + })); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector(".attention__empty")?.textContent).toContain( + "Ничего не требует внимания", + ); + }); + it("не дублирует счётчик несданных проектов в блоке внимания", () => { const base = overview(); data.set({ 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 a5e2d9989..33f7f6929 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 @@ -318,7 +318,20 @@ export class ProgramAnalyticsComponent implements OnInit { protected readonly attention = computed(() => { const overview = this.data(); if (!overview) return []; + const onboarding: AnalyticsMetric[] = + overview.attention.participantsWithoutOnboarding == null + ? [] + : [ + { + key: "without-onboarding", + label: "Участники не завершили онбординг", + value: overview.attention.participantsWithoutOnboarding, + tooltip: + "Зарегистрированные участники программы, которые ещё не завершили стартовый онбординг платформы. Участие в команде и подтверждение почты не заменяют онбординг.", + }, + ]; return [ + ...onboarding, { key: "without-team", label: "Участники без команды", diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts index ce465103a..fd26886b6 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts @@ -11,6 +11,7 @@ import { GetProgramManagerOverviewUseCase } from "@api/program/use-cases/get-pro import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerParticipantsWithoutOnboardingUseCase } from "@api/program/use-cases/get-program-manager-participants-without-onboarding.use-case"; import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case"; import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case"; import { ProgramAnalyticsInfoService } from "@api/program/facades/detail/program-analytics-info.service"; @@ -56,6 +57,7 @@ class RouteHost { describe("Attention SPA: observable route → resolver → overview", () => { it.each([ + "participants-without-onboarding", "participants-without-team", "projects-awaiting-evaluation", "projects-not-submitted", @@ -76,6 +78,7 @@ describe("Attention SPA: observable route → resolver → overview", () => { { provide: ActivatedRoute, useValue: { parent: { paramMap: params.asObservable() } } }, { provide: GetProgramManagerOverviewUseCase, useValue: overview }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: attention }, + { provide: GetProgramManagerParticipantsWithoutOnboardingUseCase, useValue: attention }, { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: attention }, { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: attention }, { provide: GetProgramManagerAssignmentsUseCase, useValue: { execute: vi.fn() } }, @@ -98,7 +101,7 @@ describe("Attention SPA: observable route → resolver → overview", () => { await fixture.whenStable(); old.next( ok( - view === "participants-without-team" + view === "participants-without-team" || view === "participants-without-onboarding" ? participantsPage() : view === "projects-not-submitted" ? notSubmittedPage() 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 f8b7f8277..bdbb57cda 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 @@ -11,6 +11,7 @@ import { firstValueFrom, of, Subject } from "rxjs"; import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerParticipantsWithoutOnboardingUseCase } from "@api/program/use-cases/get-program-manager-participants-without-onboarding.use-case"; import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case"; import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case"; import { @@ -36,6 +37,7 @@ describe("Attention views: real overlay", () => { let modal: ModalComponent; let trigger: HTMLButtonElement; const participants = { execute: vi.fn() }; + const onboarding = { execute: vi.fn() }; const projects = { execute: vi.fn() }; const notSubmitted = { execute: vi.fn() }; const dialog = () => document.querySelector('[role="dialog"]')!; @@ -46,6 +48,7 @@ describe("Attention views: real overlay", () => { beforeEach(async () => { participants.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); + onboarding.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); projects.execute.mockReset().mockReturnValue(of(ok(projectsPage()))); notSubmitted.execute.mockReset().mockReturnValue(of(ok(notSubmittedPage()))); await TestBed.configureTestingModule({ @@ -58,6 +61,7 @@ describe("Attention views: real overlay", () => { provideRouter([]), { provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: { timezone: "UTC" } }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: participants }, + { provide: GetProgramManagerParticipantsWithoutOnboardingUseCase, useValue: onboarding }, { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: projects }, { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: notSubmitted }, { @@ -130,6 +134,27 @@ describe("Attention views: real overlay", () => { expect(dialog().textContent).not.toMatch(/Ищет команду|Вуз|email|Напомнить/); }); + it("onboarding opens the participant list with server search and pagination", async () => { + onboarding.execute.mockReturnValue(of(ok(participantsPage({ count: 30 })))); + await open("participants-without-onboarding"); + expect(dialog().textContent).toContain("Участники не завершили онбординг"); + expect(dialog().textContent).toContain("Анна Петрова"); + expect(dialog().querySelector("input")?.placeholder).toBe("Поиск по имени"); + expect(dialog().querySelector("a")?.getAttribute("href")).toBe("/office/profile/123"); + expect(participants.execute).not.toHaveBeenCalled(); + button("Далее").click(); + await fixture.whenStable(); + expect(onboarding.execute).toHaveBeenLastCalledWith(12, { search: "", limit: 25, offset: 25 }); + state.searchDraft.set(" Анна "); + state.applyAttentionSearch(); + await fixture.whenStable(); + expect(onboarding.execute).toHaveBeenLastCalledWith(12, { + search: "Анна", + limit: 25, + offset: 0, + }); + }); + it("server search только submit, clear, count и pagination из detail", async () => { participants.execute.mockReturnValue( of( @@ -362,18 +387,21 @@ describe("Attention views: real overlay", () => { }); it.each([ + "participants-without-onboarding", "participants-without-team", "projects-awaiting-evaluation", "projects-not-submitted", ] as const)("%s: loading, empty, search_empty, error и retry отдельно", async view => { const api = - view === "participants-without-team" - ? participants - : view === "projects-not-submitted" - ? notSubmitted - : projects; + view === "participants-without-onboarding" + ? onboarding + : view === "participants-without-team" + ? participants + : view === "projects-not-submitted" + ? notSubmitted + : projects; const page = - view === "participants-without-team" + view === "participants-without-team" || view === "participants-without-onboarding" ? participantsPage() : view === "projects-not-submitted" ? notSubmittedPage() @@ -385,11 +413,13 @@ describe("Attention views: real overlay", () => { loading.next(ok({ ...page, count: 0, results: [] })); await fixture.whenStable(); expect(dialog().textContent).toContain( - view === "participants-without-team" - ? "Все зарегистрированные участники уже состоят в командах." - : view === "projects-not-submitted" - ? "Все проекты программы уже сданы." - : "Нет работ, ожидающих оценивания.", + view === "participants-without-onboarding" + ? "Все зарегистрированные участники завершили онбординг." + : view === "participants-without-team" + ? "Все зарегистрированные участники уже состоят в командах." + : view === "projects-not-submitted" + ? "Все проекты программы уже сданы." + : "Нет работ, ожидающих оценивания.", ); api.execute.mockReturnValueOnce(of(ok({ ...page, count: 0, results: [] }))); state.searchDraft.set("нет"); @@ -415,6 +445,7 @@ describe("Attention views: real overlay", () => { }); it.each([ + "participants-without-onboarding", "participants-without-team", "projects-awaiting-evaluation", "projects-not-submitted", @@ -450,6 +481,7 @@ describe("Attention views: real overlay", () => { }); it.each([ + "participants-without-onboarding", "participants-without-team", "projects-awaiting-evaluation", "projects-not-submitted", 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 aa1bd54d8..99f3b753b 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 @@ -273,9 +273,7 @@

{{ title() (submit)="$event.preventDefault(); state.applyAttentionSearch()" >
{{ title() id="analytics-attention-search" type="search" [placeholder]=" - state.view() === 'participants-without-team' - ? 'Поиск по имени' - : 'Поиск по названию проекта' + state.isParticipantView() ? 'Поиск по имени' : 'Поиск по названию проекта' " [value]="state.searchDraft()" (input)="state.searchDraft.set($any($event.target).value)" 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 b77a71750..f9dbc2f89 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 @@ -114,6 +114,8 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { } case "participants-without-team": return "Участники без команды"; + case "participants-without-onboarding": + return "Участники не завершили онбординг"; case "projects-awaiting-evaluation": return "Работы ожидают оценивания"; case "projects-not-submitted": @@ -148,6 +150,8 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { return "Для этой программы сдача решения не требуется."; if (this.state.appliedSearch()) return "По вашему запросу ничего не найдено."; if (this.state.view() === "projects-not-submitted") return "Все проекты программы уже сданы."; + if (this.state.view() === "participants-without-onboarding") + return "Все зарегистрированные участники завершили онбординг."; return this.state.view() === "participants-without-team" ? "Все зарегистрированные участники уже состоят в командах." : "Нет работ, ожидающих оценивания."; From aab67af294fad9e2dd81a28eb32dece424673029 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 12 Oct 2026 00:28:52 +0300 Subject: [PATCH 2/2] fix(analytics): describe expert alerts by evaluation deadline --- .../domain/program/program-analytics.model.ts | 8 +++--- .../detail/analytics/analytics.component.ts | 2 +- .../analytics-drilldown.component.html | 6 ++-- .../analytics-drilldown.component.spec.ts | 28 +++++++++++++++++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/projects/social_platform/src/app/domain/program/program-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-analytics.model.ts index d5030f280..ec6311306 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics.model.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics.model.ts @@ -160,8 +160,8 @@ export interface ProgramAnalyticsAssignmentScoreDetail extends ProgramAnalyticsA scores: ProgramAnalyticsAssignmentCriterion[]; } -/** Severity и порядок authoritative: warning — >=2 ожиданий 24ч, critical — >=1 48ч. - * Заглавная H соответствует преобразованию overdue_24h/overdue_48h interceptor-ом. +/** Backend включает эксперта за 5 суток до дедлайна: warning до него, critical после. + * overdue24H/overdue48H сохранены для совместимости и не определяют сигнал. */ export interface ProgramAnalyticsDelayedExpert extends ProgramAnalyticsAssignmentExpert { assignmentsTotal: number; @@ -169,8 +169,8 @@ export interface ProgramAnalyticsDelayedExpert extends ProgramAnalyticsAssignmen pending: number; overdue24H: number; overdue48H: number; - oldestWaitingSince: string; - oldestWaitingSeconds: number; + oldestWaitingSince: string | null; + oldestWaitingSeconds: number | null; severity: "warning" | "critical"; } 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 33f7f6929..5e3d377bc 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 @@ -350,7 +350,7 @@ export class ProgramAnalyticsComponent implements OnInit { label: "Эксперты задерживают оценивание", value: overview.attention.delayedExperts.total, tooltip: - "Эксперты с просроченными назначениями: минимум два проекта ожидают оценки 24 часа или хотя бы один проект — 48 часов.", + "Появляется за 5 суток до окончания оценивания, если у эксперта есть незавершённая оценка сданной работы. После дедлайна остаётся до завершения оценок. Без даты окончания оценивания сигнал не появляется.", }, ]; }); 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 99f3b753b..c56618484 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 @@ -561,11 +561,13 @@

{{ criterion.name }}

[class.analytics-drilldown__badge--critical]="expert.severity === 'critical'" [class.analytics-drilldown__badge--pending]="expert.severity === 'warning'" >{{ - expert.severity === "critical" ? "Критическая задержка" : "Требует внимания" + expert.severity === "critical" + ? "Дедлайн оценивания прошёл" + : "До дедлайна ≤ 5 дней" }} Выполнено {{ expert.completed }} из {{ expert.assignmentsTotal }}Ожидает: {{ expert.pending }} + >Не завершено: {{ expert.pending }} Самое долгое ожидание: {{ waiting(expert.oldestWaitingSeconds) }} Посмотреть назначения → 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 2b5a30720..a7184a3ae 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 @@ -277,7 +277,7 @@ 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(); @@ -364,13 +364,35 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { fixture.componentInstance.openDelayed(trigger); await attached; await fixture.whenStable(); - expect(dialog().textContent).toContain("Требует внимания"); - expect(dialog().textContent).not.toContain("Критическая задержка"); + expect(dialog().textContent).toContain("До дедлайна ≤ 5 дней"); + expect(dialog().textContent).not.toContain("Дедлайн оценивания прошёл"); button("Посмотреть назначения").click(); await fixture.whenStable(); expect(dialog().textContent).toContain("У эксперта нет ожидающих назначений"); }); + it("сигнал по дедлайну отображается и при неизвестном времени ожидания", async () => { + fixture.componentRef.setInput("delayedExperts", { + total: 1, + items: [ + delayedExpert({ + severity: "warning", + overdue24H: 0, + overdue48H: 0, + oldestWaitingSince: null, + oldestWaitingSeconds: null, + }), + ], + }); + const attached = firstValueFrom(modal.overlayRef!.attachments()); + fixture.componentInstance.openDelayed(trigger); + await attached; + await fixture.whenStable(); + expect(dialog().textContent).toContain("До дедлайна ≤ 5 дней"); + expect(dialog().textContent).toContain("Самое долгое ожидание: Нет данных"); + expect(dialog().textContent).toContain("Не завершено: 5"); + }); + it("не выполняет запросы для отсутствующей программы и не обрабатывает Escape без attachment", async () => { fixture.componentRef.setInput("programId", null); await fixture.whenStable();