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
89 changes: 89 additions & 0 deletions docs/modules/program.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,92 @@ attachment событием openChange и не добавляют ручной d
delayed/backlog/back, loading/error/retry, длинные имена на desktop/mobile,
Tab/Shift+Tab, Escape, backdrop, возврат на конкретный trigger и смена программы.
Backend, React, shared modal, зависимости, workflows/Docker и deploy вне изменений.

## Детализация «Требует внимания»: участники и работы (v1)

Контракт — backend PR #725 (merge `bc1fffb16071935cfce449e6c59202251cb860c6`).
Два дополнительных root-view расширяют существующий `AnalyticsDrilldownComponent`:
`participants-without-team` и `projects-awaiting-evaluation`. Все три строки внимания
теперь кнопки; нулевая строка неактивна, а при всех нулях сохранено «Ничего не требует внимания».
Tooltip — соседний контрол, его нажатие не открывает детализацию.

### API и типы

- `getManagerParticipantsWithoutTeam` / `GetProgramManagerParticipantsWithoutTeamUseCase`:
`GET /programs/:programId/manager-overview/participants-without-team/`.
- `getManagerProjectsAwaitingEvaluation` / `GetProgramManagerProjectsAwaitingEvaluationUseCase`:
`GET /programs/:programId/manager-overview/projects-awaiting-evaluation/`.

Оба метода принимают `ProgramAnalyticsAttentionQuery` (`search`, `limit`, `offset`).
Путь данных сохраняет port → HTTP adapter → repository → use case → page-local facade.
Компонент не использует HttpClient. Use cases возвращают `Result<…, ProgramAnalyticsError>`.
Тип страницы переиспользует `ApiPagination`, уточняя nullable `next`/`previous` только
в analytics contract; общая модель пагинации не меняется. Ручного snake_case parser нет.
Общий interceptor преобразует `user_id`, `registered_at`, `program_project_id`,
`submitted_at`, `reason_label`, `assignments_total`, `assignments_completed` в camelCase.

`ProgramAnalyticsAttentionParticipant` содержит только `userId`, `fullName`, `avatar`,
`city`, `registeredAt`. Дата означает регистрацию в программе, не создание аккаунта.
Legacy city показывается как прислано. Отсутствующие город/дата: «Не указано» / «Нет данных».
Отсутствие команды не означает заявленный статус «Ищет команду».

`ProgramAnalyticsAttentionProject` — одна сданная работа программы: `programProjectId`,
`project`, nullable `leader`/`submittedAt`, `status`, `reason`, `reasonLabel` и nullable
assignment-счётчики. Режим берётся из `mode` ответа списка, не из старого overview.
Пустой руководитель — «Не указан», неизвестная дата — «Дата сдачи неизвестна».

Причины backend (код используется как стабильный идентификатор, label — для отображения):

- `no_assignments` — «Эксперты не назначены»;
- `no_completed_evaluations` — «Нет завершённых оценок»;
- `partially_evaluated` — «Частично оценено»;
- `awaiting_first_evaluation` — «Ожидает первой оценки» (open).

В distributed прогресс: «Завершили: X из Y» из `assignmentsCompleted/assignmentsTotal`;
при total=0 — «Нет назначений». В open — «—», null не становится нулём.
Нет расчёта процентов, SLA, средних баллов или требуемого числа экспертов.
Несданные работы исключены backend и не добавляются обратно через assignments.

### Поиск, страницы, ошибки и отмена

Данные не кешируются между открытиями. Нажатие root-trigger очищает прошлый контекст,
поиск и offset и делает один запрос. Вместе с overview списки не загружаются.
Черновик поиска применяется по Enter/«Найти», сервер получает trimmed search.
«Очистить» возвращает полный список. Новый поиск всегда начинает с offset=0.
Размер страницы фиксирован: 25. «Назад»/«Далее» меняют offset, диапазон и count
отображаются из актуального detail response. Ссылки backend next/previous не открываются
напрямую. Расхождение со старым числом overview не ошибка; overview циклически не обновляется.

Loading, успешный список, empty без поиска и search-empty — разные состояния.
При пустом поиске: «Все зарегистрированные участники уже состоят в командах.» либо
«Нет работ, ожидающих оценивания.» При применённом поиске: «По вашему запросу ничего не найдено.»
Ошибки 401/403/404/network локальны и не скрывают основную аналитику. Показываются
только контролируемые сообщения, не HTTP body. Retry сохраняет view/program/search/limit/offset.

