From 2bd0c33bae296446a54a23abadb4cc9d1a7fcf0a Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 00:36:39 +0300 Subject: [PATCH] feat: add organizer evaluation criteria editor and expert preview --- .../use-cases/program-management.use-case.ts | 7 + .../use-cases/rate-project.use-case.ts | 7 +- .../program-management.repository.port.ts | 6 + .../domain/program/program-criteria.model.ts | 23 ++ .../program/program-management.model.ts | 2 +- .../ports/project-rating.repository.port.ts | 6 +- .../src/app/domain/project/project-rate.ts | 1 + .../project/project-rating-criterion-type.ts | 2 +- .../program-management-http.adapter.ts | 10 + .../project/project-rating-http.adapter.ts | 11 +- .../program/program-management.repository.ts | 7 + .../project/project-rating.repository.ts | 10 +- .../range-criterion-input.component.html | 9 +- .../range-criterion-input.component.scss | 68 +---- .../range-criterion-input.component.ts | 34 +-- .../project-rating.component.html | 117 ++++--- .../project-rating.component.scss | 119 ++++---- .../project-rating.component.spec.ts | 41 +++ .../project-rating.component.ts | 63 ++-- .../rating-card/rating-card.component.html | 21 ++ .../list/rating-card/rating-card.component.ts | 5 + .../services/rating-card.service.spec.ts | 66 ++++ .../services/rating-card.service.ts | 127 +++++--- .../criteria/criteria.component.html | 289 ++++++++++++++++++ .../criteria/criteria.component.scss | 194 ++++++++++++ .../criteria/criteria.component.spec.ts | 105 +++++++ .../management/criteria/criteria.component.ts | 274 +++++++++++++++++ .../management/management.component.html | 7 + .../management/management.component.scss | 4 + .../management/management.component.ts | 31 +- .../evaluation-criterion-control.spec.ts | 39 +++ .../app/utils/evaluation-criterion-control.ts | 23 ++ 32 files changed, 1467 insertions(+), 261 deletions(-) create mode 100644 projects/social_platform/src/app/domain/program/program-criteria.model.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.ts create mode 100644 projects/social_platform/src/app/utils/evaluation-criterion-control.spec.ts create mode 100644 projects/social_platform/src/app/utils/evaluation-criterion-control.ts 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 53ff4ed23..aeb0cd6bf 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 { 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"; import { @@ -13,6 +14,12 @@ import { @Injectable() export class ProgramManagementUseCase { private readonly repository = inject(ProgramManagementRepositoryPort); + criteria(id: number) { + return this.repository.criteria(id); + } + saveCriteria(id: number, body: ManagementCriteriaWrite) { + return this.repository.saveCriteria(id, body); + } projectForm(id: number) { return this.repository.projectForm(id); } diff --git a/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts index 0b323babe..523273016 100644 --- a/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts @@ -18,10 +18,15 @@ export class RateProjectUseCase { projectId: number, criteria: ProjectRatingCriterion[], outputVals: Record, + criteriaRevision?: string, ): Observable> { const dto = this.projectRatingRepositoryPort.formValuesToDTO(criteria, outputVals); - return this.projectRatingRepositoryPort.rate(projectId, dto).pipe( + const request = + criteriaRevision === undefined + ? this.projectRatingRepositoryPort.rate(projectId, dto) + : this.projectRatingRepositoryPort.rate(projectId, dto, criteriaRevision); + return request.pipe( tap(() => this.events.emit({ type: "ProgramWidgetChanged", payload: {}, occurredAt: new Date() }), ), 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 2b12c44cd..39a29a9f7 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 { ManagementCriteria, ManagementCriteriaWrite } from "../program-criteria.model"; import { ManagementProjectForm, ManagementProjectFormWrite } from "../program-project-form.model"; import { ManagementEvaluation, @@ -16,6 +17,11 @@ import { } from "../program-management.model"; export abstract class ProgramManagementRepositoryPort { + abstract criteria(programId: number): Observable; + abstract saveCriteria( + programId: number, + body: ManagementCriteriaWrite, + ): Observable; abstract projectForm(programId: number): Observable; abstract saveProjectForm( programId: number, 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 new file mode 100644 index 000000000..bdb5c7684 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-criteria.model.ts @@ -0,0 +1,23 @@ +/** @format */ + +export type CriterionType = "int" | "bool" | "str" | "float"; +export interface ManagementCriterion { + id: number; + name: string; + description: string | null; + type: CriterionType; + minValue: number | null; + maxValue: number | null; +} +export interface ManagementCriteria { + updatedAt: string; + revision: string; + locked: boolean; + supportedTypes: CriterionType[]; + criteria: ManagementCriterion[]; +} +export interface ManagementCriteriaWrite { + expectedUpdatedAt: string; + expectedRevision: string; + criteria: (Omit & { id: number | null })[]; +} 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 ac6e15337..7b1781939 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 @@ -8,7 +8,7 @@ export type ManagementSection = | "analytics" | "settings" | "evaluation"; -export type SettingsSection = "basic" | "dates" | "materials" | "project-form"; +export type SettingsSection = "basic" | "dates" | "materials" | "project-form" | "criteria"; export interface ManagementMaterial { id: number; title: string; diff --git a/projects/social_platform/src/app/domain/project/ports/project-rating.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-rating.repository.port.ts index 7ff2857d0..ef6e08057 100644 --- a/projects/social_platform/src/app/domain/project/ports/project-rating.repository.port.ts +++ b/projects/social_platform/src/app/domain/project/ports/project-rating.repository.port.ts @@ -17,7 +17,11 @@ export abstract class ProjectRatingRepositoryPort { params?: HttpParams, ): Observable>; - abstract rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable; + abstract rate( + projectId: number, + scores: ProjectRatingCriterionOutput[], + criteriaRevision?: string, + ): Observable; abstract formValuesToDTO( criteria: ProjectRatingCriterion[], diff --git a/projects/social_platform/src/app/domain/project/project-rate.ts b/projects/social_platform/src/app/domain/project/project-rate.ts index 8ce88f912..d6091f2ec 100644 --- a/projects/social_platform/src/app/domain/project/project-rate.ts +++ b/projects/social_platform/src/app/domain/project/project-rate.ts @@ -5,6 +5,7 @@ import { ProjectRatingCriterion } from "./project-rating-criterion"; // Assuming /** Интерфейс проекта для оценки */ export interface ProjectRate { + criteriaRevision?: string; id: number; name: string; leader: number; diff --git a/projects/social_platform/src/app/domain/project/project-rating-criterion-type.ts b/projects/social_platform/src/app/domain/project/project-rating-criterion-type.ts index 1781f9c61..3eda00bd0 100644 --- a/projects/social_platform/src/app/domain/project/project-rating-criterion-type.ts +++ b/projects/social_platform/src/app/domain/project/project-rating-criterion-type.ts @@ -1,4 +1,4 @@ /** @format */ /** Тип критерия оценки проекта */ -export type ProjectRatingCriterionType = "bool" | "int" | "str"; +export type ProjectRatingCriterionType = "bool" | "int" | "str" | "float"; 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 9430f2891..b6b9e2260 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,10 @@ import { inject, Injectable } from "@angular/core"; import { HttpParams } from "@angular/common/http"; import { ApiService } from "@corelib"; +import { + ManagementCriteria, + ManagementCriteriaWrite, +} from "@domain/program/program-criteria.model"; import { ManagementProjectForm, ManagementProjectFormWrite, @@ -24,6 +28,12 @@ import { @Injectable() export class ProgramManagementHttpAdapter implements ProgramManagementRepositoryPort { private readonly api = inject(ApiService); + criteria(id: number) { + return this.api.get(`${this.root(id)}/criteria/`); + } + saveCriteria(id: number, body: ManagementCriteriaWrite) { + return this.api.put(`${this.root(id)}/criteria/`, body); + } projectForm(id: number) { return this.api.get(`${this.root(id)}/project-form/`); } diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts index 67332764b..34da3e225 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts @@ -30,7 +30,14 @@ export class ProjectRatingHttpAdapter { return this.apiService.post(url, { filters }); } - rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable { - return this.apiService.post(`${this.RATE_PROJECT_URL}/rate/${projectId}`, scores); + rate( + projectId: number, + scores: ProjectRatingCriterionOutput[], + criteriaRevision?: string, + ): Observable { + return this.apiService.post( + `${this.RATE_PROJECT_URL}/rate/${projectId}`, + criteriaRevision === undefined ? scores : { expectedRevision: criteriaRevision, scores }, + ); } } 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 6090468b9..fa092d0f6 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 { ManagementCriteriaWrite } from "@domain/program/program-criteria.model"; import { ManagementProjectFormWrite } from "@domain/program/program-project-form.model"; import { EventBus } from "@domain/shared/event-bus"; import { ProgramManagementRepositoryPort } from "@domain/program/ports/program-management.repository.port"; @@ -18,6 +19,12 @@ import { export class ProgramManagementRepository implements ProgramManagementRepositoryPort { private readonly adapter = inject(ProgramManagementHttpAdapter); private readonly events = inject(EventBus); + criteria(id: number) { + return this.adapter.criteria(id); + } + saveCriteria(id: number, body: ManagementCriteriaWrite) { + return this.adapter.saveCriteria(id, body).pipe(tap(() => this.changed(id))); + } projectForm(id: number) { return this.adapter.projectForm(id); } diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts index 3bd0d0e0d..a6f72f133 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts @@ -27,8 +27,14 @@ export class ProjectRatingRepository implements ProjectRatingRepositoryPort { return this.projectRatingAdapter.postFilters(programId, filters, params); } - rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable { - return this.projectRatingAdapter.rate(projectId, scores); + rate( + projectId: number, + scores: ProjectRatingCriterionOutput[], + criteriaRevision?: string, + ): Observable { + return criteriaRevision === undefined + ? this.projectRatingAdapter.rate(projectId, scores) + : this.projectRatingAdapter.rate(projectId, scores, criteriaRevision); } /* diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.html index 30207abb6..d5bcfd85a 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.html @@ -4,19 +4,18 @@ -  / - {{ max() }} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.scss index 9d620ee46..b51f21d04 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.scss @@ -1,70 +1,18 @@ /** @format */ - -@use "styles/typography"; -@use "styles/responsive"; +@use "styles/ui-foundation"; :host { display: block; + min-width: 0; } -.field { - text-wrap: nowrap; - - span { - color: var(--black); - } - - &__input { - width: 15px; - height: 15px; - color: transparent; - text-align: center; - text-shadow: 0 0 0 var(--black); - background-color: var(--white); - border: 0.5px solid var(--gray); - border-radius: var(--rounded-sm); - outline: none; - transition: all 0.2s; - - &--error { - border-color: var(--red); - } - - &--disabled { - pointer-events: none; - } - - &:focus { - border-color: var(--accent); - - @media screen and (max-width: 999px) { - box-shadow: var(--ui-focus-ring); - } - - @media screen and (min-width: 1000px) { - box-shadow: 0 0 6px rgb(109 40 255 / 30%); - } - } - } -} - -input { - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } +.field__input { + @include ui-foundation.control; - &[type="number"] { - appearance: textfield; - } + width: 100%; + min-height: 44px; } -@media screen and (max-width: 999px) { - @include responsive.apply-mobile { - .field__input { - width: 44px; - height: 44px; - font-size: var(--ui-font-h3); - } - } +.field__input--error { + border-color: var(--red-dark); } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.ts index 664fc7405..7facc331d 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.ts @@ -30,7 +30,10 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; standalone: true, }) export class RangeCriterionInputComponent implements ControlValueAccessor { - readonly max = input(10); + readonly max = input(10); + readonly min = input(0); + readonly step = input(1); + readonly label = input("Оценка"); readonly error = input(false); value!: number | null; @@ -39,7 +42,7 @@ export class RangeCriterionInputComponent implements ControlValueAccessor { onInput(event: Event): void { const target = event.currentTarget as HTMLInputElement; - const value = target.value ? Number.parseInt(target.value) : null; + const value = target.value ? Number(target.value) : null; this.value = value; this.onChange(value); @@ -47,33 +50,28 @@ export class RangeCriterionInputComponent implements ControlValueAccessor { onPaste(event: ClipboardEvent): void { const pasteData = event.clipboardData?.getData("text/plain"); - if (pasteData && pasteData.match(/[^0-9]/)) { + if (pasteData && !Number.isFinite(Number(pasteData))) { event.preventDefault(); } } onKeydown(event: KeyboardEvent): void { - if (["e", "E", "+", "-"].some(char => event.key === char)) { + if ( + ["e", "E", "+"].includes(event.key) || + (event.key === "-" && this.min() !== null && this.min()! >= 0) + ) { event.preventDefault(); } } onBlur(): void { - if (this.value) { - const val = Math.min(this.value, this.max()); - - this.value = val; - this.onChange(val); - } this.onTouch(); } // Методы ControlValueAccessor writeValue(value: number): void { - setTimeout(() => { - this.value = value; - this.cdref.detectChanges(); - }); + this.value = value ?? null; + this.cdref.markForCheck(); } onChange: (value: number | null) => void = () => {}; @@ -92,12 +90,6 @@ export class RangeCriterionInputComponent implements ControlValueAccessor { setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; - } - - moveCursorToEnd(event: FocusEvent) { - const input = event.target as HTMLInputElement; - input.type = "text"; - input.selectionStart = input.selectionEnd = input.value.length; - input.type = "number"; + this.cdref.markForCheck(); } } 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 120d34e2e..0af485057 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 @@ -1,51 +1,78 @@ -
-
- @for (criterion of criteria; track $index) { - @if (criterion.type === "int") { -
- + @for (criterion of criteria; track criterion.id) { +
+

{{ criterion.name }}

+ @if (criterion.type === "int" || criterion.type === "float") { + +

+ {{ criterion.description }} + {{ criterion.type === "int" ? "Целые баллы" : "Дробные баллы" }} · + {{ criterion.minValue === null ? "без минимума" : "от " + criterion.minValue + }}{{ criterion.maxValue === null ? ", без максимума" : " до " + criterion.maxValue }} +

+ } @else if (criterion.type === "bool") { + @if (criterion.description) { +

{{ criterion.description }}

+ } +
+
- } - @if (criterion.type === "bool") { -
- + Да + +
+

«Нет» — заполненный ответ.

+ } @else { + @if (criterion.description) { +

{{ criterion.description }}

+ } + +

+ {{ form.get(criterion.id.toString())?.value?.length || 0 }} / 50 · необязательно +

} - } -
-
- @for (criterion of criteria; track $index) { - @if (criterion.type === "str") { -
- - - @if (form.get(criterion.id.toString())?.errors?.["maxlength"]) { -
- {{ errorMessage.VALIDATION_TOO_LONG }} - максимум - {{ form.get(criterion.id.toString())?.errors?.["maxlength"]?.["requiredLength"] }} - символов -
- } -
+ @if ( + form.get(criterion.id.toString())?.invalid && form.get(criterion.id.toString())?.touched + ) { + } - } -
- +
+ } @empty { +

Организатор ещё не настроил критерии.

+ } + diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.scss index 73a299c5b..146d1763d 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.scss @@ -1,63 +1,74 @@ /** @format */ - -@use "styles/typography"; -@use "styles/responsive"; +@use "styles/ui-foundation"; :host { + display: block; width: 100%; + min-width: 0; } -.rating { +.ui-field { + @include ui-foundation.control; + width: 100%; +} + +.ui-button { + @include ui-foundation.button; + + min-width: 96px; +} + +.rating__boolean { + display: flex; + gap: 12px; +} + +.rating__boolean .ui-button { + width: 96px; + min-width: 96px; +} + +.rating__counter { + text-align: right; +} + +.rating { + display: grid; + gap: 20px; + font-size: 12px; + line-height: 1.5; +} + +.rating__criterion { + display: grid; + gap: 8px; + min-width: 0; +} + +h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + overflow-wrap: anywhere; +} + +p { + margin: 0; + overflow-wrap: anywhere; +} + +.rating__description, +.rating__hint { + color: var(--grey-for-text); +} + +.rating__error { + color: var(--red-dark); +} - &__columns { - display: flex; - flex-direction: column; - } - - &__field { - display: flex; - gap: 10px; - align-items: center; - - label { - margin-top: 5px; - color: var(--grey-for-text); - } - } - - &__input { - grid-column: 2/3; - } - - &__comment { - margin-top: 25px; - - app-textarea { - ::ng-deep .field__input { - min-height: 66px !important; - color: var(--grey-for-text) !important; - background-color: transparent; - border-color: var(--grey-button); - - @include typography.body-10; - } - } - } -} - -@include responsive.apply-mobile { - .rating__input { - flex-shrink: 0; - } - - .rating__field label { - min-width: 0; - font-size: var(--ui-font-body); - line-height: 20px; - } - - .rating .rating__comment app-textarea ::ng-deep .field__input { - font-size: var(--ui-font-h3) !important; - } +.rating textarea.ui-field { + min-height: 162px; + resize: vertical; + border-radius: 15px; } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.spec.ts new file mode 100644 index 000000000..aacde357e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.spec.ts @@ -0,0 +1,41 @@ +/** @format */ +import { provideZonelessChangeDetection } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { ProjectRatingComponent } from "./project-rating.component"; +import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; + +describe("Shared expert form lifecycle", () => { + it("publishes zero and default No after replacing criteria, validates the new scale", async () => { + await TestBed.configureTestingModule({ + imports: [ProjectRatingComponent], + providers: [provideZonelessChangeDetection()], + }).compileComponents(); + const fixture = TestBed.createComponent(ProjectRatingComponent); + const component = fixture.componentInstance; + const changed = vi.fn(); + component.registerOnChange(changed); + const points: ProjectRatingCriterion = { + id: 1, + name: "Баллы", + description: "", + type: "int", + minValue: 0, + maxValue: 10, + value: 0, + expertId: 0, + }; + fixture.componentRef.setInput("criteria", [points]); + await fixture.whenStable(); + expect(changed).toHaveBeenLastCalledWith({ 1: 0 }); + component.writeValue({ 1: 0 }); + fixture.componentRef.setInput("criteria", [ + { ...points, minValue: 1 }, + { ...points, id: 2, name: "Новый", type: "bool", value: "" }, + ]); + await fixture.whenStable(); + expect(changed).toHaveBeenLastCalledWith({ 1: 0, 2: false }); + expect(component.form.invalid).toBe(true); + component.form.get("1")?.setValue(1); + expect(component.form.valid).toBe(true); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.ts index 7b10668e6..907004509 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.ts @@ -1,8 +1,5 @@ /** @format */ -import { FormLayoutDirective } from "@uilib"; -/** @format */ - import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, @@ -21,28 +18,25 @@ import { NG_VALUE_ACCESSOR, ReactiveFormsModule, Validator, - Validators, ValidationErrors, AbstractControl, } from "@angular/forms"; -import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; import { noop, Subscription } from "rxjs"; -import { BooleanCriterionComponent } from "./components/boolean-criterion/boolean-criterion.component"; import { RangeCriterionInputComponent } from "./components/range-criterion-input/range-criterion-input.component"; -import { ControlErrorPipe } from "@corelib"; import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { evaluationCriterionControl } from "@utils/evaluation-criterion-control"; +import { ButtonDirective, FieldDirective } from "@uilib"; /** Оценка проекта по числовым, булевым и текстовым критериям через ControlValueAccessor. */ @Component({ selector: "app-project-rating", imports: [ - FormLayoutDirective, CommonModule, - TextareaComponent, RangeCriterionInputComponent, - BooleanCriterionComponent, ReactiveFormsModule, + FieldDirective, + ButtonDirective, ], templateUrl: "./project-rating.component.html", styleUrl: "./project-rating.component.scss", @@ -67,6 +61,10 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, this._criteria.set(val); this.createFormControls(val); this.trackFormValueChange(); + queueMicrotask(() => { + this.onChange(this.form.getRawValue()); + this.validatorChanged(); + }); } get criteria(): ProjectRatingCriterion[] { @@ -77,15 +75,16 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, set disabled(value: boolean) { this._disabled = value; if (this.form) { - value ? this.form.disable() : this.form.enable(); + this.disabled ? this.form.disable() : this.form.enable(); } } get disabled(): boolean { - return this._disabled; + return this._disabled || this.controlDisabled; } private _disabled = false; + private controlDisabled = false; readonly currentUserId = input(); @@ -94,19 +93,11 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, /** Форма для управления всеми критериями оценки */ form!: FormGroup; + private pendingValue: Record | null = null; + private validatorChanged: () => void = noop; errorMessage = ErrorMessage; - /** Creates FormControls per criterion type. */ - controlCreators: Record FormControl> = { - // Числовой критерий - обязательное поле - int: val => new FormControl(val, [Validators.required]), - // Булевый критерий - преобразование строки в boolean - bool: val => new FormControl(val ? JSON.parse((val as string).toLowerCase()) : false), - // Строковый критерий - без валидации (комментарии опциональны) - str: val => new FormControl(val, Validators.maxLength(50)), - }; - /** Сигнал для хранения подписок */ subscriptions$ = signal([]); @@ -114,13 +105,15 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, onTouched: () => void = noop; writeValue(val: typeof this.form.value): void { - if (val) { - this.form.patchValue(val); - } + this.pendingValue = val ?? null; + if (this.form && val) this.form.patchValue(val, { emitEvent: false }); } registerOnChange(fn: (v: unknown) => void): void { this.onChange = fn; + queueMicrotask(() => { + if (this.form) this.onChange(this.form.getRawValue()); + }); } registerOnTouched(fn: () => void): void { @@ -130,7 +123,7 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, validate(_: AbstractControl): ValidationErrors | null { let output: ValidationErrors | null = null; - if (this.form.invalid) { + if (this.form?.invalid) { // Проверка каждого контрола на наличие ошибок\ Object.values(this.form.controls).forEach(control => { if (control.errors !== null) { @@ -141,28 +134,42 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, return output; } + registerOnValidatorChange(fn: () => void): void { + this.validatorChanged = fn; + } + + setDisabledState(disabled: boolean): void { + this.controlDisabled = disabled; + if (this.form) this.disabled ? this.form.disable() : this.form.enable(); + } + ngOnDestroy(): void { this.subscriptions$().forEach(subscription => subscription.unsubscribe()); } private createFormControls(criteria: ProjectRatingCriterion[]): void { + this.subscriptions$().forEach(subscription => subscription.unsubscribe()); + this.subscriptions$.set([]); const formGroupControls: Record = {}; criteria.forEach(criterion => { - const controlCreator = this.controlCreators[criterion.type]; - formGroupControls[criterion.id] = controlCreator(criterion.value); + formGroupControls[criterion.id] = evaluationCriterionControl(criterion); }); this.form = new FormGroup(formGroupControls); + if (this.pendingValue) this.form.patchValue(this.pendingValue, { emitEvent: false }); if (this.disabled) { this.form.disable(); } + this.validatorChanged(); } private trackFormValueChange(): void { const trackChanged$ = this.form.valueChanges.subscribe(val => { this.onChange(val); + this.onTouched(); + this.validatorChanged(); }); this.subscriptions$().push(trackChanged$); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.html index 4480966bf..7c9aa2bd6 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.html @@ -82,6 +82,27 @@ {{ error }} } + @if (ratingError()) { + + } + @if (retainedAnswers().length) { +
+ Ответы до обновления критериев + @for (answer of retainedAnswers(); track $index) { +

{{ answer.name }}: {{ answer.value }}

+ } +
+ } @if (showRatingForm() || showRatedStatus() || showConfirmedState()) { diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.ts index dffbd52d8..181d9a399 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.ts @@ -78,6 +78,11 @@ export class RatingCardComponent implements AfterViewInit { protected readonly ratedCount = this.ratingCardService.ratedCount; protected readonly isProjectCriterias = this.ratingCardService.isProjectCriterias; protected readonly form = this.ratingCardService.form; + protected readonly ratingError = this.ratingCardService.ratingError; + protected readonly criteriaChanged = this.ratingCardService.criteriaChanged; + protected readonly refreshingCriteria = this.ratingCardService.refreshingCriteria; + protected readonly retainedAnswers = this.ratingCardService.retainedAnswers; + protected readonly refreshCriteria = () => this.ratingCardService.refreshCriteria(); protected readonly profile = this.ratingCardService.profile; protected readonly evaluationDateExpired = this.ratingCardService.evaluationDateExpired; protected readonly isRatingFormDisabled = this.ratingCardService.isRatingFormDisabled; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.spec.ts index 0eeef9130..6929f0212 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.spec.ts @@ -1,8 +1,10 @@ /** @format */ import { signal } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; import { TestBed } from "@angular/core/testing"; import { RateProjectUseCase } from "@api/program/use-cases/rate-project.use-case"; +import { GetProjectRatingsUseCase } from "@api/program/use-cases/get-project-ratings.use-case"; import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; import { LoggerService } from "@core/lib/services/logger/logger.service"; @@ -18,6 +20,7 @@ describe("RatingCardService evaluation modes", () => { const past = "2026-09-01T00:00:00Z"; const future = "2026-09-30T00:00:00Z"; const execute = vi.fn(); + const refresh = vi.fn(); let service: RatingCardService; let programUI: ProgramDetailMainUIInfoService; @@ -56,6 +59,7 @@ describe("RatingCardService evaluation modes", () => { TestBed.configureTestingModule({ providers: [ RatingCardService, + { provide: GetProjectRatingsUseCase, useValue: { execute: refresh } }, { provide: RateProjectUseCase, useValue: { execute } }, ProgramDetailMainUIInfoService, { provide: ProfileInfoService, useValue: { profile: signal(user) } }, @@ -130,6 +134,60 @@ describe("RatingCardService evaluation modes", () => { expect(service.rateButtonText()).toBe("проект оценён"); }); + it("rejects stale form, retains zero/No and removed answers, and refreshes revision before retry", () => { + const criterion = { + id: 1, + name: "Баллы", + description: "", + type: "int" as const, + minValue: 0, + maxValue: 10, + value: "", + expertId: 0, + }; + const old = { + ...project(), + criteriaRevision: "old", + criterias: [ + criterion, + { ...criterion, id: 2, name: "Да/нет", type: "bool" as const }, + { ...criterion, id: 3, name: "Удалённый", type: "str" as const }, + ], + }; + service.initProject(old); + service.form().setValue({ 1: 0, 2: false, 3: "Сохраните этот ответ" }); + execute.mockReturnValueOnce( + of(fail({ kind: "rate_project_error", cause: new HttpErrorResponse({ status: 409 }) })), + ); + service.confirmRateProject(); + expect(execute).toHaveBeenCalledWith( + 12, + old.criterias, + { 1: 0, 2: false, 3: "Сохраните этот ответ" }, + "old", + ); + expect(service.criteriaChanged()).toBe(true); + expect(service.projectConfirmed()).toBe(false); + refresh.mockReturnValueOnce( + of( + ok({ + count: 1, + next: "", + previous: "", + results: [{ ...old, criteriaRevision: "new", criterias: old.criterias.slice(0, 2) }], + }), + ), + ); + service.refreshCriteria(); + expect(service.criteriaChanged()).toBe(false); + expect(service.project()?.criteriaRevision).toBe("new"); + expect(service.form().getRawValue()).toEqual({ 1: 0, 2: false }); + expect(service.retainedAnswers()).toContainEqual({ + name: "Удалённый", + value: "Сохраните этот ответ", + }); + }); + it("preserves rating-limit restrictions independently of the evaluation deadline", () => { service.initProject({ ...project(), ratedCount: 1 }); expect(service.rateButtonText()).toBe("лимит оценок достигнут"); @@ -139,6 +197,14 @@ describe("RatingCardService evaluation modes", () => { expect(service.canOpenModal()).toBe(false); }); + it("does not submit an empty configured criterion set", () => { + service.initProject({ ...project(), criteriaRevision: "empty" }); + expect(service.rateButtonText()).toBe("критерии не настроены"); + expect(service.isButtonDisabled()).toBe(true); + service.confirmRateProject(); + expect(execute).not.toHaveBeenCalled(); + }); + it.each([false, true])( "keeps a disabled CTA and readonly criteria after evaluation ends (rated=%s)", rated => { diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.ts index baf3801c6..8fcbe15c2 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.ts @@ -2,11 +2,12 @@ import { computed, inject, Injectable, signal } from "@angular/core"; import { RateProjectUseCase } from "@api/program/use-cases/rate-project.use-case"; +import { GetProjectRatingsUseCase } from "@api/program/use-cases/get-project-ratings.use-case"; import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; import { ProjectRate } from "@domain/project/project-rate"; import { LoggerService } from "@core/lib/services/logger/logger.service"; -import { HttpResponse } from "@angular/common/http"; +import { HttpErrorResponse, HttpParams } from "@angular/common/http"; import { finalize } from "rxjs"; import { FormControl } from "@angular/forms"; @@ -14,6 +15,7 @@ import { FormControl } from "@angular/forms"; @Injectable() export class RatingCardService { private readonly rateProjectUseCase = inject(RateProjectUseCase); + private readonly getRatings = inject(GetProjectRatingsUseCase); private readonly programDetailMainUIInfoService = inject(ProgramDetailMainUIInfoService); private readonly profileInfoService = inject(ProfileInfoService); private readonly logger = inject(LoggerService); @@ -32,6 +34,10 @@ export class RatingCardService { readonly projectRated = signal(false); readonly projectConfirmed = signal(false); readonly ratedCount = signal(0); + readonly ratingError = signal(null); + readonly criteriaChanged = signal(false); + readonly refreshingCriteria = signal(false); + readonly retainedAnswers = signal<{ name: string; value: string }[]>([]); readonly isProjectCriterias = computed(() => { const p = this.project(); @@ -72,6 +78,8 @@ export class RatingCardService { readonly canEdit = computed(() => !this.evaluationDateExpired()); readonly canRate = computed(() => { + const project = this.project(); + if (project?.criteriaRevision && !project.criterias.length) return false; if (this.evaluationDateExpired()) return false; if (this.isLimitReached() && !this.userRatedThisProject()) return false; return true; @@ -84,6 +92,8 @@ export class RatingCardService { readonly rateButtonText = computed(() => { if (this.evaluationDateExpired()) return "оценивание завершено"; + if (this.project()?.criteriaRevision && !this.project()?.criterias.length) + return "критерии не настроены"; if (this.projectConfirmed() && this.userRatedThisProject()) return "проект оценён"; if (this.isLimitReached() && !this.userRatedThisProject()) return "лимит оценок достигнут"; if (this.userRatedThisProject()) return "подтвердить изменения"; @@ -142,53 +152,98 @@ export class RatingCardService { /** Подтверждение оценки проекта. */ confirmRateProject(): void { - if (this.evaluationDateExpired()) return; + if (!this.canRate() || this.submitLoading() || this.form().invalid || this.criteriaChanged()) + return; const fv = this.form().getRawValue(); const p = this.project() as ProjectRate; this.submitLoading.set(true); + this.ratingError.set(null); + + const request = + p.criteriaRevision === undefined + ? this.rateProjectUseCase.execute(p.id, p.criterias, fv) + : this.rateProjectUseCase.execute(p.id, p.criterias, fv, p.criteriaRevision); + request.pipe(finalize(() => this.submitLoading.set(false))).subscribe({ + next: result => { + if (!result.ok) { + const cause = result.error.cause; + const stale = cause instanceof HttpErrorResponse && cause.status === 409; + this.criteriaChanged.set(stale); + this.ratingError.set( + stale + ? "Критерии изменились. Обновите форму: введённые ответы сохранятся для сравнения." + : "Не удалось сохранить оценку. Проверьте ответы и повторите попытку.", + ); + this.showConfirmRateModal.set(false); + return; + } - this.rateProjectUseCase - .execute(p.id, p.criterias, fv) - .pipe(finalize(() => this.submitLoading.set(false))) - .subscribe({ - next: result => { - if (!result.ok) { - if (result.error.cause instanceof HttpResponse) { - if (result.error.cause.status === 400) { - this.logger.error("Ошибка: достигнут максимальный лимит оценок"); - } - } - return; - } - - const profile = this.profile(); - const proj = this.project() as ProjectRate; - - this.locallyRatedByCurrentUser.set(true); - this.projectRated.set(true); - this.projectConfirmed.set(true); + const profile = this.profile(); + const proj = this.project() as ProjectRate; - let isFirstTimeRating = false; + this.locallyRatedByCurrentUser.set(true); + this.projectRated.set(true); + this.projectConfirmed.set(true); - if (profile) { - if (!Array.isArray(proj.ratedExperts)) { - proj.ratedExperts = []; - } + let isFirstTimeRating = false; - if (!proj.ratedExperts.some(user => user.id === profile.id)) { - proj.ratedExperts = [...proj.ratedExperts, profile]; - isFirstTimeRating = true; - } + if (profile) { + if (!Array.isArray(proj.ratedExperts)) { + proj.ratedExperts = []; } - if (isFirstTimeRating) { - this.ratedCount.update(count => count + 1); + if (!proj.ratedExperts.some(user => user.id === profile.id)) { + proj.ratedExperts = [...proj.ratedExperts, profile]; + isFirstTimeRating = true; } + } - this.project.set({ ...proj }); - this.showConfirmRateModal.set(false); - }, + if (isFirstTimeRating) { + this.ratedCount.update(count => count + 1); + } + + this.project.set({ ...proj }); + this.showConfirmRateModal.set(false); + }, + }); + } + + refreshCriteria(): void { + const project = this.project(), + programId = this.program()?.id; + if (!project || !programId || this.refreshingCriteria()) return; + const answers = this.form().getRawValue() ?? {}; + this.retainedAnswers.set( + project.criterias.map(c => ({ + name: c.name, + value: String(answers[c.id] ?? c.value ?? "—"), + })), + ); + this.refreshingCriteria.set(true); + this.getRatings + .execute(programId, new HttpParams().set("project_id", project.id)) + .pipe(finalize(() => this.refreshingCriteria.set(false))) + .subscribe(result => { + if (this.project()?.id !== project.id || this.program()?.id !== programId) return; + const fresh = result.ok ? result.value.results.find(p => p.id === project.id) : null; + if (!fresh) { + this.ratingError.set("Не удалось обновить критерии. Введённые ответы сохранены в форме."); + return; + } + const values: Record = {}; + fresh.criterias = fresh.criterias.map(c => { + const previous = project.criterias.find(old => old.id === c.id && old.type === c.type); + if (previous && Object.prototype.hasOwnProperty.call(answers, c.id)) + values[c.id] = answers[c.id]; + return c; + }); + this.form().setValue(values); + this.project.set(fresh); + this.criteriaChanged.set(false); + this.ratingError.set( + "Форма обновлена. Проверьте ответы по новым формулировкам и шкалам перед сохранением.", + ); }); } 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 new file mode 100644 index 000000000..ccde622c4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.html @@ -0,0 +1,289 @@ + +
+ @if (loading()) { +
+ Загружаем критерии… +
+ } @else { + @if (error(); as message) { +
+ {{ message }} + +
+ } + @if (saved()) { +

Критерии сохранены.

+ } + @if (snapshot()) { +
+
+

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

+

Один набор для всех кейсов программы.

+
+ +
+ @if (locked()) { +
+ Критерии зафиксированы +

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

+
+ } +
+

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

+

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

+ + @for (criterion of criteria(); track criterion.id) { +
+
+

{{ criterion.name }}

+

{{ summary(criterion) }}

+ @if (criterion.description) { +

{{ criterion.description }}

+ } +
+ @if (!locked()) { +
+ +
+ } +
+ } @empty { +

+ Критериев пока нет. Добавьте критерий, чтобы настроить форму эксперта. +

+ } + @if (!locked()) { +

+ После первой сохранённой оценки весь набор критериев будет зафиксирован. +

+ } +
+ @if (!locked()) { +
+

+ {{ dirty() ? "Есть несохранённые изменения" : "Нет несохранённых изменений" }} +

+
+ +
+
+ } + } + } +
+ + +
+
+

+ {{ editing() === "new" ? "Добавить критерий" : "Изменить критерий" }} +

+ +
+ @if (editorError()) { +

{{ editorError() }}

+ } + + + + @if (editorType() === "int") { +
+ +
+ } +
+

+ После {{ editing() === "new" ? "добавления" : "применения" }} сохраните изменения на + странице критериев. +

+
+ +
+
+
+
+ +
+
+

Предпросмотр формы эксперта

+ +
+

Так эксперт увидит критерии. Ответы в предпросмотре не сохраняются и не фиксируют набор.

+ +
+

+ Целые баллы проверяются по заданной шкале. Ноль и «Нет» — допустимые ответы. Комментарий — + до 50 символов. +

+ +
+
+
+ +
+

Удалить критерий?

+

«{{ deleting()?.name }}» будет удалён после сохранения изменений.

+
+ +
+
+
+ +
+

Появилась первая оценка

+

+ Пока вы редактировали критерии, эксперт сохранил оценку. Набор зафиксирован, ваши изменения не + применены. +

+
+ Несохранённый набор + @for (c of criteria(); track c.id) { +

{{ c.name }} · {{ summary(c) }}

+ } +
+

При переходе к действующим критериям этот черновик будет закрыт.

+
+ +
+
+
diff --git a/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.scss b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.scss new file mode 100644 index 000000000..85cb4b9c6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.scss @@ -0,0 +1,194 @@ +/** @format */ +@use "styles/ui-foundation"; + +:host { + display: block; + min-width: 0; +} + +.ui-button { + @include ui-foundation.button; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; +} + +.criteria, +.criteria__dialog { + display: grid; + gap: 20px; + min-width: 0; + font-size: 12px; + line-height: 1.5; + color: var(--black); +} + +.criteria__dialog { + width: 100%; +} + +h2, +h3, +h4 { + margin: 0; + overflow-wrap: anywhere; +} + +h2, +h3 { + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +h4 { + font-size: 14px; + font-weight: 400; +} + +p { + margin: 6px 0 0; + color: var(--grey-for-text); + overflow-wrap: anywhere; +} + +.criteria__heading, +.criteria__row, +.criteria__actions { + display: flex; + gap: 20px; + align-items: center; + justify-content: space-between; +} + +.criteria__heading { + align-items: flex-start; +} + +.criteria__heading > div, +.criteria__heading h2, +.criteria__row > div { + min-width: 0; +} + +.criteria__heading button, +.criteria__row > .criteria__actions { + flex-shrink: 0; +} + +.criteria__card { + padding: 24px; + background: var(--light-white); + border-radius: 15px; +} + +.criteria__add { + margin: 16px 0; +} + +.criteria__row { + padding: 12px 0; + border-bottom: 1px solid var(--medium-grey-for-outline); +} + +.criteria__actions { + flex-wrap: wrap; + gap: 12px; + justify-content: flex-start; +} + +.criteria__quiet.ui-button { + color: var(--black); + background: var(--grey-button); + border-color: transparent; +} + +.criteria__footer { + display: grid; + gap: 12px; +} + +.criteria__range { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.criteria__dialog label { + display: grid; + gap: 8px; + min-width: 0; +} + +.criteria__dialog textarea { + min-height: 162px; + resize: vertical; + border-radius: 15px; +} + +.criteria__dialog footer p { + margin: 0 0 20px; +} + +.criteria__notice { + margin-top: 16px; +} + +.criteria__empty { + padding: 20px 0; +} + +.criteria__note, +.ui-alert--error, +.ui-alert--success { + padding: 16px; + background: var(--light-gray); + border-radius: 12px; +} + +.ui-alert--error { + color: var(--red-dark); + background: var(--red-light); +} + +.ui-alert--success { + color: var(--green-dark); + background: var(--green-light); +} + +// The shared overlay is attached outside this component's host. +::ng-deep .modal .modal__body.criteria-modal { + width: 648px; + max-width: calc(100vw - 40px); +} + +@media (max-width: 650px) { + .criteria > .criteria__heading, + .criteria__row { + flex-direction: column; + gap: 12px; + align-items: flex-start; + } + + .criteria__card { + padding: 16px; + } + + .criteria__dialog .criteria__heading { + gap: 12px; + align-items: flex-start; + } + + .criteria__footer .criteria__actions, + .criteria__dialog footer .criteria__actions { + flex-direction: column; + align-items: stretch; + } + + ::ng-deep .modal .modal__body.criteria-modal { + padding: 20px; + } +} 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 new file mode 100644 index 000000000..16c6c6890 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.spec.ts @@ -0,0 +1,105 @@ +/** @format */ +import { provideZonelessChangeDetection } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; +import { of, Subject, throwError } from "rxjs"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { ManagementCriteria } from "@domain/program/program-criteria.model"; +import { ManagementCriteriaComponent } from "./criteria.component"; + +const initial = (): ManagementCriteria => ({ + updatedAt: "2026-10-18T00:00:00Z", + revision: "first", + locked: false, + supportedTypes: ["int", "bool", "str"], + criteria: [ + { + id: 1, + name: "Качество", + description: "Насколько продумано решение", + type: "int", + minValue: 0, + maxValue: 10, + }, + ], +}); +describe("Organizer evaluation criteria", () => { + const api = { criteria: vi.fn(), saveCriteria: vi.fn() }; + async function setup() { + const pending = new Subject(); + api.criteria.mockReset().mockReturnValue(pending); + api.saveCriteria.mockReset(); + await TestBed.configureTestingModule({ + imports: [ManagementCriteriaComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: ProgramManagementUseCase, useValue: api }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ManagementCriteriaComponent); + fixture.componentRef.setInput("programId", 8); + fixture.componentRef.setInput("confirmDiscard", () => Promise.resolve(true)); + await fixture.whenStable(); + pending.next(initial()); + await fixture.whenStable(); + return { fixture, component: fixture.componentInstance, pending }; + } + it("applies centered editor locally, then explicitly saves the complete revision", async () => { + const { component } = await setup(); + component.edit(); + component.editor.patchValue({ name: "Соответствует кейсу", type: "bool" }); + component.apply(); + expect(component.criteria()[1]).toMatchObject({ type: "bool", minValue: null, maxValue: null }); + expect(api.saveCriteria).not.toHaveBeenCalled(); + api.saveCriteria.mockReturnValue( + of({ ...initial(), revision: "second", criteria: component.criteria() }), + ); + component.save(); + expect(api.saveCriteria).toHaveBeenCalledWith( + 8, + expect.objectContaining({ + expectedRevision: "first", + criteria: [expect.anything(), expect.objectContaining({ id: null })], + }), + ); + expect(component.dirty()).toBe(false); + }); + it("rejects invalid integer range and keeps editor inputs", async () => { + const { component } = await setup(); + component.edit(); + component.editor.patchValue({ name: "Шкала", minValue: 4, maxValue: 2 }); + component.apply(); + expect(component.editorError()).toBeTruthy(); + expect(component.editing()).toBe("new"); + component.editor.patchValue({ minValue: 0.5, maxValue: 10 }); + component.apply(); + expect(component.criteria().length).toBe(1); + }); + it("first score conflict keeps draft and freezes all mutations", async () => { + const { component } = await setup(); + component.edit(); + component.editor.controls.name.setValue("Новый"); + component.apply(); + api.saveCriteria.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 409, error: { code: "criteria_locked" } })), + ); + component.save(); + expect(component.locked()).toBe(true); + expect(component.criteria().length).toBe(2); + expect(component.conflictOpen()).toBe(true); + component.edit(); + expect(component.editing()).toBeNull(); + component.discard(); + expect(component.locked()).toBe(true); + }); + it("locked snapshot stays readable; preview makes no API writes", async () => { + const { fixture, component, pending } = await setup(); + pending.next({ ...initial(), locked: true }); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain("Критерии зафиксированы"); + expect(fixture.nativeElement.querySelector(".criteria__row button")).toBeNull(); + component.openPreview(); + expect(component.previewCriteria()[0].name).toBe("Качество"); + expect(api.saveCriteria).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 000000000..bad14ffb1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/criteria/criteria.component.ts @@ -0,0 +1,274 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal, + untracked, +} from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { + CriterionType, + ManagementCriteria, + ManagementCriteriaWrite, + ManagementCriterion, +} from "@domain/program/program-criteria.model"; +import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; +import { AlertDirective, ButtonDirective, FieldDirective } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ProjectRatingComponent } from "../../detail/list/rating-card/project-rating/project-rating.component"; + +@Component({ + selector: "app-management-criteria", + imports: [ + ReactiveFormsModule, + AlertDirective, + ButtonDirective, + FieldDirective, + ModalComponent, + ProjectRatingComponent, + ], + templateUrl: "./criteria.component.html", + styleUrl: "./criteria.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManagementCriteriaComponent { + readonly programId = input.required(); + readonly confirmDiscard = input.required<() => Promise>(); + readonly statusChange = output<{ dirty: boolean; busy: boolean }>(); + readonly savedAt = output(); + private readonly api = inject(ProgramManagementUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly fb = inject(FormBuilder).nonNullable; + readonly snapshot = signal(null); + readonly criteria = signal([]); + readonly loading = signal(false); + readonly saving = signal(false); + readonly error = signal(null); + readonly conflict = signal<"criteria_locked" | "stale_criteria" | null>(null); + readonly conflictOpen = signal(false); + readonly saved = signal(false); + readonly editing = signal(null); + readonly editorDirty = signal(false); + readonly editorError = signal(null); + readonly editor = this.fb.group({ + name: ["", [Validators.required, Validators.pattern(/\S/), Validators.maxLength(50)]], + description: ["", Validators.maxLength(4000)], + type: this.fb.control("int"), + minValue: new FormControl(0), + maxValue: new FormControl(10), + }); + readonly editorType = signal("int"); + readonly previewOpen = signal(false); + readonly previewCriteria = signal([]); + readonly previewControl = new FormControl(); + readonly deleting = signal(null); + readonly labels: Record = { + int: "Целые баллы", + bool: "Да / нет", + str: "Текстовый комментарий", + float: "Дробные баллы (существующий критерий)", + }; + readonly locked = computed( + () => !!this.snapshot()?.locked || this.conflict() === "criteria_locked", + ); + readonly dirty = computed(() => { + const original = this.snapshot(); + return ( + !!original && + (JSON.stringify(this.body()) !== JSON.stringify(this.body(original)) || this.editorDirty()) + ); + }); + private nextId = -1; + private request = 0; + + constructor() { + effect(() => { + const id = this.programId(); + untracked(() => this.load(id)); + }); + effect(() => + this.statusChange.emit({ dirty: this.dirty(), busy: this.loading() || this.saving() }), + ); + this.editor.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.editorDirty.set(this.editor.dirty); + this.editorType.set(this.editor.controls.type.value); + }); + } + private body(source?: ManagementCriteria): ManagementCriteriaWrite { + const snapshot = source ?? this.snapshot(); + return { + expectedUpdatedAt: snapshot?.updatedAt ?? "", + expectedRevision: snapshot?.revision ?? "", + criteria: (source?.criteria ?? this.criteria()).map(c => ({ + ...c, + id: c.id > 0 ? c.id : null, + })), + }; + } + load(id = this.programId()): void { + const request = ++this.request; + this.loading.set(true); + this.error.set(null); + this.api + .criteria(id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: snapshot => { + if (request !== this.request) return; + this.snapshot.set(snapshot); + this.discard(); + this.loading.set(false); + }, + error: (e: HttpErrorResponse) => { + if (request !== this.request) return; + this.error.set(this.errorText(e)); + this.loading.set(false); + }, + }); + } + async reload(): Promise { + if (this.saving() || (this.dirty() && !(await this.confirmDiscard()()))) return; + this.load(); + } + discard(): void { + this.criteria.set(this.snapshot()?.criteria.map(c => ({ ...c })) ?? []); + this.editing.set(null); + this.editorDirty.set(false); + this.error.set(null); + this.conflict.set(null); + this.conflictOpen.set(false); + this.saved.set(false); + } + async cancel(): Promise { + if (!this.dirty() || (await this.confirmDiscard()())) this.discard(); + } + edit(criterion?: ManagementCriterion): void { + if (this.locked() || this.saving() || criterion?.type === "float") return; + this.editing.set(criterion ?? "new"); + this.editor.reset({ + name: criterion?.name ?? "", + description: criterion?.description ?? "", + type: criterion?.type ?? "int", + minValue: criterion?.minValue ?? 0, + maxValue: criterion?.maxValue ?? 10, + }); + this.editorType.set(this.editor.controls.type.value); + this.editorDirty.set(false); + this.editorError.set(null); + this.saved.set(false); + } + async closeEditor(): Promise { + if (this.editorDirty() && !(await this.confirmDiscard()())) return; + this.editing.set(null); + this.editorDirty.set(false); + } + apply(): void { + const editing = this.editing(); + if (!editing || this.locked()) return; + const value = this.editor.getRawValue(); + this.editor.markAllAsTouched(); + if (this.editor.invalid) { + this.editorError.set("Укажите название до 50 символов и описание до 4000 символов."); + return; + } + if ( + value.type === "int" && + (value.minValue === null || + value.maxValue === null || + !Number.isSafeInteger(value.minValue) || + !Number.isSafeInteger(value.maxValue) || + value.minValue > value.maxValue) + ) { + this.editorError.set("Укажите целые минимум и максимум. Максимум не меньше минимума."); + return; + } + const criterion: ManagementCriterion = { + ...value, + name: value.name.trim(), + id: editing === "new" ? this.nextId-- : editing.id, + minValue: value.type === "int" ? value.minValue : null, + maxValue: value.type === "int" ? value.maxValue : null, + }; + this.criteria.update(rows => + editing === "new" + ? [...rows, criterion] + : rows.map(c => (c.id === criterion.id ? criterion : c)), + ); + this.editing.set(null); + this.editorDirty.set(false); + } + remove(): void { + const criterion = this.deleting(); + if (!criterion || this.locked() || this.saving()) return; + this.criteria.update(rows => rows.filter(c => c.id !== criterion.id)); + this.deleting.set(null); + this.saved.set(false); + } + save(): void { + if (!this.snapshot() || this.locked() || this.saving() || !this.dirty()) return; + this.saving.set(true); + this.error.set(null); + this.saved.set(false); + this.api + .saveCriteria(this.programId(), this.body()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: data => { + this.snapshot.set(data); + this.discard(); + this.saved.set(true); + this.saving.set(false); + this.savedAt.emit(data.updatedAt); + }, + error: (e: HttpErrorResponse) => { + this.saving.set(false); + this.error.set(this.errorText(e)); + if (e.status === 409) { + const code = e.error?.code === "criteria_locked" ? "criteria_locked" : "stale_criteria"; + this.conflict.set(code); + if (code === "criteria_locked") + this.snapshot.update(s => (s ? { ...s, locked: true } : s)); + this.conflictOpen.set(code === "criteria_locked"); + } + }, + }); + } + openPreview(): void { + this.previewCriteria.set( + this.criteria().map(c => ({ + ...c, + description: c.description ?? "", + value: "", + expertId: 0, + })), + ); + this.previewControl.reset(); + this.previewOpen.set(true); + } + summary(c: ManagementCriterion): string { + return ( + this.labels[c.type] + + (c.type === "int" || c.type === "float" + ? ` · от ${c.minValue ?? "без минимума"} до ${c.maxValue ?? "без максимума"}` + : "") + ); + } + private errorText(e: HttpErrorResponse): string { + if (e.status === 403 || e.status === 401) + return "Нет доступа к управлению критериями этой программы."; + if (e.status === 409) return e.error?.detail ?? "Критерии изменились. Обновите данные."; + return e.status === 400 + ? "Проверьте названия, типы и шкалы критериев. Изменения не сохранены." + : "Не удалось загрузить или сохранить критерии. Попробуйте ещё раз."; + } +} 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 c5212496b..1cb1ea9fc 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 @@ -394,6 +394,13 @@

{{ evaluation.name }}

(savedAt)="projectFormSaved($event)" (materials)="changeQuery({ tab: 'materials' })" /> + } @else if (settingsTab() === "criteria") { + } @else if (settingsTab() === "basic") {

Основная информация

diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.scss b/projects/social_platform/src/app/ui/pages/program/management/management.component.scss index e76a6cac5..44174ce84 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.scss @@ -277,6 +277,10 @@ @media (max-width: 650px) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + + a:last-child { + grid-column: 1 / -1; + } } a { diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts index 95df16710..ac53dcbe2 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts @@ -51,6 +51,7 @@ import { ModalComponent } from "@ui/primitives/modal/modal.component"; import { ProgramAnalyticsComponent } from "../detail/analytics/analytics.component"; import { ProgramBrandingPreviewComponent } from "./branding-preview/branding-preview.component"; import { ManagementProjectFormComponent } from "./project-form/project-form.component"; +import { ManagementCriteriaComponent } from "./criteria/criteria.component"; import { presentationHref } from "@utils/program-case-analytics"; import { evaluationLabel, fromMoscowInput, toMoscowInput } from "@utils/program-management"; import { DEFAULT_IMAGE_FRAME, programImageFraming } from "@utils/program-branding"; @@ -77,6 +78,7 @@ import { ProgramImageFraming, ProgramImageKey } from "@domain/program/program-im ProgramAnalyticsComponent, ProgramBrandingPreviewComponent, ManagementProjectFormComponent, + ManagementCriteriaComponent, ], providers: [ ProgramManagementService, @@ -112,6 +114,8 @@ export class ProgramManagementComponent implements OnInit { readonly saved = signal(false); readonly projectForm = viewChild(ManagementProjectFormComponent); readonly projectFormStatus = signal({ dirty: false, busy: false }); + readonly criteriaForm = viewChild(ManagementCriteriaComponent); + readonly criteriaStatus = signal({ dirty: false, busy: false }); readonly confirmProjectFormDiscard = () => this.confirm( "Есть несохранённые изменения", @@ -146,6 +150,7 @@ export class ProgramManagementComponent implements OnInit { { id: "dates", label: "Сроки" }, { id: "materials", label: "Материалы" }, { id: "project-form", label: "Кейсы и поля" }, + { id: "criteria", label: "Оценивание" }, ]; readonly basicForm = this.fb.group({ name: ["", [Validators.required, Validators.pattern(/\S/)]], @@ -238,7 +243,13 @@ export class ProgramManagementComponent implements OnInit { this.mobile.set(window.innerWidth < 1000); } @HostListener("window:beforeunload", ["$event"]) onBeforeUnload(event: BeforeUnloadEvent): void { - if (this.dirty() || this.saving() || this.uploading() || this.projectFormStatus().busy) { + if ( + this.dirty() || + this.saving() || + this.uploading() || + this.projectFormStatus().busy || + this.criteriaStatus().busy + ) { event.preventDefault(); event.returnValue = ""; } @@ -269,7 +280,9 @@ export class ProgramManagementComponent implements OnInit { const q = this.route.snapshot.queryParamMap; const tab = q.get("tab"); this.settingsTab.set( - tab === "dates" || tab === "materials" || tab === "project-form" ? tab : "basic", + tab === "dates" || tab === "materials" || tab === "project-form" || tab === "criteria" + ? tab + : "basic", ); this.section.set(section); this.formError.set(null); @@ -379,11 +392,18 @@ export class ProgramManagementComponent implements OnInit { this.basicForm.dirty || this.datesForm.dirty || this.materialForm.dirty || - this.projectFormStatus().dirty + this.projectFormStatus().dirty || + this.criteriaStatus().dirty ); } async canLeave(): Promise { - if (this.saving() || this.uploading() || this.projectFormStatus().busy) return false; + if ( + this.saving() || + this.uploading() || + this.projectFormStatus().busy || + this.criteriaStatus().busy + ) + return false; if (!this.dirty()) return true; const yes = await this.confirm( "Есть несохранённые изменения", @@ -398,6 +418,8 @@ export class ProgramManagementComponent implements OnInit { this.editingMaterial.set(null); this.projectForm()?.discard(); this.projectFormStatus.set({ dirty: false, busy: false }); + this.criteriaForm()?.discard(); + this.criteriaStatus.set({ dirty: false, busy: false }); } return yes; } @@ -435,6 +457,7 @@ export class ProgramManagementComponent implements OnInit { !p || section === "materials" || section === "project-form" || + section === "criteria" || this.saving() || this.uploading() ) diff --git a/projects/social_platform/src/app/utils/evaluation-criterion-control.spec.ts b/projects/social_platform/src/app/utils/evaluation-criterion-control.spec.ts new file mode 100644 index 000000000..af39cd959 --- /dev/null +++ b/projects/social_platform/src/app/utils/evaluation-criterion-control.spec.ts @@ -0,0 +1,39 @@ +/** @format */ +import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; +import { evaluationCriterionControl } from "./evaluation-criterion-control"; + +const definition: ProjectRatingCriterion = { + id: 1, + name: "Качество", + description: "", + type: "int", + minValue: 0, + maxValue: 10, + value: "", + expertId: 0, +}; +describe("Shared expert / preview criterion validation", () => { + it("distinguishes empty, zero, fraction, and out-of-range answers", () => { + const control = evaluationCriterionControl(definition); + expect(control.invalid).toBe(true); + control.setValue(0); + expect(control.valid).toBe(true); + for (const value of [0.5, -1, 11, NaN, Infinity]) { + control.setValue(value); + expect(control.invalid).toBe(true); + } + }); + it("supports negative scales, legacy fractions, No, and optional 50-character comments", () => { + expect(evaluationCriterionControl({ ...definition, minValue: -5, value: -2 }).valid).toBe(true); + expect(evaluationCriterionControl({ ...definition, type: "float", value: 0.5 }).valid).toBe( + true, + ); + const no = evaluationCriterionControl({ ...definition, type: "bool", value: "False" }); + expect(no.value).toBe(false); + expect(no.valid).toBe(true); + const comment = evaluationCriterionControl({ ...definition, type: "str" }); + expect(comment.valid).toBe(true); + comment.setValue("a".repeat(51)); + expect(comment.invalid).toBe(true); + }); +}); diff --git a/projects/social_platform/src/app/utils/evaluation-criterion-control.ts b/projects/social_platform/src/app/utils/evaluation-criterion-control.ts new file mode 100644 index 000000000..c02a0e122 --- /dev/null +++ b/projects/social_platform/src/app/utils/evaluation-criterion-control.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { AbstractControl, FormControl, Validators } from "@angular/forms"; +import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; + +/** One validation contract for real expert forms and organizer preview. */ +export function evaluationCriterionControl(c: ProjectRatingCriterion): FormControl { + if (c.type === "bool") return new FormControl(String(c.value).toLowerCase() === "true"); + if (c.type === "str") return new FormControl(c.value ?? "", Validators.maxLength(50)); + const validators = [ + Validators.required, + (control: AbstractControl) => { + if (control.value === null || control.value === "") return null; + const value = Number(control.value); + return !Number.isFinite(value) || (c.type === "int" && !Number.isInteger(value)) + ? { numeric: true } + : null; + }, + ]; + if (c.minValue !== null && c.minValue !== undefined) validators.push(Validators.min(c.minValue)); + if (c.maxValue !== null && c.maxValue !== undefined) validators.push(Validators.max(c.maxValue)); + return new FormControl(c.value === "" || c.value == null ? null : Number(c.value), validators); +}