From a6336666eb3203e0a9e112e25328d942a9029750 Mon Sep 17 00:00:00 2001 From: Fedor Date: Sun, 6 Sep 2026 12:42:38 +0300 Subject: [PATCH] Add not-submitted projects attention UI --- docs/modules/program.md | 87 +++++- .../camelcase.interceptor.spec.ts | 45 +++ ...rogram-analytics-attention.service.spec.ts | 40 ++- ...rogram-analytics-drilldown.service.spec.ts | 2 + .../program-analytics-drilldown.service.ts | 40 ++- .../program-analytics-info.service.spec.ts | 1 + ...program-manager-attention.use-case.spec.ts | 31 ++ ...manager-projects-not-submitted.use-case.ts | 39 +++ .../program/ports/program.repository.port.ts | 7 + .../program-analytics-attention.fixture.ts | 25 ++ .../program-analytics-attention.model.ts | 16 + .../domain/program/program-analytics.model.ts | 2 + .../adapters/program/program-http.adapter.ts | 12 + .../repository/program/program.repository.ts | 9 + .../detail/analytics/analytics.component.html | 12 +- .../analytics/analytics.component.spec.ts | 71 ++++- .../detail/analytics/analytics.component.ts | 16 +- .../analytics-attention-route.spec.ts | 150 +++++---- .../analytics-attention.component.spec.ts | 291 +++++++++++++----- .../analytics-drilldown.component.html | 63 ++++ .../analytics-drilldown.component.scss | 5 + .../analytics-drilldown.component.spec.ts | 2 + .../analytics-drilldown.component.ts | 12 +- 23 files changed, 811 insertions(+), 167 deletions(-) create mode 100644 projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-not-submitted.use-case.ts diff --git a/docs/modules/program.md b/docs/modules/program.md index fe0d005af..e4022cc75 100644 --- a/docs/modules/program.md +++ b/docs/modules/program.md @@ -466,7 +466,7 @@ Loading, успешный список, empty без поиска и search-empt ### Общая модалка, навигация и ограничения -Shared `app-modal` не меняется. У всех шести views один overlay/dialog/focus trap. +Shared `app-modal` не меняется. У всех views один overlay/dialog/focus trap. Focus на close только после attachments, Escape через overlay keydownEvents, backdrop и close используют единый flow; при detach возврат на конкретный connected trigger. Смена программы/destroy не возвращают focus в старый контекст. Сохранён bottom-up cleanup. @@ -483,3 +483,88 @@ V1 не добавляет вуз/роль/кейс, contact/team actions, на Targeted tests покрывают pipeline, camelcase/null, поиск/страницы/retry/races, реальный overlay lifecycle и observable route change до resolver. Ручной DEV smoke и mobile/keyboard проверяются отдельно от unit/component tests. + +## Детализация «Проекты не сдали решение» + +Backend #726 смержен как `4dea5bd8893839e46800f5c9ee20646fc6307aab`; успешный +DEV deploy подтверждён для этого exact SHA. Angular база — +`ed06dff90feff709737b7c47ab405ec99623167f`. + +### Единицы воронок + +Строка «Сдали проект» удалена только из `participantFunnel` computed UI. +`participantFunnel.submittedProjectCreators` остаётся частью domain/API contract: +это уникальные зарегистрированные руководители, а не число сданных проектов. +Два руководителя могут сдать шесть проектов. Воронка участников теперь содержит +«Зарегистрировались», «Уникальные участники», «В команде», «Создали проект». +Tooltip описывает путь до создания проекта; сдача относится к воронке проектов. +Её стадии и значения не меняются: «Создано», «Черновик / не сдано», «Сдано», +«Оценено». «Сдано» по-прежнему использует `solutionFunnel.submitted`. + +### Применимость и путь данных + +Overview `attention.projectsNotSubmitted: { applicable: boolean; total: number }` +задаёт применимость. При `applicable=false` строки нет и detail не запрашивается, +даже если старый `solutionFunnel.notSubmitted` ненулевой. При true строка +«Проекты не сдали решение» стоит после работ, ожидающих оценивания, перед экспертами. +Нулевой total виден неактивной кнопкой; сообщение «Ничего не требует внимания» +сохраняется при всех нулевых метриках. Tooltip — отдельный соседний контрол: +«Проекты конкурсной программы, которые ещё не отправили решение.» + +`GetProgramManagerProjectsNotSubmittedUseCase` вызывает repository port → repository +→ HTTP adapter → `GET /programs/:programId/manager-overview/projects-not-submitted/`. +Компоненты не используют HttpClient. `ProgramAnalyticsNotSubmittedProjectsPage` +расширяет существующий `ProgramAnalyticsAttentionPage`. +Новые общие pagination-модели и manual snake_case преобразования не добавлены. +`CamelcaseInterceptor` преобразует `projects_not_submitted`, `program_project_id`, +`user_id`, `full_name`, `linked_at`, `submission_deadline`, `submission_open`. + +Строка содержит `programProjectId`, безопасные `project` и nullable `leader`, +`linkedAt` — дату связи с программой, не создания проекта. Unknown leader — +«Не указан», пустой avatar использует существующий placeholder. Никаких email, +кейсов, предполагаемых процентов готовности или дополнительных пользовательских +запросов нет. + +### View, сроки и страницы + +Новый root-view `projects-not-submitted` расширяет ту же модалку, overlay, dialog +и единственный CdkTrapFocus. Desktop — таблица, mobile/tablet — stacked rows с +подписями; длинные имена переносятся. Колонки: проект, руководитель, «В программе с» +(`dd.MM.yyyy`), статус, «Открыть проект» (`RouterLink /office/projects/:projectId`). + +Metadata берётся из detail, не из потенциально устаревшего overview: + +- известный `submissionDeadline` и `submissionOpen=true`: «Сдача открыта до …»; +- известный срок и false: «Срок сдачи завершён …»; +- null: «Срок сдачи не указан». + +Дата отображается DatePipe в локальном часовом поясе браузера как +`dd.MM.yyyy, HH:mm`. Статус строки — «Не сдано» или «Срок сдачи завершён» строго +по backend `submissionOpen`, не по Date.now(). Клиент не рассчитывает SLA, +оставшиеся часы или severity. Если detail сообщает `applicable=false`, UI +показывает «Для этой программы сдача решения не требуется.», не утверждает, +что все проекты сданы. + +Поиск по названию отправляется только Enter/«Найти». «Очистить» сбрасывает search +и offset; limit=25. Диапазон, count и «Назад»/«Далее» берутся из detail. По next URL +клиент не переходит. Retry сохраняет program/search/offset. Loading: «Загружаем список…»; +empty: «Все проекты программы уже сданы.»; search-empty: «По вашему запросу ничего +не найдено.» Ошибки 401/403/404/network контролируемые, raw HTTP body не выводится +и основная аналитика не скрывается. + +### Отмена, доступность и проверки + +Повторно используется `cancelAttention`/`takeUntilDestroyed`: close, другой root, +search, page, смена программы, destroy и RouterLink отменяют запрос и очищают +старую страницу. Нет второго механизма race-control. Observable parent paramMap +12 → 13 обнуляет exposed programId до resolver, закрывает модалку, отписывает +старый Subject. Program(13) запускает только overview; detail ждёт нового клика. + +Сохранены initial focus только после attachments, Escape/backdrop/close через +единый flow, возврат на connected trigger, отсутствие возврата при RouterLink, +смене программы, disconnected trigger и bottom-up destroy. Новый view включён +в parameterized lifecycle, search/pagination/race и SPA regression tests. + +V1 не меняет backend, React, shared modal, submission/scoring lifecycle, зависимости, +workflows, Docker или deploy. Unit/component tests с реальным CDK Overlay не +заменяют отдельную браузерную desktop/mobile/keyboard/E2E проверку. diff --git a/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts b/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts index 6e967cb50..000ead9fb 100644 --- a/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts +++ b/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts @@ -116,6 +116,51 @@ describe("CamelcaseInterceptor", () => { }); }); + it("not-submitted: overview, nullable metadata и вложенные поля преобразуются автоматически", () => { + const interceptor = TestBed.inject(CamelcaseInterceptor); + const body = { + attention: { projects_not_submitted: { applicable: true, total: 1 } }, + count: 1, + next: null, + previous: null, + applicable: true, + submission_deadline: null, + submission_open: true, + results: [ + { + program_project_id: 70, + project: { id: 55, name: "Проект" }, + leader: { user_id: 123, full_name: "Анна", avatar: null }, + linked_at: "2026-09-01T10:00:00Z", + }, + ], + }; + interceptor + .intercept(new HttpRequest("GET", "/programs/12/manager-overview/projects-not-submitted/"), { + handle: () => of(new HttpResponse({ body })), + }) + .subscribe(event => { + if (!(event instanceof HttpResponse)) return; + expect(event.body).toEqual({ + attention: { projectsNotSubmitted: { applicable: true, total: 1 } }, + count: 1, + next: null, + previous: null, + applicable: true, + submissionDeadline: null, + submissionOpen: true, + results: [ + { + programProjectId: 70, + project: { id: 55, name: "Проект" }, + leader: { userId: 123, fullName: "Анна", avatar: null }, + linkedAt: "2026-09-01T10:00:00Z", + }, + ], + }); + }); + }); + it("преобразует вложенный manager overview из snake_case в domain camelCase", () => { const interceptor = TestBed.inject(CamelcaseInterceptor); const request = new HttpRequest("GET", "/programs/12/manager-overview/"); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts index 76a54fddc..269407ae0 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts @@ -5,9 +5,11 @@ import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get- 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 { 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 { participantsPage, projectsPage, + notSubmittedPage, } from "@domain/program/program-analytics-attention.fixture"; import { fail, ok } from "@domain/shared/result.type"; import { @@ -18,21 +20,34 @@ import { describe.each([ "participants-without-team", "projects-awaiting-evaluation", + "projects-not-submitted", ] as AnalyticsAttentionView[])("Attention facade: %s", view => { const participants = { execute: vi.fn() }; const projects = { execute: vi.fn() }; + const notSubmitted = { execute: vi.fn() }; const assignments = { execute: vi.fn().mockReturnValue(of(ok([]))) }; const response = () => - view === "participants-without-team" ? participantsPage() : projectsPage(); - const active = () => (view === "participants-without-team" ? participants : projects); + view === "participants-without-team" + ? participantsPage() + : view === "projects-not-submitted" + ? notSubmittedPage() + : projectsPage(); + const active = () => + view === "participants-without-team" + ? participants + : view === "projects-not-submitted" + ? notSubmitted + : projects; let service: ProgramAnalyticsDrilldownService; beforeEach(() => { participants.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); projects.execute.mockReset().mockReturnValue(of(ok(projectsPage()))); + notSubmitted.execute.mockReset().mockReturnValue(of(ok(notSubmittedPage()))); assignments.execute.mockClear(); TestBed.configureTestingModule({ providers: [ ProgramAnalyticsDrilldownService, + { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: notSubmitted }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: participants }, { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: projects }, { provide: GetProgramManagerAssignmentsUseCase, useValue: assignments }, @@ -42,9 +57,16 @@ describe.each([ service = TestBed.inject(ProgramAnalyticsDrilldownService); }); + it("неприменимый или неподтверждённый сценарий не загружается", () => { + service.openAttention(12, "projects-not-submitted", false); + service.openAttention(12, "projects-not-submitted"); + expect(notSubmitted.execute).not.toHaveBeenCalled(); + expect(service.open()).toBe(false); + }); + it("lazy open, свежий count, нет назначения/автоподгрузки страниц", () => { expect(active().execute).not.toHaveBeenCalled(); - service.openAttention(12, view); + service.openAttention(12, view, true); expect(active().execute).toHaveBeenCalledExactlyOnceWith(12, { search: "", limit: 25, @@ -61,7 +83,7 @@ describe.each([ active().execute.mockReturnValue( of(ok({ ...page, count: 61, results: Array.from({ length: 25 }, () => page.results[0]) })), ); - service.openAttention(12, view); + service.openAttention(12, view, true); service.changeAttentionPage(1); expect(service.attentionOffset()).toBe(25); expect(service.attentionRange()).toBe("26–50 из 61"); @@ -77,7 +99,7 @@ describe.each([ }); it("search empty не error; repeated open очищает draft и старые results", () => { - service.openAttention(12, view); + service.openAttention(12, view, true); active().execute.mockReturnValueOnce(of(ok({ ...response(), count: 0, results: [] }))); service.searchDraft.set("несуществующий"); service.applyAttentionSearch(); @@ -86,7 +108,7 @@ describe.each([ expect(service.appliedSearch()).toBe("несуществующий"); const pending = new Subject(); active().execute.mockReturnValueOnce(pending); - service.openAttention(12, view); + service.openAttention(12, view, true); expect(service.searchDraft()).toBe(""); expect(service.appliedSearch()).toBe(""); expect(service.attentionPage()).toBeNull(); @@ -97,7 +119,7 @@ describe.each([ "retry %s сохраняет search/offset", kind => { active().execute.mockReturnValue(of(ok({ ...response(), count: 61 }))); - service.openAttention(12, view); + service.openAttention(12, view, true); service.searchDraft.set("Анна"); service.applyAttentionSearch(); active().execute.mockReturnValueOnce(of(fail({ kind }))); @@ -120,7 +142,7 @@ describe.each([ action => { const old = new Subject(); active().execute.mockReturnValueOnce(old); - service.openAttention(12, view); + service.openAttention(12, view, true); expect(old.observed).toBe(true); switch (action) { case "search": @@ -135,7 +157,7 @@ describe.each([ service.openAssignments(12, "all"); break; case "program": - service.openAttention(13, view); + service.openAttention(13, view, true); break; case "destroy": TestBed.resetTestingModule(); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts index 57f3880f8..1dc00e783 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts @@ -4,6 +4,7 @@ import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get- 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 { 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 { provideRouter } from "@angular/router"; import { ProgramAnalyticsAssignment, @@ -29,6 +30,7 @@ describe("ProgramAnalyticsDrilldownService", () => { TestBed.configureTestingModule({ providers: [ provideRouter([]), + { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: { execute: vi.fn() } }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: { execute: vi.fn() }, 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 da94344ee..6aa602192 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 @@ -6,10 +6,12 @@ import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get- 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 { 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 { ProgramAnalyticsAttentionPage, ProgramAnalyticsAttentionParticipant, ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProjectsPage, } from "@domain/program/program-analytics-attention.model"; import { ProgramAnalyticsAssignment, @@ -20,8 +22,11 @@ import { ProgramAnalyticsError, } from "@domain/program/program-analytics.model"; -/** Два root-списка внимания используют тот же modal context, не вложенную модалку. */ -export type AnalyticsAttentionView = "participants-without-team" | "projects-awaiting-evaluation"; +/** Root-списки внимания используют тот же modal context, не вложенную модалку. */ +export type AnalyticsAttentionView = + | "participants-without-team" + | "projects-awaiting-evaluation" + | "projects-not-submitted"; export type AnalyticsDrilldownView = | "assignments" | "scores" @@ -36,6 +41,7 @@ export class ProgramAnalyticsDrilldownService { private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase); private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase); private readonly getProjects = inject(GetProgramManagerProjectsAwaitingEvaluationUseCase); + private readonly getNotSubmitted = inject(GetProgramManagerProjectsNotSubmittedUseCase); private readonly destroyRef = inject(DestroyRef); private readonly cancelAssignments = new Subject(); private readonly cancelScores = new Subject(); @@ -62,14 +68,21 @@ export class ProgramAnalyticsDrilldownService { readonly participantsPage = signal | null>(null); readonly projectsPage = signal(null); + readonly notSubmittedPage = signal(null); readonly attentionPending = signal(false); readonly attentionError = signal(null); readonly isAttentionView = computed( () => - this.view() === "participants-without-team" || this.view() === "projects-awaiting-evaluation", + this.view() === "participants-without-team" || + this.view() === "projects-awaiting-evaluation" || + this.view() === "projects-not-submitted", ); readonly attentionPage = computed(() => - this.view() === "participants-without-team" ? this.participantsPage() : this.projectsPage(), + this.view() === "participants-without-team" + ? this.participantsPage() + : this.view() === "projects-not-submitted" + ? this.notSubmittedPage() + : this.projectsPage(), ); readonly attentionCount = computed(() => this.attentionPage()?.count ?? 0); readonly attentionHasNext = computed(() => { @@ -87,7 +100,13 @@ export class ProgramAnalyticsDrilldownService { } /** Каждое root-open очищает поиск/страницу/старые данные и делает ровно один запрос. */ - openAttention(programId: number, view: AnalyticsAttentionView): void { + openAttention( + programId: number, + view: AnalyticsAttentionView, + notSubmittedApplicable = false, + ): void { + // Только явно подтверждённая overview применимость разрешает новый сценарий. + if (view === "projects-not-submitted" && !notSubmittedApplicable) return; if (!this.start(programId)) return; this.view.set(view); this.loadAttention(); @@ -122,6 +141,7 @@ export class ProgramAnalyticsDrilldownService { this.cancelAttention.next(); this.participantsPage.set(null); this.projectsPage.set(null); + this.notSubmittedPage.set(null); this.attentionError.set(null); this.attentionPending.set(true); const query = { @@ -138,6 +158,15 @@ export class ProgramAnalyticsDrilldownService { if (result.ok) this.participantsPage.set(result.value); else this.attentionError.set(result.error); }); + } else if (this.view() === "projects-not-submitted") { + this.getNotSubmitted + .execute(this.programId, query) + .pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.attentionPending.set(false); + if (result.ok) this.notSubmittedPage.set(result.value); + else this.attentionError.set(result.error); + }); } else { this.getProjects .execute(this.programId, query) @@ -251,6 +280,7 @@ export class ProgramAnalyticsDrilldownService { this.attentionOffset.set(0); this.participantsPage.set(null); this.projectsPage.set(null); + this.notSubmittedPage.set(null); this.attentionPending.set(false); this.attentionError.set(null); this.open.set(false); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts index cb9eab511..791dfe3b8 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts @@ -35,6 +35,7 @@ const overview: ProgramAnalyticsOverview = { attention: { participantsWithoutTeam: 1, projectsAwaitingEvaluation: 1, + projectsNotSubmitted: { applicable: false, total: 0 }, delayedExperts: { total: 0, items: [] }, }, activity: [], 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 f8be29dcc..c8b3d515a 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 @@ -9,9 +9,11 @@ import { ProgramRepository } from "@infrastructure/repository/program/program.re import { participantsPage, projectsPage, + notSubmittedPage, } from "@domain/program/program-analytics-attention.fixture"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "./get-program-manager-participants-without-team.use-case"; import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "./get-program-manager-projects-awaiting-evaluation.use-case"; +import { GetProgramManagerProjectsNotSubmittedUseCase } from "./get-program-manager-projects-not-submitted.use-case"; describe("Attention: adapter → repository → use cases", () => { const api = { get: vi.fn() }; @@ -26,6 +28,12 @@ describe("Attention: adapter → repository → use cases", () => { }); for (const [method, endpoint, useCase, page] of [ + [ + "getManagerProjectsNotSubmitted", + "projects-not-submitted", + GetProgramManagerProjectsNotSubmittedUseCase, + notSubmittedPage(), + ], [ "getManagerParticipantsWithoutTeam", "participants-without-team", @@ -43,6 +51,7 @@ describe("Attention: adapter → repository → use cases", () => { TestBed.inject< | GetProgramManagerParticipantsWithoutTeamUseCase | GetProgramManagerProjectsAwaitingEvaluationUseCase + | GetProgramManagerProjectsNotSubmittedUseCase >(useCase); it(`${endpoint}: forwarding, response passthrough, отсутствие cache`, async () => { api.get.mockReturnValue(of(page)); @@ -89,6 +98,28 @@ describe("Attention: adapter → repository → use cases", () => { }); } + it("not-submitted сохраняет nullable leader/deadline и applicable=false", async () => { + const page = notSubmittedPage({ + submissionDeadline: null, + results: [{ ...notSubmittedPage().results[0], leader: null }], + }); + api.get.mockReturnValueOnce(of(page)); + const service = TestBed.inject(GetProgramManagerProjectsNotSubmittedUseCase); + expect(await firstValueFrom(service.execute(12, {}))).toEqual({ ok: true, value: page }); + const noncompetitive = notSubmittedPage({ + applicable: false, + count: 0, + results: [], + submissionDeadline: null, + submissionOpen: false, + }); + api.get.mockReturnValueOnce(of(noncompetitive)); + expect(await firstValueFrom(service.execute(12, {}))).toEqual({ + ok: true, + value: noncompetitive, + }); + }); + it("open mode: null assignment counters не превращаются в 0", async () => { const page = projectsPage({ mode: "open", diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-not-submitted.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-not-submitted.use-case.ts new file mode 100644 index 000000000..fd57e9063 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-not-submitted.use-case.ts @@ -0,0 +1,39 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAnalyticsNotSubmittedProjectsPage, + 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"; + +/** Read-only страница несданных проектов; применимость и сроки определяет backend. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerProjectsNotSubmittedUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + /** Сохраняет серверные данные и отдаёт контролируемую ошибку без сырого HTTP body. */ + execute( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.repository.getManagerProjectsNotSubmitted(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 7616f6e9f..0cd3f4c67 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 @@ -15,6 +15,7 @@ import { ProgramAnalyticsAttentionPage, ProgramAnalyticsAttentionParticipant, ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProjectsPage, ProgramAnalyticsAttentionQuery, } from "../program-analytics-attention.model"; import { @@ -48,6 +49,12 @@ export abstract class ProgramRepositoryPort { query: ProgramAnalyticsAttentionQuery, ): Observable; + /** Несданные связи конкурсной программы с серверным поиском, страницей и сроком. */ + abstract getManagerProjectsNotSubmitted( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable; + /** Manager-only назначения; pending включает несданные проекты. */ abstract getManagerAssignments( programId: number, diff --git a/projects/social_platform/src/app/domain/program/program-analytics-attention.fixture.ts b/projects/social_platform/src/app/domain/program/program-analytics-attention.fixture.ts index 48919a08a..c12960a86 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics-attention.fixture.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics-attention.fixture.ts @@ -4,6 +4,8 @@ import { ProgramAnalyticsAttentionParticipant, ProgramAnalyticsAttentionProject, ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProject, + ProgramAnalyticsNotSubmittedProjectsPage, } from "./program-analytics-attention.model"; export const attentionParticipant = ( @@ -52,3 +54,26 @@ export const projectsPage = ( mode: "distributed", ...overrides, }); + +export const notSubmittedProject = ( + overrides: Partial = {}, +): ProgramAnalyticsNotSubmittedProject => ({ + programProjectId: 70, + project: { id: 55, name: "Проект А" }, + leader: { userId: 123, fullName: "Анна Петрова", avatar: null }, + linkedAt: "2026-09-01T10:00:00+03:00", + ...overrides, +}); + +export const notSubmittedPage = ( + overrides: Partial = {}, +): ProgramAnalyticsNotSubmittedProjectsPage => ({ + count: 1, + next: null, + previous: null, + results: [notSubmittedProject()], + applicable: true, + submissionDeadline: "2026-09-10T23:59:00+03:00", + submissionOpen: true, + ...overrides, +}); 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 acb6707e5..d0f2b27d0 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 @@ -51,3 +51,19 @@ export interface ProgramAnalyticsAttentionProject { /** Режим берём из ответа списка, не из потенциально устаревшей сводки. */ export type ProgramAnalyticsAttentionProjects = ProgramAnalyticsAttentionPage & { mode: ProgramEvaluationMode }; + +/** Несданная связь проекта с программой; linkedAt — дата связи, не создания проекта. */ +export interface ProgramAnalyticsNotSubmittedProject { + programProjectId: number; + project: { id: number; name: string }; + leader: { userId: number; fullName: string; avatar: string | null } | null; + linkedAt: string; +} + +/** Применимость и срок задаёт backend; клиент не пересчитывает дедлайн или SLA. */ +export type ProgramAnalyticsNotSubmittedProjectsPage = + ProgramAnalyticsAttentionPage & { + applicable: boolean; + submissionDeadline: string | null; + submissionOpen: boolean; + }; 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 2a79e715f..9d32c5121 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: { participantsWithoutTeam: number; projectsAwaitingEvaluation: number; + /** Несданные связи: требование сдачи применимо только к конкурсной программе. */ + projectsNotSubmitted: { applicable: boolean; total: number }; delayedExperts: ProgramAnalyticsDelayedExperts; }; activity: ProgramAnalyticsActivityPoint[]; 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 0cc0aa43f..385b8e6be 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 @@ -17,6 +17,7 @@ import { ProgramAnalyticsAttentionPage, ProgramAnalyticsAttentionParticipant, ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProjectsPage, ProgramAnalyticsAttentionQuery, } from "@domain/program/program-analytics-attention.model"; import { @@ -78,6 +79,17 @@ export class ProgramHttpAdapter { ); } + /** Отдельный список несданных связей; camelcase выполняет общий interceptor. */ + getManagerProjectsNotSubmitted( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/manager-overview/projects-not-submitted/`, + this.attentionParams(query), + ); + } + private attentionParams(query: ProgramAnalyticsAttentionQuery): HttpParams { let params = new HttpParams().set("limit", query.limit ?? 25).set("offset", query.offset ?? 0); const search = query.search?.trim(); 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 93ea09613..14670927c 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 @@ -22,6 +22,7 @@ import { ProgramAnalyticsAttentionPage, ProgramAnalyticsAttentionParticipant, ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProjectsPage, ProgramAnalyticsAttentionQuery, } from "@domain/program/program-analytics-attention.model"; import { @@ -78,6 +79,14 @@ export class ProgramRepository implements ProgramRepositoryPort { return this.programAdapter.getManagerProjectsAwaitingEvaluation(programId, query); } + /** Передаёт страницу несданных связей без кэша, нормализации или пересчёта срока. */ + getManagerProjectsNotSubmitted( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable { + return this.programAdapter.getManagerProjectsNotSubmitted(programId, query); + } + /** Не кешируем manager-данные между открытиями или программами. */ getManagerAssignments( 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 87c729589..cd9ae5da0 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 @@ -171,7 +171,7 @@

