| Эксперт |
Проект |
Статус |
- Прогресс |
Ожидание |
@@ -433,24 +439,25 @@ Ещё не сданы
{{ item.project.name }} |
- {{ statusLabel(item.status) }}
- @if (item.status === "completed") {
-
- }
+ @if (item.status === "completed") {
+
+ }
+
|
- {{ progress(item) }} |
{{ waiting(item.waitingSeconds, item.status) }} |
}
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 "Не оценено";