From 8bc17cb216b33d368692b4b7322b837d5771552b Mon Sep 17 00:00:00 2001 From: For Study and work Date: Wed, 11 Nov 2026 23:16:28 +0300 Subject: [PATCH] feat: configure and explain percentage criterion weights --- docs/contracts/program-solutions-v1/README.md | 2 + .../program-solutions-v1/examples.json | 389 +++++++++++++++++- .../program-solutions-v1/openapi.json | 33 +- docs/weighted-case-criteria.md | 50 +++ .../domain/program/program-criteria.model.ts | 4 + .../domain/program/program-results.model.ts | 4 +- .../project/project-rating-criterion.ts | 1 + .../solution/solution-contract.fixture.ts | 382 +++++++++++++++++ .../solution/solution-contract.model.ts | 6 +- .../project-rating.component.html | 3 + .../project-rating.component.spec.ts | 2 + .../criteria/criteria.component.html | 43 +- .../criteria/criteria.component.spec.ts | 80 ++++ .../management/criteria/criteria.component.ts | 83 +++- .../management/results/results.component.html | 24 +- .../results/results.component.spec.ts | 49 +++ .../management/results/results.component.ts | 4 +- 17 files changed, 1145 insertions(+), 14 deletions(-) create mode 100644 docs/weighted-case-criteria.md diff --git a/docs/contracts/program-solutions-v1/README.md b/docs/contracts/program-solutions-v1/README.md index 1d9a0d6ff..337f2e97a 100644 --- a/docs/contracts/program-solutions-v1/README.md +++ b/docs/contracts/program-solutions-v1/README.md @@ -1,5 +1,7 @@ +Изменение 1.22.0: optional weight_percent у критериев, scoring_mode у результатов и contributions у строки решения. Режим weighted_percent нормализует каждый числовой критерий по его шкале и процентному весу, максимум равен 100; legacy сохраняет прежнюю сумму. Старые payload оценок, required поля, URL и примеры совместимы. Веса и режим входят в revision и фиксируются первой оценкой; правила Project и ProgramSolution используют общую формулу. Для внедрения нужны добавочные миграции программы/критерия и backend до Angular. + # C00 Контракт решения в программе Изменение 1.21.0: публичные GET охватывают уже сданные конкурсные решения КЧ/хакатона после окончания программы и установки marker существующим publisher. Неконкурсные сохраненные материалы остаются без сдачи. DTO/156 schemas/59 operations сохраняются;6 новых независимых сценариев, итого305. Временный сценарий published_solution_list_competitive_pending_rule намеренно меняется404→200 с пустой страницей: весь конкурсный список больше не закрыт из-за отдельного вопроса о draft. Остальные298 старых сценариев структурно неизменны. Несданные конкурсные работы и архивы не выдаются, окончательная eligibility draft все еще ожидает ответа. Нового Angular UI, схемы БД или Project нет. diff --git a/docs/contracts/program-solutions-v1/examples.json b/docs/contracts/program-solutions-v1/examples.json index e0d4745c0..02eb5ff35 100644 --- a/docs/contracts/program-solutions-v1/examples.json +++ b/docs/contracts/program-solutions-v1/examples.json @@ -1,5 +1,5 @@ { - "contract_version": "1.21.0", + "contract_version": "1.22.0", "description": "Независимые примеры, не последовательность операций; фиктивные данные.", "scenarios": [ { @@ -12645,6 +12645,393 @@ "errors": [], "meta": {} } + }, + { + "name": "solution_results_weighted_percent", + "operation_id": "getSolutionResults", + "status": 200, + "response": { + "program_id": 12, + "program_name": "Кейс-чемпионат PROCOLLAB", + "generated_at": "2026-10-24T20:00:00Z", + "required_evaluators": 2, + "maximum": 100, + "has_numeric_criteria": true, + "case_options": [ + "Цифровой сервис" + ], + "groups": [ + { + "case_name": "Цифровой сервис", + "solutions": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "solution_id": 301, + "name": "Сервис для студентов", + "case_name": "Цифровой сервис", + "completed": 1, + "required": 2, + "evaluation_complete": false, + "ready": false, + "score": 54, + "place": null, + "averages": { + "21": 0, + "22": 4.5 + }, + "experts": [ + { + "user_id": 91, + "name": "Эксперт программы", + "email": "expert@local.example", + "avatar": null, + "complete": true, + "total": 54, + "values": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "" + } + ] + } + ], + "contributions": { + "21": 0, + "22": 54 + } + } + ] + } + ], + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "weight_percent": 40 + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "weight_percent": 60 + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "weight_percent": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "weight_percent": null + } + ], + "scoring_mode": "weighted_percent" + } + }, + { + "name": "solution_rating_weighted_percent", + "operation_id": "rateSolution", + "status": 200, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА" + } + ] + }, + "response": { + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "id": 301, + "program_id": 12, + "version": 10, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:05:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": "2026-10-23T10:05:00Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0", + "weight_percent": 40 + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "value": "4.5", + "weight_percent": 60 + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False", + "weight_percent": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА", + "weight_percent": null + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 3, + "required_evaluators": 2, + "deadline": "2026-10-30T18:00:00Z" + } } ] } diff --git a/docs/contracts/program-solutions-v1/openapi.json b/docs/contracts/program-solutions-v1/openapi.json index c425736cd..913545332 100644 --- a/docs/contracts/program-solutions-v1/openapi.json +++ b/docs/contracts/program-solutions-v1/openapi.json @@ -3,7 +3,7 @@ "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "info": { "title": "PROCOLLAB Program Solutions C00", - "version": "1.21.0", + "version": "1.22.0", "description": "Контракт решений в программах для реализации и моков BE01–BE09. API base URL задается окружением. snake_case на проводе. Старые API не меняются." }, "servers": [ @@ -11549,6 +11549,15 @@ ], "maxLength": 1000, "description": "Строка/null по прежнему типу критерия. Текстовый комментарий допускает до 1000 символов, как текущий ProjectScore; числовой диапазон и True/False продолжают проверяться отдельно." + }, + "weight_percent": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Процентный вес числового критерия; null, если вес не применяется." } }, "required": [ @@ -12279,6 +12288,15 @@ "number", "null" ] + }, + "weight_percent": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Процентный вес числового критерия; null, если вес не применяется." } }, "required": [ @@ -12396,6 +12414,12 @@ "items": { "$ref": "#/components/schemas/ResultExpert" } + }, + "contributions": { + "type": "object", + "additionalProperties": { + "type": "number" + } } }, "required": [ @@ -12481,6 +12505,13 @@ "items": { "$ref": "#/components/schemas/ResultCriterion" } + }, + "scoring_mode": { + "type": "string", + "enum": [ + "legacy", + "weighted_percent" + ] } }, "required": [ diff --git a/docs/weighted-case-criteria.md b/docs/weighted-case-criteria.md new file mode 100644 index 000000000..f70c90b4c --- /dev/null +++ b/docs/weighted-case-criteria.md @@ -0,0 +1,50 @@ + + +# Процентные веса критериев кейса + +Согласовано 11 ноября 2026: сумма весов числовых критериев — 100%, итог кейса — +0–100. [Требования, формула, API и полный AC audit](https://github.com/PROCOLLAB-github/api/blob/codex/weighted-case-criteria-20261111/docs/weighted-case-criteria.md) +находятся в API repo. Квиз и общий рейтинг квиза с кейсом в эту задачу не входят. + +## Поведение + +В существующем разделе «Оценивание → Критерии» организатор выбирает «Веса +критериев, 0–100», задает проценты в редакторах и сохраняет весь набор. Поле веса +есть у числовых критериев; сумма и валидность шкал проверяются до сохранения и на +сервере. Изменения при 409 остаются в черновике; первая сохраненная оценка +фиксирует критерии и режим. Для прежних программ остается «Сумма исходных баллов». + +Эксперт видит вес и вводит исходные баллы. Расчет выполняет API: +`(балл − минимум) / (максимум − минимум) × вес`. Итог команды — среднее полных +оценок экспертов. В результатах видны максимум 100, объяснение и вклады критериев. +Общая форма работает для Project и ProgramSolution; смешивания их сущностей нет. + +`scoringMode`, `weightPercent`, `contributions` — добавочные поля. Старый ответ API +без них читается как legacy. C00 1.22.0 и generated solution DTO/fixtures обновлены; +поля запроса оценки и маршруты сохраняются. Сначала устанавливаются API и его +добавочные миграции, затем Angular. Автоматического включения весов нет. + +## Проверка + +- Checked by tests: 57 тестов, 7 файлов — criteria, Project/Solution results, + shared expert form, management adapter, solution contract и solution evaluation. + TypeScript solution contract check прошел. В API прошли 235 тестов на отдельном + PostgreSQL; схемы C00 и 307 сценариев проверены валидатором. +- Checked by build: `ng build social_platform --configuration production` прошел. + Остаются прежние предупреждения Sass, budgets и CommonJS. Scoped ESLint и + Prettier проверяют только измененные файлы; глобальные автоисправления не нужны. +- Checked in browser: production build + настоящий локальный Django, синтетические + аккаунты, все внешние сервисы заблокированы. Организатор сохранил 40/35/25; + эксперт через UI отправил 8/7/6, второй через API — 10/8/8. Итоги 71,5 и 88, + рейтинг 79,75 из 100. Проверены блокировка после первой оценки, формула, выгрузка, + desktop 1440 px, mobile 390 px без горизонтального скролла, Enter/Escape и + доступное имя числового поля. Ошибок страницы нет. XLSX после пересчета + LibreOffice совпал с API. +- Checked by source/design review: расширены существующие controls, cards, modal + и shared rating form; Mont и текущие tokens сохраняются, нового CSS/анимаций нет. + Loading/error/disabled/409/old API покрыты компонентными тестами. После + визуальной проверки сокращено только название режима, которое обрезалось на + телефоне. Невыполненных требований к этому расширению формы не осталось. + +Not checked: установка на pilot/dev/prod, реальная почта, нагрузка 100 участников +и приемка клиента. Скриншоты, логи и учетные данные QA остаются вне Git. diff --git a/projects/social_platform/src/app/domain/program/program-criteria.model.ts b/projects/social_platform/src/app/domain/program/program-criteria.model.ts index bdb5c7684..905270c9a 100644 --- a/projects/social_platform/src/app/domain/program/program-criteria.model.ts +++ b/projects/social_platform/src/app/domain/program/program-criteria.model.ts @@ -1,6 +1,7 @@ /** @format */ export type CriterionType = "int" | "bool" | "str" | "float"; +export type EvaluationScoringMode = "legacy" | "weighted_percent"; export interface ManagementCriterion { id: number; name: string; @@ -8,6 +9,7 @@ export interface ManagementCriterion { type: CriterionType; minValue: number | null; maxValue: number | null; + weightPercent?: number | null; } export interface ManagementCriteria { updatedAt: string; @@ -15,9 +17,11 @@ export interface ManagementCriteria { locked: boolean; supportedTypes: CriterionType[]; criteria: ManagementCriterion[]; + scoringMode?: EvaluationScoringMode; } export interface ManagementCriteriaWrite { expectedUpdatedAt: string; expectedRevision: string; + scoringMode?: EvaluationScoringMode; criteria: (Omit & { id: number | null })[]; } 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 index 7ec528248..6f9a40566 100644 --- a/projects/social_platform/src/app/domain/program/program-results.model.ts +++ b/projects/social_platform/src/app/domain/program/program-results.model.ts @@ -1,5 +1,5 @@ /** @format */ -import { ManagementCriterion } from "./program-criteria.model"; +import { EvaluationScoringMode, ManagementCriterion } from "./program-criteria.model"; import { SolutionRef } from "@domain/solution/solution-contract.model"; export interface ResultExpert { @@ -23,6 +23,7 @@ export interface ResultProject { score: number | null; place: number | null; averages: Record; + contributions?: Record; experts: ResultExpert[]; } export interface ResultsGroup { @@ -38,6 +39,7 @@ export interface ProgramResults { generatedAt: string; requiredEvaluators: number; maximum: number | null; + scoringMode?: EvaluationScoringMode; hasNumericCriteria: boolean; caseOptions: string[]; criteria: ManagementCriterion[]; diff --git a/projects/social_platform/src/app/domain/project/project-rating-criterion.ts b/projects/social_platform/src/app/domain/project/project-rating-criterion.ts index d429dcd30..58bfdd9f3 100644 --- a/projects/social_platform/src/app/domain/project/project-rating-criterion.ts +++ b/projects/social_platform/src/app/domain/project/project-rating-criterion.ts @@ -10,6 +10,7 @@ export interface ProjectRatingCriterion { type: ProjectRatingCriterionType; minValue: number | null; maxValue: number | null; + weightPercent?: number | null; value: string | number; expertId: number; } diff --git a/projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts b/projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts index f0fb808d1..6374b17d9 100644 --- a/projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts +++ b/projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts @@ -12360,4 +12360,386 @@ export const solutionContractScenarios: SolutionContractScenario[] = [ meta: {}, }, }, + { + name: "solution_results_weighted_percent", + operationId: "getSolutionResults", + status: 200, + response: { + programId: 12, + programName: "Кейс-чемпионат PROCOLLAB", + generatedAt: "2026-10-24T20:00:00Z", + requiredEvaluators: 2, + maximum: 100, + hasNumericCriteria: true, + caseOptions: ["Цифровой сервис"], + groups: [ + { + caseName: "Цифровой сервис", + solutions: [ + { + application: { + kind: "solution", + programId: 12, + solutionId: 301, + name: "Сервис для студентов", + }, + solutionId: 301, + name: "Сервис для студентов", + caseName: "Цифровой сервис", + completed: 1, + required: 2, + evaluationComplete: false, + ready: false, + score: 54, + place: null, + averages: { + "21": 0, + "22": 4.5, + }, + experts: [ + { + userId: 91, + name: "Эксперт программы", + email: "expert@local.example", + avatar: null, + complete: true, + total: 54, + values: [ + { + criterionId: 21, + value: "0", + }, + { + criterionId: 22, + value: "4.5", + }, + { + criterionId: 23, + value: "False", + }, + { + criterionId: 24, + value: "", + }, + ], + }, + ], + contributions: { + "21": 0, + "22": 54, + }, + }, + ], + }, + ], + criteria: [ + { + id: 21, + name: "Практическая ценность", + description: "Польза аудитории", + type: "int", + minValue: 0, + maxValue: 10, + weightPercent: 40, + }, + { + id: 22, + name: "Проработанность", + description: null, + type: "float", + minValue: 0, + maxValue: 5, + weightPercent: 60, + }, + { + id: 23, + name: "Показан прототип", + description: null, + type: "bool", + minValue: null, + maxValue: null, + weightPercent: null, + }, + { + id: 24, + name: "Рекомендация", + description: null, + type: "str", + minValue: null, + maxValue: null, + weightPercent: null, + }, + ], + scoringMode: "weighted_percent", + }, + }, + { + name: "solution_rating_weighted_percent", + operationId: "rateSolution", + status: 200, + request: { + expectedCriteriaRevision: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + scores: [ + { + criterionId: 21, + value: "0", + }, + { + criterionId: 22, + value: "4.5", + }, + { + criterionId: 23, + value: "False", + }, + { + criterionId: 24, + value: + "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА", + }, + ], + }, + response: { + program: { + id: 12, + name: "Кейс-чемпионат PROCOLLAB", + }, + solution: { + id: 301, + programId: 12, + version: 10, + name: "Сервис для студентов", + captainId: 7, + createdAt: "2026-10-23T09:00:00Z", + updatedAt: "2026-10-23T10:05:00Z", + team: { + members: [ + { + user: { + id: 7, + firstName: "Иван", + lastName: "Петров", + avatarUrl: null, + }, + role: null, + specialization: null, + joinedAt: "2026-10-23T09:00:00Z", + isCaptain: true, + }, + { + user: { + id: 8, + firstName: "Анна", + lastName: "Смирнова", + avatarUrl: null, + }, + role: null, + specialization: null, + joinedAt: "2026-10-23T09:00:00Z", + isCaptain: false, + }, + ], + acceptedCount: 2, + pendingCount: 1, + reservedCount: 3, + minSize: 2, + maxSize: 4, + isFrozen: true, + }, + form: { + programId: 12, + revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + template: "case_championship", + isConfigured: true, + isSnapshot: true, + fields: [ + { + id: 1001, + type: "select", + purpose: "case", + label: "Кейс", + helpText: "", + required: true, + position: 0, + options: ["Цифровой сервис", "Операционная эффективность"], + validation: { + minLength: null, + maxLength: null, + minimum: null, + maximum: null, + allowedExtensions: [], + maxFileSizeBytes: null, + }, + }, + { + id: 1002, + type: "textarea", + purpose: "other", + label: "Краткое описание", + helpText: "", + required: true, + position: 1, + options: [], + validation: { + minLength: null, + maxLength: 3000, + minimum: null, + maximum: null, + allowedExtensions: [], + maxFileSizeBytes: null, + }, + }, + { + id: 1003, + type: "file", + purpose: "presentation", + label: "Презентация PDF", + helpText: "", + required: true, + position: 2, + options: [], + validation: { + minLength: null, + maxLength: null, + minimum: null, + maximum: null, + allowedExtensions: ["pdf"], + maxFileSizeBytes: 20971520, + }, + }, + { + id: 1004, + type: "number", + purpose: "other", + label: "Ожидаемый эффект", + helpText: "", + required: false, + position: 3, + options: [], + validation: { + minLength: null, + maxLength: null, + minimum: 0, + maximum: null, + allowedExtensions: [], + maxFileSizeBytes: null, + }, + }, + { + id: 1005, + type: "checkbox", + purpose: "other", + label: "Приложены расчеты", + helpText: "", + required: false, + position: 4, + options: [], + validation: { + minLength: null, + maxLength: null, + minimum: null, + maximum: null, + allowedExtensions: [], + maxFileSizeBytes: null, + }, + }, + ], + }, + answers: [ + { + fieldId: 1001, + value: "Цифровой сервис", + }, + { + fieldId: 1002, + value: "Единый сервис поддержки студентов.", + }, + { + fieldId: 1004, + value: 0, + }, + { + fieldId: 1005, + value: false, + }, + { + fieldId: 1003, + value: { + url: "https://cdn.example.test/solutions/301/presentation.pdf", + }, + }, + ], + submission: { + required: true, + status: "submitted", + submissionOpen: true, + deadline: "2026-10-30T18:00:00Z", + submittedAt: "2026-10-23T10:05:00Z", + }, + permissions: { + canEdit: false, + canManageTeam: false, + canInvite: false, + canSubmit: false, + canLeave: false, + }, + readiness: { + isReady: true, + issues: [], + }, + }, + criteriaRevision: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + criteria: [ + { + id: 21, + name: "Практическая ценность", + description: "Польза аудитории", + type: "int", + minValue: 0, + maxValue: 10, + value: "0", + weightPercent: 40, + }, + { + id: 22, + name: "Проработанность", + description: null, + type: "float", + minValue: 0, + maxValue: 5, + value: "4.5", + weightPercent: 60, + }, + { + id: 23, + name: "Показан прототип", + description: null, + type: "bool", + minValue: null, + maxValue: null, + value: "False", + weightPercent: null, + }, + { + id: 24, + name: "Рекомендация", + description: null, + type: "str", + minValue: null, + maxValue: null, + value: + "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА", + weightPercent: null, + }, + ], + managedEvaluation: true, + canRate: true, + blockedReason: null, + isCompleted: true, + ratedCount: 1, + maxRates: 3, + requiredEvaluators: 2, + deadline: "2026-10-30T18:00:00Z", + }, + }, ]; diff --git a/projects/social_platform/src/app/domain/solution/solution-contract.model.ts b/projects/social_platform/src/app/domain/solution/solution-contract.model.ts index 4ec855694..2cbb482fd 100644 --- a/projects/social_platform/src/app/domain/solution/solution-contract.model.ts +++ b/projects/social_platform/src/app/domain/solution/solution-contract.model.ts @@ -1,6 +1,6 @@ /** @format */ -// Сгенерировано tools/generate-solution-contract.py из C00 1.21.0. +// Сгенерировано tools/generate-solution-contract.py из C00 1.22.0. // camelCase соответствует CamelcaseInterceptor; значения ID и revision не преобразуются. export type SolutionUser = { @@ -379,6 +379,7 @@ export type SolutionRatingCriterion = { minValue: number | null; maxValue: number | null; value: string | null; + weightPercent?: number | null; }; export type SolutionRatingValue = { @@ -537,6 +538,7 @@ export type SolutionResultCriterion = { type: "int" | "float" | "bool" | "str"; minValue: number | null; maxValue: number | null; + weightPercent?: number | null; }; export type SolutionResultExpert = { @@ -562,6 +564,7 @@ export type SolutionResult = { place: number | null; averages: Record; experts: Array; + contributions?: Record; }; export type SolutionResultGroup = { @@ -579,6 +582,7 @@ export type SolutionResults = { caseOptions: Array; groups: Array; criteria: Array; + scoringMode?: "legacy" | "weighted_percent"; }; export type SolutionCabinetApplication = { diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.html index c263dbef0..77f97321f 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.html @@ -19,6 +19,9 @@