{{ card.title }}

Воронка участников

Требует внимания (hide)="hideTooltip('attention')" >
- @if (hasValues(attention())) { + @if (hasValues(attention()) || data()?.attention?.projectsNotSubmitted?.applicable) {
    @for (item of attention(); track item.key) {
  • @@ -387,7 +387,9 @@

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

    drilldown.openAttention( item.key === 'without-team' ? 'participants-without-team' - : 'projects-awaiting-evaluation', + : item.key === 'projects-not-submitted' + ? 'projects-not-submitted' + : 'projects-awaiting-evaluation', attentionTrigger ) " @@ -408,7 +410,8 @@

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

  • }
- } @else { + } + @if (!hasValues(attention())) {

Ничего не требует внимания

@@ -536,5 +539,6 @@

Динамика активности

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 1217d8bf1..925b04787 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 @@ -14,6 +14,7 @@ import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get- 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 { 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 { provideRouter } from "@angular/router"; import { of } from "rxjs"; import { ok } from "@domain/shared/result.type"; @@ -56,7 +57,7 @@ function overview(overrides: Partial = {}): ProgramAna uniqueParticipants: 18, withTeam: 12, projectCreators: 7, - submittedProjectCreators: 6, + submittedProjectCreators: 2, }, solutionFunnel: { created: 7, notSubmitted: 1, submitted: 6, evaluated: 4 }, evaluationStatus: { @@ -73,6 +74,7 @@ function overview(overrides: Partial = {}): ProgramAna attention: { participantsWithoutTeam: 6, projectsAwaitingEvaluation: 2, + projectsNotSubmitted: { applicable: false, total: 0 }, delayedExperts: { total: 0, items: [] }, }, activity: activity(), @@ -117,6 +119,7 @@ describe("ProgramAnalyticsComponent", () => { imports: [ProgramAnalyticsComponent], providers: [ provideRouter([]), + { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: { execute: vi.fn() } }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: { execute: vi.fn() }, @@ -223,6 +226,7 @@ describe("ProgramAnalyticsComponent", () => { attention: { participantsWithoutTeam: 0, projectsAwaitingEvaluation: 0, + projectsNotSubmitted: { applicable: false, total: 0 }, delayedExperts: { total: 1, items: [delayedExpert()] }, }, }); @@ -268,6 +272,63 @@ describe("ProgramAnalyticsComponent", () => { expect(open).toHaveBeenCalledTimes(1); }); + it("not-submitted: порядок, активная/нулевая строка и tooltip отдельно", () => { + const base = overview(); + data.set({ + ...base, + attention: { ...base.attention, projectsNotSubmitted: { applicable: true, total: 4 } }, + }); + 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 buttons = Array.from( + fixture.nativeElement.querySelectorAll(".attention__action"), + ) as HTMLButtonElement[]; + expect(buttons.map(button => button.querySelector("span")?.textContent)).toEqual([ + "Участники без команды", + "Работы ожидают оценивания", + "Проекты не сдали решение", + "Эксперты задерживают оценивание", + ]); + const trigger = buttons[2]; + expect(trigger.disabled).toBe(false); + expect(drilldown.notSubmittedApplicable()).toBe(true); + expect(trigger.querySelector("app-tooltip")).toBeNull(); + trigger.parentElement!.querySelector("app-tooltip")!.click(); + expect(open).not.toHaveBeenCalled(); + trigger.click(); + expect(open).toHaveBeenCalledExactlyOnceWith("projects-not-submitted", trigger); + data.set({ + ...base, + attention: { + participantsWithoutTeam: 0, + projectsAwaitingEvaluation: 0, + delayedExperts: { total: 0, items: [] }, + projectsNotSubmitted: { applicable: true, total: 0 }, + }, + }); + fixture.detectChanges(); + expect(trigger.disabled).toBe(true); + expect(fixture.nativeElement.textContent).toContain("Ничего не требует внимания"); + trigger.click(); + expect(open).toHaveBeenCalledTimes(1); + }); + + it("non-competitive скрывает строку даже с notSubmitted > 0 и не делает detail request", () => { + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const drilldown = fixture.debugElement.query(By.directive(AnalyticsDrilldownComponent)) + .componentInstance as AnalyticsDrilldownComponent; + expect(data()?.solutionFunnel.notSubmitted).toBeGreaterThan(0); + expect(fixture.nativeElement.textContent).not.toContain("Проекты не сдали решение"); + drilldown.openAttention("projects-not-submitted", document.createElement("button")); + expect( + TestBed.inject(GetProgramManagerProjectsNotSubmittedUseCase).execute, + ).not.toHaveBeenCalled(); + }); + it("renders independent project and participant region cards without normalizing legacy names", () => { const fixture = TestBed.createComponent(ProgramAnalyticsComponent); fixture.detectChanges(); @@ -362,7 +423,11 @@ describe("ProgramAnalyticsComponent", () => { expect(participants).toContain("Уникальные участники"); expect(participants).toContain("В команде"); expect(participants).toContain("Создали проект"); - expect(participants).toContain("Сдали проект"); + expect(participants).not.toContain("Сдали проект"); + const submitted = Array.from(root.querySelectorAll('[data-testid="solution-funnel"] li')).find( + row => row.querySelector(".funnel__label")?.textContent?.trim().startsWith("Сдано"), + ); + expect(submitted?.querySelector("strong")?.textContent?.trim()).toBe("6"); expect(solutions).toContain("Создано"); expect(solutions).toContain("Черновик / не сдано"); expect(solutions).toContain("Сдано"); @@ -438,6 +503,7 @@ describe("ProgramAnalyticsComponent", () => { attention: { participantsWithoutTeam: 3, projectsAwaitingEvaluation: 1, + projectsNotSubmitted: { applicable: false, total: 0 }, delayedExperts: { total: 0, items: [] }, }, }); @@ -574,6 +640,7 @@ describe("ProgramAnalyticsComponent", () => { attention: { participantsWithoutTeam: 0, projectsAwaitingEvaluation: 0, + projectsNotSubmitted: { applicable: false, total: 0 }, delayedExperts: { total: 0, items: [] }, }, activity: activity(30, true), 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 672d590a3..4994916aa 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 @@ -162,12 +162,6 @@ export class ProgramAnalyticsComponent implements OnInit { funnel.projectCreators, "Участники, ставшие руководителями проекта программы.", ), - this.metric( - "submitted-project-creators", - "Сдали проект", - funnel.submittedProjectCreators, - "Руководители проектов, отправившие решение программы.", - ), ]; }); @@ -277,6 +271,16 @@ export class ProgramAnalyticsComponent implements OnInit { value: overview.attention.projectsAwaitingEvaluation, tooltip: this.awaitingEvaluationTooltip(overview.evaluationStatus.mode), }, + ...(overview.attention.projectsNotSubmitted.applicable + ? [ + { + key: "projects-not-submitted", + label: "Проекты не сдали решение", + value: overview.attention.projectsNotSubmitted.total, + tooltip: "Проекты конкурсной программы, которые ещё не отправили решение.", + }, + ] + : []), { key: "delayed-experts", 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 dbd273e10..82fd0dea9 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 @@ -9,12 +9,14 @@ import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get- 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 { 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"; import { ProgramAnalyticsDrilldownService } from "@api/program/facades/detail/program-analytics-drilldown.service"; import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; import { participantsPage, projectsPage, + notSubmittedPage, } from "@domain/program/program-analytics-attention.fixture"; import { Program } from "@domain/program/program.model"; import { ok } from "@domain/shared/result.type"; @@ -30,7 +32,11 @@ import { AnalyticsDrilldownComponent } from "./analytics-drilldown.component"; (click)="drilldown.openAttention('participants-without-team', trigger)" > Участники без команды`, + >`, }) class RouteHost { readonly analytics = inject(ProgramAnalyticsInfoService); @@ -40,72 +46,82 @@ class RouteHost { } describe("Attention SPA: observable route → resolver → overview", () => { - it.each(["participants-without-team", "projects-awaiting-evaluation"] as const)( - "%s отменяется до Program(13), без нового auto request", - async view => { - const params = new BehaviorSubject(convertToParamMap({ programId: "12" })); - const old = new Subject(); - const attention = { execute: vi.fn().mockReturnValue(old) }; - const overview = { execute: vi.fn().mockReturnValue(of(ok({}))) }; - await TestBed.configureTestingModule({ - imports: [RouteHost], - providers: [ - provideRouter([]), - ProgramDetailMainUIInfoService, - { provide: ActivatedRoute, useValue: { parent: { paramMap: params.asObservable() } } }, - { provide: GetProgramManagerOverviewUseCase, useValue: overview }, - { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: attention }, - { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: attention }, - { provide: GetProgramManagerAssignmentsUseCase, useValue: { execute: vi.fn() } }, - { provide: GetProgramManagerAssignmentScoresUseCase, useValue: { execute: vi.fn() } }, - ], - }).compileComponents(); - const ui = TestBed.inject(ProgramDetailMainUIInfoService); - ui.program.set({ ...Program.default(), id: 12, isUserManager: true }); - const fixture = TestBed.createComponent(RouteHost); - fixture.autoDetectChanges(); - await fixture.whenStable(); - const child = fixture.debugElement.query(By.directive(AnalyticsDrilldownComponent)); - const component = child.componentInstance as AnalyticsDrilldownComponent; - const state = child.injector.get(ProgramAnalyticsDrilldownService); - const modal = child.query(By.directive(ModalComponent)).componentInstance as ModalComponent; - const trigger = fixture.nativeElement.querySelector("button") as HTMLButtonElement; - const attached = firstValueFrom(modal.overlayRef!.attachments()); - component.openAttention(view, trigger); - await attached; - await fixture.whenStable(); - old.next(ok(view === "participants-without-team" ? participantsPage() : projectsPage())); - await fixture.whenStable(); - expect(old.observed).toBe(true); - expect(state.attentionPage()?.count).toBe(1); - const focus = vi.spyOn(trigger, "focus"); + it.each([ + "participants-without-team", + "projects-awaiting-evaluation", + "projects-not-submitted", + ] as const)("%s отменяется до Program(13), без нового auto request", async view => { + const params = new BehaviorSubject(convertToParamMap({ programId: "12" })); + const old = new Subject(); + const attention = { execute: vi.fn().mockReturnValue(old) }; + const overview = { execute: vi.fn().mockReturnValue(of(ok({}))) }; + await TestBed.configureTestingModule({ + imports: [RouteHost], + providers: [ + provideRouter([]), + ProgramDetailMainUIInfoService, + { provide: ActivatedRoute, useValue: { parent: { paramMap: params.asObservable() } } }, + { provide: GetProgramManagerOverviewUseCase, useValue: overview }, + { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: attention }, + { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: attention }, + { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: attention }, + { provide: GetProgramManagerAssignmentsUseCase, useValue: { execute: vi.fn() } }, + { provide: GetProgramManagerAssignmentScoresUseCase, useValue: { execute: vi.fn() } }, + ], + }).compileComponents(); + const ui = TestBed.inject(ProgramDetailMainUIInfoService); + ui.program.set({ ...Program.default(), id: 12, isUserManager: true }); + const fixture = TestBed.createComponent(RouteHost); + fixture.autoDetectChanges(); + await fixture.whenStable(); + const child = fixture.debugElement.query(By.directive(AnalyticsDrilldownComponent)); + const component = child.componentInstance as AnalyticsDrilldownComponent; + const state = child.injector.get(ProgramAnalyticsDrilldownService); + const modal = child.query(By.directive(ModalComponent)).componentInstance as ModalComponent; + const trigger = fixture.nativeElement.querySelector("button") as HTMLButtonElement; + const attached = firstValueFrom(modal.overlayRef!.attachments()); + component.openAttention(view, trigger); + await attached; + await fixture.whenStable(); + old.next( + ok( + view === "participants-without-team" + ? participantsPage() + : view === "projects-not-submitted" + ? notSubmittedPage() + : projectsPage(), + ), + ); + await fixture.whenStable(); + expect(old.observed).toBe(true); + expect(state.attentionPage()?.count).toBe(1); + const focus = vi.spyOn(trigger, "focus"); - // Router emits parent params before resolver installs a new Program. No snapshot mutations. - params.next(convertToParamMap({ programId: "13" })); - expect(fixture.componentInstance.analytics.programId()).toBeNull(); - expect(ui.program()?.id).toBe(12); - // Let the normal Angular interaction render propagate the signal input; no detectChanges hack. - await fixture.whenStable(); - expect(old.observed).toBe(false); - expect(state.open()).toBe(false); - expect(state.attentionPage()).toBeNull(); - expect(attention.execute).toHaveBeenCalledExactlyOnceWith(12, { - search: "", - limit: 25, - offset: 0, - }); - expect(overview.execute).toHaveBeenCalledExactlyOnceWith(12); + // Router emits parent params before resolver installs a new Program. No snapshot mutations. + params.next(convertToParamMap({ programId: "13" })); + expect(fixture.componentInstance.analytics.programId()).toBeNull(); + expect(ui.program()?.id).toBe(12); + // Let the normal Angular interaction render propagate the signal input; no detectChanges hack. + await fixture.whenStable(); + expect(old.observed).toBe(false); + expect(state.open()).toBe(false); + expect(state.attentionPage()).toBeNull(); + expect(attention.execute).toHaveBeenCalledExactlyOnceWith(12, { + search: "", + limit: 25, + offset: 0, + }); + expect(overview.execute).toHaveBeenCalledExactlyOnceWith(12); - ui.program.set({ ...Program.default(), id: 13, isUserManager: true }); - await fixture.whenStable(); - expect(overview.execute).toHaveBeenCalledTimes(2); - expect(overview.execute).toHaveBeenLastCalledWith(13); - expect(attention.execute).toHaveBeenCalledTimes(1); - old.next(ok(participantsPage({ count: 999 }))); - await fixture.whenStable(); - expect(state.attentionPage()).toBeNull(); - expect(focus).not.toHaveBeenCalled(); - fixture.destroy(); - }, - ); + ui.program.set({ ...Program.default(), id: 13, isUserManager: true }); + await fixture.whenStable(); + expect(overview.execute).toHaveBeenCalledTimes(2); + expect(overview.execute).toHaveBeenLastCalledWith(13); + expect(attention.execute).toHaveBeenCalledTimes(1); + old.next(ok(participantsPage({ count: 999 }))); + await fixture.whenStable(); + expect(state.attentionPage()).toBeNull(); + expect(focus).not.toHaveBeenCalled(); + fixture.destroy(); + }); }); 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 788b43255..940fdd371 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 @@ -3,11 +3,13 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { provideRouter, Router } from "@angular/router"; import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { DATE_PIPE_DEFAULT_OPTIONS } from "@angular/common"; 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 { 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 { ProgramAnalyticsDrilldownService, AnalyticsAttentionView, @@ -17,6 +19,8 @@ import { attentionProject, participantsPage, projectsPage, + notSubmittedPage, + notSubmittedProject, } from "@domain/program/program-analytics-attention.fixture"; import { fail, ok } from "@domain/shared/result.type"; import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; @@ -30,6 +34,7 @@ describe("Attention views: real overlay", () => { let trigger: HTMLButtonElement; const participants = { execute: vi.fn() }; const projects = { execute: vi.fn() }; + const notSubmitted = { execute: vi.fn() }; const dialog = () => document.querySelector('[role="dialog"]')!; const button = (text: string) => Array.from(dialog().querySelectorAll("button")).find( @@ -39,12 +44,15 @@ describe("Attention views: real overlay", () => { beforeEach(async () => { participants.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); projects.execute.mockReset().mockReturnValue(of(ok(projectsPage()))); + notSubmitted.execute.mockReset().mockReturnValue(of(ok(notSubmittedPage()))); await TestBed.configureTestingModule({ imports: [AnalyticsDrilldownComponent], providers: [ provideRouter([]), + { provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: { timezone: "UTC" } }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: participants }, { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: projects }, + { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: notSubmitted }, { provide: GetProgramManagerAssignmentsUseCase, useValue: { execute: vi.fn().mockReturnValue(of(ok([]))) }, @@ -54,6 +62,7 @@ describe("Attention views: real overlay", () => { }).compileComponents(); fixture = TestBed.createComponent(AnalyticsDrilldownComponent); fixture.componentRef.setInput("programId", 12); + fixture.componentRef.setInput("notSubmittedApplicable", true); fixture.autoDetectChanges(); await fixture.whenStable(); state = fixture.debugElement.injector.get(ProgramAnalyticsDrilldownService); @@ -202,6 +211,126 @@ describe("Attention views: real overlay", () => { expect(dialog().textContent).not.toContain("Ещё не сданы"); }); + it.each([true, false, null])( + "not-submitted: deadline open=%s, row fields и mobile labels", + async openState => { + notSubmitted.execute.mockReturnValue( + of( + ok( + notSubmittedPage({ + submissionOpen: openState === true, + submissionDeadline: openState === null ? null : "2026-09-10T23:59:00Z", + count: 2, + results: [ + notSubmittedProject(), + notSubmittedProject({ + programProjectId: 71, + leader: null, + project: { id: 56, name: "Очень длинное название проекта ".repeat(10) }, + }), + ], + }), + ), + ), + ); + await open("projects-not-submitted"); + const metadata = dialog().querySelector('[data-testid="submission-deadline"]')?.textContent; + expect(metadata).toContain( + openState === null + ? "Срок сдачи не указан" + : openState + ? "Сдача открыта до" + : "Срок сдачи завершён", + ); + if (openState !== null) expect(metadata).toMatch(/10\.09\.2026, 23:59/); + for (const text of [ + "Проекты не сдали решение", + "Проекты программы, которые ещё не отправили решение.", + "Проект А", + "Анна Петрова", + "Не указан", + "01.09.2026", + "Найдено: 2", + ]) + expect(dialog().textContent).toContain(text); + expect(dialog().querySelector('[data-label="Статус"]')?.textContent).toContain( + openState === true ? "Не сдано" : "Срок сдачи завершён", + ); + expect( + Array.from(dialog().querySelectorAll("tbody tr:first-child td"), cell => + cell.getAttribute("data-label"), + ), + ).toEqual(["Проект", "Руководитель", "В программе с", "Статус", "Действие"]); + expect(dialog().querySelector("a")?.getAttribute("href")).toBe("/office/projects/55"); + expect(dialog().querySelectorAll("tbody tr")).toHaveLength(2); + }, + ); + + it("not-submitted: server search via submit/button, clear and page", async () => { + notSubmitted.execute.mockReturnValue( + of( + ok( + notSubmittedPage({ + count: 61, + results: Array.from({ length: 25 }, (_, i) => + notSubmittedProject({ programProjectId: i + 1 }), + ), + }), + ), + ), + ); + await open("projects-not-submitted"); + const input = dialog().querySelector("input")!; + input.value = " Проект "; + input.dispatchEvent(new Event("input", { bubbles: true })); + await fixture.whenStable(); + expect(notSubmitted.execute).toHaveBeenCalledTimes(1); + button("Найти").click(); + await fixture.whenStable(); + expect(notSubmitted.execute).toHaveBeenLastCalledWith(12, { + search: "Проект", + limit: 25, + offset: 0, + }); + button("Далее").click(); + await fixture.whenStable(); + expect(dialog().textContent).toContain("26–50 из 61"); + expect(notSubmitted.execute).toHaveBeenLastCalledWith(12, { + search: "Проект", + limit: 25, + offset: 25, + }); + button("Очистить").click(); + await fixture.whenStable(); + expect(input.value).toBe(""); + expect(notSubmitted.execute).toHaveBeenLastCalledWith(12, { search: "", limit: 25, offset: 0 }); + }); + + it.each(["disconnected", "destroy"])( + "not-submitted %s: no focus restoration, cancellation", + async action => { + const pending = new Subject(); + notSubmitted.execute.mockReturnValue(pending); + await open("projects-not-submitted"); + const focus = vi.spyOn(trigger, "focus"); + if (action === "disconnected") { + trigger.remove(); + const detached = firstValueFrom(modal.overlayRef!.detachments()); + fixture.componentInstance.closeAnalyticsModal(); + await detached; + await fixture.whenStable(); + } else { + fixture.destroy(); + (modal.overlayRef!.keydownEvents() as Subject).next( + new KeyboardEvent("keydown", { key: "Escape" }), + ); + } + expect(focus).not.toHaveBeenCalled(); + expect(pending.observed).toBe(false); + expect(state.notSubmittedPage()).toBeNull(); + }, + ); + it("open: nullable progress —, не 0 из 0", async () => { projects.execute.mockReturnValue( of( @@ -225,83 +354,101 @@ describe("Attention views: real overlay", () => { expect(dialog().textContent).toContain("Ожидает первой оценки"); }); - it.each(["participants-without-team", "projects-awaiting-evaluation"] as const)( - "%s: loading, empty, search_empty, error и retry отдельно", - async view => { - const api = view === "participants-without-team" ? participants : projects; - const page = view === "participants-without-team" ? participantsPage() : projectsPage(); - const loading = new Subject(); - api.execute.mockReturnValueOnce(loading); - await open(view); - expect(dialog().textContent).toContain("Загружаем список"); - loading.next(ok({ ...page, count: 0, results: [] })); - await fixture.whenStable(); - expect(dialog().textContent).toContain( - view === "participants-without-team" - ? "Все зарегистрированные участники уже состоят в командах." + it.each([ + "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; + const page = + view === "participants-without-team" + ? participantsPage() + : view === "projects-not-submitted" + ? notSubmittedPage() + : projectsPage(); + const loading = new Subject(); + api.execute.mockReturnValueOnce(loading); + await open(view); + expect(dialog().textContent).toContain("Загружаем список"); + loading.next(ok({ ...page, count: 0, results: [] })); + await fixture.whenStable(); + expect(dialog().textContent).toContain( + view === "participants-without-team" + ? "Все зарегистрированные участники уже состоят в командах." + : view === "projects-not-submitted" + ? "Все проекты программы уже сданы." : "Нет работ, ожидающих оценивания.", - ); - api.execute.mockReturnValueOnce(of(ok({ ...page, count: 0, results: [] }))); - state.searchDraft.set("нет"); - state.applyAttentionSearch(); + ); + api.execute.mockReturnValueOnce(of(ok({ ...page, count: 0, results: [] }))); + state.searchDraft.set("нет"); + state.applyAttentionSearch(); + await fixture.whenStable(); + expect(dialog().textContent).toContain("По вашему запросу ничего не найдено."); + for (const [kind, message] of [ + ["unauthorized", "Авторизуйтесь заново"], + ["forbidden", "Нет доступа к данным этой программы."], + ["not_found", "Программа не найдена."], + ["network", "Проверьте соединение"], + ] as const) { + api.execute.mockReturnValueOnce(of(fail({ kind, body: "secret" }))); + state.loadAttention(); await fixture.whenStable(); - expect(dialog().textContent).toContain("По вашему запросу ничего не найдено."); - for (const [kind, message] of [ - ["unauthorized", "Авторизуйтесь заново"], - ["forbidden", "Нет доступа к данным этой программы."], - ["not_found", "Программа не найдена."], - ["network", "Проверьте соединение"], - ] as const) { - api.execute.mockReturnValueOnce(of(fail({ kind, body: "secret" }))); - state.loadAttention(); - await fixture.whenStable(); - expect(dialog().querySelector('[role="alert"]')?.textContent).toContain(message); - expect(dialog().textContent).not.toContain("secret"); - expect(dialog().textContent).not.toContain("По вашему запросу ничего не найдено."); - button("Повторить загрузку").click(); - await fixture.whenStable(); - expect(dialog().querySelector('[role="alert"]')).toBeNull(); - } - }, - ); + expect(dialog().querySelector('[role="alert"]')?.textContent).toContain(message); + expect(dialog().textContent).not.toContain("secret"); + expect(dialog().textContent).not.toContain("По вашему запросу ничего не найдено."); + button("Повторить загрузку").click(); + await fixture.whenStable(); + expect(dialog().querySelector('[role="alert"]')).toBeNull(); + } + }); - it.each(["participants-without-team", "projects-awaiting-evaluation"] as const)( - "%s: один trap, focus, Escape/backdrop/button единый close", - async view => { - let trap: CdkTrapFocus | undefined; - for (const method of ["Escape", "backdrop", "button"]) { - await open(view); - const currentTrap = fixture.debugElement - .query(By.directive(CdkTrapFocus)) - .injector.get(CdkTrapFocus); - trap ??= currentTrap; - expect(currentTrap).toBe(trap); - expect(document.activeElement).toBe(dialog().querySelector(".analytics-drilldown__close")); - expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); - expect(trap.enabled).toBe(true); - expect(dialog().querySelectorAll('.cdk-focus-trap-anchor[tabindex="0"]')).toHaveLength(2); - const close = vi.spyOn(fixture.componentInstance, "closeAnalyticsModal"); - const detached = firstValueFrom(modal.overlayRef!.detachments()); - if (method === "Escape") - (modal.overlayRef!.keydownEvents() as Subject).next( - new KeyboardEvent("keydown", { key: "Escape" }), - ); - else if (method === "backdrop") - document.querySelector(".modal__overlay")!.click(); - else button("×").click(); - await detached; - await fixture.whenStable(); - expect(close).toHaveBeenCalledOnce(); - close.mockRestore(); - expect(document.activeElement).toBe(trigger); - expect(state.attentionPage()).toBeNull(); - } - }, - ); + it.each([ + "participants-without-team", + "projects-awaiting-evaluation", + "projects-not-submitted", + ] as const)("%s: один trap, focus, Escape/backdrop/button единый close", async view => { + let trap: CdkTrapFocus | undefined; + for (const method of ["Escape", "backdrop", "button"]) { + await open(view); + const currentTrap = fixture.debugElement + .query(By.directive(CdkTrapFocus)) + .injector.get(CdkTrapFocus); + trap ??= currentTrap; + expect(currentTrap).toBe(trap); + expect(document.activeElement).toBe(dialog().querySelector(".analytics-drilldown__close")); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(trap.enabled).toBe(true); + expect(dialog().querySelectorAll('.cdk-focus-trap-anchor[tabindex="0"]')).toHaveLength(2); + const close = vi.spyOn(fixture.componentInstance, "closeAnalyticsModal"); + const detached = firstValueFrom(modal.overlayRef!.detachments()); + if (method === "Escape") + (modal.overlayRef!.keydownEvents() as Subject).next( + new KeyboardEvent("keydown", { key: "Escape" }), + ); + else if (method === "backdrop") + document.querySelector(".modal__overlay")!.click(); + else button("×").click(); + await detached; + await fixture.whenStable(); + expect(close).toHaveBeenCalledOnce(); + close.mockRestore(); + expect(document.activeElement).toBe(trigger); + expect(state.attentionPage()).toBeNull(); + } + }); - it("RouterLink закрывает без return focus в покидаемую программу", async () => { + it.each([ + "participants-without-team", + "projects-awaiting-evaluation", + "projects-not-submitted", + ] as const)("RouterLink %s закрывает без return focus в покидаемую программу", async view => { const navigate = vi.spyOn(TestBed.inject(Router), "navigateByUrl").mockResolvedValue(true); - await open(); + await open(view); const focus = vi.spyOn(trigger, "focus"); const detached = firstValueFrom(modal.overlayRef!.detachments()); dialog().querySelector("a")!.click(); 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 f8950b939..7c25a82af 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 @@ -34,6 +34,23 @@

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

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

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

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