`cancelAttention` + `takeUntilDestroyed` отписывают активный запрос при закрытии,
другом root-open, новом поиске, смене страницы/программы и destroy. Поздний ответ
не может перезаписать новую страницу. Перед загрузкой старые results очищаются.
Сохраняется реактивный parent `ActivatedRoute.paramMap` из #332: смена 12 → 13 сразу
обнуляет публичный programId, отменяет overview и через signal input закрывает drilldown.
До соответствующего resolver Program(13) новый overview не запрашивается; новый attention
список не запрашивается и после resolver, пока пользователь снова не нажмёт строку.

### Общая модалка, навигация и ограничения

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.
Новых таймеров, polling, глобальных keyboard handlers или второго trap нет.

Явные RouterLink-действия: «Открыть профиль» → `/office/profile/:userId`,
«Открыть проект» → `/office/projects/:projectId`. При уходе ссылка-trigger очищается,
модалка закрывается, запросы отменяются. Новых маршрутов и вложенных modal нет.
На desktop — таблицы; mobile/tablet — stacked cards с подписями, переносом длинных
значений и вертикальным скроллом внутри существующего dialog до 880px шириной.

V1 не добавляет вуз/роль/кейс, contact/team actions, напоминания, назначения,
несданные работы, exports или fake data. Контракт/семантика backend не расширяются.
Targeted tests покрывают pipeline, camelcase/null, поиск/страницы/retry/races,
реальный overlay lifecycle и observable route change до resolver.
Ручной DEV smoke и mobile/keyboard проверяются отдельно от unit/component tests.
50 changes: 50 additions & 0 deletions projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,56 @@ describe("CamelcaseInterceptor", () => {
});
});