{{ criterion.name }}

} @if (criterion.type === "int" || criterion.type === "float") { + @if (criterion.weightPercent != null) { +

Вес в итоговой оценке кейса: {{ criterion.weightPercent }}%

+ } { type: "int", minValue: 0, maxValue: 10, + weightPercent: 40, value: 0, expertId: 0, }; @@ -33,6 +34,7 @@ describe("Shared expert form lifecycle", () => { expect(root.querySelector("details")?.textContent).toContain(criterion.description); expect(root.querySelector("details")?.open).toBe(false); expect(root.querySelector(".field__scale")?.textContent).toContain("/ 10"); + expect(root.textContent).toContain("Вес в итоговой оценке кейса: 40%"); expect(root.querySelector("textarea")?.rows).toBe(2); const answers = fixture.componentInstance.form.getRawValue(); expect(answers).toEqual({ 1: 0, 2: false, 3: "Хорошее решение" }); diff --git a/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html index 7358b4059..3b521408a 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html @@ -42,6 +42,27 @@

Критерии оценки

Критерии программы

Настройте форму до первой сохраненной оценки.

+ + + @if (scoringMode() === "weighted_percent") { +

+ Минимум шкалы дает 0, максимум — полный вес критерия. Оценки экспертов учитываются + одинаково. +

+

Сумма весов: {{ weightTotal() }}% из 100%

+ @if (weightsError(); as message) { +

{{ message }}

+ } + } {{ criterion.name }} /> } + @if ( + scoringMode() === "weighted_percent" && (editorType() === "int" || editorType() === "float") + ) { + +

Сумма весов всех числовых критериев должна быть ровно 100%.

+ }

После {{ editing() === "new" ? "добавления" : "применения" }} сохраните изменения на diff --git a/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.spec.ts index 16c6c6890..525132521 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.spec.ts @@ -102,4 +102,84 @@ describe("Organizer evaluation criteria", () => { expect(component.previewCriteria()[0].name).toBe("Качество"); expect(api.saveCriteria).not.toHaveBeenCalled(); }); + it("mode change is dirty for an old API snapshot and invalid totals never write", async () => { + const { component, fixture } = await setup(); + const select = fixture.nativeElement.querySelector( + "#criteria-scoring-mode", + ) as HTMLSelectElement; + select.value = "weighted_percent"; + select.dispatchEvent(new Event("change")); + await fixture.whenStable(); + expect(component.dirty()).toBe(true); + expect(component.weightsError()).toBeTruthy(); + component.save(); + expect(api.saveCriteria).not.toHaveBeenCalled(); + component.edit(component.criteria()[0]); + component.editor.controls.weightPercent.setValue(100); + component.apply(); + expect(component.weightTotal()).toBe(100); + expect(component.weightsError()).toBeNull(); + api.saveCriteria.mockReturnValue( + of({ ...initial(), scoringMode: "weighted_percent", criteria: component.criteria() }), + ); + component.save(); + expect(api.saveCriteria).toHaveBeenCalledWith( + 8, + expect.objectContaining({ + scoringMode: "weighted_percent", + criteria: [expect.objectContaining({ weightPercent: 100 })], + }), + ); + expect(component.dirty()).toBe(false); + }); + it("validates fractional weights and counts cents without floating point sum errors", async () => { + const { component } = await setup(); + component.scoringMode.set("weighted_percent"); + component.edit(component.criteria()[0]); + for (const weight of [0, -2, 100.001, NaN, Infinity]) { + component.editor.controls.weightPercent.setValue(weight); + component.apply(); + expect(component.editing()).not.toBeNull(); + } + component.criteria.set( + [33.33, 33.33, 33.34].map((weightPercent, i) => ({ + ...initial().criteria[0], + id: i + 1, + weightPercent, + })), + ); + expect(component.weightTotal()).toBe(100); + expect(component.weightsError()).toBeNull(); + }); + it("keeps existing float definitions when only their weight changes", async () => { + const { component, pending } = await setup(); + const criterion = { + ...initial().criteria[0], + type: "float" as const, + minValue: 0.1, + maxValue: 2.5, + description: null, + weightPercent: 100, + }; + pending.next({ ...initial(), scoringMode: "weighted_percent", criteria: [criterion] }); + component.edit(criterion); + expect(component.editor.controls.name.disabled).toBe(true); + expect(component.editor.controls.weightPercent.enabled).toBe(true); + component.editor.controls.weightPercent.setValue(80); + component.apply(); + expect(component.criteria()[0]).toEqual({ ...criterion, weightPercent: 80 }); + }); + it("locked weighted mode cannot be switched or edited and preview retains its weights", async () => { + const { component, pending } = await setup(); + pending.next({ + ...initial(), + locked: true, + scoringMode: "weighted_percent", + criteria: initial().criteria.map(c => ({ ...c, weightPercent: 100 })), + }); + component.changeScoringMode({ target: { value: "legacy" } } as unknown as Event); + expect(component.scoringMode()).toBe("weighted_percent"); + component.openPreview(); + expect(component.previewCriteria()[0].weightPercent).toBe(100); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.ts b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.ts index 72b6a9744..2241a6f48 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.ts @@ -19,6 +19,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; import { CriterionType, + EvaluationScoringMode, ManagementCriteria, ManagementCriteriaWrite, ManagementCriterion, @@ -70,8 +71,35 @@ export class ManagementCriteriaComponent { type: this.fb.control("int"), minValue: new FormControl(0), maxValue: new FormControl(10), + weightPercent: new FormControl(null), }); readonly editorType = signal("int"); + readonly scoringMode = signal("legacy"); + readonly weightTotal = computed( + () => + this.criteria() + .filter(c => c.type === "int" || c.type === "float") + .reduce((total, c) => total + Math.round((c.weightPercent ?? 0) * 100), 0) / 100, + ); + readonly weightsError = computed(() => { + if (this.scoringMode() !== "weighted_percent") return null; + const numeric = this.criteria().filter(c => c.type === "int" || c.type === "float"); + if (!numeric.length) return "Добавьте числовой критерий и распределите 100% веса."; + if (numeric.some(c => !this.validWeight(c.weightPercent))) + return "Укажите вес каждого числового критерия: от 0,01 до 100%, до двух знаков после запятой."; + if ( + numeric.some( + c => + c.minValue === null || + c.maxValue === null || + !Number.isFinite(c.minValue) || + !Number.isFinite(c.maxValue) || + c.maxValue <= c.minValue, + ) + ) + return "Для весов укажите конечный максимум строго больше минимума каждого числового критерия."; + return this.weightTotal() === 100 ? null : "Сумма весов должна быть ровно 100%."; + }); readonly previewOpen = signal(false); readonly previewCriteria = signal([]); readonly previewControl = new FormControl(); @@ -113,9 +141,11 @@ export class ManagementCriteriaComponent { return { expectedUpdatedAt: snapshot?.updatedAt ?? "", expectedRevision: snapshot?.revision ?? "", + scoringMode: source ? (source.scoringMode ?? "legacy") : this.scoringMode(), criteria: (source?.criteria ?? this.criteria()).map(c => ({ ...c, id: c.id > 0 ? c.id : null, + weightPercent: c.weightPercent ?? null, })), }; } @@ -145,6 +175,7 @@ export class ManagementCriteriaComponent { this.load(); } discard(): void { + this.scoringMode.set(this.snapshot()?.scoringMode ?? "legacy"); this.criteria.set(this.snapshot()?.criteria.map(c => ({ ...c })) ?? []); this.editing.set(null); this.editorDirty.set(false); @@ -157,7 +188,12 @@ export class ManagementCriteriaComponent { if (!this.dirty() || (await this.confirmDiscard()())) this.discard(); } edit(criterion?: ManagementCriterion): void { - if (this.locked() || this.saving() || criterion?.type === "float") return; + if ( + this.locked() || + this.saving() || + (criterion?.type === "float" && this.scoringMode() !== "weighted_percent") + ) + return; this.editing.set(criterion ?? "new"); this.editor.reset({ name: criterion?.name ?? "", @@ -165,7 +201,13 @@ export class ManagementCriteriaComponent { type: criterion?.type ?? "int", minValue: criterion?.minValue ?? 0, maxValue: criterion?.maxValue ?? 10, + weightPercent: criterion?.weightPercent ?? null, }); + for (const [key, control] of Object.entries(this.editor.controls)) { + if (criterion?.type === "float" && key !== "weightPercent") + control.disable({ emitEvent: false }); + else control.enable({ emitEvent: false }); + } this.editorType.set(this.editor.controls.type.value); this.editorDirty.set(false); this.editorError.set(null); @@ -185,6 +227,14 @@ export class ManagementCriteriaComponent { this.editorError.set("Укажите название до 50 символов и описание до 4000 символов."); return; } + if ( + this.scoringMode() === "weighted_percent" && + (value.type === "int" || value.type === "float") && + !this.validWeight(value.weightPercent) + ) { + this.editorError.set("Укажите вес от 0,01 до 100%, до двух знаков после запятой."); + return; + } if ( value.type === "int" && (value.minValue === null || @@ -202,7 +252,10 @@ export class ManagementCriteriaComponent { id: editing === "new" ? this.nextId-- : editing.id, minValue: value.type === "int" ? value.minValue : null, maxValue: value.type === "int" ? value.maxValue : null, + weightPercent: value.type === "int" || value.type === "float" ? value.weightPercent : null, }; + if (editing !== "new" && editing.type === "float") + Object.assign(criterion, editing, { weightPercent: value.weightPercent }); this.criteria.update(rows => editing === "new" ? [...rows, criterion] @@ -220,6 +273,10 @@ export class ManagementCriteriaComponent { } save(): void { if (!this.snapshot() || this.locked() || this.saving() || !this.dirty()) return; + if (this.weightsError()) { + this.error.set(this.weightsError()); + return; + } this.saving.set(true); this.error.set(null); this.saved.set(false); @@ -251,6 +308,7 @@ export class ManagementCriteriaComponent { this.previewCriteria.set( this.criteria().map(c => ({ ...c, + weightPercent: this.scoringMode() === "weighted_percent" ? c.weightPercent : null, description: c.description ?? "", value: "", expertId: 0, @@ -264,16 +322,37 @@ export class ManagementCriteriaComponent { this.labels[c.type] + (c.type === "int" || c.type === "float" ? ` · от ${c.minValue ?? "без минимума"} до ${c.maxValue ?? "без максимума"}` + : "") + + (this.scoringMode() === "weighted_percent" && c.weightPercent != null + ? ` · вес ${c.weightPercent}%` : "") ); } + changeScoringMode(event: Event): void { + if (this.locked() || this.saving()) return; + const value = (event.target as HTMLSelectElement).value; + if (value === "legacy" || value === "weighted_percent") this.scoringMode.set(value); + this.error.set(null); + this.saved.set(false); + } + private validWeight(value: number | null | undefined): value is number { + return ( + value != null && + Number.isFinite(value) && + value >= 0.01 && + value <= 100 && + Math.abs(value - Math.round(value * 100) / 100) < 1e-9 + ); + } private errorText(e: HttpErrorResponse): string { if (e.status === 403 || e.status === 401) return "Нет доступа к управлению критериями этой программы."; if (e.status === 409) return userErrorMessage(e.error?.detail, "Критерии изменились. Обновите данные."); return e.status === 400 - ? "Проверьте названия, типы и шкалы критериев. Изменения не сохранены." + ? this.scoringMode() === "weighted_percent" + ? "Проверьте названия, шкалы и веса критериев. Сумма весов должна быть 100%. Изменения не сохранены." + : "Проверьте названия, типы и шкалы критериев. Изменения не сохранены." : "Не удалось загрузить или сохранить критерии. Попробуйте еще раз."; } } diff --git a/projects/social_platform/src/app/ui/pages/program/management/results/results.component.html b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.html index 4a792a36f..22dc11028 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/results/results.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/results/results.component.html @@ -337,14 +337,30 @@

>

} @else if (modal() === "formula") { -

- Итог — среднее сумм баллов экспертов. Учитываются только полные оценки, вес экспертов - одинаковый. -

+ @if (snapshot()?.scoringMode === "weighted_percent") { +

+ Итог кейса — от 0 до 100. Вклад критерия: (балл − минимум) / (максимум − минимум) × вес в + процентах. Итог — среднее полных оценок экспертов. +

+ } @else { +

+ Итог — среднее сумм баллов экспертов. Учитываются только полные оценки, вес экспертов + одинаковый. +

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

{{ sampleFormula() }}

+ @if (snapshot()?.scoringMode === "weighted_percent") { + @for (criterion of numericCriteria(); track criterion.id) { +

+ {{ criterion.name }} · вес {{ criterion.weightPercent }}% · вклад + {{ format(sample()?.contributions?.[criterion.id]) }} из + {{ criterion.weightPercent }} +

+ } + }
}

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 index db19e60a0..38bd2745b 100644 --- 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 @@ -62,6 +62,55 @@ function setup() { return { fixture, component: fixture.componentInstance, api }; } describe("Результаты программы", () => { + it("объясняет процентную формулу и вклад критерия в результат команды", async () => { + const { fixture, component } = setup(); + component.snapshot.set({ + ...snapshot, + scoringMode: "weighted_percent", + maximum: 100, + criteria: [ + { + id: 1, + name: "Качество", + description: null, + type: "int", + minValue: 0, + maxValue: 10, + weightPercent: 100, + }, + ], + groups: [ + { + caseName: 'Кейс "А"', + projects: [ + { + ...project(1, "Команда", 80, 1), + contributions: { "1": 80 }, + experts: [ + { + userId: 1, + name: "Эксперт", + email: "expert@example.test", + avatar: null, + complete: true, + total: 80, + values: [{ criterionId: 1, value: "8" }], + }, + ], + }, + ], + }, + ], + }); + component.openModal("formula"); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise(resolve => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(document.body.textContent).toContain("Итог кейса — от 0 до 100"); + expect(document.body.textContent).toMatch(/вес\s+100%/); + expect(document.body.textContent).toMatch(/вклад\s+80,00/); + }); it("не показывает нулевую оцененность и безбалльное оценивание, когда оценивание не применяется", async () => { const { fixture, component } = setup(); fixture.componentRef.setInput("submissionApplicable", false); 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 index 75c69a637..939c86b33 100644 --- 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 @@ -17,7 +17,7 @@ import { ConnectedPosition } from "@angular/cdk/overlay"; import { PluralizePipe } from "@corelib"; 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 { Observable, Subject, finalize, switchMap, catchError, of, tap, takeUntil } from "rxjs"; import { saveAs } from "file-saver"; import { AlertDirective, @@ -204,7 +204,7 @@ export class ManagementResultsComponent { switchMap(id => { this.loading.set(true); this.error.set(false); - const request = this.solutionMode() + const request: Observable = this.solutionMode() ? this.injector .get(GetSolutionResultsUseCase) .execute(id)