Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion docs/modules/program.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<ProgramAnalyticsNotSubmittedProject>`.
Новые общие 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 проверку.
45 changes: 45 additions & 0 deletions projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 },
Expand All @@ -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,
Expand All @@ -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");
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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 })));
Expand All @@ -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":
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +30,7 @@ describe("ProgramAnalyticsDrilldownService", () => {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{ provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: { execute: vi.fn() } },
{
provide: GetProgramManagerParticipantsWithoutTeamUseCase,
useValue: { execute: vi.fn() },
Expand Down
Loading