From e50666d8c1e2f990fba3dbea3b851443b8630573 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Thu, 10 Sep 2026 21:23:15 +0300 Subject: [PATCH] fix: polish production rating and release UI --- docs/modules/program.md | 2 +- .../src/app/domain/project/project-rate.ts | 4 +- .../project/project-rating.repository.spec.ts | 30 +++++++ .../project/project-rating.repository.ts | 24 +++--- .../set-password/set-password.component.html | 60 +++++++------ .../set-password.component.spec.ts | 31 +++++++ .../set-password/set-password.component.ts | 9 +- .../analytics/analytics.component.spec.ts | 2 +- .../detail/analytics/analytics.component.ts | 2 +- .../analytics-drilldown.component.spec.ts | 2 +- .../project-rating.component.spec.ts | 84 ++++++++++++++++++ .../project-rating.component.ts | 41 +++++++-- .../rating-card/rating-card.component.html | 2 +- .../rating-card/rating-card.component.spec.ts | 64 +++++++++++++- .../list/rating-card/rating-card.component.ts | 2 +- .../services/rating-card.service.spec.ts | 75 +++++++++++++++- .../services/rating-card.service.ts | 86 +++++++++---------- .../project-main-step.component.html | 9 -- .../project-main-step.component.scss | 24 +++--- .../project-main-step.component.spec.ts | 18 ++++ .../detail/detail-cleanup.component.spec.ts | 26 ++++++ .../ui/widgets/detail/detail.component.html | 26 +++--- .../ui/widgets/detail/detail.component.scss | 27 ++++++ .../region-select.component.html | 3 + .../region-select.component.scss | 11 +++ .../region-select.component.spec.ts | 11 +-- 26 files changed, 525 insertions(+), 150 deletions(-) create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.spec.ts diff --git a/docs/modules/program.md b/docs/modules/program.md index 5f61bf538..3387b0475 100644 --- a/docs/modules/program.md +++ b/docs/modules/program.md @@ -339,7 +339,7 @@ DI-биндинги (`infrastructure/di/program/`): ## Детализация экспертного оценивания в аналитике На `/office/program/:programId/analytics` организатор открывает назначения из трёх -метрик: «Назначений всего», «Выполнено», «Ожидает». Нулевые метрики остаются видимыми, +метрик: «Назначений», «Выполнено», «Ожидает». Нулевые метрики остаются видимыми, но неактивными. Доступ окончательно проверяет backend. Новых маршрутов нет. ### API и архитектура 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..db89470c5 100644 --- a/projects/social_platform/src/app/domain/project/project-rate.ts +++ b/projects/social_platform/src/app/domain/project/project-rate.ts @@ -1,6 +1,5 @@ /** @format */ -import { User } from "@domain/auth/user.model"; import { ProjectRatingCriterion } from "./project-rating-criterion"; // Assuming this is where ProjectRatingCriterion is declared /** Интерфейс проекта для оценки */ @@ -15,8 +14,7 @@ export interface ProjectRate { viewsCount: number; industry: number; scored: boolean; - scoredExpertId: number | null; - ratedExperts: User[]; + ratedExperts: number[]; ratedCount: number; maxRates: number; criterias: ProjectRatingCriterion[]; diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.spec.ts index a2e31211a..5322d1ca1 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.spec.ts @@ -82,4 +82,34 @@ describe("ProjectRatingRepository", () => { expect(out).toEqual([{ criterionId: 3, value: "hello" }]); }); + + it("formValuesToDTO всегда отправляет полный backend snapshot критериев", () => { + setup(); + const criteria = [ + { id: 1, type: "int", value: 2 }, + { id: 2, type: "bool", value: "true" }, + { id: 3, type: "str", value: "Existing" }, + ] as ProjectRatingCriterion[]; + + expect(repository.formValuesToDTO(criteria, {})).toEqual([ + { criterionId: 1, value: 2 }, + { criterionId: 2, value: "true" }, + { criterionId: 3, value: "Existing" }, + ]); + }); + + it("formValuesToDTO сохраняет полный набор при изменении одного критерия", () => { + setup(); + const criteria = [ + { id: 1, type: "int", value: 2 }, + { id: 2, type: "bool", value: "false" }, + { id: 3, type: "str", value: "Existing" }, + ] as ProjectRatingCriterion[]; + + expect(repository.formValuesToDTO(criteria, { 1: "5" })).toEqual([ + { criterionId: 1, value: 5 }, + { criterionId: 2, value: "false" }, + { criterionId: 3, value: "Existing" }, + ]); + }); }); 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..2a583c34c 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 @@ -40,24 +40,24 @@ export class ProjectRatingRepository implements ProjectRatingRepositoryPort { criteria: ProjectRatingCriterion[], outputVals: Record, ): ProjectRatingCriterionOutput[] { - const output: ProjectRatingCriterionOutput[] = []; - const normalizedOutputVals = { ...outputVals }; + return criteria.map(criterion => { + const key = String(criterion.id); + let value = Object.prototype.hasOwnProperty.call(outputVals, key) + ? outputVals[key] + : criterion.value; - for (const key in normalizedOutputVals) { // оценки с boolean значением переводятся в "string-boolean" (true => "True") - if (typeof normalizedOutputVals[key] === "boolean") { - const boolString = String(normalizedOutputVals[key]); - normalizedOutputVals[key] = boolString.charAt(0).toUpperCase() + boolString.slice(1); + if (typeof value === "boolean") { + const boolString = String(value); + value = boolString.charAt(0).toUpperCase() + boolString.slice(1); } // оценки с числовым значением из инпута приходят строкой, их нужно привести к number - if (criteria.find(c => c.id === Number(key))?.type === "int") { - normalizedOutputVals[key] = Number(normalizedOutputVals[key]); + if (criterion.type === "int") { + value = Number(value); } - output.push({ criterionId: Number(key), value: normalizedOutputVals[key] }); - } - - return output; + return { criterionId: criterion.id, value }; + }); } } diff --git a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html index 5ee24640e..d28966b0a 100644 --- a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html @@ -8,36 +8,29 @@

