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) {
+
+ @if (page.submissionDeadline) {
+ {{ page.submissionOpen ? "Сдача открыта до" : "Срок сдачи завершён" }}
+ {{ page.submissionDeadline | date: "dd.MM.yyyy, HH:mm" }}
+ } @else {
+ Срок сдачи не указан
+ }
+
+ }
+ }
+ }