From e076413954363eb19e9b7f2a731fbf6098ceac02 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 19 Oct 2026 11:57:04 +0300 Subject: [PATCH] feat: add organizer results ranking and Excel export UI --- .../use-cases/program-management.use-case.ts | 7 + .../program-management.repository.port.ts | 3 + .../program/program-management.model.ts | 1 + .../domain/program/program-results.model.ts | 47 ++ .../program-management-http.adapter.ts | 13 + .../program/program-management.repository.ts | 7 + .../management/management.component.html | 3 + .../management/management.component.scss | 4 - .../management/management.component.ts | 4 + .../management/results/results.component.html | 376 +++++++++++++ .../management/results/results.component.scss | 505 ++++++++++++++++++ .../results/results.component.spec.ts | 124 +++++ .../management/results/results.component.ts | 246 +++++++++ .../src/assets/images/results-arrow-down.svg | 5 + 14 files changed, 1341 insertions(+), 4 deletions(-) create mode 100644 projects/social_platform/src/app/domain/program/program-results.model.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/results/results.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/management/results/results.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/management/results/results.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/results/results.component.ts create mode 100644 projects/social_platform/src/assets/images/results-arrow-down.svg diff --git a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts index aeb0cd6bf..6550425f7 100644 --- a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts @@ -1,6 +1,7 @@ /** @format */ import { inject, Injectable } from "@angular/core"; +import { ResultsQuery } from "@domain/program/program-results.model"; import { ManagementCriteriaWrite } from "@domain/program/program-criteria.model"; import { ProgramManagementRepositoryPort } from "@domain/program/ports/program-management.repository.port"; import { ManagementProjectFormWrite } from "@domain/program/program-project-form.model"; @@ -14,6 +15,12 @@ import { @Injectable() export class ProgramManagementUseCase { private readonly repository = inject(ProgramManagementRepositoryPort); + results(id: number) { + return this.repository.results(id); + } + exportResults(id: number, query: ResultsQuery) { + return this.repository.exportResults(id, query); + } criteria(id: number) { return this.repository.criteria(id); } diff --git a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts index 39a29a9f7..a50ddad59 100644 --- a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts @@ -1,6 +1,7 @@ /** @format */ import { Observable } from "rxjs"; +import { ProgramResults, ResultsQuery } from "../program-results.model"; import { ManagementCriteria, ManagementCriteriaWrite } from "../program-criteria.model"; import { ManagementProjectForm, ManagementProjectFormWrite } from "../program-project-form.model"; import { @@ -17,6 +18,8 @@ import { } from "../program-management.model"; export abstract class ProgramManagementRepositoryPort { + abstract results(programId: number): Observable; + abstract exportResults(programId: number, query: ResultsQuery): Observable; abstract criteria(programId: number): Observable; abstract saveCriteria( programId: number, diff --git a/projects/social_platform/src/app/domain/program/program-management.model.ts b/projects/social_platform/src/app/domain/program/program-management.model.ts index fda76f0e8..6bdcf7773 100644 --- a/projects/social_platform/src/app/domain/program/program-management.model.ts +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -6,6 +6,7 @@ export type ManagementSection = | "participants" | "projects" | "experts" + | "results" | "analytics" | "settings" | "evaluation"; diff --git a/projects/social_platform/src/app/domain/program/program-results.model.ts b/projects/social_platform/src/app/domain/program/program-results.model.ts new file mode 100644 index 000000000..1bab93f16 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-results.model.ts @@ -0,0 +1,47 @@ +/** @format */ +import { ManagementCriterion } from "./program-criteria.model"; + +export interface ResultExpert { + userId: number; + name: string; + email: string; + avatar: string | null; + complete: boolean; + total: number | null; + values: { criterionId: number; value: string | null }[]; +} +export interface ResultProject { + id: number; + projectId: number; + name: string; + caseName: string | null; + completed: number; + required: number; + ready: boolean; + score: number | null; + place: number | null; + averages: Record; + experts: ResultExpert[]; +} +export interface ResultsGroup { + caseName: string | null; + projects: ResultProject[]; +} +export interface ProgramResults { + programId: number; + programName: string; + generatedAt: string; + requiredEvaluators: number; + maximum: number | null; + hasNumericCriteria: boolean; + caseOptions: string[]; + criteria: ManagementCriterion[]; + groups: ResultsGroup[]; +} +export interface ResultsQuery { + search: string; + caseScope: "all" | "selected" | "without_case"; + caseName?: string; + order: "asc" | "desc"; + scope: "current" | "all"; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts index b6b9e2260..7d3223c6c 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts @@ -3,6 +3,7 @@ import { inject, Injectable } from "@angular/core"; import { HttpParams } from "@angular/common/http"; import { ApiService } from "@corelib"; +import { ProgramResults, ResultsQuery } from "@domain/program/program-results.model"; import { ManagementCriteria, ManagementCriteriaWrite, @@ -28,6 +29,18 @@ import { @Injectable() export class ProgramManagementHttpAdapter implements ProgramManagementRepositoryPort { private readonly api = inject(ApiService); + results(id: number) { + return this.api.get(`${this.root(id)}/results/`); + } + exportResults(id: number, query: ResultsQuery) { + let params = new HttpParams() + .set("search", query.search) + .set("case_scope", query.caseScope) + .set("order", query.order) + .set("scope", query.scope); + if (query.caseName) params = params.set("case_name", query.caseName); + return this.api.getFile(`${this.root(id)}/results/export/`, params); + } criteria(id: number) { return this.api.get(`${this.root(id)}/criteria/`); } diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts index fa092d0f6..fe04595bd 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from "@angular/core"; import { tap } from "rxjs"; +import { ResultsQuery } from "@domain/program/program-results.model"; import { ManagementCriteriaWrite } from "@domain/program/program-criteria.model"; import { ManagementProjectFormWrite } from "@domain/program/program-project-form.model"; import { EventBus } from "@domain/shared/event-bus"; @@ -18,6 +19,12 @@ import { @Injectable() export class ProgramManagementRepository implements ProgramManagementRepositoryPort { private readonly adapter = inject(ProgramManagementHttpAdapter); + results(id: number) { + return this.adapter.results(id); + } + exportResults(id: number, query: ResultsQuery) { + return this.adapter.exportResults(id, query); + } private readonly events = inject(EventBus); criteria(id: number) { return this.adapter.criteria(id); diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.html b/projects/social_platform/src/app/ui/pages/program/management/management.component.html index 9872d8ddf..891b4663b 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.html @@ -35,6 +35,9 @@

{{ requestError() }}

Загружаем данные программы… } @else if (state.program(); as program) { + @if (section() === "results") { + + } @if (section() === "experts") { +
+
+

Рейтинг проектов

+
+ + +
+
+ @if (snapshot(); as data) { +

+ Предварительные результаты · Оценок на проект: {{ data.requiredEvaluators }} +

+
+ + +
+
+

+ В рейтинге: {{ readyCount() }} · Ещё оцениваются: {{ pendingCount() }} +

+ +
+ @if (!data.hasNumericCriteria) { +

+ Нет балльных критериев. Ответы экспертов доступны ниже; итоговый балл и места не + рассчитываются. +

+ } + @if (exportSuccess()) { +

Выгрузка Excel скачана.

+ } + @if (!data.groups.length) { +
+

Сданных проектов пока нет

+

Рейтинг появится после сдачи решений и оценивания.

+
+ } @else if (!groups().length) { +
+

Ничего не найдено

+

Измените запрос или выберите другой кейс.

+ +
+ } + @for (group of groups(); track group.caseName) { +
+ @if (caseFilter.value === "all") { +

{{ group.caseName ?? "Без кейса" }}

+ } + + @for (project of ready(group.projects); track project.id) { + + } + @if (pending(group.projects).length) { +

Ещё оцениваются · {{ pending(group.projects).length }}

+ @for (project of pending(group.projects); track project.id) { + + } + } +
+ } + @if (groups().length) { +

+ Равный балл — общее место: 1, 2, 2, 4. Места присваиваются внутри кейса и не меняются при + сортировке. +

+ } + } + @if (error()) { +
+ Не удалось загрузить результаты. + +
+ } @else if (loading() && !snapshot()) { +

Загружаем результаты…

+ } +
+ + + + @if (expanded().has(project.id)) { +
+
+ Оценки экспертов{{ project.ready ? "Среднее" : "Предварительно" }}: {{ format(project.score) }} +
+ @if (!project.ready) { +

+ Получено {{ project.completed }} из {{ project.required }} полных оценок. Место появится + после завершения оценивания. +

+ } + @if (!project.experts.length) { +

Оценок пока нет.

+ } @else { +
+ + + + + @for (expert of project.experts; track expert.userId) { + + } + + + + + @for (criterion of matrixCriteria(); track criterion.id) { + + + @for (expert of project.experts; track expert.userId) { + + } + + + } + + + @for (expert of project.experts; track expert.userId) { + + } + + + +
Критерий + {{ expert.name }}{{ expert.complete ? "Оценено" : "Ожидается оценка" }} + Среднее
+ {{ criterion.name + }}{{ criterion.maxValue === null ? "" : " · до " + criterion.maxValue }} + {{ value(expert, criterion) }}{{ format(project.averages[criterion.id]) }}
Сумма баллов + {{ format(expert.total) }} + + {{ format(project.score) }} +
+ @for (criterion of comments(); track criterion.id) { +
+ {{ criterion.name }} + @for (expert of project.experts; track expert.userId) { +

+ {{ expert.name }}: {{ value(expert, criterion) }} +

+ } +
+ } +
+
+ @for (expert of project.experts; track expert.userId) { + + @if (expandedExperts().has(project.id + ":" + expert.userId)) { +
+ @for (criterion of snapshot()?.criteria ?? []; track criterion.id) { +
+
{{ criterion.name }}
+
{{ value(expert, criterion) }}
+
+ } +
+ } + } +
+ } +
+ } +
+ + +
+
+

+ {{ modal() === "formula" ? "Как считается балл" : "Выгрузить в Excel" }} +

+ +
+ @if (modal() === "formula") { +

+ Складываем баллы по числовым критериям у каждого эксперта, затем берём среднее полных + оценок. Вес экспертов одинаковый. +

+ @if (sampleFormula()) { +
+ {{ sample()?.name }} +

{{ sampleFormula() }}

+
+ } +

+ «Да/нет» и комментарии в сумму не входят. Нулевые баллы учитываются. Итог округляется один + раз до двух знаков. +

+

+ Для места нужно полных оценок: {{ snapshot()?.requiredEvaluators ?? "—" }}. Места + присваиваются внутри каждого кейса. Равные итоги дают одинаковое место. +

+

+ Если исторически получено больше оценок, учитываются все полные оценки. +

+ } @else { +
+ Какие результаты выгрузить + +
+

+ {{ + exportScope() === "current" + ? "Учитываются выбранный кейс и поиск, все найденные проекты." + : "Все проекты программы из всех кейсов." + }} +

+
+ В файле два листаРейтинг +

Место, проект, кейс, средние баллы по критериям и итог.

+ Оценки экспертов +

Баллы каждого эксперта, сумма, ответы «Да/нет» и комментарии.

+
+ Итог — с формулой в Excel +

+ Неполные результаты — предварительные, без места. Пустая оценка не равна нулю. +

+ @if (exportScope() === "current" && !projectCount()) { +

+ В текущей выборке нет проектов. Измените поиск или выберите всю программу. +

+ } + @if (exportError()) { +

Не удалось подготовить файл. Попробуйте ещё раз.

+ } + + } +
+
diff --git a/projects/social_platform/src/app/ui/pages/program/management/results/results.component.scss b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.scss new file mode 100644 index 000000000..0df0ad947 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.scss @@ -0,0 +1,505 @@ +/** @format */ +@use "styles/ui-foundation"; + +:host { + display: block; + min-width: 0; +} + +.ui-button.ui-button { + @include ui-foundation.button; + + position: relative; + min-height: 32px; + padding: 5px 20px; + font-size: 12px; + font-weight: 400; + line-height: 20px; + white-space: nowrap; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; + font-size: 12px; +} + +.ui-choice { + @include ui-foundation.choice; + + flex-shrink: 0; +} + +.results, +.results__dialog { + display: grid; + gap: 16px; + min-width: 0; + font-size: 12px; + line-height: 1.5; + color: var(--black); +} + +.results { + padding: 24px; + background: var(--light-white); + border-radius: 15px; +} + +h2, +h3, +p { + margin: 0; +} + +h2 { + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +h3 { + font-size: 12px; + font-weight: 600; + line-height: 20px; +} + +.results__muted, +small { + color: var(--grey-for-text); +} + +.results__heading, +.results__actions, +.results__toolbar, +.results__detail-heading { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-width: 0; +} + +.results__actions { + justify-content: flex-end; +} + +.results__filters { + display: grid; + grid-template-columns: minmax(0, 1fr) 245px; + gap: 16px; +} + +.results__filters label { + display: grid; + gap: 6px; + min-width: 0; + font-weight: 600; +} + +.results__filters input, +.results__filters select { + min-height: 41px; + font-weight: 400; + border-radius: 30px; +} + +.results__group { + min-width: 0; +} + +.results__columns, +.results__row { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) 100px 110px 128px 28px; + gap: 16px; + align-items: center; +} + +.results__columns { + padding: 8px 0; + color: var(--grey-for-text); +} + +.results__row { + width: 100%; + min-height: 57px; + padding: 12px 0; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; + background: transparent; + border: 0; + border-bottom: 1px solid var(--medium-grey-for-outline); +} + +.results__row:hover, +.results__row--selected { + background: var(--bg-grey); +} + +.results__row:focus-visible, +.results__expert:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.results__name, +.results__case { + min-width: 0; + overflow-wrap: anywhere; +} + +.results__score, +.results__number { + text-align: right; +} + +.results__score strong { + font-size: 14px; + font-weight: 600; +} + +.results__arrow { + justify-self: center; + transition: transform 120ms; +} + +.results__arrow--open { + transform: rotate(180deg); +} + +.results__mobile-label { + display: none; +} + +.results__pending { + padding-top: 16px; +} + +.results__detail { + display: grid; + gap: 12px; + min-width: 0; + padding: 16px; + background: var(--bg-grey); + border-radius: 10px; +} + +.results__matrix { + min-width: 0; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + min-width: 100px; + padding: 12px 8px; + font-size: 12px; + font-weight: 400; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--medium-grey-for-outline); +} + +th:first-child { + min-width: 180px; + padding-left: 0; +} + +th app-avatar, +th span, +th small { + display: block; + margin-bottom: 4px; +} + +.results__comments { + display: grid; + gap: 8px; + padding-top: 16px; +} + +.results__comments p, +.results__expert-values dd { + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.results__comments p { + color: var(--grey-for-text); +} + +.results__expert-list { + display: none; +} + +.results__empty { + display: grid; + gap: 12px; + padding: 24px 0; +} + +.results__empty button { + justify-self: start; +} + +.results__sheet-info { + display: grid; + gap: 12px; + padding: 16px; + background: var(--bg-grey); + border-radius: 10px; +} + +.results__sheet-info p { + color: var(--grey-for-text); +} + +.results__scope { + display: grid; + gap: 4px; + padding: 0; + margin: 0; + border: 0; +} + +.results__scope label { + display: flex; + gap: 12px; + align-items: center; + min-height: 44px; + cursor: pointer; +} + +.results__download { + justify-self: end; + min-width: 180px; +} + +.results__sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); +} + +::ng-deep .modal .modal__body.results-modal { + width: 640px; + max-width: calc(100vw - 40px); + max-height: calc(var(--visual-viewport-height, 100dvh) - 40px); + padding: 24px; + overflow-y: auto; +} + +@media (max-width: 999px) { + .results { + padding: 16px; + } + + .results__heading { + display: grid; + gap: 8px; + } + + .results__actions { + flex-wrap: nowrap; + justify-content: flex-start; + width: 100%; + } + + .results__actions .ui-button:first-child { + flex: 0 0 128px; + } + + .results__actions .ui-button:last-child { + flex: 1; + min-width: 0; + padding-inline: 12px; + } + + .results__desktop-label { + display: none; + } + + .results__actions .results__mobile-label { + color: inherit; + } + + .results__filters { + grid-template-columns: minmax(0, 1fr); + } + + .results__toolbar { + flex-wrap: wrap; + justify-content: flex-end; + } + + .results__toolbar p { + width: 100%; + } + + .results__columns { + display: none; + } + + .results__row { + grid-template-columns: 24px minmax(0, 1fr) 28px; + gap: 8px; + min-height: 118px; + } + + .results__place { + grid-row: 1; + grid-column: 1; + font-weight: 600; + } + + .results__name { + grid-row: 1; + grid-column: 2; + } + + .results__arrow { + grid-row: 1; + grid-column: 3; + } + + .results__case { + grid-row: 2; + grid-column: 1 / 3; + padding-right: 92px; + } + + .results__count { + grid-row: 2; + grid-column: 2 / 4; + justify-self: end; + color: var(--grey-for-text); + } + + .results__score { + display: flex; + grid-row: 3; + grid-column: 1 / -1; + gap: 12px; + align-items: center; + justify-content: space-between; + } + + .results__mobile-label { + display: inline; + color: var(--grey-for-text); + } + + .results__detail { + padding: 12px 0; + background: transparent; + } + + .results__detail-heading { + flex-wrap: wrap; + } + + .results__matrix { + display: none; + } + + .results__expert-list { + display: grid; + gap: 0; + } + + .results__expert { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto 16px; + gap: 8px; + align-items: center; + min-width: 0; + min-height: 72px; + padding: 12px 0; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; + background: transparent; + border: 0; + border-bottom: 1px solid var(--medium-grey-for-outline); + } + + .results__expert span { + min-width: 0; + overflow-wrap: anywhere; + } + + .results__expert strong, + .results__expert small { + display: block; + font-size: 12px; + } + + .results__expert-values { + display: grid; + gap: 12px; + min-width: 0; + padding: 12px; + margin: 0; + background: var(--bg-grey); + } + + .results__expert-values div { + display: grid; + gap: 6px; + min-width: 0; + } + + .results__expert-values dd { + margin: 0; + } + + .results__expert[aria-expanded="true"] img { + transform: rotate(180deg); + } + + .ui-button.ui-button { + margin-block: 6px; + } + + .ui-button.ui-button::after { + position: absolute; + inset: -6px 0; + content: ""; + } + + .results__dialog .results__heading { + display: flex; + } + + .results__dialog .results__heading h2 { + min-width: 0; + } + + .results__download { + width: 100%; + } + + ::ng-deep .modal .modal__body.results-modal { + width: 320px; + padding: 20px; + } +} + +@media (prefers-reduced-motion: reduce) { + .results__arrow { + transition: none; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/results/results.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.spec.ts new file mode 100644 index 000000000..091ff0af0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.spec.ts @@ -0,0 +1,124 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { of, Subject, throwError } from "rxjs"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { ProgramResults, ResultProject } from "@domain/program/program-results.model"; +import { ManagementResultsComponent } from "./results.component"; + +const project = ( + id: number, + name: string, + score: number | null, + place: number | null, +): ResultProject => ({ + id, + projectId: id, + name, + score, + place, + ready: place !== null, + completed: score === null ? 0 : place === null ? 2 : 3, + required: 3, + caseName: 'Кейс "А"', + averages: {}, + experts: [], +}); +const snapshot: ProgramResults = { + programId: 42, + programName: "Программа", + generatedAt: "2026-10-19T12:00:00Z", + requiredEvaluators: 3, + maximum: 30, + hasNumericCriteria: true, + caseOptions: ['Кейс "А"', "Т-Банк"], + criteria: [], + groups: [ + { + caseName: 'Кейс "А"', + projects: [ + project(1, "Город", 24, 1), + project(2, "Маршрут", 22.33, 2), + project(3, "Кампус", 22.33, 2), + project(4, "Река", 20.67, 4), + project(5, "Район", 26.5, null), + ], + }, + { caseName: "Т-Банк", projects: [{ ...project(6, "Ноль", 0, 1), caseName: "Т-Банк" }] }, + ], +}; +function setup() { + const api = { + results: vi.fn(() => of(structuredClone(snapshot))), + exportResults: vi.fn(() => new Subject()), + }; + TestBed.configureTestingModule({ + imports: [ManagementResultsComponent], + providers: [{ provide: ProgramManagementUseCase, useValue: api }], + }); + const fixture = TestBed.createComponent(ManagementResultsComponent); + fixture.componentRef.setInput("programId", 42); + fixture.detectChanges(); + return { fixture, component: fixture.componentInstance, api }; +} +describe("Результаты программы", () => { + it("переставляет баллы, сохраняя места и отделяя предварительный балл", () => { + const { component } = setup(); + component.order.set("asc"); + const rows = component.groups()[0].projects; + expect(rows.filter(p => p.ready).map(p => p.place)).toEqual([4, 2, 2, 1]); + expect(rows.at(-1)?.name).toBe("Район"); + expect(component.groups()[1].projects[0].score).toBe(0); + expect(component.format(null)).toBe("—"); + expect(component.format(0)).toContain("0,00"); + }); + it("сохраняет фокус поиска при фильтрации и сортировке", () => { + const { fixture, component } = setup(); + const input = fixture.nativeElement.querySelector('input[type="search"]') as HTMLInputElement; + input.focus(); + input.value = "Ре"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + component.order.set("asc"); + fixture.detectChanges(); + expect(document.activeElement).toBe(input); + expect(input.value).toBe("Ре"); + expect(component.groups()[0].projects[0].place).toBe(4); + }); + it("фильтрует кейс с кавычками и передаёт его в выгрузку без потери поиска", () => { + const { fixture, component, api } = setup(); + component.caseFilter.setValue(component.caseKey('Кейс "А"')); + component.search.setValue("Река"); + fixture.detectChanges(); + expect(component.groups()).toHaveLength(1); + component.openModal("export"); + component.download(); + component.download(); + expect(api.exportResults).toHaveBeenCalledTimes(1); + expect(api.exportResults).toHaveBeenCalledWith(42, { + search: "Река", + caseScope: "selected", + caseName: 'Кейс "А"', + order: "desc", + scope: "current", + }); + expect(component.exporting()).toBe(true); + }); + it("показывает ошибку выгрузки и разрешает повторить", () => { + const { component, api } = setup(); + api.exportResults.mockReturnValue(throwError(() => new Error("offline"))); + component.openModal("export"); + component.download(); + expect(component.exportError()).toBe(true); + expect(component.exporting()).toBe(false); + expect(component.modal()).toBe("export"); + }); + it("показывает сохранённые оценки только после раскрытия проекта", () => { + const { fixture, component } = setup(); + component.toggle(1); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("#result-1")).not.toBeNull(); + component.toggle(1); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("#result-1")).toBeNull(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/management/results/results.component.ts b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.ts new file mode 100644 index 000000000..146057d49 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.ts @@ -0,0 +1,246 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + signal, + untracked, +} from "@angular/core"; +import { NgTemplateOutlet } from "@angular/common"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { Subject, finalize, switchMap, catchError, of, tap, takeUntil } from "rxjs"; +import { saveAs } from "file-saver"; +import { + AlertDirective, + AvatarComponent, + ButtonDirective, + ChoiceDirective, + FieldDirective, +} from "@uilib"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { + ProgramResults, + ResultExpert, + ResultProject, + ResultsQuery, +} from "@domain/program/program-results.model"; +import { ManagementCriterion } from "@domain/program/program-criteria.model"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { presentationHref } from "@utils/program-case-analytics"; + +@Component({ + selector: "app-management-results", + imports: [ + ReactiveFormsModule, + NgTemplateOutlet, + AlertDirective, + AvatarComponent, + ButtonDirective, + ChoiceDirective, + FieldDirective, + ModalComponent, + ], + templateUrl: "./results.component.html", + styleUrl: "./results.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManagementResultsComponent { + readonly programId = input.required(); + private readonly api = inject(ProgramManagementUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly fb = inject(FormBuilder).nonNullable; + private readonly requests = new Subject(); + readonly snapshot = signal(null); + readonly loading = signal(false); + readonly error = signal(false); + readonly search = this.fb.control(""); + readonly caseFilter = this.fb.control("all"); + private readonly searchValue = toSignal(this.search.valueChanges, { initialValue: "" }); + private readonly caseValue = toSignal(this.caseFilter.valueChanges, { initialValue: "all" }); + readonly order = signal<"asc" | "desc">("desc"); + readonly expanded = signal(new Set()); + readonly expandedExperts = signal(new Set()); + readonly modal = signal<"formula" | "export" | null>(null); + readonly exportScope = signal<"current" | "all">("current"); + readonly exporting = signal(false); + readonly exportError = signal(false); + readonly exportSuccess = signal(false); + readonly href = presentationHref; + readonly caseKey = (name: string): string => JSON.stringify(name); + readonly format = (value: number | null | undefined): string => + value == null + ? "—" + : value.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + readonly numericCriteria = computed( + () => this.snapshot()?.criteria.filter(c => c.type === "int" || c.type === "float") ?? [], + ); + readonly matrixCriteria = computed( + () => this.snapshot()?.criteria.filter(c => c.type !== "str") ?? [], + ); + readonly comments = computed(() => this.snapshot()?.criteria.filter(c => c.type === "str") ?? []); + readonly groups = computed(() => { + const key = this.caseValue(), + query = this.searchValue().trim().toLocaleLowerCase("ru"); + return (this.snapshot()?.groups ?? []) + .filter(g => key === "all" || JSON.stringify(g.caseName) === key) + .map(g => ({ + ...g, + projects: g.projects + .filter(p => p.name.toLocaleLowerCase("ru").includes(query)) + .sort( + (a, b) => + Number(!a.ready) - Number(!b.ready) || + Number(a.score === null) - Number(b.score === null) || + (this.order() === "desc" ? -1 : 1) * ((a.score ?? 0) - (b.score ?? 0)) || + this.compareNames(a, b), + ), + })) + .filter(g => g.projects.length); + }); + readonly readyCount = computed( + () => + this.groups() + .flatMap(g => g.projects) + .filter(p => p.ready).length, + ); + readonly pendingCount = computed( + () => + this.groups() + .flatMap(g => g.projects) + .filter(p => !p.ready).length, + ); + readonly projectCount = computed(() => this.readyCount() + this.pendingCount()); + readonly sample = computed(() => + this.snapshot() + ?.groups.flatMap(g => g.projects) + .find(p => p.score !== null), + ); + readonly sampleFormula = computed(() => { + const p = this.sample(), + experts = p?.experts.filter(e => e.complete) ?? []; + return p && experts.length + ? `(${experts.map(e => this.format(e.total)).join(" + ")}) / ${experts.length} = ${this.format(p.score)}` + : null; + }); + constructor() { + this.requests + .pipe( + tap(() => { + this.loading.set(true); + this.error.set(false); + }), + switchMap(id => + this.api.results(id).pipe( + tap(data => this.snapshot.set(data)), + catchError(() => { + this.error.set(true); + return of(null); + }), + finalize(() => this.loading.set(false)), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + effect(() => { + const id = this.programId(); + untracked(() => { + this.snapshot.set(null); + this.expanded.set(new Set()); + this.expandedExperts.set(new Set()); + this.search.setValue(""); + this.caseFilter.setValue("all"); + this.order.set("desc"); + this.modal.set(null); + this.exportSuccess.set(false); + this.load(id); + }); + }); + } + load(id = this.programId()): void { + this.requests.next(id); + } + private compareNames(a: ResultProject, b: ResultProject): number { + const first = a.name.toLowerCase(), + second = b.name.toLowerCase(); + return first === second ? a.id - b.id : first < second ? -1 : 1; + } + toggle(id: number): void { + this.expanded.update(ids => { + const next = new Set(ids); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + toggleExpert(key: string): void { + this.expandedExperts.update(ids => { + const next = new Set(ids); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + ready(projects: ResultProject[]): ResultProject[] { + return projects.filter(p => p.ready); + } + pending(projects: ResultProject[]): ResultProject[] { + return projects.filter(p => !p.ready); + } + initials(expert: ResultExpert): string { + return expert.name + .split(/\s+/) + .slice(0, 2) + .map(v => v[0]) + .join(""); + } + value(expert: ResultExpert, criterion: ManagementCriterion): string { + const value = expert.values.find(v => v.criterionId === criterion.id)?.value; + return value === "True" && criterion.type === "bool" + ? "Да" + : value === "False" && criterion.type === "bool" + ? "Нет" + : value == null || value === "" + ? "—" + : value; + } + openModal(kind: "formula" | "export"): void { + this.exportError.set(false); + this.exportScope.set("current"); + this.modal.set(kind); + } + download(): void { + if (this.exporting()) return; + const selected = this.caseValue(); + const query: ResultsQuery = { + search: this.searchValue().trim(), + caseScope: selected === "all" ? "all" : selected === "null" ? "without_case" : "selected", + caseName: selected !== "all" && selected !== "null" ? JSON.parse(selected) : undefined, + order: this.order(), + scope: this.exportScope(), + }; + this.exporting.set(true); + this.exportError.set(false); + this.exportSuccess.set(false); + this.api + .exportResults(this.programId(), query) + .pipe( + takeUntil(this.requests), + takeUntilDestroyed(this.destroyRef), + finalize(() => this.exporting.set(false)), + ) + .subscribe({ + next: file => { + saveAs(file, `program-${this.programId()}-results.xlsx`); + this.modal.set(null); + this.exportSuccess.set(true); + }, + error: () => this.exportError.set(true), + }); + } +} diff --git a/projects/social_platform/src/assets/images/results-arrow-down.svg b/projects/social_platform/src/assets/images/results-arrow-down.svg new file mode 100644 index 000000000..8f474ab5a --- /dev/null +++ b/projects/social_platform/src/assets/images/results-arrow-down.svg @@ -0,0 +1,5 @@ + + + + +