diff --git a/docs/modules/program.md b/docs/modules/program.md index 3387b0475..98aae695d 100644 --- a/docs/modules/program.md +++ b/docs/modules/program.md @@ -348,9 +348,8 @@ DI-биндинги (`infrastructure/di/program/`): - `GET /programs/:programId/project-analytics/assignments/?scope=all|completed|pending` — массив назначений. - `GET /programs/:programId/project-analytics/assignments/:assignmentId/scores/` — поля назначения и все критерии в `scores`. -Backend API в этом PR не изменялся. Данные проходят через существующий -`CamelcaseInterceptor`: `assignment_id → assignmentId`, `criteria_total → criteriaTotal`, -`criteria_scored → criteriaScored`, `waiting_seconds → waitingSeconds`, +Данные проходят через существующий +`CamelcaseInterceptor`: `assignment_id → assignmentId`, `waiting_seconds → waitingSeconds`, `delayed_experts → delayedExperts`. Важно: установленный camelcase-keys преобразует `overdue_24h → overdue24H` и `overdue_48h → overdue48H` (заглавная H). Ручного преобразования SLA-полей нет. @@ -375,8 +374,23 @@ Use cases возвращают `Result` с `ProgramAnalyticsError`; сырой - `in_progress` — «В процессе»; - `completed` — «Выполнено». -Прогресс — например, «2 из 5 критериев»; при отсутствии критериев — «Нет критериев», -для несданного проекта — «—». В open-режиме показываются реальные назначения, +Таблица назначений содержит только «Эксперт», «Проект», «Статус», «Ожидание». +Количество оценённых критериев остаётся внутренней деталью расчёта статуса +на backend. Angular не использует эти счётчики ни в списке, ни в детализации; +массив `scores` с критериями и фактическими оценками сохраняется. +«Завершили: X из Y» в разделе «Работы ожидают оценивания» по-прежнему считает +завершённые назначения экспертов, а не критерии одного назначения. + +Все три scope используют один `assignmentTable`. Локальные модификаторы +`analytics-drilldown-body--assignments` и `analytics-drilldown__table--assignments` +применяются только при `view === "assignments"`. Backlog, scores detail и таблицы +внимания сохраняют прежнее оформление; shared `app-modal` не меняется. +Desktop-окно имеет ширину `min(980px, calc(100vw - 32px))`, padding 28 px и колонки +27/31/29/13%. На экранах уже 1000 px назначения представлены карточками, статус +и действие находятся на отдельных строках. Escape, focus trap и возврат фокуса +используют прежний lifecycle. + +В open-режиме показываются реальные назначения, но frontend не синтезирует задержки экспертов. В distributed-режиме «Частично оценено» означает, что хотя бы один назначенный эксперт полностью оценил проект, но не все назначенные эксперты завершили оценивание. @@ -435,14 +449,15 @@ bottom-up destroy Angular: дочерний modal может detach до cleanup проверяется DestroyRef владельца view, без изменения shared primitive. Таймеров, polling, MutationObserver и document-global Escape handler нет. -Desktop: таблица внутри модалки шириной до 880px. Mobile/tablet: stacked cards, +Desktop: назначения — таблица в модалке шириной до 980px; для остальных view +сохранено прежнее оформление. Mobile/tablet: stacked cards, перенос длинных имён и названий, ограничение высоты с вертикальным скроллом. Сохранены Mont, токены и существующие zero states аналитики. ### Проверка Regression tests покрывают adapter/repository/use cases/facade/interceptor, -scope/status/progress, nullable ожидание, критерии, задержки, смену программы, +scope/status, отсутствие прогресса, nullable ожидание, критерии, задержки, смену программы, отмену запросов и настоящий CDK Overlay lifecycle. Focus tests не подменяют attachment событием openChange и не добавляют ручной detectChanges после клика. diff --git a/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts b/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts index 650ea8d70..a62334349 100644 --- a/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts +++ b/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts @@ -104,8 +104,6 @@ describe("CamelcaseInterceptor", () => { assignments: [ { assignment_id: 17, - criteria_total: 5, - criteria_scored: 2, waiting_seconds: 108000, expert: { full_name: "Иван Иванов", user_id: 123 }, scores: [{ criterion_id: 1, min_value: 0, is_scored: true }], @@ -129,8 +127,6 @@ describe("CamelcaseInterceptor", () => { assignments: [ { assignmentId: 17, - criteriaTotal: 5, - criteriaScored: 2, waitingSeconds: 108000, expert: { fullName: "Иван Иванов", userId: 123 }, scores: [{ criterionId: 1, minValue: 0, isScored: true }], diff --git a/projects/social_platform/src/app/domain/program/program-analytics-assignment.fixture.ts b/projects/social_platform/src/app/domain/program/program-analytics-assignment.fixture.ts index c29f89e0a..063303753 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics-assignment.fixture.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics-assignment.fixture.ts @@ -21,8 +21,6 @@ export function assignment( }, project: { id: 55, name: "Проект А" }, status: "completed", - criteriaTotal: 5, - criteriaScored: 5, assignedAt: "2026-09-01T10:00:00Z", projectSubmitted: true, projectSubmittedAt: "2026-09-01T12:00:00Z", diff --git a/projects/social_platform/src/app/domain/program/program-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-analytics.model.ts index f1a7438a8..55833d728 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics.model.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics.model.ts @@ -123,14 +123,15 @@ export interface ProgramAnalyticsAssignmentProject { name: string; } -/** Ожидание вычисляет backend на момент запроса. null не означает нулевое ожидание. */ +/** + * Статус вычисляет backend без публикации внутренних счётчиков критериев. + * Ожидание фиксируется на момент запроса; null не означает нулевое ожидание. + */ export interface ProgramAnalyticsAssignment { assignmentId: number; expert: ProgramAnalyticsAssignmentExpert; project: ProgramAnalyticsAssignmentProject; status: ProgramAnalyticsAssignmentStatus; - criteriaTotal: number; - criteriaScored: number; assignedAt: string; projectSubmitted: boolean; projectSubmittedAt: string | null; 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 7c25a82af..a49049ef5 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 @@ -1,7 +1,11 @@
Ещё не сданы - +
- @@ -433,24 +439,25 @@

