diff --git a/projects/social_platform/src/app/api/program/evaluation-navigation.service.ts b/projects/social_platform/src/app/api/program/evaluation-navigation.service.ts new file mode 100644 index 000000000..62edbcd90 --- /dev/null +++ b/projects/social_platform/src/app/api/program/evaluation-navigation.service.ts @@ -0,0 +1,28 @@ +/** @format */ +import { Injectable } from "@angular/core"; + +/** In-memory position only; assessment values are never persisted here. */ +@Injectable({ providedIn: "root" }) +export class EvaluationNavigationService { + private readonly positions = new Map(); + remember(url: string, limit = 10): void { + this.positions.set(url, { + top: document.querySelector(".office__body")?.scrollTop ?? 0, + windowTop: window.scrollY, + limit, + }); + } + limit(url: string): number { + return this.positions.get(url)?.limit ?? 10; + } + restore(url: string): void { + const position = this.positions.get(url); + if (!position) return; + requestAnimationFrame(() => + requestAnimationFrame(() => { + document.querySelector(".office__body")?.scrollTo({ top: position.top }); + window.scrollTo({ top: position.windowTop }); + }), + ); + } +} 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 9a5942612..96a0cf719 100644 --- a/projects/social_platform/src/app/domain/project/project-rate.ts +++ b/projects/social_platform/src/app/domain/project/project-rate.ts @@ -17,8 +17,8 @@ export interface ProjectRate { viewsCount: number; industry: number; scored: boolean; - scoredExpertId: number | null; - ratedExperts: User[]; + scoredExpertId?: number | null; + ratedExperts: (number | User)[]; ratedCount: number; maxRates: number; criterias: ProjectRatingCriterion[]; diff --git a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.html b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.html index d7aa3b181..0b109b006 100644 --- a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.html +++ b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.html @@ -144,8 +144,9 @@

{{ project.name }}

@if (project.canRate || project.isCompleted) { {{ project.isCompleted ? "Посмотреть" : "Оценить" }} } @else { diff --git a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.spec.ts b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.spec.ts index a96129a47..3a37163fc 100644 --- a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.spec.ts @@ -111,7 +111,7 @@ describe("Кабинет эксперта: только реальные наз offset: 0, }); const link = fixture.nativeElement.querySelector(".cabinet__project a") as HTMLAnchorElement; - expect(link.getAttribute("href")).toContain("/office/program/7/projects-rating?project_id=42"); + expect(link.getAttribute("href")).toContain("/office/program/7/projects-rating/42?returnUrl="); expect(fixture.nativeElement.textContent).toContain("Осталось 1 из 2"); expect(fixture.nativeElement.textContent).not.toContain("Продолжить"); }); diff --git a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.ts b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.ts index 1a3e1ba26..aee45c2a0 100644 --- a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.ts +++ b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.ts @@ -30,6 +30,7 @@ import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; import { AuthInfoService } from "@api/auth/facades/auth-info.service"; import { evaluationError } from "@utils/evaluation-workspace"; import { presentationHref } from "@utils/program-case-analytics"; +import { EvaluationNavigationService } from "@api/program/evaluation-navigation.service"; type CabinetTab = "pending" | "completed" | "invitations"; const positive = (value: string | null) => { @@ -55,6 +56,7 @@ const positive = (value: string | null) => { changeDetection: ChangeDetectionStrategy.OnPush, }) export class EvaluationProgramsComponent { + readonly navigation = inject(EvaluationNavigationService); private readonly api = inject(EvaluationUseCase); private readonly profile = inject(ProfileInfoService); private readonly auth = inject(AuthInfoService); @@ -167,7 +169,10 @@ export class EvaluationProgramsComponent { if (!data.projects || !Array.isArray(data.projects.results)) { this.snapshot.set(null); this.failed.set("Кабинет временно недоступен. Попробуйте обновить страницу."); - } else this.snapshot.set({ accountId, data }); + } else { + this.snapshot.set({ accountId, data }); + this.navigation.restore(this.router.url); + } this.loading.set(false); }, error: e => { diff --git a/projects/social_platform/src/app/ui/pages/office/nav/nav.component.html b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.html index 986e48d2c..bbae91a12 100644 --- a/projects/social_platform/src/app/ui/pages/office/nav/nav.component.html +++ b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.html @@ -129,7 +129,7 @@ routerLink="/office/evaluation" routerLinkActive="nav__link--active" > } diff --git a/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts index 3c72a81c5..487e2391a 100644 --- a/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts +++ b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts @@ -141,7 +141,7 @@ export class NavComponent implements OnInit, OnDestroy { projects: "Проекты", members: "Участники", program: "Программы", - evaluation: "Кабинет эксперта", + evaluation: "Оценивание", courses: "Курсы", vacancies: "Вакансии", profile: "Профиль", diff --git a/projects/social_platform/src/app/ui/pages/office/office.component.ts b/projects/social_platform/src/app/ui/pages/office/office.component.ts index 250d7fc90..658ae990d 100644 --- a/projects/social_platform/src/app/ui/pages/office/office.component.ts +++ b/projects/social_platform/src/app/ui/pages/office/office.component.ts @@ -77,7 +77,7 @@ export class OfficeComponent implements OnInit { const items = [...this.officeUIInfoService.navItems()]; if (this.profile()?.relations.hasEvaluationAccess) { const index = items.findIndex(item => item.link === "program"); - items.splice(index + 1, 0, { name: "кабинет эксперта", icon: "check", link: "evaluation" }); + items.splice(index + 1, 0, { name: "оценивание", icon: "check", link: "evaluation" }); } return items; }); 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 970b80fa0..c263dbef0 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 @@ -5,7 +5,11 @@ criterion.type === "str" && criterion.description === "Доп. поле для впечатлений о проекте" ? "" : criterion.description; -
+
@if (layout() === "compact" && description) {
{{ criterion.name }} @@ -47,7 +51,7 @@

{{ criterion.name }}

" [attr.aria-pressed]="form.get(criterion.id.toString())?.value === true" [disabled]="disabled" - (click)="form.get(criterion.id.toString())?.setValue(true)" + (click)="chooseBoolean(criterion.id, true)" > Да @@ -58,14 +62,11 @@

{{ criterion.name }}

" [attr.aria-pressed]="form.get(criterion.id.toString())?.value === false" [disabled]="disabled" - (click)="form.get(criterion.id.toString())?.setValue(false)" + (click)="chooseBoolean(criterion.id, false)" > Нет - @if (layout() === "full") { -

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

- } } @else { @if (layout() === "full" && description) {

{{ description }}

@@ -74,18 +75,26 @@

{{ criterion.name }}

appField [attr.aria-label]="criterion.name" [formControlName]="criterion.id" - maxlength="50" + maxlength="1000" [rows]="layout() === 'compact' ? 2 : 6" placeholder="Впечатления и рекомендации эксперта" >

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

} @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 8a76a8b6f..15891023c 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 @@ -169,3 +169,21 @@ p { } } } + +.rating:not(.rating--compact) .ui-button { + height: 32px; + min-height: 32px; + padding: 0 16px; +} + +@media (max-width: 999px) { + .rating:not(.rating--compact) .ui-button { + position: relative; + + &::after { + position: absolute; + inset: -6px 0; + content: ""; + } + } +} 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 index 481b8f1db..afd245d9a 100644 --- 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 @@ -47,7 +47,7 @@ describe("Shared expert form lifecycle", () => { expect(fixture.componentInstance.form.getRawValue()).toEqual(answers); }); - it("publishes zero and default No after replacing criteria, validates the new scale", async () => { + it("requires a deliberate boolean answer after criteria change and preserves zero", async () => { await TestBed.configureTestingModule({ imports: [ProjectRatingComponent], providers: [provideZonelessChangeDetection()], @@ -75,9 +75,11 @@ describe("Shared expert form lifecycle", () => { { ...points, id: 2, name: "Новый", type: "bool", value: "" }, ]); await fixture.whenStable(); - expect(changed).toHaveBeenLastCalledWith({ 1: 0, 2: false }); + expect(changed).toHaveBeenLastCalledWith({ 1: 0, 2: null }); expect(component.form.invalid).toBe(true); component.form.get("1")?.setValue(1); + expect(component.form.invalid).toBe(true); + component.chooseBoolean(2, false); 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 417a729a6..370acf7fc 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 @@ -5,6 +5,8 @@ import { ChangeDetectionStrategy, Component, forwardRef, + ElementRef, + inject, input, Input, OnDestroy, @@ -58,8 +60,9 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, @Input({ required: true }) set criteria(val: ProjectRatingCriterion[]) { if (!val) return; - this._criteria.set(val); - this.createFormControls(val); + const ordered = [...val.filter(c => c.type !== "str"), ...val.filter(c => c.type === "str")]; + this._criteria.set(ordered); + this.createFormControls(ordered); this.trackFormValueChange(); queueMicrotask(() => { this.onChange(this.form.getRawValue()); @@ -75,7 +78,9 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, set disabled(value: boolean) { this._disabled = value; if (this.form) { - this.disabled ? this.form.disable() : this.form.enable(); + this.disabled + ? this.form.disable({ emitEvent: false }) + : this.form.enable({ emitEvent: false }); } } @@ -85,6 +90,27 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, private _disabled = false; private controlDisabled = false; + private readonly element = inject>(ElementRef); + + chooseBoolean(id: number, value: boolean): void { + const control = this.form.get(String(id)); + control?.markAsDirty(); + control?.markAsTouched(); + control?.setValue(value); + } + + validateAndFocus(): boolean { + this.form.markAllAsTouched(); + if (this.form.valid) return true; + const invalid = this.criteria.find(c => this.form.get(String(c.id))?.invalid); + queueMicrotask(() => { + const section = this.element.nativeElement.querySelector(`[data-criterion="${invalid?.id}"]`); + const target = section?.querySelector("input, textarea, button"); + target?.focus(); + target?.scrollIntoView?.({ block: "center", behavior: "smooth" }); + }); + return false; + } readonly currentUserId = input(); readonly layout = input<"compact" | "full">("compact"); @@ -141,7 +167,10 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, setDisabledState(disabled: boolean): void { this.controlDisabled = disabled; - if (this.form) this.disabled ? this.form.disable() : this.form.enable(); + if (this.form) + this.disabled + ? this.form.disable({ emitEvent: false }) + : this.form.enable({ emitEvent: false }); } ngOnDestroy(): void { @@ -161,7 +190,7 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, if (this.pendingValue) this.form.patchValue(this.pendingValue, { emitEvent: false }); if (this.disabled) { - this.form.disable(); + this.form.disable({ emitEvent: false }); } this.validatorChanged(); } 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 7c9aa2bd6..3c09a7924 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 @@ -1,184 +1,153 @@ - -@if (project) { -
-
-
-
- - -
- -

- {{ (desktopMode$ | async) ? (project.name | truncate: 15) : project.name }} -

-
- - @if (industryRepositoryGetOne(project.industry); as industry) { - - {{ industry.name }} - - } -
-
- - @if (project.presentationAddress) { - - - презентация - - - +@if (state.project(); as p) { +
+ +
+
+

Моя оценка

+ @if (state.projectConfirmed()) { + Оценка сохранена }
- -
-
-

о проекте

- -
- @if (project.description) { -
-

- @if (descriptionExpandable()) { -
- {{ readFullDescription() ? "скрыть" : "подробнее" }} -
- } -
+ @if (state.evaluationDateExpired()) { +

+ {{ + state.projectConfirmed() + ? "Срок оценивания завершен. Оценка доступна для просмотра." + : "Срок оценивания завершен. Отправить оценку уже нельзя." + }} +

+ } @else if (state.isLimitReached() && !state.userRatedThisProject()) { +

Достигнут лимит оценок этого проекта.

+ } + @if (!p.criterias.length) { +

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

+ } @else if (state.projectConfirmed()) { +
+ @for (c of orderedCriteria(); track c.id) { +
+
{{ c.name }}
+
{{ answer(c) }}
+
+ } +
+ @if (state.showEditButton()) { + } -
- -
-

{{ ratedCount() }} / {{ project.maxRates }}

- -
-
- -
-
-
-

оценка проекта

- -
+ } @else if (!state.evaluationDateExpired()) { - @if (form() | controlError: "required"; as error) { -
- {{ error }} -
- } - @if (ratingError()) { - - - @if (showRatingForm() || showRatedStatus() || showConfirmedState()) { -
- +
} +
+ - -
-
-

подтвердите оценку

- -
- -

{{ project.name }}

+ +
+
+

Проверьте оценку

+ +
+
+

{{ p.name }}

+
+ @for (c of orderedCriteria(); track c.id) { +
+
{{ c.name }}
+
{{ answer(c) }}
-
- -
- @if (showConfirmRateModal()) { - - } -
- -
- - подтверждаю - - - - изменить оценку - -
-
-
+ } + +
+
+ + +
-
+
} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.scss index aafbc5a57..c2fff9b2d 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.scss @@ -1,264 +1,224 @@ /** @format */ +@use "styles/ui-foundation"; -@use "styles/responsive"; -@use "styles/typography"; - -.card { - display: grid; - grid-template-columns: 1fr; - gap: 20px; - align-items: start; +:host { + display: block; + min-width: 0; + color: var(--ui-text); +} - @include responsive.apply-desktop { - grid-template-columns: 5fr 3fr; - } +.ui-button { + @include ui-foundation.button; - &__wrapper { - padding: 24px; - background-color: var(--light-white); - border: 0.5px solid var(--grey-button); - border-radius: var(--rounded-lg); - } + min-height: 32px; + font-size: 12px; + text-decoration: none; +} - &__inner { - display: flex; - align-items: center; - justify-content: space-between; - } +.scoring { + display: grid; + grid-template-columns: 268px minmax(0, 1fr); + gap: 24px; + align-items: start; +} - &__info { - display: flex; - gap: 10px; - align-items: center; - width: 100%; +.scoring__materials, +.scoring__form { + display: grid; + gap: 24px; + min-width: 0; + padding: 24px; + background: var(--ui-surface); + border-radius: 15px; +} - @include responsive.apply-desktop { - gap: 20px; - } - } +h2, +h3, +p, +dl, +dd { + margin: 0; +} - &__naming { - display: flex; - flex-direction: column; - gap: 5px; - width: 60%; +h2 { + font-size: 16px; + font-weight: 600; + line-height: 24px; +} - &--text { - color: var(--black); - } - } +h3 { + font-size: 14px; + font-weight: 600; + line-height: 21px; +} - &__industry { - display: inline-flex; - } +.scoring__materials { + gap: 16px; +} - &__title { - overflow: hidden; - color: var(--black); - text-align: center; - text-overflow: ellipsis; - } +.scoring__description { + font-size: 12px; + line-height: 1.6; + overflow-wrap: anywhere; + white-space: pre-wrap; +} - &__rated { - display: flex; - gap: 4px; - align-items: center; - margin-top: 14px; - - &--icon { - ::ng-deep { - button { - width: 50px; - padding: 8px !important; - } - } - } - } +.scoring__muted { + font-size: 12px; + line-height: 1.5; + color: var(--grey-for-text); +} - &__presentation { - ::ng-deep { - app-button { - button { - padding-top: 4px; - padding-bottom: 4px; - } - } - } - } +.scoring__heading { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} - &__experts { - display: flex; - gap: 5px; - align-items: center; - margin-top: 6px; +.scoring__status { + padding: 5px 8px; + font-size: 12px; + background: var(--green-light); + border-radius: 5px; +} - @include responsive.apply-desktop { - margin: 0; - } +.scoring__notice, +.scoring__error { + padding: 12px; + font-size: 12px; + line-height: 1.5; + background: var(--gold-light); + border-radius: 10px; +} - p { - color: var(--grey-for-text); - } - } +.scoring__error { + display: grid; + gap: 12px; + background: var(--red-light); +} - &__lower { - display: flex; - flex-direction: column; +.scoring__answers { + display: grid; + gap: 0; + font-size: 14px; + line-height: 1.5; +} - &--info { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 10px; - margin-bottom: 10px; - border-bottom: 0.5px solid var(--dark-grey); - - p, - i { - color: var(--accent); - } - } - - ::ng-deep { - app-button { - button { - padding-top: 4px; - padding-bottom: 4px; - } - } - } - } +.scoring__answers > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--ui-border); } -.about { - /* stylelint-disable value-no-vendor-prefix */ - margin-top: 10px; +.scoring__answers dt, +.scoring__answers dd { + overflow-wrap: anywhere; +} - &__title { - color: var(--accent); - } +.scoring__answers dd { + max-width: 100%; + white-space: pre-wrap; +} - &__top { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 10px; - border-bottom: 0.5px solid var(--dark-grey); +.scoring__answers > .scoring__answer--text { + grid-template-columns: minmax(0, 1fr); +} - i { - color: var(--accent); - } - } +.scoring__previous { + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} - &__text { - margin-top: 10px; - - p { - display: -webkit-box; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - word-wrap: break-word; - transition: all 0.7s ease-in-out; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - - &.expanded { - -webkit-line-clamp: unset; - } - } - - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } - } - } - /* stylelint-enable value-no-vendor-prefix */ +.scoring__actions { + display: flex; + justify-content: flex-end; +} - &__read-full { - margin-top: 2px; - color: var(--accent); - cursor: pointer; - } +.scoring-dialog { + display: flex; + flex-direction: column; + gap: 20px; + width: min(632px, calc(100vw - 72px)); + max-height: calc(100dvh - 96px); } -.cancel { - position: relative; - display: block; - width: 350px; - max-height: calc(100vh - 40px); +.scoring-dialog header { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; +} - &__top { - display: flex; - flex-direction: column; - align-items: center; - margin-bottom: 10px; - } +.scoring-dialog__close { + flex: 0 0 32px; + width: 32px; + height: 32px; + cursor: pointer; + background: var(--ui-border); + border: 0; + border-radius: 50%; +} - &__project { - display: flex; - gap: 8px; - align-items: center; - margin: 20px 0; - } +.scoring-dialog__body { + min-height: 0; + overflow-y: auto; +} - &__criterias { - margin-bottom: 20px; - } +.scoring-dialog footer { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-end; +} - &__title { - text-align: center; +@media (max-width: 999px) { + .scoring { + grid-template-columns: minmax(0, 1fr); + gap: 20px; } - &__text { - width: 40%; - color: var(--dark-grey); - text-align: center; + .scoring__materials, + .scoring__form { + padding: 20px; } - &__buttons { - display: flex; - gap: 20px; - align-items: center; - justify-content: center; + .scoring__actions .ui-button { + width: 100%; } -} -@include responsive.apply-mobile { - .card__wrapper { - padding: 16px; + .scoring-dialog { + width: 100%; + max-height: calc(100dvh - 80px); } - .card__inner, - .card__rated, - .card__experts, - .card__lower--info { - @include responsive.mobile-actions; + .scoring-dialog footer { + flex-direction: column; } - .card__info, - .card__naming { - min-width: 0; + .ui-button { + position: relative; } - .card__naming { - flex: 1; - width: auto; + .ui-button::after { + position: absolute; + inset: -6px 0; + content: ""; } - .card__title { - overflow: visible; - overflow-wrap: anywhere; + .scoring-dialog__close { + min-width: 44px; + min-height: 44px; } +} - .cancel__project { - flex-wrap: wrap; - } +.ui-button.ui-button { + height: 32px; + min-height: 32px; + padding: 0 16px; + font-size: 12px; + white-space: nowrap; } 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 0827d8460..6186122d4 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 @@ -88,12 +88,16 @@ describe("RatingCardComponent", () => { } const cta = () => - fixture.nativeElement.querySelector(".card__rated app-button button") as HTMLButtonElement; + fixture.nativeElement.querySelector(".scoring__actions button") as HTMLButtonElement; const criteria = () => fixture.debugElement.query(By.directive(ProjectRatingComponent)) .componentInstance as ProjectRatingComponent; function expectReadonly(disabled: boolean): void { + if (disabled) { + expect(fixture.nativeElement.querySelector("app-project-rating")).toBeNull(); + return; + } expect(criteria().disabled).toBe(disabled); for (const control of Object.values(criteria().form.controls)) expect(control.disabled).toBe(disabled); @@ -153,26 +157,44 @@ describe("RatingCardComponent", () => { expect(component).toBeTruthy(); }); + it("validates all required answers before opening confirmation, focuses the first invalid field", async () => { + render(); + criteria().form.get("1")!.setValue(null); + criteria().form.get("2")!.setValue(null); + cta().click(); + fixture.detectChanges(); + await Promise.resolve(); + expect(component.state.showConfirmRateModal()).toBe(false); + expect(criteria().form.get("1")!.touched).toBe(true); + expect(fixture.nativeElement.textContent).toContain("Выберите «Да» или «Нет»"); + expect(execute).not.toHaveBeenCalled(); + criteria().form.get("1")!.setValue(0); + criteria().chooseBoolean(2, false); + expect(component.hasUnsavedChanges()).toBe(true); + cta().click(); + expect(component.state.showConfirmRateModal()).toBe(true); + }); + it("keeps unrated CTA active and criteria editable when registration is past but evaluation is future", () => { render(); expect(TestBed.inject(ProgramDetailMainUIInfoService).registerDateExpired()).toBe(true); expect(cta()).not.toBeNull(); - expect(cta().textContent).toContain("оценить проект"); + expect(cta().textContent).toContain("Сохранить оценку"); expect(cta().disabled).toBe(false); expectReadonly(false); }); it("allows editing an existing rating until the evaluation deadline, then returns to readonly on success", async () => { render(true); - expect(cta().textContent).toContain("проект оценен"); + expect(fixture.nativeElement.textContent).toContain("Оценка сохранена"); expectReadonly(true); const edit = fixture.nativeElement.querySelector( - ".card__rated--icon button", + ".scoring__form > button", ) as HTMLButtonElement; expect(edit.disabled).toBe(false); edit.click(); fixture.detectChanges(); - expect(cta().textContent).toContain("подтвердить изменения"); + expect(cta().textContent).toContain("Сохранить оценку"); expect(cta().disabled).toBe(false); expectReadonly(false); criteria().form.get("1")!.setValue(4); @@ -180,28 +202,23 @@ describe("RatingCardComponent", () => { fixture.detectChanges(); await new Promise(resolve => setTimeout(resolve, 0)); fixture.detectChanges(); - document.querySelector(".cancel__button button")!.click(); + document.querySelector(".scoring-dialog footer button:last-child")!.click(); fixture.detectChanges(); await new Promise(resolve => setTimeout(resolve, 0)); expect(execute).toHaveBeenCalledOnce(); expect(execute.mock.calls[0][2]).toEqual({ 1: 4, 2: true, 3: "Note" }); - expect(cta().textContent).toContain("проект оценен"); + expect(fixture.nativeElement.textContent).toContain("Оценка сохранена"); expectReadonly(true); }); it.each([false, true])( - "keeps expired CTA visible and all criteria readonly (rated=%s)", + "shows the expired state without a save or edit action (rated=%s)", rated => { render(rated, past, future); - expect(cta()).not.toBeNull(); - expect(cta().textContent).toContain("оценивание завершено"); - expect(cta().disabled).toBe(true); - expect( - fixture.nativeElement.querySelector(".card__rated app-button").getAttribute("title"), - ).toBe("Срок оценивания завершен"); - expect(fixture.nativeElement.querySelector(".card__rated--icon")).toBeNull(); + expect(cta()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain("Срок оценивания завершен"); + expect(fixture.nativeElement.querySelector(".scoring__form > button")).toBeNull(); expectReadonly(true); - cta().click(); fixture.detectChanges(); expect(execute).not.toHaveBeenCalled(); }, @@ -210,7 +227,7 @@ describe("RatingCardComponent", () => { it.each(["", "invalid"])("keeps CTA and criteria available for %s deadline", evaluation => { render(false, evaluation); expect(cta().disabled).toBe(false); - expect(cta().textContent).toContain("оценить проект"); + expect(cta().textContent).toContain("Сохранить оценку"); expectReadonly(false); }); @@ -219,11 +236,12 @@ describe("RatingCardComponent", () => { const ui = TestBed.inject(ProgramDetailMainUIInfoService); ui.applyFormatingProgramData({ ...ui.program()!, datetimeEvaluationEnds: past }); fixture.detectChanges(); - expect(cta().textContent).toContain("оценивание завершено"); + expect(cta()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain("Срок оценивания завершен"); expectReadonly(true); ui.applyFormatingProgramData({ ...ui.program()!, datetimeEvaluationEnds: future }); fixture.detectChanges(); - expect(cta().textContent).toContain("оценить проект"); + expect(cta().textContent).toContain("Сохранить оценку"); expect(cta().disabled).toBe(false); expectReadonly(false); }); 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 181d9a399..c22019187 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 @@ -1,33 +1,25 @@ /** @format */ - import { - AfterViewInit, + ChangeDetectionStrategy, Component, - ElementRef, Input, - ViewChild, + computed, inject, - ChangeDetectionStrategy, viewChild, } from "@angular/core"; -import { ButtonComponent, IconComponent } from "@ui/primitives"; -import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; import { CommonModule } from "@angular/common"; -import { BreakpointObserver } from "@angular/cdk/layout"; -import { ProjectRatingComponent } from "./project-rating/project-rating.component"; -import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { ReactiveFormsModule } from "@angular/forms"; import { RouterLink } from "@angular/router"; -import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { ButtonDirective, IconComponent } from "@uilib"; import { ModalComponent } from "@ui/primitives/modal/modal.component"; -import { TruncatePipe, ControlErrorPipe, ParseBreaksPipe, ParseLinksPipe } from "@corelib"; import { ProjectRate } from "@domain/project/project-rate"; +import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; import { AppRoutes } from "@api/paths/app-routes"; -import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { presentationHref } from "@utils/program-case-analytics"; +import { ProjectRatingComponent } from "./project-rating/project-rating.component"; import { RatingCardService } from "./services/rating-card.service"; -import { ExpandService } from "@api/expand/expand.service"; -import { map } from "rxjs"; -/** Карточка оценки проекта экспертом: форма с критериями, навигация, переоценка. */ +/** One complete assessment, shared by the cabinet and the program entry. */ @Component({ selector: "app-rating-card", templateUrl: "./rating-card.component.html", @@ -35,114 +27,59 @@ import { map } from "rxjs"; imports: [ CommonModule, ReactiveFormsModule, - AvatarComponent, - IconComponent, - ButtonComponent, - ParseLinksPipe, - ParseBreaksPipe, - ProjectRatingComponent, - ControlErrorPipe, RouterLink, - TagComponent, + ButtonDirective, + IconComponent, ModalComponent, - TruncatePipe, + ProjectRatingComponent, ], + providers: [RatingCardService], changeDetection: ChangeDetectionStrategy.OnPush, - providers: [RatingCardService, ExpandService], }) -export class RatingCardComponent implements AfterViewInit { - private readonly ratingCardService = inject(RatingCardService); - private readonly expandService = inject(ExpandService); - private readonly breakpointObserver = inject(BreakpointObserver); - +export class RatingCardComponent { + readonly state = inject(RatingCardService); + readonly editor = viewChild(ProjectRatingComponent); + readonly orderedCriteria = computed(() => { + const criteria = this.state.project()?.criterias ?? []; + return [...criteria.filter(c => c.type !== "str"), ...criteria.filter(c => c.type === "str")]; + }); protected readonly AppRoutes = AppRoutes; - private readonly industryRepository = inject(IndustryRepositoryPort); + protected readonly presentationHref = presentationHref; - private readonly descEl = viewChild("descEl"); - - @Input({ required: true }) set project(proj: ProjectRate | null) { - this.ratingCardService.initProject(proj); + @Input({ required: true }) set project(value: ProjectRate | null) { + this.state.initProject(value); } - get project(): ProjectRate | null { - return this.ratingCardService.project(); + return this.state.project(); } - protected readonly desktopMode$ = this.breakpointObserver - .observe("(min-width: 1000px)") - .pipe(map(result => result.matches)); - - protected readonly descriptionExpandable = this.expandService.descriptionExpandable; - protected readonly readFullDescription = this.expandService.readFullDescription; - - 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; - protected readonly showRatingForm = this.ratingCardService.showRatingForm; - protected readonly showRatedStatus = this.ratingCardService.showRatedStatus; - protected readonly showConfirmedState = this.ratingCardService.showConfirmedState; - protected readonly submitLoading = this.ratingCardService.submitLoading; - protected readonly confirmLoading = this.ratingCardService.confirmLoading; - protected readonly isButtonDisabled = this.ratingCardService.isButtonDisabled; - protected readonly buttonOpacity = this.ratingCardService.buttonOpacity; - protected readonly buttonColor = this.ratingCardService.buttonColor; - protected readonly buttonTooltip = this.ratingCardService.buttonTooltip; - protected readonly rateButtonText = this.ratingCardService.rateButtonText; - protected readonly showEditButton = this.ratingCardService.showEditButton; - protected readonly showConfirmRateModal = this.ratingCardService.showConfirmRateModal; - - protected readonly industryRepositoryGetOne = (id: number) => this.industryRepository.getOne(id); - - ngAfterViewInit(): void { - setTimeout(() => { - this.expandService.checkExpandable("description", true, this.descEl()); - }); - } - - protected onExpandDescription(elem: HTMLElement): void { - this.expandService.onExpand( - "description", - elem, - "expanded", - this.expandService.readFullDescription(), + hasUnsavedChanges(): boolean { + return ( + !this.state.projectConfirmed() && + (!!this.editor()?.form.dirty || !!this.state.retainedAnswers().length) ); } - protected openPresentation(url: string): void { - if (url) { - window.open(url, "_blank"); - } - } - protected handleRateButtonClick(): void { - if (this.ratingCardService.canOpenModal()) { - this.ratingCardService.showConfirmRateModal.set(true); - } + if (!this.editor()?.validateAndFocus()) return; + if (this.state.canOpenModal() && !this.state.criteriaChanged()) + this.state.showConfirmRateModal.set(true); } - protected confirmRateProject(): void { - this.ratingCardService.form().markAsTouched(); - if (this.ratingCardService.form().invalid) return; - this.ratingCardService.confirmRateProject(); + this.state.confirmRateProject(); } - protected redoRating(): void { - this.ratingCardService.redoRating(); - } - - protected toggleConfirmRateModal(): void { - this.ratingCardService.showConfirmRateModal.set(!this.ratingCardService.showConfirmRateModal()); + this.state.redoRating(); } - protected closeConfirmRateModal(): void { - this.ratingCardService.showConfirmRateModal.set(false); + if (!this.state.submitLoading()) this.state.showConfirmRateModal.set(false); + } + protected answer(criterion: ProjectRatingCriterion): string { + const value = this.state.form().value?.[criterion.id] ?? criterion.value; + if (criterion.type === "bool") { + if (value == null || value === "") return "—"; + return String(value).toLowerCase() === "true" ? "Да" : "Нет"; + } + return value == null || value === "" ? "—" : String(value); } } 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 5ecc70803..fb9ddf47e 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 @@ -73,6 +73,15 @@ describe("RatingCardService evaluation modes", () => { afterEach(() => vi.restoreAllMocks()); + it("recognizes an existing own score from the real API ID list and permits editing at the cap", () => { + service.initProject({ ...project(true), scoredExpertId: undefined, ratedExperts: [user.id] }); + expect(service.showEditButton()).toBe(true); + service.redoRating(); + expect(service.canOpenModal()).toBe(true); + service.confirmRateProject(); + expect(service.ratedCount()).toBe(1); + }); + it("starts with create mode and returns to readonly after the first successful rating", () => { service.initProject(project()); expect(programUI.registerDateExpired()).toBe(true); 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 3cf1c6fd3..1cd0e6036 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 @@ -60,7 +60,10 @@ export class RatingCardService { const p = this.project(); if (!currentUser || !p) return false; - return p.ratedExperts.some(user => user.id === currentUser.id); + return ( + p.scored || + p.ratedExperts.some(user => (typeof user === "number" ? user : user.id) === currentUser.id) + ); }); readonly userRatedThisProject = computed(() => { @@ -143,6 +146,11 @@ export class RatingCardService { /** Инициализация начального состояния проекта. */ initProject(project: ProjectRate | null): void { if (!project) return; + this.form.set(new FormControl()); + this.locallyRatedByCurrentUser.set(false); + this.ratingError.set(null); + this.criteriaChanged.set(false); + this.retainedAnswers.set([]); this.project.set(project); const isScored = project.scored || false; this.projectConfirmed.set(isScored); @@ -193,7 +201,11 @@ export class RatingCardService { proj.ratedExperts = []; } - if (!proj.ratedExperts.some(user => user.id === profile.id)) { + if ( + !proj.ratedExperts.some( + user => (typeof user === "number" ? user : user.id) === profile.id, + ) + ) { proj.ratedExperts = [...proj.ratedExperts, profile]; isFirstTimeRating = true; } @@ -203,7 +215,8 @@ export class RatingCardService { this.ratedCount.update(count => count + 1); } - this.project.set({ ...proj }); + this.project.set({ ...proj, scored: true }); + this.form().markAsPristine(); this.showConfirmRateModal.set(false); }, }); diff --git a/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.html b/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.html new file mode 100644 index 000000000..2e68a8d00 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.html @@ -0,0 +1,234 @@ + +
+ {{ + projectId + ? fromCabinet + ? "В кабинет эксперта" + : "К оценке проектов" + : "На страницу программы" + }} +
+
+

{{ projectId ? project()?.name || "Оценка проекта" : "Оценка проектов" }}

+

{{ program()?.name }}

+ @if (program()?.datetimeEvaluationEnds; as ends) { +

+ {{ expired() ? "Оценивание завершено" : "Оценить до" }} + {{ ends | date: "dd.MM.yyyy, HH:mm" }} +

+ } +
+ @if (!projectId) { + + } +
+ @if (!projectId) { +
+
+ + +
+
+ + +
+ @if (exports.loadingExports$().status === "failure") { +

Не удалось выгрузить оценки. Попробуйте еще раз.

+ } + @if (!loading() && !failed() && !rows().length) { +
+

+ {{ + search.value || Object.keys(activeFilters).length + ? "Проекты не найдены" + : status() === "completed" + ? "Сохраненных оценок пока нет" + : counts().completed + ? "Все проекты оценены" + : "Пока нет проектов для оценки" + }} +

+

+ {{ + search.value || Object.keys(activeFilters).length + ? "Измените поиск или фильтры." + : "Здесь появятся назначенные вам проекты." + }} +

+
+ } + @if (rows().length) { + + + @if (rows().length < count()) { + + } + } +
+ } @else if (project(); as current) { + + } + @if (loading()) { +

Загружаем проекты…

+ } + @if (failed(); as error) { + + } +
+ +
+
+

Фильтры проектов

+ +
+
+ @for (field of fields(); track field.id) { + + } @empty { + @if (!filtersFailed()) { +

Для этой программы дополнительные фильтры не настроены.

+ } + } + @if (filtersFailed()) { +

Не удалось загрузить фильтры.

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

Выйти без сохранения?

+ +
+

Введенные ответы не сохранятся.

+
+ +
+
+
diff --git a/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.scss b/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.scss new file mode 100644 index 000000000..355f6d060 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.scss @@ -0,0 +1,297 @@ +/** @format */ +@use "styles/ui-foundation"; + +:host { + display: block; + min-width: 0; + color: var(--ui-text); +} + +.ui-button { + @include ui-foundation.button; + + min-height: 32px; + font-size: 12px; + text-decoration: none; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; + min-width: 0; + height: 41px; + min-height: 41px; + font-size: 12px; +} + +h1, +h2, +p, +ul { + margin: 0; +} + +h1 { + font-size: 18px; + font-weight: 600; + line-height: 27px; + overflow-wrap: anywhere; +} + +h2 { + font-size: 16px; + font-weight: 600; +} + +.rating-workspace { + display: grid; + gap: 20px; + max-width: 863px; + margin-inline: auto; +} + +.rating-workspace__back { + width: fit-content; + font-size: 12px; + color: var(--accent); + text-decoration: none; +} + +.rating-workspace__header { + display: flex; + gap: 16px; + align-items: start; + justify-content: space-between; +} + +.rating-workspace__header p { + margin-top: 4px; + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.rating-workspace__deadline { + color: var(--grey-for-text); +} + +.rating-workspace__export { + display: grid; + flex: 0 0 32px; + place-items: center; + width: 32px; + height: 32px; + color: var(--accent); + cursor: pointer; + background: var(--ui-surface); + border: 1px solid var(--ui-border); + border-radius: 10px; +} + +.rating-list { + display: grid; + gap: 20px; + padding: 20px; + background: var(--ui-surface); + border-radius: 15px; +} + +.rating-list__tabs { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.rating-list__toolbar { + display: flex; + gap: 12px; + align-items: center; +} + +.rating-list__toolbar input { + flex: 1; +} + +.rating-list__columns, +.rating-list__rows li { + display: grid; + grid-template-columns: minmax(0, 1fr) 100px 128px; + gap: 16px; + align-items: center; + padding: 12px; +} + +.rating-list__columns { + font-size: 12px; + background: var(--light-gray); +} + +.rating-list__rows { + padding: 0; + list-style: none; +} + +.rating-list__rows li { + min-height: 68px; + border-bottom: 1px solid var(--ui-border-subtle); +} + +.rating-list__rows h2 { + font-size: 14px; + line-height: 21px; + overflow-wrap: anywhere; +} + +.rating-list__status { + font-size: 12px; + color: var(--grey-for-text); +} + +.rating-list__status--done { + color: var(--green-dark); +} + +.rating-list__empty, +.rating-workspace__message { + display: grid; + gap: 12px; + justify-items: start; + padding: 24px; + font-size: 14px; + line-height: 1.5; +} + +.rating-workspace__message { + background: var(--ui-surface); + border-radius: 15px; +} + +.rating-dialog { + display: flex; + flex-direction: column; + gap: 24px; + width: min(480px, calc(100vw - 72px)); + max-height: calc(100dvh - 96px); + font-size: 14px; + line-height: 1.5; +} + +.rating-dialog header { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; +} + +.rating-dialog__close { + flex: 0 0 32px; + width: 32px; + height: 32px; + cursor: pointer; + background: var(--ui-border); + border: 0; + border-radius: 50%; +} + +.rating-dialog__body { + display: grid; + gap: 20px; + overflow-y: auto; +} + +.rating-dialog label { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; +} + +.rating-dialog footer { + display: flex; + gap: 12px; + justify-content: space-between; +} + +@media (max-width: 999px) { + .rating-workspace { + padding: 16px; + } + + .rating-list { + gap: 16px; + padding: 16px; + } + + .rating-list__tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .rating-list__tabs .ui-button { + padding-inline: 8px; + font-size: 10px; + white-space: nowrap; + } + + .rating-list__toolbar { + flex-wrap: wrap; + } + + .rating-list__toolbar input { + flex-basis: 100%; + } + + .rating-list__columns { + display: none; + } + + .rating-list__rows { + display: grid; + gap: 12px; + } + + .rating-list__rows li { + grid-template-columns: minmax(0, 1fr) 104px; + gap: 12px; + padding: 12px 0; + } + + .rating-list__status { + display: none; + } + + .rating-list__rows h2 { + font-size: 12px; + line-height: 18px; + } + + .rating-dialog { + width: 100%; + max-height: calc(100dvh - 80px); + } + + .rating-dialog__close, + .rating-workspace__export { + min-width: 44px; + min-height: 44px; + } + + .ui-button { + position: relative; + } + + .ui-button::after { + position: absolute; + inset: -6px 0; + content: ""; + } +} + +.ui-button.ui-button { + height: 32px; + min-height: 32px; + padding: 0 16px; + font-size: 12px; + white-space: nowrap; +} diff --git a/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.spec.ts new file mode 100644 index 000000000..d460a4fb9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.spec.ts @@ -0,0 +1,170 @@ +/** @format */ +import { signal } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ActivatedRoute, Router, convertToParamMap } from "@angular/router"; +import { BehaviorSubject, of } from "rxjs"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { GetProjectRatingsUseCase } from "@api/program/use-cases/get-project-ratings.use-case"; +import { FilterProjectRatingsUseCase } from "@api/program/use-cases/filter-project-ratings.use-case"; +import { GetProgramFiltersUseCase } from "@api/program/use-cases/get-program-filters.use-case"; +import { ExportFileInfoService } from "@api/export-file/facades/export-file-info.service"; +import { RatingCardComponent } from "../detail/list/rating-card/rating-card.component"; +import { fail, ok } from "@domain/shared/result.type"; +import { RatingWorkspaceComponent } from "./rating-workspace.component"; + +describe("Program assessment workspace", () => { + const get = vi.fn(), + post = vi.fn(), + navigate = vi.fn(); + let query: BehaviorSubject>; + let route: { + snapshot: { + paramMap: ReturnType; + queryParamMap: ReturnType; + }; + queryParamMap: typeof query; + }; + beforeEach(async () => { + get + .mockReset() + .mockReturnValue( + of(ok({ count: 2, results: [{ id: 42 }], evaluationCounts: { pending: 2, completed: 7 } })), + ); + post.mockReset().mockImplementation(get); + navigate.mockReset().mockResolvedValue(true); + query = new BehaviorSubject(convertToParamMap({})); + route = { + snapshot: { paramMap: convertToParamMap({}), queryParamMap: query.value }, + queryParamMap: query, + }; + await TestBed.configureTestingModule({ + imports: [RatingWorkspaceComponent], + providers: [ + { provide: ActivatedRoute, useValue: route }, + { + provide: Router, + useValue: { + url: "/office/program/7/projects-rating", + navigate, + parseUrl: (url: string) => url, + }, + }, + { provide: ProgramDetailMainUIInfoService, useValue: { program: signal({ id: 7 }) } }, + { provide: GetProjectRatingsUseCase, useValue: { execute: get } }, + { provide: FilterProjectRatingsUseCase, useValue: { execute: post } }, + { provide: GetProgramFiltersUseCase, useValue: { execute: () => of(ok([])) } }, + ], + }) + .overrideComponent(RatingWorkspaceComponent, { + set: { + template: "", + imports: [], + providers: [{ provide: ExportFileInfoService, useValue: {} }], + }, + }) + .compileComponents(); + }); + function create() { + const fixture = TestBed.createComponent(RatingWorkspaceComponent); + fixture.detectChanges(); + return fixture.componentInstance; + } + it("uses own status and authoritative counts, independently of loaded rows", () => { + const component = create(); + expect(get.mock.calls[0][0]).toBe(7); + expect(get.mock.calls[0][1].get("evaluation_status")).toBe("pending"); + expect(get.mock.calls[0][1].has("is_rated_by_expert")).toBe(false); + expect(component.counts()).toEqual({ pending: 2, completed: 7 }); + expect(component.rows()).toHaveLength(1); + query.next(convertToParamMap({ status: "completed", q: "Город" })); + const params = get.mock.calls.at(-1)![1]; + expect(params.get("evaluation_status")).toBe("completed"); + expect(params.get("name__contains")).toBe("Город"); + }); + it("preserves configured field filters through the existing POST contract", () => { + query.next(convertToParamMap({ "filter.case": ["A", "B"] })); + create(); + expect(post.mock.calls[0][1]).toEqual({ case: ["A", "B"] }); + expect(post.mock.calls[0][2].get("evaluation_status")).toBe("pending"); + }); + it("restores historical search, raw field filters and the legacy any-expert filter", () => { + const params = convertToParamMap({ + name__contains: "Город", + case: "A", + is_rated_by_expert: "true", + }); + route.snapshot.queryParamMap = params; + query.next(params); + TestBed.overrideProvider(GetProgramFiltersUseCase, { + useValue: { + execute: () => + of(ok([{ id: 1, name: "case", label: "Кейс", fieldType: "select", options: ["A"] }])), + }, + }); + const component = create(); + expect(component.search.value).toBe("Город"); + expect(post.mock.calls.at(-1)![1]).toEqual({ case: ["A"] }); + expect(post.mock.calls.at(-1)![2].get("is_rated_by_expert")).toBe("true"); + component.navigate({ q: null }); + expect(navigate.mock.calls.at(-1)![1].queryParams).toEqual({ q: null, name__contains: null }); + }); + it("shows an explicit error if the API does not supply authoritative tab counts", () => { + get.mockReturnValue(of(ok({ count: 1, results: [{ id: 42 }] }))); + const component = create(); + expect(component.rows()).toEqual([]); + expect(component.failed()).toContain("данные об оценках"); + }); + it("does not expose a form on permission errors", () => { + get.mockReturnValue( + of( + fail({ kind: "get_project_ratings_error", cause: new HttpErrorResponse({ status: 403 }) }), + ), + ); + route.snapshot.paramMap = convertToParamMap({ projectId: "42" }); + const component = create(); + expect(component.project()).toBeNull(); + expect(component.failed()).toContain("нет доступа"); + }); + it.each([ + "https://example.com", + "//example.com", + "/office/program/8/projects-rating", + "/office/program/7/projects-rating?project_id=42", + ])("rejects an unrelated return destination %s", url => { + route.snapshot.queryParamMap = convertToParamMap({ returnUrl: url }); + expect(create().returnUrl).toBe("/office/program/7/projects-rating"); + }); + it("normalizes old cabinet links to the shared form route and preserves the source", () => { + route.snapshot.queryParamMap = convertToParamMap({ + project_id: "42", + returnUrl: "/office/evaluation?tab=completed&program=7", + }); + create(); + expect(navigate).toHaveBeenCalledWith(["/office/program/7/projects-rating", 42], { + queryParams: { returnUrl: "/office/evaluation?tab=completed&program=7" }, + replaceUrl: true, + }); + expect(get).not.toHaveBeenCalled(); + }); + it("blocks leaving during submission and offers a centered-dialog decision for unsaved input", async () => { + const component = create(), + busy = signal(false), + confirm = signal(true); + vi.spyOn(component, "card").mockReturnValue({ + state: { submitLoading: busy, showConfirmRateModal: confirm }, + hasUnsavedChanges: () => true, + } as unknown as RatingCardComponent); + busy.set(true); + expect(component.canLeave()).toBe(false); + busy.set(false); + const pending = component.canLeave(); + expect(component.leaveOpen()).toBe(true); + expect(confirm()).toBe(false); + component.answerLeave(false); + expect(await pending).toBe(false); + const leave = component.canLeave(); + component.answerLeave(true); + expect(await leave).toBe(true); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.ts b/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.ts new file mode 100644 index 000000000..9b79ef43f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/rating/rating-workspace.component.ts @@ -0,0 +1,292 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + HostListener, + OnDestroy, + OnInit, + computed, + effect, + inject, + signal, + viewChild, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { HttpErrorResponse, HttpParams } from "@angular/common/http"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Observable, Subscription, debounceTime, distinctUntilChanged } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Result } from "@domain/shared/result.type"; +import { ButtonDirective, FieldDirective, IconComponent } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { GetProjectRatingsUseCase } from "@api/program/use-cases/get-project-ratings.use-case"; +import { FilterProjectRatingsUseCase } from "@api/program/use-cases/filter-project-ratings.use-case"; +import { GetProgramFiltersUseCase } from "@api/program/use-cases/get-program-filters.use-case"; +import { ExportFileInfoService } from "@api/export-file/facades/export-file-info.service"; +import { EvaluationNavigationService } from "@api/program/evaluation-navigation.service"; +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; +import { ProjectRate } from "@domain/project/project-rate"; +import { RatingCardComponent } from "../detail/list/rating-card/rating-card.component"; + +@Component({ + selector: "app-rating-workspace", + templateUrl: "./rating-workspace.component.html", + styleUrl: "./rating-workspace.component.scss", + imports: [ + CommonModule, + RouterLink, + ReactiveFormsModule, + ButtonDirective, + FieldDirective, + IconComponent, + ModalComponent, + RatingCardComponent, + ], + providers: [ExportFileInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RatingWorkspaceComponent implements OnInit, OnDestroy { + protected readonly Object = Object; + private readonly route = inject(ActivatedRoute); + readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly getRatings = inject(GetProjectRatingsUseCase); + private readonly filterRatings = inject(FilterProjectRatingsUseCase); + private readonly getFilters = inject(GetProgramFiltersUseCase); + readonly exports = inject(ExportFileInfoService); + readonly navigation = inject(EvaluationNavigationService); + readonly program = inject(ProgramDetailMainUIInfoService).program; + readonly card = viewChild(RatingCardComponent); + readonly more = viewChild>("more"); + readonly rows = signal([]); + readonly project = signal(null); + readonly loading = signal(false); + readonly failed = signal(null); + readonly unauthorized = signal(false); + readonly count = signal(0); + readonly counts = signal({ pending: 0, completed: 0 }); + readonly status = signal<"pending" | "completed">("pending"); + readonly fields = signal([]); + readonly filtersFailed = signal(false); + readonly filtersOpen = signal(false); + readonly leaveOpen = signal(false); + readonly search = new FormControl("", { nonNullable: true }); + readonly projectId = Number(this.route.snapshot.paramMap.get("projectId")) || null; + readonly base = `/office/program/${this.program()?.id}/projects-rating`; + readonly returnUrl = this.validReturn(this.route.snapshot.queryParamMap.get("returnUrl")); + readonly returnTree = this.router.parseUrl(this.returnUrl); + readonly fromCabinet = this.returnUrl.startsWith("/office/evaluation"); + readonly expired = computed(() => { + const end = Date.parse(this.program()?.datetimeEvaluationEnds ?? ""); + return Number.isFinite(end) && end < Date.now(); + }); + activeFilters: Record = {}; + draftFilters: Record = {}; + private request?: Subscription; + private visibleLimit = 10; + private resolveLeave?: (leave: boolean) => void; + + constructor() { + effect(onCleanup => { + const target = this.more()?.nativeElement; + if (!target || this.loading() || this.failed()) return; + const observer = new IntersectionObserver( + entries => { + if (entries.some(entry => entry.isIntersecting) && !this.loading()) this.load(true); + }, + { root: document.querySelector(".office__body"), rootMargin: "120px" }, + ); + observer.observe(target); + onCleanup(() => observer.disconnect()); + }); + } + + ngOnInit(): void { + const oldId = Number(this.route.snapshot.queryParamMap.get("project_id")); + if (!this.projectId && Number.isSafeInteger(oldId) && oldId > 0) { + void this.router.navigate([this.base, oldId], { + queryParams: { returnUrl: this.returnUrl }, + replaceUrl: true, + }); + return; + } + this.search.valueChanges + .pipe(debounceTime(250), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe(q => this.navigate({ q: q.trim() || null })); + this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => { + this.status.set(params.get("status") === "completed" ? "completed" : "pending"); + this.search.setValue(params.get("q") ?? params.get("name__contains") ?? "", { + emitEvent: false, + }); + this.activeFilters = {}; + for (const key of params.keys) + if (key.startsWith("filter.")) this.activeFilters[key.slice(7)] = params.getAll(key); + for (const field of this.fields()) { + if (!this.activeFilters[field.name] && params.has(field.name)) + this.activeFilters[field.name] = params.getAll(field.name); + } + this.visibleLimit = this.navigation.limit(this.router.url); + this.load(); + }); + if (!this.projectId) this.loadFilters(); + } + loadFilters(): void { + this.filtersFailed.set(false); + this.getFilters + .execute(this.program()!.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + if (result.ok) { + this.fields.set( + result.value.filter(field => ["select", "radio", "checkbox"].includes(field.fieldType)), + ); + // Existing bookmarks used raw field names, before namespaced filter URLs. + const params = this.route.snapshot.queryParamMap; + let restored = false; + for (const field of this.fields()) { + if (!this.activeFilters[field.name] && params.has(field.name)) { + this.activeFilters[field.name] = params.getAll(field.name); + restored = true; + } + } + if (restored) this.load(); + } else this.filtersFailed.set(true); + }); + } + load(append = false): void { + this.request?.unsubscribe(); + this.loading.set(true); + this.failed.set(null); + this.unauthorized.set(false); + if (!append) { + this.rows.set([]); + this.project.set(null); + } + let params = new HttpParams() + .set("limit", append ? 10 : this.visibleLimit) + .set("offset", append ? this.rows().length : 0); + if (this.projectId) params = params.set("project_id", this.projectId); + else { + params = params.set("evaluation_status", this.status()); + if (this.search.value.trim()) params = params.set("name__contains", this.search.value.trim()); + const legacyRated = this.route.snapshot.queryParamMap.get("is_rated_by_expert"); + if (legacyRated !== null) params = params.set("is_rated_by_expert", legacyRated); + } + const source: Observable< + Result, { kind: string; cause?: unknown }> + > = + !this.projectId && Object.keys(this.activeFilters).length + ? this.filterRatings.execute(this.program()!.id, this.activeFilters, params) + : this.getRatings.execute(this.program()!.id, params); + this.request = source.subscribe(result => { + this.loading.set(false); + if (!result.ok) { + const error = result.error.cause; + this.unauthorized.set(error instanceof HttpErrorResponse && error.status === 401); + this.failed.set( + error instanceof HttpErrorResponse && error.status === 403 + ? "У вас нет доступа к оценке проектов этой программы." + : "Не удалось загрузить проекты. Попробуйте еще раз.", + ); + return; + } + if (this.projectId) { + this.project.set(result.value.results.find(p => p.id === this.projectId) ?? null); + if (!this.project()) + this.failed.set("Проект недоступен для оценки. Возможно, назначение было отменено."); + } else { + this.rows.update(rows => + append ? [...rows, ...result.value.results] : result.value.results, + ); + this.visibleLimit = this.rows().length; + this.count.set(result.value.count); + const data = result.value as typeof result.value & { + evaluationCounts?: { pending: number; completed: number }; + }; + if (!data.evaluationCounts) { + this.rows.set([]); + this.failed.set("Не удалось загрузить данные об оценках. Обновите страницу."); + return; + } + this.counts.set(data.evaluationCounts); + if (!append) this.navigation.restore(this.router.url); + } + }); + } + navigate(queryParams: Record): void { + if ("q" in queryParams) queryParams = { ...queryParams, name__contains: null }; + void this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: "merge", + }); + } + setStatus(status: "pending" | "completed"): void { + this.navigate({ status: status === "pending" ? null : status }); + } + openFilters(): void { + this.draftFilters = structuredClone(this.activeFilters); + this.filtersOpen.set(true); + } + setFilter(name: string, event: Event): void { + const select = event.target as HTMLSelectElement; + this.draftFilters[name] = Array.from(select.selectedOptions, option => option.value).filter( + Boolean, + ); + } + applyFilters(): void { + const query: Record = {}; + for (const name of new Set([ + ...Object.keys(this.activeFilters), + ...Object.keys(this.draftFilters), + ])) { + query[`filter.${name}`] = this.draftFilters[name]?.length ? this.draftFilters[name] : null; + query[name] = null; + } + this.filtersOpen.set(false); + this.navigate(query); + } + remember(): void { + this.navigation.remember(this.router.url, this.visibleLimit); + } + canLeave(): Promise | boolean { + if (this.card()?.state.submitLoading()) return false; + if (!this.card()?.hasUnsavedChanges()) return true; + this.card()?.state.showConfirmRateModal.set(false); + if (this.resolveLeave) return false; + this.leaveOpen.set(true); + return new Promise(resolve => { + this.resolveLeave = resolve; + }); + } + answerLeave(leave: boolean): void { + this.leaveOpen.set(false); + this.resolveLeave?.(leave); + this.resolveLeave = undefined; + } + @HostListener("window:beforeunload", ["$event"]) + beforeUnload(event: BeforeUnloadEvent): void { + if (this.card()?.hasUnsavedChanges() || this.card()?.state.submitLoading()) { + event.preventDefault(); + event.returnValue = ""; + } + } + ngOnDestroy(): void { + this.request?.unsubscribe(); + this.resolveLeave?.(false); + } + private validReturn(value: string | null): string { + if ( + value && + (/^\/office\/evaluation(?:\?[^#]*)?$/.test(value) || value.split("?")[0] === this.base) && + !value.includes("project_id=") + ) + return value; + return this.base; + } +} diff --git a/projects/social_platform/src/app/ui/routes/program/detail.routes.ts b/projects/social_platform/src/app/ui/routes/program/detail.routes.ts index 2bd0a0e76..c15f66332 100644 --- a/projects/social_platform/src/app/ui/routes/program/detail.routes.ts +++ b/projects/social_platform/src/app/ui/routes/program/detail.routes.ts @@ -11,9 +11,18 @@ import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/p import { ProgramAnalyticsComponent } from "@ui/pages/program/detail/analytics/analytics.component"; import { ProgramManagementComponent } from "@ui/pages/program/management/management.component"; import { canLeaveManagement } from "./management-leave.guard"; +import { RatingWorkspaceComponent } from "@ui/pages/program/rating/rating-workspace.component"; /** Маршруты детальной страницы программы: информация, проекты и участники. */ export const PROGRAM_DETAIL_ROUTES: Routes = [ + ...["projects-rating", "projects-rating/:projectId"].map(path => ({ + path, + component: RatingWorkspaceComponent, + providers: [ProgramDetailMainUIInfoService], + resolve: { data: ProgramDetailResolver }, + canDeactivate: [canLeaveManagement], + runGuardsAndResolvers: "pathParamsChange" as const, + })), { path: "solutions/new", loadComponent: () => @@ -75,11 +84,6 @@ export const PROGRAM_DETAIL_ROUTES: Routes = [ }, data: { listType: "members" }, }, - { - path: "projects-rating", - component: ProgramListComponent, - data: { listType: "rating" }, - }, { path: "analytics", component: ProgramAnalyticsComponent, 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 index af39cd959..339bca3f8 100644 --- a/projects/social_platform/src/app/utils/evaluation-criterion-control.spec.ts +++ b/projects/social_platform/src/app/utils/evaluation-criterion-control.spec.ts @@ -23,7 +23,7 @@ describe("Shared expert / preview criterion validation", () => { expect(control.invalid).toBe(true); } }); - it("supports negative scales, legacy fractions, No, and optional 50-character comments", () => { + it("supports negative scales, legacy fractions, deliberate No, and optional 1000-character comments", () => { expect(evaluationCriterionControl({ ...definition, minValue: -5, value: -2 }).valid).toBe(true); expect(evaluationCriterionControl({ ...definition, type: "float", value: 0.5 }).valid).toBe( true, @@ -31,9 +31,16 @@ describe("Shared expert / preview criterion validation", () => { const no = evaluationCriterionControl({ ...definition, type: "bool", value: "False" }); expect(no.value).toBe(false); expect(no.valid).toBe(true); + const unanswered = evaluationCriterionControl({ ...definition, type: "bool" }); + expect(unanswered.value).toBeNull(); + expect(unanswered.invalid).toBe(true); + unanswered.setValue(false); + expect(unanswered.valid).toBe(true); const comment = evaluationCriterionControl({ ...definition, type: "str" }); expect(comment.valid).toBe(true); - comment.setValue("a".repeat(51)); + comment.setValue("я".repeat(1000)); + expect(comment.valid).toBe(true); + comment.setValue("я".repeat(1001)); 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 index c02a0e122..99dc3632f 100644 --- a/projects/social_platform/src/app/utils/evaluation-criterion-control.ts +++ b/projects/social_platform/src/app/utils/evaluation-criterion-control.ts @@ -5,8 +5,12 @@ 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)); + if (c.type === "bool") { + const value = + c.value == null || c.value === "" ? null : String(c.value).toLowerCase() === "true"; + return new FormControl(value, Validators.required); + } + if (c.type === "str") return new FormControl(c.value ?? "", Validators.maxLength(1000)); const validators = [ Validators.required, (control: AbstractControl) => {