it("attention pages: вложенные snake_case поля и null проходят без потерь", () => {
const interceptor = TestBed.inject(CamelcaseInterceptor);
const body = {
count: 1,
next: null,
previous: null,
mode: "open",
results: [
{
program_project_id: 70,
user_id: 123,
registered_at: null,
submitted_at: null,
full_name: "Анна",
reason_label: "Ожидает первой оценки",
assignments_total: null,
assignments_completed: null,
leader: { user_id: 123, full_name: "Анна", avatar: null },
},
],
};
interceptor
.intercept(
new HttpRequest("GET", "/programs/12/manager-overview/projects-awaiting-evaluation/"),
{ handle: () => of(new HttpResponse({ body })) },
)
.subscribe(event => {
if (!(event instanceof HttpResponse)) return;
expect(event.body).toEqual({
count: 1,
next: null,
previous: null,
mode: "open",
results: [
{
programProjectId: 70,
userId: 123,
registeredAt: null,
submittedAt: null,
fullName: "Анна",
reasonLabel: "Ожидает первой оценки",
assignmentsTotal: null,
assignmentsCompleted: null,
leader: { userId: 123, fullName: "Анна", avatar: null },
},
],
});
});
});

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
@@ -0,0 +1,152 @@
/** @format */
import { TestBed } from "@angular/core/testing";
import { 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 {
participantsPage,
projectsPage,
} from "@domain/program/program-analytics-attention.fixture";
import { fail, ok } from "@domain/shared/result.type";
import {
ProgramAnalyticsDrilldownService,
AnalyticsAttentionView,
} from "./program-analytics-drilldown.service";

describe.each([
"participants-without-team",
"projects-awaiting-evaluation",
] as AnalyticsAttentionView[])("Attention facade: %s", view => {
const participants = { execute: vi.fn() };
const projects = { 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);
let service: ProgramAnalyticsDrilldownService;
beforeEach(() => {
participants.execute.mockReset().mockReturnValue(of(ok(participantsPage())));
projects.execute.mockReset().mockReturnValue(of(ok(projectsPage())));
assignments.execute.mockClear();
TestBed.configureTestingModule({
providers: [
ProgramAnalyticsDrilldownService,
{ provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: participants },
{ provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: projects },
{ provide: GetProgramManagerAssignmentsUseCase, useValue: assignments },
{ provide: GetProgramManagerAssignmentScoresUseCase, useValue: { execute: vi.fn() } },
],
});
service = TestBed.inject(ProgramAnalyticsDrilldownService);
});

it("lazy open, свежий count, нет назначения/автоподгрузки страниц", () => {
expect(active().execute).not.toHaveBeenCalled();
service.openAttention(12, view);
expect(active().execute).toHaveBeenCalledExactlyOnceWith(12, {
search: "",
limit: 25,
offset: 0,
});
expect(service.attentionCount()).toBe(1);
expect(service.attentionRange()).toBe("1–1 из 1");
expect(service.attentionPage()).toEqual(response());
expect(assignments.execute).not.toHaveBeenCalled();
});

it("draft без request, search trim/reset offset, clear, next/previous", () => {
const page = response();
active().execute.mockReturnValue(
of(ok({ ...page, count: 61, results: Array.from({ length: 25 }, () => page.results[0]) })),
);
service.openAttention(12, view);
service.changeAttentionPage(1);
expect(service.attentionOffset()).toBe(25);
expect(service.attentionRange()).toBe("26–50 из 61");
service.changeAttentionPage(-1);
expect(service.attentionOffset()).toBe(0);
service.changeAttentionPage(1);
service.searchDraft.set(" Анна ");
expect(active().execute).toHaveBeenCalledTimes(4);
service.applyAttentionSearch();
expect(active().execute).toHaveBeenLastCalledWith(12, { search: "Анна", limit: 25, offset: 0 });
service.clearAttentionSearch();
expect(active().execute).toHaveBeenLastCalledWith(12, { search: "", limit: 25, offset: 0 });
});

it("search empty не error; repeated open очищает draft и старые results", () => {
service.openAttention(12, view);
active().execute.mockReturnValueOnce(of(ok({ ...response(), count: 0, results: [] })));
service.searchDraft.set("несуществующий");
service.applyAttentionSearch();
expect(service.attentionCount()).toBe(0);
expect(service.attentionError()).toBeNull();
expect(service.appliedSearch()).toBe("несуществующий");
const pending = new Subject();
active().execute.mockReturnValueOnce(pending);
service.openAttention(12, view);
expect(service.searchDraft()).toBe("");
expect(service.appliedSearch()).toBe("");
expect(service.attentionPage()).toBeNull();
expect(service.attentionPending()).toBe(true);
});

it.each(["unauthorized", "forbidden", "not_found", "network"] as const)(
"retry %s сохраняет search/offset",
kind => {
active().execute.mockReturnValue(of(ok({ ...response(), count: 61 })));
service.openAttention(12, view);
service.searchDraft.set("Анна");
service.applyAttentionSearch();
active().execute.mockReturnValueOnce(of(fail({ kind })));
service.changeAttentionPage(1);
expect(service.attentionError()).toEqual({ kind });
expect(service.open()).toBe(true);
expect(service.attentionPage()).toBeNull();
service.loadAttention();
expect(active().execute).toHaveBeenLastCalledWith(12, {
search: "Анна",
limit: 25,
offset: 25,
});
expect(service.attentionError()).toBeNull();
},
);

it.each(["search", "page", "close", "other-root", "program", "destroy"])(
"%s отменяет Subject и поздний ответ",
action => {
const old = new Subject();
active().execute.mockReturnValueOnce(old);
service.openAttention(12, view);
expect(old.observed).toBe(true);
switch (action) {
case "search":
service.searchDraft.set("Анна");
service.applyAttentionSearch();
break;
case "page":
service.attentionOffset.set(25);
service.changeAttentionPage(-1);
break;
case "other-root":
service.openAssignments(12, "all");
break;
case "program":
service.openAttention(13, view);
break;
case "destroy":
TestBed.resetTestingModule();
break;
default:
service.close();
}
expect(old.observed).toBe(false);
const current = service.attentionPage();
old.next(ok({ ...response(), count: 999 }));
expect(service.attentionPage()).toBe(current);
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import { TestBed } from "@angular/core/testing";
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 { provideRouter } from "@angular/router";
import {
ProgramAnalyticsAssignment,
ProgramAnalyticsAssignmentScoreDetail,
Expand All @@ -25,6 +28,15 @@ describe("ProgramAnalyticsDrilldownService", () => {
scores.execute.mockReset().mockReturnValue(of(ok(scoreDetail())));
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: GetProgramManagerParticipantsWithoutTeamUseCase,
useValue: { execute: vi.fn() },
},
{
provide: GetProgramManagerProjectsAwaitingEvaluationUseCase,
useValue: { execute: vi.fn() },
},
ProgramAnalyticsDrilldownService,
{ provide: GetProgramManagerAssignmentsUseCase, useValue: assignments },
{ provide: GetProgramManagerAssignmentScoresUseCase, useValue: scores },
Expand Down
Loading