Ещё не сданы

- } 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 c361ac607..cf0cf17d8 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 @@ -10,6 +10,37 @@ overflow-y: auto; } +// Тот же шаблон используется в backlog: оформление меняется только у трёх списков назначений. +.modal__body.analytics-drilldown-body.analytics-drilldown-body--assignments { + width: min(980px, calc(100vw - 32px)); + padding: 16px; + + @include responsive.apply-desktop { + padding: 28px; + } + + .analytics-drilldown__header { + align-items: center; + margin-bottom: 24px; + + h2 { + font-size: 24px; + line-height: 30px; + } + } + + .analytics-drilldown__close { + display: grid; + place-items: center; + padding: 0; + line-height: 1; + + &:hover { + background: var(--light-gray); + } + } +} + .analytics-drilldown { width: 100%; min-width: 0; @@ -313,4 +344,114 @@ } } } + + &__table--assignments { + tr { + display: grid; + gap: 12px; + padding: 14px; + } + + td { + padding: 0; + line-height: 22px; + } + + td::before { + margin-bottom: 4px; + } + + .analytics-drilldown__identity { + gap: 12px; + font-weight: 500; + } + + .analytics-drilldown__assignment-status { + display: flex; + flex-flow: column wrap; + gap: 12px 14px; + align-items: flex-start; + } + + .analytics-drilldown__badge { + display: inline-flex; + gap: 6px; + align-items: center; + min-height: 28px; + padding: 4px 9px; + border-radius: 8px; + + &::before { + flex: 0 0 6px; + width: 6px; + height: 6px; + content: ""; + background: currentcolor; + border-radius: 50%; + } + } + + .analytics-drilldown__action { + padding: 4px 0; + font-size: 13px; + font-weight: 500; + line-height: 20px; + } + + @include responsive.apply-desktop { + border-spacing: 0; + border-collapse: separate; + + .analytics-drilldown__assignment-status { + flex-direction: row; + align-items: center; + } + + tr { + display: table-row; + height: 72px; + padding: 0; + } + + th, + td { + padding: 16px 12px; + vertical-align: middle; + } + + thead tr { + height: auto; + } + + th { + padding-block: 13px; + color: var(--dark-grey); + background: var(--light-gray); + border-bottom: 0; + + &:first-child { + width: 27%; + border-radius: 8px 0 0 8px; + } + + &:nth-child(2) { + width: 31%; + } + + &:nth-child(3) { + width: 29%; + } + + &:last-child { + width: 13%; + border-radius: 0 8px 8px 0; + } + } + + th:nth-child(3), + td:nth-child(3) { + padding-inline: 8px; + } + } + } } 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 61914aecc..cb7f2b951 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 @@ -17,6 +17,11 @@ import { scoreDetail, } from "@domain/program/program-analytics-assignment.fixture"; import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { + participantsPage, + projectsPage, + notSubmittedPage, +} from "@domain/program/program-analytics-attention.fixture"; import { firstValueFrom, of, Subject } from "rxjs"; import { AnalyticsDrilldownComponent } from "./analytics-drilldown.component"; @@ -40,14 +45,17 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { imports: [AnalyticsDrilldownComponent], providers: [ provideRouter([]), - { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: { execute: vi.fn() } }, + { + provide: GetProgramManagerProjectsNotSubmittedUseCase, + useValue: { execute: vi.fn().mockReturnValue(of(ok(notSubmittedPage()))) }, + }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, - useValue: { execute: vi.fn() }, + useValue: { execute: vi.fn().mockReturnValue(of(ok(participantsPage()))) }, }, { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, - useValue: { execute: vi.fn() }, + useValue: { execute: vi.fn().mockReturnValue(of(ok(projectsPage()))) }, }, { provide: GetProgramManagerAssignmentsUseCase, useValue: assignments }, { provide: GetProgramManagerAssignmentScoresUseCase, useValue: scores }, @@ -79,6 +87,74 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { await fixture.whenStable(); } + it.each([ + ["all", "Все назначения экспертов"], + ["completed", "Выполненные назначения"], + ["pending", "Ожидают оценки"], + ] as const)("%s: единое оформление списка и правильный заголовок", async (scope, title) => { + await open(scope); + expect(dialog().querySelector("h2")?.textContent?.trim()).toBe(title); + expect(assignments.execute).toHaveBeenCalledWith(12, scope); + expect(dialog().closest(".analytics-drilldown-body--assignments")).not.toBeNull(); + expect(dialog().querySelectorAll(".analytics-drilldown__table--assignments")).toHaveLength(1); + expect(dialog().querySelectorAll('th[scope="col"]')).toHaveLength(4); + }); + + it.each([ + "participants-without-team", + "projects-awaiting-evaluation", + "projects-not-submitted", + ] as const)("%s: таблица и окно не получают оформление назначений", async view => { + fixture.componentRef.setInput("notSubmittedApplicable", true); + await fixture.whenStable(); + const attached = firstValueFrom(modal.overlayRef!.attachments()); + fixture.componentInstance.openAttention(view, trigger); + await attached; + await fixture.whenStable(); + expect(dialog().querySelector("table")).not.toBeNull(); + expect(dialog().querySelector(".analytics-drilldown__table--assignments")).toBeNull(); + expect(dialog().closest(".analytics-drilldown-body--assignments")).toBeNull(); + }); + + it.each([ + ["pending", "not_ready", null, "Проект не сдан", "Проект не сдан"], + ["all", "pending", 108000, "Не начал оценивание", "1 д 6 ч"], + ["pending", "in_progress", 187200, "В процессе", "2 д 4 ч"], + ["completed", "completed", null, "Выполнено", "—"], + ] as const)( + "%s / %s: четыре колонки без прогресса сохраняют статус, ожидание и действие", + async (scope, status, waitingSeconds, statusText, waitingText) => { + assignments.execute.mockReturnValue(of(ok([assignment({ status, waitingSeconds })]))); + await open(scope); + const labels = ["Эксперт", "Проект", "Статус", "Ожидание"]; + expect( + Array.from(dialog().querySelectorAll("thead th"), cell => cell.textContent?.trim()), + ).toEqual(labels); + const row = dialog().querySelector('[data-assignment-id="17"]')!; + expect( + Array.from(row.querySelectorAll("td"), cell => cell.getAttribute("data-label")), + ).toEqual(labels); + expect(dialog().textContent).not.toContain("Прогресс"); + expect(dialog().querySelector('[data-label="Прогресс"]')).toBeNull(); + expect(row.querySelector('[data-label="Эксперт"]')?.textContent).toContain("Иван Иванов"); + expect(row.querySelector('[data-label="Проект"]')?.textContent).toContain("Проект А"); + expect(row.querySelector('[data-label="Статус"]')?.textContent).toContain(statusText); + expect(row.querySelector('[data-label="Ожидание"]')?.textContent?.trim()).toBe(waitingText); + expect(!!row.querySelector('button[aria-label^="Посмотреть оценку"]')).toBe( + status === "completed", + ); + const statusCell = row.querySelector(".analytics-drilldown__assignment-status")!; + expect(statusCell.querySelector(`.analytics-drilldown__badge--${status}`)?.textContent).toBe( + statusText, + ); + if (status === "completed") { + expect(statusCell.querySelector("button")?.getAttribute("aria-label")).toBe( + "Посмотреть оценку: Проект А, Иван Иванов", + ); + } + }, + ); + it("initial focus только после attachment; один dialog и активный trap", async () => { expect(assignments.execute).not.toHaveBeenCalled(); await open(); @@ -146,13 +222,23 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); button("Посмотреть оценку").click(); await fixture.whenStable(); + expect(scores.execute).toHaveBeenCalledWith(12, 17); expect(document.activeElement).toBe(dialog().querySelector("h2")); expect(dialog().textContent).toContain("Оценка проекта"); - for (const text of ["8", "Хорошая проработка", "Да", "Не оценено"]) + expect(dialog().closest(".analytics-drilldown-body--assignments")).toBeNull(); + for (const text of [ + "Новизна", + "Оцените новизну решения", + "8", + "Хорошая проработка", + "Да", + "Не оценено", + ]) expect(dialog().textContent).toContain(text); button("Назад").click(); await fixture.whenStable(); expect(dialog().textContent).toContain("Выполненные назначения"); + expect(dialog().closest(".analytics-drilldown-body--assignments")).not.toBeNull(); expect(document.activeElement).toBe(dialog().querySelector("h2")); expect(fixture.debugElement.queryAll(By.directive(CdkTrapFocus))).toHaveLength(1); expect(fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus)).toBe( @@ -168,7 +254,6 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { assignment({ assignmentId: 1, status: "pending", - criteriaScored: 0, waitingSeconds: 187200, }), assignment({ assignmentId: 2, status: "not_ready" }), @@ -192,6 +277,10 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { await fixture.whenStable(); expect(dialog().querySelector('[data-assignment-id="3"]')).toBeNull(); expect(dialog().textContent).toContain("Ещё не сданы"); + expect(dialog().querySelector(".analytics-drilldown__table--assignments")).toBeNull(); + expect(dialog().closest(".analytics-drilldown-body--assignments")).toBeNull(); + expect(dialog().querySelector('[data-label="Прогресс"]')).toBeNull(); + expect(dialog().textContent).not.toContain("Прогресс"); expect(document.activeElement).toBe(dialog().querySelector("h2")); button("Назад").click(); await fixture.whenStable(); 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 a05d287a7..9bb14783e 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 @@ -34,7 +34,6 @@ import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; import { analyticsRequestError, assignmentCriterionValue, - assignmentProgress, assignmentStatusLabels, formatAssignmentWaiting, } from "@utils/program-analytics-assignment"; @@ -69,7 +68,6 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { protected readonly statusLabel = (status: ProgramAnalyticsAssignmentStatus): string => assignmentStatusLabels[status]; protected readonly waiting = formatAssignmentWaiting; - protected readonly progress = assignmentProgress; protected readonly criterionValue = assignmentCriterionValue; protected readonly requestError = analyticsRequestError; protected readonly title = computed(() => { diff --git a/projects/social_platform/src/app/utils/program-analytics-assignment.spec.ts b/projects/social_platform/src/app/utils/program-analytics-assignment.spec.ts index fbd619f1c..fa464e2cb 100644 --- a/projects/social_platform/src/app/utils/program-analytics-assignment.spec.ts +++ b/projects/social_platform/src/app/utils/program-analytics-assignment.spec.ts @@ -1,8 +1,7 @@ /** @format */ -import { assignment, criterion } from "@domain/program/program-analytics-assignment.fixture"; +import { criterion } from "@domain/program/program-analytics-assignment.fixture"; import { assignmentCriterionValue, - assignmentProgress, assignmentStatusLabels, formatAssignmentWaiting, } from "./program-analytics-assignment"; @@ -23,16 +22,13 @@ describe("Assignment presentation", () => { expect(formatAssignmentWaiting(null, "not_ready")).toBe("Проект не сдан"); expect(formatAssignmentWaiting(null, "completed")).toBe("—"); }); - it("отображает статусы и прогресс без расчёта статуса на клиенте", () => { + it("отображает серверные статусы без расчёта на клиенте", () => { expect(Object.values(assignmentStatusLabels)).toEqual([ "Проект не сдан", "Не начал оценивание", "В процессе", "Выполнено", ]); - expect(assignmentProgress(assignment({ criteriaScored: 2 }))).toBe("2 из 5 критериев"); - expect(assignmentProgress(assignment({ criteriaTotal: 0 }))).toBe("Нет критериев"); - expect(assignmentProgress(assignment({ status: "not_ready" }))).toBe("—"); }); it("различает numeric, bool, text, unscored и сохранённое пустое значение", () => { expect(assignmentCriterionValue(criterion({ value: "0" }))).toBe("0"); diff --git a/projects/social_platform/src/app/utils/program-analytics-assignment.ts b/projects/social_platform/src/app/utils/program-analytics-assignment.ts index 3fcb43249..7f40815bb 100644 --- a/projects/social_platform/src/app/utils/program-analytics-assignment.ts +++ b/projects/social_platform/src/app/utils/program-analytics-assignment.ts @@ -1,6 +1,5 @@ /** @format */ import { - ProgramAnalyticsAssignment, ProgramAnalyticsAssignmentCriterion, ProgramAnalyticsAssignmentStatus, ProgramAnalyticsError, @@ -27,12 +26,6 @@ export function formatAssignmentWaiting( return `${Math.floor(hours / 24)} д ${hours % 24} ч`; } -export function assignmentProgress(assignment: ProgramAnalyticsAssignment): string { - if (assignment.status === "not_ready") return "—"; - if (!assignment.criteriaTotal) return "Нет критериев"; - return `${assignment.criteriaScored} из ${assignment.criteriaTotal} критериев`; -} - /** Наличие записи и её содержимое — разные состояния; числовые строки не агрегируются. */ export function assignmentCriterionValue(criterion: ProgramAnalyticsAssignmentCriterion): string { if (!criterion.isScored) return "Не оценено";
Эксперт Проект СтатусПрогресс Ожидание
{{ item.project.name }} - {{ statusLabel(item.status) }} - @if (item.status === "completed") { - - } + @if (item.status === "completed") { + + } + {{ progress(item) }} {{ waiting(item.waitingSeconds, item.status) }}