diff --git a/docs/modules/program.md b/docs/modules/program.md index bb8269d20..fe0d005af 100644 --- a/docs/modules/program.md +++ b/docs/modules/program.md @@ -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. diff --git a/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts b/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts index 28b630e2a..6e967cb50 100644 --- a/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts +++ b/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts @@ -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/"); 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 new file mode 100644 index 000000000..76a54fddc --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts @@ -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); + }, + ); +}); 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 2fcff074e..57f3880f8 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 @@ -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, @@ -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 }, 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 e356f6220..da94344ee 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 @@ -4,6 +4,13 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Subject, takeUntil } 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 { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProjects, +} from "@domain/program/program-analytics-attention.model"; import { ProgramAnalyticsAssignment, ProgramAnalyticsAssignmentScope, @@ -13,16 +20,26 @@ import { ProgramAnalyticsError, } from "@domain/program/program-analytics.model"; -export type AnalyticsDrilldownView = "assignments" | "scores" | "delayed" | "backlog"; +/** Два root-списка внимания используют тот же modal context, не вложенную модалку. */ +export type AnalyticsAttentionView = "participants-without-team" | "projects-awaiting-evaluation"; +export type AnalyticsDrilldownView = + | "assignments" + | "scores" + | "delayed" + | "backlog" + | AnalyticsAttentionView; /** Живёт вместе с одной analytics-модалкой, не сохраняет manager-данные между открытиями. */ @Injectable() export class ProgramAnalyticsDrilldownService { private readonly getAssignments = inject(GetProgramManagerAssignmentsUseCase); private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase); + private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase); + private readonly getProjects = inject(GetProgramManagerProjectsAwaitingEvaluationUseCase); private readonly destroyRef = inject(DestroyRef); private readonly cancelAssignments = new Subject(); private readonly cancelScores = new Subject(); + private readonly cancelAttention = new Subject(); private programId: number | null = null; private selectedAssignmentId: number | null = null; @@ -38,6 +55,101 @@ export class ProgramAnalyticsDrilldownService { readonly delayedExperts = signal([]); readonly selectedExpert = signal(null); + readonly searchDraft = signal(""); + readonly appliedSearch = signal(""); + readonly attentionLimit = 25; + readonly attentionOffset = signal(0); + readonly participantsPage = + signal | null>(null); + readonly projectsPage = signal(null); + readonly attentionPending = signal(false); + readonly attentionError = signal(null); + readonly isAttentionView = computed( + () => + this.view() === "participants-without-team" || this.view() === "projects-awaiting-evaluation", + ); + readonly attentionPage = computed(() => + this.view() === "participants-without-team" ? this.participantsPage() : this.projectsPage(), + ); + readonly attentionCount = computed(() => this.attentionPage()?.count ?? 0); + readonly attentionHasNext = computed(() => { + const page = this.attentionPage(); + return !!page && this.attentionOffset() + page.results.length < page.count; + }); + readonly attentionRange = computed(() => { + const page = this.attentionPage(); + if (!page?.results.length) return `0 из ${page?.count ?? 0}`; + return `${this.attentionOffset() + 1}–${this.attentionOffset() + page.results.length} из ${page.count}`; + }); + + constructor() { + this.destroyRef.onDestroy(() => this.close()); + } + + /** Каждое root-open очищает поиск/страницу/старые данные и делает ровно один запрос. */ + openAttention(programId: number, view: AnalyticsAttentionView): void { + if (!this.start(programId)) return; + this.view.set(view); + this.loadAttention(); + } + + /** Query остаётся черновиком до Enter; сервер получает trimmed search с первой страницы. */ + applyAttentionSearch(): void { + if (!this.open() || !this.isAttentionView()) return; + this.appliedSearch.set(this.searchDraft().trim()); + this.attentionOffset.set(0); + this.loadAttention(); + } + + /** Явная очистка возвращает первую страницу полного списка. */ + clearAttentionSearch(): void { + this.searchDraft.set(""); + this.applyAttentionSearch(); + } + + /** Переход отменяет запрос; ссылка next от backend не используется как произвольный URL. */ + changeAttentionPage(direction: -1 | 1): void { + if (!this.open() || !this.isAttentionView()) return; + if (direction === 1 && (this.attentionPending() || !this.attentionHasNext())) return; + if (direction === -1 && this.attentionOffset() === 0) return; + this.attentionOffset.update(offset => Math.max(0, offset + direction * this.attentionLimit)); + this.loadAttention(); + } + + /** Retry сохраняет search/offset; takeUntil не допускает позднего ответа прошлого контекста. */ + loadAttention(): void { + if (!this.open() || this.programId === null || !this.isAttentionView()) return; + this.cancelAttention.next(); + this.participantsPage.set(null); + this.projectsPage.set(null); + this.attentionError.set(null); + this.attentionPending.set(true); + const query = { + search: this.appliedSearch(), + limit: this.attentionLimit, + offset: this.attentionOffset(), + }; + if (this.view() === "participants-without-team") { + this.getParticipants + .execute(this.programId, query) + .pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.attentionPending.set(false); + if (result.ok) this.participantsPage.set(result.value); + else this.attentionError.set(result.error); + }); + } else { + this.getProjects + .execute(this.programId, query) + .pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.attentionPending.set(false); + if (result.ok) this.projectsPage.set(result.value); + else this.attentionError.set(result.error); + }); + } + } + /** Фильтруем по expertId, не userId/имени; порядок backend внутри группы сохраняется. */ readonly backlog = computed(() => this.assignments().filter(item => item.expert.expertId === this.selectedExpert()?.expertId), @@ -133,6 +245,14 @@ export class ProgramAnalyticsDrilldownService { close(): void { this.cancelAssignments.next(); this.cancelScores.next(); + this.cancelAttention.next(); + this.searchDraft.set(""); + this.appliedSearch.set(""); + this.attentionOffset.set(0); + this.participantsPage.set(null); + this.projectsPage.set(null); + this.attentionPending.set(false); + this.attentionError.set(null); this.open.set(false); this.programId = null; this.selectedAssignmentId = null; 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 new file mode 100644 index 000000000..f8be29dcc --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts @@ -0,0 +1,112 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse, HttpParams } from "@angular/common/http"; +import { ApiService } from "@corelib"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramHttpAdapter } from "@infrastructure/adapters/program/program-http.adapter"; +import { ProgramRepository } from "@infrastructure/repository/program/program.repository"; +import { + participantsPage, + projectsPage, +} 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"; + +describe("Attention: adapter → repository → use cases", () => { + const api = { get: vi.fn() }; + beforeEach(() => { + api.get.mockReset(); + TestBed.configureTestingModule({ + providers: [ + { provide: ApiService, useValue: api }, + { provide: ProgramRepositoryPort, useClass: ProgramRepository }, + ], + }); + }); + + for (const [method, endpoint, useCase, page] of [ + [ + "getManagerParticipantsWithoutTeam", + "participants-without-team", + GetProgramManagerParticipantsWithoutTeamUseCase, + participantsPage(), + ], + [ + "getManagerProjectsAwaitingEvaluation", + "projects-awaiting-evaluation", + GetProgramManagerProjectsAwaitingEvaluationUseCase, + projectsPage(), + ], + ] as const) { + const injectUseCase = () => + TestBed.inject< + | GetProgramManagerParticipantsWithoutTeamUseCase + | GetProgramManagerProjectsAwaitingEvaluationUseCase + >(useCase); + it(`${endpoint}: forwarding, response passthrough, отсутствие cache`, async () => { + api.get.mockReturnValue(of(page)); + const adapter = TestBed.inject(ProgramHttpAdapter); + const delegate = vi.spyOn(adapter, method); + const service = injectUseCase(); + const query = { search: " Анна ", limit: 25, offset: 25 }; + expect(await firstValueFrom(service.execute(12, query))).toEqual({ + ok: true, + value: page, + }); + await firstValueFrom(service.execute(12, query)); + expect(delegate).toHaveBeenCalledTimes(2); + expect(delegate).toHaveBeenLastCalledWith(12, query); + expect(api.get).toHaveBeenCalledTimes(2); + const [url, params] = api.get.mock.lastCall! as [string, HttpParams]; + expect(url).toBe(`/programs/12/manager-overview/${endpoint}/`); + expect(params.get("search")).toBe("Анна"); + expect(params.get("limit")).toBe("25"); + expect(params.get("offset")).toBe("25"); + }); + + it(`${endpoint}: default page, blank search absent`, async () => { + api.get.mockReturnValue(of(page)); + await firstValueFrom(injectUseCase().execute(12, { search: " " })); + const params = api.get.mock.lastCall![1] as HttpParams; + expect(params.toString()).toBe("limit=25&offset=0"); + }); + + it.each([ + [401, "unauthorized"], + [403, "forbidden"], + [404, "not_found"], + [0, "network"], + [500, "network"], + ] as const)(`${endpoint}: %s не раскрывает HTTP body`, async (status, kind) => { + api.get.mockReturnValue( + throwError(() => new HttpErrorResponse({ status, error: "secret backend body" })), + ); + expect(await firstValueFrom(injectUseCase().execute(12, {}))).toEqual({ + ok: false, + error: { kind }, + }); + }); + } + + it("open mode: null assignment counters не превращаются в 0", async () => { + const page = projectsPage({ + mode: "open", + results: [ + { + ...projectsPage().results[0], + assignmentsTotal: null, + assignmentsCompleted: null, + reason: "awaiting_first_evaluation", + reasonLabel: "Ожидает первой оценки", + }, + ], + }); + api.get.mockReturnValue(of(page)); + expect( + await firstValueFrom( + TestBed.inject(GetProgramManagerProjectsAwaitingEvaluationUseCase).execute(12, {}), + ), + ).toEqual({ ok: true, value: page }); + }); +}); diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-team.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-team.use-case.ts new file mode 100644 index 000000000..1c210a90e --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-team.use-case.ts @@ -0,0 +1,45 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; +import { ProgramAnalyticsError } from "@domain/program/program-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +/** Страница участников без команды текущей программы, без приватных данных. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerParticipantsWithoutTeamUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + /** Передаёт поиск/страницу и возвращает только безопасную категорию ошибки, не HTTP body. */ + execute( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable< + Result< + ProgramAnalyticsAttentionPage, + ProgramAnalyticsError + > + > { + return this.repository.getManagerParticipantsWithoutTeam(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/api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case.ts new file mode 100644 index 000000000..4c2594b0c --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-awaiting-evaluation.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 { + ProgramAnalyticsAttentionProjects, + 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"; + +/** Страница сданных работ, ожидающих оценивания; режим и причины задаёт backend. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerProjectsAwaitingEvaluationUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + /** Передаёт поиск/страницу и возвращает только безопасную категорию ошибки, не HTTP body. */ + execute( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.repository.getManagerProjectsAwaitingEvaluation(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 cc0f26c5b..7616f6e9f 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 @@ -11,6 +11,12 @@ import { PartnerProgramFields } from "../partner-program-fields.model"; import { ProjectAdditionalFields } from "../../project/project-additional-fields.model"; import { ApplyToProgramDTO } from "../dto/apply-to-program.model"; import { ApplyToProgramResponse } from "../results/apply-to-program"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProjects, + ProgramAnalyticsAttentionQuery, +} from "../program-analytics-attention.model"; import { ProgramAnalyticsOverview, ProgramAnalyticsAssignment, @@ -30,6 +36,18 @@ export abstract class ProgramRepositoryPort { abstract getManagerOverview(programId: number): Observable; + /** Уникальные участники без команды текущей программы, серверный поиск/пагинация. */ + abstract getManagerParticipantsWithoutTeam( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable>; + + /** Только сданные, ещё не оценённые работы; одна работа — одна строка. */ + abstract getManagerProjectsAwaitingEvaluation( + 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 new file mode 100644 index 000000000..48919a08a --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-analytics-attention.fixture.ts @@ -0,0 +1,54 @@ +/** @format */ +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProject, + ProgramAnalyticsAttentionProjects, +} from "./program-analytics-attention.model"; + +export const attentionParticipant = ( + overrides: Partial = {}, +): ProgramAnalyticsAttentionParticipant => ({ + userId: 123, + fullName: "Анна Петрова", + avatar: null, + city: "Набережные Челны", + registeredAt: "2026-09-01T10:00:00+03:00", + ...overrides, +}); + +export const attentionProject = ( + overrides: Partial = {}, +): ProgramAnalyticsAttentionProject => ({ + programProjectId: 70, + project: { id: 55, name: "Проект А" }, + leader: { userId: 123, fullName: "Анна Петрова", avatar: null }, + submittedAt: "2026-09-03T12:00:00+03:00", + status: "partially_evaluated", + reason: "partially_evaluated", + reasonLabel: "Частично оценено", + assignmentsTotal: 3, + assignmentsCompleted: 1, + ...overrides, +}); + +export const participantsPage = ( + overrides: Partial> = {}, +): ProgramAnalyticsAttentionPage => ({ + count: 1, + next: null, + previous: null, + results: [attentionParticipant()], + ...overrides, +}); + +export const projectsPage = ( + overrides: Partial = {}, +): ProgramAnalyticsAttentionProjects => ({ + count: 1, + next: null, + previous: null, + results: [attentionProject()], + mode: "distributed", + ...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 new file mode 100644 index 000000000..acb6707e5 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts @@ -0,0 +1,53 @@ +/** @format */ +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProgramEvaluationMode } from "./program-analytics.model"; + +/** Серверный поиск и пагинация: UI не фильтрует одну страницу как полный набор. */ +export interface ProgramAnalyticsAttentionQuery { + search?: string; + limit?: number; + offset?: number; +} + +/** Контракт ApiPagination с фактическими nullable ссылками DRF, без изменения общего типа. */ +export type ProgramAnalyticsAttentionPage = Omit, "next" | "previous"> & { + next: string | null; + previous: string | null; +}; + +/** Уникальный участник без команды в этой программе, не признак «ищет команду». + * registeredAt — регистрация в программе, city сохраняет legacy-значения backend. + */ +export interface ProgramAnalyticsAttentionParticipant { + userId: number; + fullName: string; + avatar: string | null; + city: string | null; + registeredAt: string | null; +} + +/** Стабильные причины backend; пороги и завершённость не пересчитываются на frontend. */ +export type ProgramAnalyticsAttentionReason = + | "no_assignments" + | "no_completed_evaluations" + | "partially_evaluated" + | "awaiting_first_evaluation"; + +/** Одна сданная работа программы, не строка назначения эксперта. + * В open assignment-счётчики null; неизвестная дата сдачи не подменяется другой датой. + */ +export interface ProgramAnalyticsAttentionProject { + programProjectId: number; + project: { id: number; name: string }; + leader: { userId: number; fullName: string; avatar: string | null } | null; + submittedAt: string | null; + status: "awaiting_evaluation" | "partially_evaluated"; + reason: ProgramAnalyticsAttentionReason; + reasonLabel: string; + assignmentsTotal: number | null; + assignmentsCompleted: number | null; +} + +/** Режим берём из ответа списка, не из потенциально устаревшей сводки. */ +export type ProgramAnalyticsAttentionProjects = + ProgramAnalyticsAttentionPage & { mode: ProgramEvaluationMode }; 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 a04d7b380..0cc0aa43f 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 @@ -13,6 +13,12 @@ import { Project } from "@domain/project/project.model"; import { ProjectAdditionalFields } from "@domain/project/project-additional-fields.model"; import { ApplyToProgramDTO } from "@domain/program/dto/apply-to-program.model"; import { ApplyToProgramResponse } from "@domain/program/results/apply-to-program"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProjects, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; import { ProgramAnalyticsOverview, ProgramAnalyticsAssignment, @@ -50,6 +56,35 @@ export class ProgramHttpAdapter { return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/manager-overview/`); } + /** Без автозагрузки остальных страниц; camelcase преобразует общий interceptor. */ + getManagerParticipantsWithoutTeam( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/manager-overview/participants-without-team/`, + this.attentionParams(query), + ); + } + + /** Список работ не подменяется assignments и не включает несданные проекты. */ + getManagerProjectsAwaitingEvaluation( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/manager-overview/projects-awaiting-evaluation/`, + 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(); + if (search) params = params.set("search", search); + return params; + } + /** Query scope передаётся backend без переопределения его статусной семантики. */ getManagerAssignments( programId: number, diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts index 37d3b0654..93ea09613 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 @@ -18,6 +18,12 @@ import { ApplyToProgramResponse } from "@domain/program/results/apply-to-program import { userFromRaw } from "@utils/userRaw"; import { EventBus } from "@domain/shared/event-bus"; import { LoggedOut } from "@domain/auth/events/logged-out.event"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProjects, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; import { ProgramAnalyticsOverview, ProgramAnalyticsAssignment, @@ -56,6 +62,22 @@ export class ProgramRepository implements ProgramRepositoryPort { return this.programAdapter.getManagerOverview(programId); } + /** Свежий manager-список при каждом открытии/поиске, без EntityCache. */ + getManagerParticipantsWithoutTeam( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.programAdapter.getManagerParticipantsWithoutTeam(programId, query); + } + + /** Проксируем актуальный count/mode и nullable поля без вычислений. */ + getManagerProjectsAwaitingEvaluation( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable { + return this.programAdapter.getManagerProjectsAwaitingEvaluation(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 65c0c3264..87c729589 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 @@ -378,8 +378,23 @@

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

>{{ item.value }} } @else { - {{ item.label }} - {{ item.value }} + } { TestBed.configureTestingModule({ imports: [ProgramAnalyticsComponent], providers: [ + provideRouter([]), + { + provide: GetProgramManagerParticipantsWithoutTeamUseCase, + useValue: { execute: vi.fn() }, + }, + { + provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, + useValue: { execute: vi.fn() }, + }, { provide: GetProgramManagerAssignmentsUseCase, useValue: { execute: vi.fn().mockReturnValue(of(ok([]))) }, @@ -219,7 +231,9 @@ describe("ProgramAnalyticsComponent", () => { const drilldown = fixture.debugElement.query(By.directive(AnalyticsDrilldownComponent)) .componentInstance as AnalyticsDrilldownComponent; const open = vi.spyOn(drilldown, "openDelayed").mockImplementation(() => {}); - const trigger = fixture.nativeElement.querySelector(".attention__action") as HTMLButtonElement; + const trigger = Array.from( + fixture.nativeElement.querySelectorAll(".attention__action") as NodeListOf, + ).find(button => button.textContent?.includes("Эксперты задерживают"))!; expect(trigger.textContent).toContain("Эксперты задерживают оценивание"); expect(trigger.textContent).toContain("1"); trigger.click(); @@ -228,6 +242,32 @@ describe("ProgramAnalyticsComponent", () => { expect(fixture.nativeElement.querySelector(".attention--empty")).toBeNull(); }); + it.each([ + ["Участники без команды", "participants-without-team", "participantsWithoutTeam"], + ["Работы ожидают оценивания", "projects-awaiting-evaluation", "projectsAwaitingEvaluation"], + ] as const)("%s: button, zero disabled, tooltip отдельно", (label, view, key) => { + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const drilldown = fixture.debugElement.query(By.directive(AnalyticsDrilldownComponent)) + .componentInstance as AnalyticsDrilldownComponent; + const open = vi.spyOn(drilldown, "openAttention").mockImplementation(() => {}); + const trigger = Array.from( + fixture.nativeElement.querySelectorAll(".attention__action") as NodeListOf, + ).find(button => button.textContent?.includes(label))!; + expect(trigger.disabled).toBe(false); + expect(trigger.querySelector("app-tooltip")).toBeNull(); + trigger.parentElement!.querySelector("app-tooltip")!.click(); + expect(open).not.toHaveBeenCalled(); + trigger.click(); + expect(open).toHaveBeenCalledExactlyOnceWith(view, trigger); + const current = data()!; + data.set({ ...current, attention: { ...current.attention, [key]: 0 } }); + fixture.detectChanges(); + expect(trigger.disabled).toBe(true); + trigger.click(); + expect(open).toHaveBeenCalledTimes(1); + }); + it("renders independent project and participant region cards without normalizing legacy names", () => { const fixture = TestBed.createComponent(ProgramAnalyticsComponent); fixture.detectChanges(); 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 new file mode 100644 index 000000000..dbd273e10 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts @@ -0,0 +1,111 @@ +/** @format */ +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { ActivatedRoute, convertToParamMap, provideRouter } from "@angular/router"; +import { BehaviorSubject, firstValueFrom, of, Subject } from "rxjs"; +import { GetProgramManagerOverviewUseCase } from "@api/program/use-cases/get-program-manager-overview.use-case"; +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 { 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, +} from "@domain/program/program-analytics-attention.fixture"; +import { Program } from "@domain/program/program.model"; +import { ok } from "@domain/shared/result.type"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { AnalyticsDrilldownComponent } from "./analytics-drilldown.component"; + +@Component({ + imports: [AnalyticsDrilldownComponent], + providers: [ProgramAnalyticsInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +class RouteHost { + readonly analytics = inject(ProgramAnalyticsInfoService); + constructor() { + this.analytics.initialize(); + } +} + +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"); + + // 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(); + }, + ); +}); 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 new file mode 100644 index 000000000..788b43255 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts @@ -0,0 +1,314 @@ +/** @format */ +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 { 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 { + ProgramAnalyticsDrilldownService, + AnalyticsAttentionView, +} from "@api/program/facades/detail/program-analytics-drilldown.service"; +import { + attentionParticipant, + attentionProject, + participantsPage, + projectsPage, +} from "@domain/program/program-analytics-attention.fixture"; +import { fail, ok } from "@domain/shared/result.type"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { AnalyticsDrilldownComponent } from "./analytics-drilldown.component"; + +describe("Attention views: real overlay", () => { + let fixture: ComponentFixture; + let state: ProgramAnalyticsDrilldownService; + let modal: ModalComponent; + let trigger: HTMLButtonElement; + const participants = { execute: vi.fn() }; + const projects = { execute: vi.fn() }; + const dialog = () => document.querySelector('[role="dialog"]')!; + const button = (text: string) => + Array.from(dialog().querySelectorAll("button")).find( + item => item.textContent?.trim() === text, + )!; + + beforeEach(async () => { + participants.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); + projects.execute.mockReset().mockReturnValue(of(ok(projectsPage()))); + await TestBed.configureTestingModule({ + imports: [AnalyticsDrilldownComponent], + providers: [ + provideRouter([]), + { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: participants }, + { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: projects }, + { + provide: GetProgramManagerAssignmentsUseCase, + useValue: { execute: vi.fn().mockReturnValue(of(ok([]))) }, + }, + { provide: GetProgramManagerAssignmentScoresUseCase, useValue: { execute: vi.fn() } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(AnalyticsDrilldownComponent); + fixture.componentRef.setInput("programId", 12); + fixture.autoDetectChanges(); + await fixture.whenStable(); + state = fixture.debugElement.injector.get(ProgramAnalyticsDrilldownService); + modal = fixture.debugElement.query(By.directive(ModalComponent)).componentInstance; + trigger = document.createElement("button"); + document.body.append(trigger); + trigger.focus(); + }); + afterEach(() => { + fixture.destroy(); + trigger.remove(); + }); + + async function open(view: AnalyticsAttentionView = "participants-without-team"): Promise { + const attached = firstValueFrom(modal.overlayRef!.attachments()); + fixture.componentInstance.openAttention(view, trigger); + expect(document.activeElement).toBe(trigger); + await attached; + await fixture.whenStable(); + } + + it("participants: реальные поля, local date, legacy city, fallback, актуальный count", async () => { + participants.execute.mockReturnValue( + of( + ok( + participantsPage({ + count: 2, + results: [ + attentionParticipant(), + attentionParticipant({ + userId: 124, + fullName: "Участник №124", + city: null, + registeredAt: null, + }), + ], + }), + ), + ), + ); + await open(); + for (const text of [ + "Участники без команды", + "Анна Петрова", + "Набережные Челны", + "01.09.2026", + "Не указано", + "Нет данных", + "Найдено: 2", + ]) + expect(dialog().textContent).toContain(text); + expect( + fixture.debugElement + .queryAll(By.directive(AvatarComponent)) + .every(item => item.componentInstance.url() === ""), + ).toBe(true); + expect(dialog().querySelector("a")?.getAttribute("href")).toBe("/office/profile/123"); + expect(dialog().textContent).not.toMatch(/Ищет команду|Вуз|email|Напомнить/); + }); + + it("server search только submit, clear, count и pagination из detail", async () => { + participants.execute.mockReturnValue( + of( + ok( + participantsPage({ + count: 61, + results: Array.from({ length: 25 }, (_, i) => attentionParticipant({ userId: i + 1 })), + }), + ), + ), + ); + await open(); + expect(dialog().textContent).toContain("1–25 из 61"); + button("Далее").click(); + await fixture.whenStable(); + expect(participants.execute).toHaveBeenLastCalledWith(12, { + search: "", + limit: 25, + offset: 25, + }); + expect(dialog().textContent).toContain("26–50 из 61"); + const input = dialog().querySelector("input")!; + input.value = " Анна "; + input.dispatchEvent(new Event("input", { bubbles: true })); + await fixture.whenStable(); + expect(participants.execute).toHaveBeenCalledTimes(2); + dialog() + .querySelector("form")! + .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await fixture.whenStable(); + expect(participants.execute).toHaveBeenLastCalledWith(12, { + search: "Анна", + limit: 25, + offset: 0, + }); + button("Очистить").click(); + await fixture.whenStable(); + expect(input.value).toBe(""); + expect(participants.execute).toHaveBeenLastCalledWith(12, { search: "", limit: 25, offset: 0 }); + }); + + it("distributed: одна работа на строку, причины, прогресс, nullable leader/date", async () => { + projects.execute.mockReturnValue( + of( + ok( + projectsPage({ + count: 3, + results: [ + attentionProject(), + attentionProject({ + programProjectId: 71, + leader: null, + submittedAt: null, + reason: "no_assignments", + reasonLabel: "Эксперты не назначены", + assignmentsTotal: 0, + assignmentsCompleted: 0, + }), + attentionProject({ + programProjectId: 72, + status: "awaiting_evaluation", + reason: "no_completed_evaluations", + reasonLabel: "Нет завершённых оценок", + assignmentsCompleted: 0, + }), + ], + }), + ), + ), + ); + await open("projects-awaiting-evaluation"); + for (const text of [ + "Работы ожидают оценивания", + "03.09.2026", + "Частично оценено", + "Завершили: 1 из 3", + "Не указан", + "Дата сдачи неизвестна", + "Нет назначений", + "Нет завершённых оценок", + ]) + expect(dialog().textContent).toContain(text); + expect(dialog().querySelectorAll("tbody tr")).toHaveLength(3); + expect(dialog().querySelector('[data-reason="no_assignments"]')).not.toBeNull(); + expect(dialog().querySelector("a")?.getAttribute("href")).toBe("/office/projects/55"); + expect(dialog().textContent).not.toContain("Ещё не сданы"); + }); + + it("open: nullable progress —, не 0 из 0", async () => { + projects.execute.mockReturnValue( + of( + ok( + projectsPage({ + mode: "open", + results: [ + attentionProject({ + reason: "awaiting_first_evaluation", + reasonLabel: "Ожидает первой оценки", + assignmentsTotal: null, + assignmentsCompleted: null, + }), + ], + }), + ), + ), + ); + await open("projects-awaiting-evaluation"); + expect(dialog().querySelector('[data-label="Прогресс"]')?.textContent?.trim()).toBe("—"); + 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" + ? "Все зарегистрированные участники уже состоят в командах." + : "Нет работ, ожидающих оценивания.", + ); + 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().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("RouterLink закрывает без return focus в покидаемую программу", async () => { + const navigate = vi.spyOn(TestBed.inject(Router), "navigateByUrl").mockResolvedValue(true); + await open(); + const focus = vi.spyOn(trigger, "focus"); + const detached = firstValueFrom(modal.overlayRef!.detachments()); + dialog().querySelector("a")!.click(); + await detached; + await fixture.whenStable(); + expect(navigate).toHaveBeenCalledOnce(); + expect(focus).not.toHaveBeenCalled(); + expect(state.open()).toBe(false); + }); +}); 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 e19807cb6..f8950b939 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 @@ -23,19 +23,210 @@

{{ title() @if (state.view() === "backlog") {

Ожидающие назначения

} + @if (state.view() === "participants-without-team") { +

+ Зарегистрированные участники, которые пока не состоят в команде проекта этой + программы. +

+ } + @if (state.view() === "projects-awaiting-evaluation") { +

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

+ } - @if (state.view() === "scores") { + @if (state.isAttentionView()) { + + @if (state.attentionPending()) { +

Загружаем список…

+ } @else if (state.attentionError()) { + + } @else if (state.attentionPage()) { +

+ Найдено: {{ state.attentionCount() }} +

+ @if (!state.attentionPage()?.results?.length) { +

{{ attentionEmptyMessage() }}

+ } @else { + @if (state.participantsPage(); as page) { + + + + + + + + + + + @for (item of page.results; track item.userId) { + + + + + + + } + +
УчастникГородДата регистрацииДействие
+ {{ + item.fullName + }} + {{ item.city || "Не указано" }} + {{ + item.registeredAt + ? (item.registeredAt | date: "dd.MM.yyyy") + : "Нет данных" + }} + + Открыть профиль +
+ } + @if (state.projectsPage(); as page) { + + + + + + + + + + + + + @for (item of page.results; track item.programProjectId) { + + + + + + + + + } + +
ПроектРуководительДата сдачиПричинаПрогрессДействие
{{ item.project.name }} + @if (item.leader; as leader) { + {{ + leader.fullName + }} + } @else { + Не указан + } + + {{ + item.submittedAt + ? (item.submittedAt | date: "dd.MM.yyyy") + : "Дата сдачи неизвестна" + }} + + {{ + item.reasonLabel + }} + + @if (page.mode === "open") { + — + } @else if (item.assignmentsTotal === 0) { + Нет назначений + } @else if ( + item.assignmentsTotal !== null && item.assignmentsCompleted !== null + ) { + Завершили: {{ item.assignmentsCompleted }} из {{ item.assignmentsTotal }} + } @else { + Нет данных + } + + Открыть проект +
+ } + } + + } + } @else if (state.view() === "scores") { @if (state.scoreDetailPending()) {

Загружаем оценку…

} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss index 57b5e9f6f..d0e5139e1 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss @@ -39,6 +39,8 @@ } button:focus-visible, + a:focus-visible, + input:focus-visible, h2:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; @@ -75,6 +77,52 @@ text-decoration: underline; } + &__action:disabled { + color: var(--dark-grey); + text-decoration: none; + cursor: default; + opacity: 0.6; + } + + &__search { + display: grid; + gap: 6px; + margin-bottom: 16px; + } + + &__search-controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + + input { + box-sizing: border-box; + flex: 1 1 220px; + width: 100%; + min-width: 0; + padding: 10px 12px; + font: inherit; + color: var(--black); + background: var(--white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + } + } + + &__count { + margin-bottom: 12px; + } + + &__pagination { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-top: 16px; + } + &__identity { display: flex; gap: 8px; @@ -252,4 +300,12 @@ } } } + + &__table--projects { + @include responsive.apply-desktop { + th:first-child { + width: 18%; + } + } + } } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts index bdd52ff81..5f45f107a 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts @@ -4,6 +4,9 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; 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 { ProgramAnalyticsDrilldownService } from "@api/program/facades/detail/program-analytics-drilldown.service"; import { ProgramAnalyticsAssignmentScope } from "@domain/program/program-analytics.model"; import { fail, ok } from "@domain/shared/result.type"; @@ -35,6 +38,15 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { await TestBed.configureTestingModule({ imports: [AnalyticsDrilldownComponent], providers: [ + provideRouter([]), + { + provide: GetProgramManagerParticipantsWithoutTeamUseCase, + useValue: { execute: vi.fn() }, + }, + { + provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, + useValue: { execute: vi.fn() }, + }, { provide: GetProgramManagerAssignmentsUseCase, useValue: assignments }, { provide: GetProgramManagerAssignmentScoresUseCase, useValue: scores }, ], @@ -68,7 +80,9 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { it("initial focus только после attachment; один dialog и активный trap", async () => { expect(assignments.execute).not.toHaveBeenCalled(); await open(); - expect(document.activeElement?.getAttribute("aria-label")).toBe("Закрыть аналитику назначений"); + expect(document.activeElement?.getAttribute("aria-label")).toBe( + "Закрыть детализацию аналитики", + ); expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); expect(dialog().getAttribute("aria-modal")).toBe("true"); expect(dialog().getAttribute("aria-labelledby")).toBe(dialog().querySelector("h2")?.id); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts index 89577c6f6..90ad2b479 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts @@ -18,8 +18,12 @@ import { ViewEncapsulation, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { NgTemplateOutlet } from "@angular/common"; -import { ProgramAnalyticsDrilldownService } from "@api/program/facades/detail/program-analytics-drilldown.service"; +import { DatePipe, NgTemplateOutlet } from "@angular/common"; +import { RouterLink } from "@angular/router"; +import { + AnalyticsAttentionView, + ProgramAnalyticsDrilldownService, +} from "@api/program/facades/detail/program-analytics-drilldown.service"; import { ProgramAnalyticsAssignmentScope, ProgramAnalyticsAssignmentStatus, @@ -40,7 +44,7 @@ import { selector: "app-analytics-drilldown", templateUrl: "./analytics-drilldown.component.html", styleUrl: "./analytics-drilldown.component.scss", - imports: [A11yModule, ModalComponent, AvatarComponent, NgTemplateOutlet], + imports: [A11yModule, ModalComponent, AvatarComponent, NgTemplateOutlet, DatePipe, RouterLink], providers: [ProgramAnalyticsDrilldownService], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, @@ -69,6 +73,10 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { protected readonly requestError = analyticsRequestError; protected readonly title = computed(() => { switch (this.state.view()) { + case "participants-without-team": + return "Участники без команды"; + case "projects-awaiting-evaluation": + return "Работы ожидают оценивания"; case "scores": return "Оценка проекта"; case "delayed": @@ -91,6 +99,18 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { pending: "Все назначения выполнены", })[this.state.scope()], ); + protected readonly attentionEmptyMessage = computed(() => { + if (this.state.appliedSearch()) return "По вашему запросу ничего не найдено."; + return this.state.view() === "participants-without-team" + ? "Все зарегистрированные участники уже состоят в командах." + : "Нет работ, ожидающих оценивания."; + }); + protected readonly attentionRequestError = computed(() => { + const error = this.state.attentionError(); + if (error?.kind === "forbidden") return "Нет доступа к данным этой программы."; + if (error?.kind === "not_found") return "Программа не найдена."; + return analyticsRequestError(error); + }); constructor() { effect(() => { @@ -175,6 +195,21 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { this.state.openDelayed(id, this.delayedExperts()); } + /** Attention root сохраняет конкретную строку-trigger для обычного закрытия. */ + openAttention(view: AnalyticsAttentionView, trigger: HTMLElement): void { + const id = this.programId(); + if (id === null || this.state.open() || this.attached() || trigger.hasAttribute("disabled")) + return; + this.trigger = trigger; + this.state.openAttention(id, view); + } + + /** Уходим из программы по RouterLink без попытки фокусировать старый контекст. */ + protected leaveAnalytics(): void { + this.trigger = null; + this.closeAnalyticsModal(); + } + /** Backdrop, Escape и кнопка закрывают весь drilldown. Фокус возвращается на detach. */ closeAnalyticsModal(): void { this.state.close();