Новый пароль

- @if (showPassword()) { + @if (credsSubmitInitiated()) { @@ -98,13 +91,32 @@

Новый пароль

+ > + + @if (credsSubmitInitiated()) { @if (passwordRepeated | controlError: "required") { diff --git a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.spec.ts index 12a7cdbc6..5a224dd01 100644 --- a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.spec.ts @@ -47,4 +47,35 @@ describe("SetPasswordComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("uses independent accessible new-password controls for both fields", () => { + const inputs = fixture.nativeElement.querySelectorAll( + 'input[autocomplete="new-password"]', + ) as NodeListOf; + const toggles = fixture.nativeElement.querySelectorAll( + ".auth__password-toggle", + ) as NodeListOf; + + expect(inputs).toHaveLength(2); + expect(inputs[0].name).toBe("new-password"); + expect(inputs[1].name).toBe("new-password-confirmation"); + expect(inputs[0].type).toBe("password"); + expect(inputs[1].type).toBe("password"); + expect(toggles).toHaveLength(2); + expect(toggles[0].type).toBe("button"); + expect(toggles[0].getAttribute("aria-label")).toBe("Показать пароль"); + expect(toggles[1].getAttribute("aria-label")).toBe("Показать повторный пароль"); + + toggles[0].click(); + fixture.detectChanges(); + expect(inputs[0].type).toBe("text"); + expect(inputs[1].type).toBe("password"); + expect(toggles[0].getAttribute("aria-pressed")).toBe("true"); + + toggles[1].click(); + fixture.detectChanges(); + expect(inputs[0].type).toBe("text"); + expect(inputs[1].type).toBe("text"); + expect(toggles[1].getAttribute("aria-pressed")).toBe("true"); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts index 37792dd5f..108cddb28 100644 --- a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts +++ b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts @@ -4,7 +4,7 @@ import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/cor import { ReactiveFormsModule } from "@angular/forms"; import { ControlErrorPipe } from "@corelib"; import { ErrorMessage } from "@core/lib/models/error/error-message"; -import { ButtonComponent, InputComponent } from "@ui/primitives"; +import { ButtonComponent, IconComponent, InputComponent } from "@ui/primitives"; import { AuthPasswordService } from "@api/auth/facades/auth-password.service"; import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; @@ -14,7 +14,7 @@ import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; templateUrl: "./set-password.component.html", styleUrl: "./set-password.component.scss", providers: [AuthPasswordService, AuthUIInfoService], - imports: [ReactiveFormsModule, InputComponent, ButtonComponent, ControlErrorPipe], + imports: [ReactiveFormsModule, InputComponent, ButtonComponent, IconComponent, ControlErrorPipe], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SetPasswordComponent implements OnInit { @@ -30,13 +30,14 @@ export class SetPasswordComponent implements OnInit { protected readonly errorMessage = ErrorMessage; protected readonly showPassword = this.authUIInfoService.showPassword; + protected readonly showPasswordRepeat = this.authUIInfoService.showPasswordRepeat; ngOnInit(): void { this.authPasswordService.init(); } - toggleShowPassword() { - this.authUIInfoService.toggleShowPassword("login"); + toggleShowPassword(type: "first" | "repeat") { + this.authUIInfoService.toggleShowPassword("register", type); } onSubmit() { diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts index 2ca1d9f4b..29b499cbb 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts @@ -741,7 +741,7 @@ describe("ProgramAnalyticsComponent", () => { "Хотя бы один назначенный эксперт полностью оценил проект, но не все назначенные эксперты завершили оценивание.", ); expect(evaluation?.querySelector('[data-testid="assignment-statuses"]')?.textContent).toContain( - "Назначений всего", + "Назначений", ); }); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts index 541bfa890..ae402f7c0 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts @@ -289,7 +289,7 @@ export class ProgramAnalyticsComponent implements OnInit { const assignments = this.data()?.evaluationStatus.assignments; if (!assignments) return []; return [ - { ...this.metric("assignments-total", "Назначений всего", assignments.total), scope: "all" }, + { ...this.metric("assignments-total", "Назначений", assignments.total), scope: "all" }, { ...this.metric("assignments-evaluated", "Выполнено", assignments.evaluated), scope: "completed", diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts index 491dd9e9f..61914aecc 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts @@ -61,7 +61,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { modal = fixture.debugElement.query(By.directive(ModalComponent)).componentInstance; state = fixture.debugElement.injector.get(ProgramAnalyticsDrilldownService); trigger = document.createElement("button"); - trigger.textContent = "Назначений всего"; + trigger.textContent = "Назначений"; document.body.append(trigger); trigger.focus(); }); 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..4ee8da08f --- /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,84 @@ +/** @format */ + +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; +import { ProjectRatingComponent } from "./project-rating.component"; + +type RatingValue = Record; + +@Component({ + imports: [ReactiveFormsModule, ProjectRatingComponent], + template: ``, +}) +class ProjectRatingHostComponent { + readonly criteria: ProjectRatingCriterion[] = [ + { + id: 1, + name: "Score", + description: "", + type: "int", + minValue: 0, + maxValue: 5, + value: 4, + expertId: 7, + }, + { + id: 2, + name: "Approved", + description: "", + type: "bool", + minValue: null, + maxValue: null, + value: "true", + expertId: 7, + }, + { + id: 3, + name: "Comment", + description: "", + type: "str", + minValue: null, + maxValue: null, + value: "Existing", + expertId: 7, + }, + ]; + readonly rating = new FormControl(null); +} + +describe("ProjectRatingComponent", () => { + let fixture: ComponentFixture; + let host: ProjectRatingHostComponent; + let rating: ProjectRatingComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProjectRatingHostComponent], + }).compileComponents(); + fixture = TestBed.createComponent(ProjectRatingHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + rating = fixture.debugElement.children[0].componentInstance as ProjectRatingComponent; + }); + + it("synchronizes every backend criterion into the parent control without user input", () => { + expect(rating.form.getRawValue()).toEqual({ 1: 4, 2: true, 3: "Existing" }); + expect(host.rating.getRawValue()).toEqual({ 1: 4, 2: true, 3: "Existing" }); + }); + + it("keeps the full parent snapshot when one criterion changes", () => { + rating.form.get("1")!.setValue(5); + + expect(host.rating.getRawValue()).toEqual({ 1: 5, 2: true, 3: "Existing" }); + }); + + it("applies a parent value written after rebuild without dropping other criteria", () => { + host.rating.setValue({ 1: 3, 2: false, 3: "Updated" }); + fixture.detectChanges(); + + expect(rating.form.getRawValue()).toEqual({ 1: 3, 2: false, 3: "Updated" }); + expect(host.rating.getRawValue()).toEqual({ 1: 3, 2: false, 3: "Updated" }); + }); +}); 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 576aafeca..3eeccd255 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 @@ -63,6 +63,8 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, this._criteria.set(val); this.createFormControls(val); this.trackFormValueChange(); + this.applyWrittenValue(); + this.emitCurrentValue(); } get criteria(): ProjectRatingCriterion[] { @@ -106,17 +108,23 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, /** Сигнал для хранения подписок */ subscriptions$ = signal([]); - onChange: (val: unknown) => void = noop; + onChange: (val: RatingFormValue) => void = noop; onTouched: () => void = noop; - - writeValue(val: typeof this.form.value): void { - if (val) { - this.form.patchValue(val); - } + private onChangeRegistered = false; + private writeValueCalled = false; + private writtenValue: RatingFormValue | null = null; + + writeValue(val: RatingFormValue | null): void { + this.writeValueCalled = true; + this.writtenValue = val; + this.applyWrittenValue(); + this.emitCurrentValue(); } - registerOnChange(fn: (v: unknown) => void): void { + registerOnChange(fn: (v: RatingFormValue) => void): void { this.onChange = fn; + this.onChangeRegistered = true; + this.emitCurrentValue(); } registerOnTouched(fn: () => void): void { @@ -157,10 +165,25 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, } private trackFormValueChange(): void { - const trackChanged$ = this.form.valueChanges.subscribe(val => { - this.onChange(val); + this.subscriptions$().forEach(subscription => subscription.unsubscribe()); + this.subscriptions$.set([]); + + const trackChanged$ = this.form.valueChanges.subscribe(() => { + this.onChange(this.form.getRawValue() as RatingFormValue); }); this.subscriptions$().push(trackChanged$); } + + private applyWrittenValue(): void { + if (!this.form || !this.writtenValue) return; + this.form.patchValue(this.writtenValue, { emitEvent: false }); + } + + private emitCurrentValue(): void { + if (!this.form || !this.onChangeRegistered || !this.writeValueCalled) return; + this.onChange(this.form.getRawValue() as RatingFormValue); + } } + +type RatingFormValue = Record; 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 050ba8c4d..d1d4c5346 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 @@ -138,7 +138,7 @@ size="big" class="cancel__button" customTypographyClass="text-body-12" - [disabled]="evaluationDateExpired()" + [disabled]="evaluationClosed()" (click)="confirmRateProject()" > подтверждаю diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.spec.ts index a6433cfd0..c560a342d 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.spec.ts @@ -18,8 +18,10 @@ import { RateProjectUseCase } from "@api/program/use-cases/rate-project.use-case import { Program } from "@domain/program/program.model"; import { ProjectRate } from "@domain/project/project-rate"; import { User } from "@domain/auth/user.model"; -import { ok } from "@domain/shared/result.type"; +import { fail, ok } from "@domain/shared/result.type"; import { ProjectRatingComponent } from "./project-rating/project-rating.component"; +import { HttpErrorResponse } from "@angular/common/http"; +import { RatingCardService } from "./services/rating-card.service"; describe("RatingCardComponent", () => { let component: RatingCardComponent; @@ -46,8 +48,7 @@ describe("RatingCardComponent", () => { viewsCount: 0, industry: 1, scored: rated, - scoredExpertId: rated ? user.id : null, - ratedExperts: rated ? [user] : [], + ratedExperts: rated ? [user.id] : [], ratedCount: rated ? 1 : 0, maxRates: 1, criterias: [ @@ -189,6 +190,63 @@ describe("RatingCardComponent", () => { expectReadonly(true); }); + it("submits every existing criterion after reload and edit without touching the form", async () => { + render(true); + const edit = fixture.nativeElement.querySelector( + ".card__rated--icon button", + ) as HTMLButtonElement; + edit.click(); + fixture.detectChanges(); + + expect(fixture.debugElement.injector.get(RatingCardService).form().getRawValue()).toEqual({ + 1: 2, + 2: true, + 3: "Note", + }); + + cta().click(); + fixture.detectChanges(); + await new Promise(resolve => setTimeout(resolve, 0)); + document.querySelector(".cancel__button button")!.click(); + fixture.detectChanges(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(execute).toHaveBeenCalledExactlyOnceWith(12, expect.any(Array), { + 1: 2, + 2: true, + 3: "Note", + }); + expect(cta().textContent).toContain("проект оценён"); + expectReadonly(true); + }); + + it("renders the controlled closed state after an authoritative backend deadline 409", async () => { + execute.mockReturnValueOnce( + of( + fail({ + kind: "rate_project_error" as const, + cause: new HttpErrorResponse({ + status: 409, + error: { error: "evaluation_deadline_passed" }, + }), + }), + ), + ); + render(); + cta().click(); + fixture.detectChanges(); + await new Promise(resolve => setTimeout(resolve, 0)); + document.querySelector(".cancel__button button")!.click(); + fixture.detectChanges(); + await new Promise(resolve => setTimeout(resolve, 0)); + fixture.detectChanges(); + + expect(cta().textContent).toContain("оценивание завершено"); + expect(cta().disabled).toBe(true); + expect(fixture.nativeElement.querySelector(".card__rated--icon")).toBeNull(); + expectReadonly(true); + }); + it.each([false, true])( "keeps expired CTA visible and all criteria readonly (rated=%s)", rated => { 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..5514b0c5c 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 @@ -79,7 +79,7 @@ export class RatingCardComponent implements AfterViewInit { protected readonly isProjectCriterias = this.ratingCardService.isProjectCriterias; protected readonly form = this.ratingCardService.form; protected readonly profile = this.ratingCardService.profile; - protected readonly evaluationDateExpired = this.ratingCardService.evaluationDateExpired; + protected readonly evaluationClosed = this.ratingCardService.evaluationClosed; protected readonly isRatingFormDisabled = this.ratingCardService.isRatingFormDisabled; protected readonly showRatingForm = this.ratingCardService.showRatingForm; protected readonly showRatedStatus = this.ratingCardService.showRatedStatus; 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..575170916 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 @@ -2,6 +2,7 @@ import { signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; import { RateProjectUseCase } from "@api/program/use-cases/rate-project.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"; @@ -18,6 +19,7 @@ describe("RatingCardService evaluation modes", () => { const past = "2026-09-01T00:00:00Z"; const future = "2026-09-30T00:00:00Z"; const execute = vi.fn(); + const loggerError = vi.fn(); let service: RatingCardService; let programUI: ProgramDetailMainUIInfoService; @@ -42,8 +44,7 @@ describe("RatingCardService evaluation modes", () => { viewsCount: 0, industry: 1, scored, - scoredExpertId: scored ? user.id : null, - ratedExperts: scored ? [user] : [], + ratedExperts: scored ? [user.id] : [], ratedCount: scored ? 1 : 0, maxRates: 1, criterias: [], @@ -53,13 +54,14 @@ describe("RatingCardService evaluation modes", () => { beforeEach(() => { vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-09T12:00:00Z")); execute.mockReset().mockReturnValue(of(ok(undefined))); + loggerError.mockReset(); TestBed.configureTestingModule({ providers: [ RatingCardService, { provide: RateProjectUseCase, useValue: { execute } }, ProgramDetailMainUIInfoService, { provide: ProfileInfoService, useValue: { profile: signal(user) } }, - { provide: LoggerService, useValue: { error: vi.fn() } }, + { provide: LoggerService, useValue: { error: loggerError } }, ], }); service = TestBed.inject(RatingCardService); @@ -84,6 +86,16 @@ describe("RatingCardService evaluation modes", () => { expect(service.showEditButton()).toBe(true); expect(service.isRatingFormDisabled()).toBe(true); expect(service.ratedCount()).toBe(1); + expect(service.project()).toMatchObject({ scored: true, ratedExperts: [user.id] }); + }); + + it("uses backend scored as the current expert state and treats ratedExperts as numeric IDs", () => { + service.initProject({ ...project(true), ratedExperts: [99], ratedCount: 1, maxRates: 1 }); + + expect(service.isRatedByCurrentUser()).toBe(true); + expect(service.rateButtonText()).toBe("проект оценён"); + expect(service.showEditButton()).toBe(true); + expect(service.isButtonDisabled()).toBe(false); }); it("keeps editing until update succeeds, then returns to readonly without incrementing count", () => { @@ -130,6 +142,63 @@ describe("RatingCardService evaluation modes", () => { expect(service.rateButtonText()).toBe("проект оценён"); }); + it("turns a backend deadline 409 into a closed readonly state without exposing the response", () => { + service.initProject(project(true)); + service.redoRating(); + service.form().setValue({ score: 5 }); + execute.mockReturnValueOnce( + of( + fail({ + kind: "rate_project_error" as const, + cause: new HttpErrorResponse({ + status: 409, + error: { + error: "evaluation_deadline_passed", + detail: "Срок оценивания завершён.", + }, + }), + }), + ), + ); + service.showConfirmRateModal.set(true); + + service.confirmRateProject(); + + expect(service.backendEvaluationClosed()).toBe(true); + expect(service.evaluationClosed()).toBe(true); + expect(service.showConfirmRateModal()).toBe(false); + expect(service.rateButtonText()).toBe("оценивание завершено"); + expect(service.buttonTooltip()).toBe("Срок оценивания завершён"); + expect(service.isRatingFormDisabled()).toBe(true); + expect(service.showEditButton()).toBe(false); + expect(service.isButtonDisabled()).toBe(true); + service.confirmRateProject(); + expect(execute).toHaveBeenCalledTimes(1); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it("keeps max_project_rates 400 separate from the deadline state", () => { + service.initProject(project()); + execute.mockReturnValueOnce( + of( + fail({ + kind: "rate_project_error" as const, + cause: new HttpErrorResponse({ + status: 400, + error: { error: "max_project_rates" }, + }), + }), + ), + ); + + service.confirmRateProject(); + + expect(service.backendEvaluationClosed()).toBe(false); + expect(service.evaluationClosed()).toBe(false); + expect(service.showRatingForm()).toBe(true); + expect(loggerError).toHaveBeenCalledOnce(); + }); + it("preserves rating-limit restrictions independently of the evaluation deadline", () => { service.initProject({ ...project(), ratedCount: 1 }); expect(service.rateButtonText()).toBe("лимит оценок достигнут"); 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..fa53b15f1 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 @@ -6,7 +6,7 @@ import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/p 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 } from "@angular/common/http"; import { finalize } from "rxjs"; import { FormControl } from "@angular/forms"; @@ -28,7 +28,7 @@ export class RatingCardService { readonly submitLoading = signal(false); readonly confirmLoading = signal(false); readonly showConfirmRateModal = signal(false); - readonly locallyRatedByCurrentUser = signal(false); + readonly backendEvaluationClosed = signal(false); readonly projectRated = signal(false); readonly projectConfirmed = signal(false); readonly ratedCount = signal(0); @@ -39,40 +39,26 @@ export class RatingCardService { return p.criterias.filter(c => c.type !== "str").length; }); - readonly isCurrentUserExpert = computed(() => { - const currentProfile = this.profile(); - const p = this.project(); - if (!currentProfile || !p) return false; - - const isExpertFromBackend = !!p.scoredExpertId && p.scoredExpertId === currentProfile.id; - - return isExpertFromBackend || this.locallyRatedByCurrentUser(); - }); - readonly isRatedByCurrentUser = computed(() => { - const currentUser = this.profile(); const p = this.project(); - if (!currentUser || !p) return false; - - return p.ratedExperts.some(user => user.id === currentUser.id); + return p?.scored === true; }); - readonly userRatedThisProject = computed(() => { - return ( - this.locallyRatedByCurrentUser() || - (this.project()?.ratedExperts && this.isRatedByCurrentUser()) - ); - }); + readonly userRatedThisProject = this.isRatedByCurrentUser; + + readonly evaluationClosed = computed( + () => this.evaluationDateExpired() || this.backendEvaluationClosed(), + ); readonly isLimitReached = computed(() => { const p = this.project(); return !!p && p.ratedCount >= p.maxRates; }); - readonly canEdit = computed(() => !this.evaluationDateExpired()); + readonly canEdit = computed(() => !this.evaluationClosed()); readonly canRate = computed(() => { - if (this.evaluationDateExpired()) return false; + if (this.evaluationClosed()) return false; if (this.isLimitReached() && !this.userRatedThisProject()) return false; return true; }); @@ -83,7 +69,7 @@ export class RatingCardService { }); readonly rateButtonText = computed(() => { - if (this.evaluationDateExpired()) return "оценивание завершено"; + if (this.evaluationClosed()) return "оценивание завершено"; if (this.projectConfirmed() && this.userRatedThisProject()) return "проект оценён"; if (this.isLimitReached() && !this.userRatedThisProject()) return "лимит оценок достигнут"; if (this.userRatedThisProject()) return "подтвердить изменения"; @@ -94,7 +80,7 @@ export class RatingCardService { readonly isRatingFormDisabled = computed( () => - this.evaluationDateExpired() || + this.evaluationClosed() || ((this.projectRated() || this.projectConfirmed()) && this.isRatedByCurrentUser()), ); @@ -106,7 +92,7 @@ export class RatingCardService { readonly isButtonDisabled = computed(() => { if (this.isLimitReached() && !this.userRatedThisProject()) return true; - if (this.evaluationDateExpired()) return true; + if (this.evaluationClosed()) return true; return !this.canRate(); }); @@ -117,11 +103,11 @@ export class RatingCardService { readonly buttonOpacity = computed(() => (this.isButtonDisabled() ? "0.5" : "1")); readonly showConfirmedState = computed( - () => this.evaluationDateExpired() || (this.isLimitReached() && !this.userRatedThisProject()), + () => this.evaluationClosed() || (this.isLimitReached() && !this.userRatedThisProject()), ); readonly buttonTooltip = computed(() => { - if (this.evaluationDateExpired()) return "Срок оценивания завершён"; + if (this.evaluationClosed()) return "Срок оценивания завершён"; if (this.isLimitReached() && !this.userRatedThisProject()) return "Достигнут максимальный лимит оценок"; if (this.userRatedThisProject()) return "Нажмите для переоценки"; @@ -133,6 +119,7 @@ export class RatingCardService { /** Инициализация начального состояния проекта. */ initProject(project: ProjectRate | null): void { if (!project) return; + this.backendEvaluationClosed.set(false); this.project.set(project); const isScored = project.scored || false; this.projectConfirmed.set(isScored); @@ -142,7 +129,7 @@ export class RatingCardService { /** Подтверждение оценки проекта. */ confirmRateProject(): void { - if (this.evaluationDateExpired()) return; + if (this.evaluationClosed()) return; const fv = this.form().getRawValue(); const p = this.project() as ProjectRate; @@ -154,10 +141,17 @@ export class RatingCardService { .subscribe({ next: result => { if (!result.ok) { - if (result.error.cause instanceof HttpResponse) { - if (result.error.cause.status === 400) { - this.logger.error("Ошибка: достигнут максимальный лимит оценок"); - } + if (this.isEvaluationDeadlineError(result.error.cause)) { + this.backendEvaluationClosed.set(true); + this.showConfirmRateModal.set(false); + return; + } + + if ( + result.error.cause instanceof HttpErrorResponse && + result.error.cause.status === 400 + ) { + this.logger.error("Ошибка: достигнут максимальный лимит оценок"); } return; } @@ -165,28 +159,21 @@ export class RatingCardService { const profile = this.profile(); const proj = this.project() as ProjectRate; - this.locallyRatedByCurrentUser.set(true); this.projectRated.set(true); this.projectConfirmed.set(true); - let isFirstTimeRating = false; - - if (profile) { - if (!Array.isArray(proj.ratedExperts)) { - proj.ratedExperts = []; - } + const isFirstTimeRating = !proj.scored; + let ratedExperts = Array.isArray(proj.ratedExperts) ? proj.ratedExperts : []; - if (!proj.ratedExperts.some(user => user.id === profile.id)) { - proj.ratedExperts = [...proj.ratedExperts, profile]; - isFirstTimeRating = true; - } + if (profile && !ratedExperts.includes(profile.id)) { + ratedExperts = [...ratedExperts, profile.id]; } if (isFirstTimeRating) { this.ratedCount.update(count => count + 1); } - this.project.set({ ...proj }); + this.project.set({ ...proj, scored: true, ratedExperts }); this.showConfirmRateModal.set(false); }, }); @@ -198,4 +185,11 @@ export class RatingCardService { this.projectRated.set(false); this.projectConfirmed.set(false); } + + private isEvaluationDeadlineError(error: unknown): boolean { + if (!(error instanceof HttpErrorResponse) || error.status !== 409) return false; + if (!error.error || typeof error.error !== "object") return false; + + return (error.error as { error?: unknown }).error === "evaluation_deadline_passed"; + } } diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html index 779c4bd76..a24681874 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html @@ -134,15 +134,6 @@ [error]="showError(region)" placeholder="выберите регион" > - @if (showError(region)) { - - } @if (showError(region, "required")) {
diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss index d69a041d3..8c1ad24ad 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss @@ -9,15 +9,22 @@ } &__validated-input ::ng-deep { + .field__input { + padding-right: 120px; + } + .field__right-icon { top: 50%; - right: 16px !important; + right: 88px !important; pointer-events: none; transform: translateY(-50%); } - .field__input--error { - padding-right: 44px; + .field__counter { + right: 16px; + min-width: 60px; + text-align: right; + pointer-events: none; } } @@ -29,18 +36,10 @@ position: relative; &--error ::ng-deep .region-select__control input { - padding-right: 68px; + padding-right: 76px; } } - &__region-error { - position: absolute; - top: 11px; - right: 42px; - color: var(--red); - pointer-events: none; - } - &__grid { display: grid; grid-template-columns: minmax(0, 1fr); @@ -59,6 +58,7 @@ &--main { grid-template-columns: 1fr 1fr 1fr 0.2fr; + align-items: start; } } } diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts index c7741905e..f801267ac 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts @@ -163,6 +163,24 @@ describe("ProjectMainStepComponent", () => { ).toBeNull(); }); + it("keeps validation icons, counters and region controls in separate DOM zones", async () => { + fixture.componentRef.setInput("projSubmitInitiated", true); + await fixture.whenStable(); + + for (const id of ["name", "problem"]) { + const field = fixture.nativeElement.querySelector(`app-input#${id} .field`) as HTMLElement; + expect(field.querySelector(".field__input")).not.toBeNull(); + expect(field.querySelector(".field__right-icon .project__input-error")).not.toBeNull(); + expect(field.querySelector(".field__counter")).not.toBeNull(); + } + + const region = fixture.nativeElement.querySelector("app-region-select#region") as HTMLElement; + expect(region.querySelector(".region-select__error")).not.toBeNull(); + expect(region.querySelector(".region-select__control--error")).not.toBeNull(); + expect(fixture.nativeElement.querySelector(".project__region-error")).toBeNull(); + expect(fixture.nativeElement.querySelector(".project__grid--main")).not.toBeNull(); + }); + it("renders every new project contact input in the originating UI cycle", async () => { const addButton = Array.from(fixture.nativeElement.querySelectorAll("app-button")).find( (button: Element) => button.textContent?.includes("добавить ссылку"), diff --git a/projects/social_platform/src/app/ui/widgets/detail/detail-cleanup.component.spec.ts b/projects/social_platform/src/app/ui/widgets/detail/detail-cleanup.component.spec.ts index 20f114cf8..510c7733f 100644 --- a/projects/social_platform/src/app/ui/widgets/detail/detail-cleanup.component.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/detail/detail-cleanup.component.spec.ts @@ -148,6 +148,32 @@ describe("Detail cleanup actions", () => { expect(copyLink).toHaveBeenCalledExactlyOnceWith(id); }); + it("keeps a long profile title in normal flow while leaving the project title in its header", () => { + type.set("profile"); + info.set({ + id: 7, + firstName: "Очень длинное составное имя пользователя", + lastName: "Очень длинная составная фамилия пользователя", + skills: [], + }); + const profileFixture = TestBed.createComponent(DeatilComponent); + profileFixture.detectChanges(); + + const profileHeading = profileFixture.nativeElement.querySelector( + ".info__profile-heading .info__title--profile", + ) as HTMLElement; + expect(profileHeading.textContent).toContain("Очень длинное составное имя пользователя"); + expect(profileFixture.nativeElement.querySelector(".info__avatar .info__title")).toBeNull(); + + type.set("project"); + info.set({ id: 55, name: "Проект", collaborators: [], partnerProgram: null }); + profileFixture.detectChanges(); + expect(profileFixture.nativeElement.querySelector(".info__profile-heading")).toBeNull(); + expect( + profileFixture.nativeElement.querySelector(".info__avatar .info__title--project"), + ).not.toBeNull(); + }); + it("does not wait for the current profile before offering application creation", () => { profile.set(null); const fixture = TestBed.createComponent(DeatilComponent); diff --git a/projects/social_platform/src/app/ui/widgets/detail/detail.component.html b/projects/social_platform/src/app/ui/widgets/detail/detail.component.html index b9596de1b..fe9da4ea4 100644 --- a/projects/social_platform/src/app/ui/widgets/detail/detail.component.html +++ b/projects/social_platform/src/app/ui/widgets/detail/detail.component.html @@ -50,29 +50,27 @@ " [isOnline]="cache[info().id] !== undefined ? cache[info().id] : info().isOnline" > - @if (listType() === "project" || listType() === "profile") { + @if (listType() === "project") {

- {{ - listType() === "project" - ? info().name - : listType() === "profile" - ? info().firstName + " " + info().lastName - : "" - }} + {{ info().name }}

} }
+ @if (listType() === "profile") { +
+

+ {{ info().firstName }} {{ info().lastName }} +

+
+ } +
+ @if (error()) { + + } @if (query()) {