diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.html b/projects/social_platform/src/app/office/features/info-card/info-card.component.html index 02e4dfa9..4bfc300a 100644 --- a/projects/social_platform/src/app/office/features/info-card/info-card.component.html +++ b/projects/social_platform/src/app/office/features/info-card/info-card.component.html @@ -239,17 +239,19 @@

Вы действительно хотите отписаться от пр
- Отписаться + отписаться - Отменить + отменить
diff --git a/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.html b/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.html index 8473c2f3..e8863398 100644 --- a/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.html +++ b/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.html @@ -7,6 +7,7 @@ [min]="0" [max]="max" [step]="1" + [disabled]="disabled" (input)="onInput($event)" (keydown)="onKeydown($event)" (blur)="onBlur()" diff --git a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.html b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.html index 78cbe6da..7b4709ee 100644 --- a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.html +++ b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.html @@ -7,6 +7,7 @@ class="rating__input" [max]="criterion.maxValue!" [formControlName]="criterion.id" + [disabled]="disabled" > @@ -15,6 +16,7 @@ diff --git a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts index c47be125..ab008d29 100644 --- a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts +++ b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts @@ -102,6 +102,8 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, private _disabled = false; + @Input() currentUserId!: number; + /** Сигнал для хранения критериев оценки */ _criteria = signal([]); diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.html b/projects/social_platform/src/app/office/program/detail/list/list.component.html index 6b734975..31d170ce 100644 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.html +++ b/projects/social_platform/src/app/office/program/detail/list/list.component.html @@ -13,7 +13,7 @@
- Фильтр + Фильтры
@@ -40,7 +40,7 @@ @if (listType !== 'members') {
- @if (listType === 'projects') { + @if (listType === 'projects' || listType === 'rating') {
@@ -50,19 +50,13 @@ (touchmove)="onSwipeMove($event)" (touchend)="onSwipeEnd($event)" >
- +
- } @else { -
-

фильтр

-
- -
-
}
diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.scss b/projects/social_platform/src/app/office/program/detail/list/list.component.scss index f57575e4..3401734d 100644 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.scss +++ b/projects/social_platform/src/app/office/program/detail/list/list.component.scss @@ -87,23 +87,6 @@ } } - &__controls { - display: flex; - flex-direction: column; - gap: 14px; - margin-top: 18px; - } - - &__tags { - display: flex; - gap: 12px; - align-items: center; - - p { - color: var(--grey-for-text); - } - } - &__bar { position: fixed; display: flex; diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.ts b/projects/social_platform/src/app/office/program/detail/list/list.component.ts index 49cb4263..d24d859c 100644 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.ts +++ b/projects/social_platform/src/app/office/program/detail/list/list.component.ts @@ -57,20 +57,12 @@ import { tagsFilter } from "projects/core/src/consts/filters/tags-filter.const"; ProjectsFilterComponent, SearchComponent, RatingCardComponent, - CheckboxComponent, InfoCardComponent, ], standalone: true, }) export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { constructor() { - const isRatedByExpert = - this.route.snapshot.queryParams["is_rated_by_expert"] === "true" - ? true - : this.route.snapshot.queryParams["is_rated_by_expert"] === "false" - ? false - : null; - const searchValue = this.route.snapshot.queryParams["search"] || this.route.snapshot.queryParams["name__contains"]; @@ -79,10 +71,6 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { this.searchForm = this.fb.group({ search: [decodedSearchValue], }); - - this.filterForm = this.fb.group({ - filterTag: [isRatedByExpert, Validators.required], - }); } @ViewChild("listRoot") listRoot?: ElementRef; @@ -102,7 +90,6 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { private availableFilters: PartnerProgramFields[] = []; searchForm: FormGroup; - filterForm: FormGroup; listTotalCount?: number; listPage = 0; @@ -157,10 +144,6 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { } this.setupFilters(); - - if (this.listType === "rating") { - this.setupRatingQueryParams(); - } } ngAfterViewInit(): void { @@ -233,93 +216,57 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { } private setupFilters(): void { - if (this.listType !== "projects") return; + if (this.listType === "members") return; const filtersObservable$ = this.route.queryParams .pipe( - distinctUntilChanged(), concatMap(q => { - const reqQuery = this.buildFilterQuery(q); + const { filters, extraParams } = this.buildFilterQuery(q); const programId = this.route.parent?.snapshot.params["programId"]; - if (JSON.stringify(reqQuery) !== JSON.stringify(this.previousReqQuery)) { - this.previousReqQuery = reqQuery; + const filtersChanged = + JSON.stringify(filters) !== JSON.stringify(this.previousReqQuery["filters"]); + const extraParamsChanged = + JSON.stringify(extraParams) !== JSON.stringify(this.previousReqQuery["filters"]); + + this.previousReqQuery = { filters, extraParams }; - const hasFilters = - reqQuery && reqQuery["filters"] && Object.keys(reqQuery["filters"]).length > 0; - const params = new HttpParams({ fromObject: { offset: 0, limit: this.perPage } }); + const params = new HttpParams({ + fromObject: { + offset: 0, + limit: this.itemsPerPage, + ...extraParams, + }, + }); - if (hasFilters) { - return this.programService.createProgramFilters(programId, reqQuery["filters"]).pipe( - catchError(err => { - console.error("createFilters failed, fallback to getAllProjects()", err); - return this.programService.getAllProjects(programId, params); - }) - ); + if (this.listType === "rating") { + if (Object.keys(filters).length > 0) { + return this.projectRatingService.postFilters(programId, filters, params); } - return this.programService.getAllProjects(programId, params).pipe( - catchError(err => { - console.error("getAllProjects failed", err); - return this.programService.getAllProjects(programId, params); - }) - ); + return this.projectRatingService.getAll(programId, params); + } + + if (Object.keys(filters).length > 0) { + return this.programService.createProgramFilters(programId, filters); } - return of(null); + return this.programService.getAllProjects(programId, params); }) ) .subscribe(result => { - if (result && typeof result !== "number") { - this.list = result.results; - this.searchedList = result.results; - this.listTotalCount = result.count; - this.listPage = 0; - this.cdref.detectChanges(); - } + if (!result) return; + + this.list = result.results; + this.searchedList = result.results; + this.listTotalCount = result.count; + this.listPage = 0; + this.cdref.detectChanges(); }); this.subscriptions$.push(filtersObservable$); } - private setupRatingQueryParams(): void { - const queryParams$ = this.route.queryParams - .pipe( - debounceTime(200), - tap(params => { - const isRatedByExpert = - params["is_rated_by_expert"] === "true" - ? true - : params["is_rated_by_expert"] === "false" - ? false - : undefined; - const searchValue = params["name__contains"] || ""; - - this.isRatedByExpert.set(isRatedByExpert); - this.searchValue.set(searchValue); - }), - switchMap(() => { - this.listPage = 0; - return this.onFetch(); - }) - ) - .subscribe(); - - this.subscriptions$.push(queryParams$); - } - - // Методы фильтрации - setValue(event: Event): void { - event.stopPropagation(); - this.filterForm.get("filterTag")?.setValue(!this.filterForm.get("filterTag")?.value); - - this.router.navigate([], { - queryParams: { is_rated_by_expert: this.filterForm.get("filterTag")?.value }, - relativeTo: this.route, - queryParamsHandling: "merge", - }); - } - // Универсальный метод скролла private onScroll() { if (this.listTotalCount && this.list.length >= this.listTotalCount) return of({}); @@ -392,7 +339,7 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { case "rating": return this.projectRatingService - .getAll(programId, offset, this.itemsPerPage, this.isRatedByExpert(), this.searchValue()) + .getAll(programId, new HttpParams({ fromObject: { offset, limit: this.itemsPerPage } })) .pipe( tap(({ count, results }) => { this.listTotalCount = count; @@ -411,28 +358,39 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { } } - // Построение запроса для фильтров (только для проектов) + // Построение запроса для фильтров (кроме участников) private buildFilterQuery(q: any): Record { - if (this.listType !== "projects") return {}; + if (this.listType === "members") return {}; const filters: Record = {}; + const extraParams: Record = {}; + + Object.keys(q).forEach(key => { + const value = q[key]; + if (value === undefined || value === "") return; + + // ⭐ Для rating search → name__contains + if (this.listType === "rating" && key === "search") { + extraParams["name__contains"] = value; + return; + } + + // ⭐ Эти два ключа должны идти ТОЛЬКО как GET-параметры + if (this.listType === "rating" && key === "name__contains") { + extraParams["name__contains"] = value; + return; + } + + if (this.listType === "rating" && key === "is_rated_by_expert") { + extraParams["is_rated_by_expert"] = value; + return; + } + + // ⭐ Для других ключей: обычные filters + filters[key] = Array.isArray(value) ? value : [value]; + }); - if (this.availableFilters.length === 0) { - Object.keys(q).forEach(key => { - if (key !== "search" && q[key] !== undefined && q[key] !== "") { - filters[key] = Array.isArray(q[key]) ? q[key] : [q[key]]; - } - }); - } else { - this.availableFilters.forEach((filter: PartnerProgramFields) => { - const value = q[filter.name]; - if (value !== undefined && value !== "") { - filters[filter.name] = Array.isArray(value) ? value : [value]; - } - }); - } - - return { filters }; + return { filters, extraParams }; } onFiltersLoaded(filters: PartnerProgramFields[]): void { @@ -482,6 +440,24 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { this.isFilterOpen = false; } + /** + * Сброс всех активных фильтров + * Очищает все query параметры и возвращает к состоянию по умолчанию + */ + onClearFilters(): void { + this.searchForm.reset(); + + this.router + .navigate([], { + queryParams: { + search: undefined, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => console.log("Query change from ProjectsComponent")); + } + private get itemsPerPage(): number { return this.listType === "rating" ? 8 @@ -493,4 +469,19 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { private get searchParamName(): string { return this.listType === "rating" ? "name__contains" : "search"; } + + private flattenFilters(filters: Record): Record { + const flattened: Record = {}; + + Object.keys(filters).forEach(key => { + const value = filters[key]; + if (Array.isArray(value) && value.length > 0) { + flattened[key] = Array.isArray(value[0]) ? value.join(",") : value.toString(); + } else if (value !== undefined && value !== null) { + flattened[key] = value.toString(); + } + }); + + return flattened; + } } diff --git a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html b/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html index 4b9fd6f7..c0589595 100644 --- a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html +++ b/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html @@ -1,7 +1,7 @@

фильтры

- + cбросить
@if (filters()?.length) { @@ -52,7 +52,12 @@ } } - } } } + } } } @if (listType === 'rating') { + + } } diff --git a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.scss b/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.scss index 062d2b3e..e26d7494 100644 --- a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.scss +++ b/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.scss @@ -34,6 +34,16 @@ flex-direction: column; } + &__tags { + display: flex; + gap: 12px; + align-items: center; + + p { + color: var(--grey-for-text); + } + } + &__titles { display: flex; align-items: center; diff --git a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.ts b/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.ts index 08eb9147..beafb5ac 100644 --- a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.ts +++ b/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.ts @@ -1,8 +1,8 @@ /** @format */ -import { Component, EventEmitter, OnInit, Output, signal } from "@angular/core"; +import { Component, EventEmitter, Input, OnInit, Output, signal } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { debounceTime, distinctUntilChanged, map, Subscription } from "rxjs"; +import { debounceTime, distinctUntilChanged, filter, map, Subscription } from "rxjs"; import { SwitchComponent } from "@ui/components/switch/switch.component"; import { CheckboxComponent, SelectComponent } from "@ui/components"; import { ProgramService } from "@office/program/services/program.service"; @@ -67,6 +67,8 @@ export class ProjectsFilterComponent implements OnInit { this.filterForm = this.fb.group({}); } + @Input() listType?: "projects" | "members" | "rating"; + @Output() clear = new EventEmitter(); @Output() filtersLoaded = new EventEmitter(); // Константы для фильтрации по типу проекта @@ -75,18 +77,20 @@ export class ProjectsFilterComponent implements OnInit { ngOnInit(): void { this.programId = this.route.parent?.snapshot.params["programId"]; - this.programService.getProgramFilters(this.programId).subscribe({ - next: filter => { - this.filters.set(filter); - this.initializeFilterForm(); - this.restoreFiltersFromUrl(); - this.subscribeToFormChanges(); - this.filtersLoaded.emit(filter); - }, - error(err) { - console.log(err); - }, - }); + if (this.listType === "projects" || this.listType === "rating") { + this.programService.getProgramFilters(this.programId).subscribe({ + next: filter => { + this.filters.set(filter); + this.initializeFilterForm(); + this.restoreFiltersFromUrl(); + this.subscribeToFormChanges(); + this.filtersLoaded.emit(filter); + }, + error(err) { + console.log(err); + }, + }); + } } ngOnDestroy(): void { @@ -119,6 +123,14 @@ export class ProjectsFilterComponent implements OnInit { } } + // Методы фильтрации + setValue(event: Event): void { + event.stopPropagation(); + this.filterForm + .get("is_rated_by_expert") + ?.setValue(!this.filterForm.get("is_rated_by_expert")?.value); + } + /** * Сброс всех активных фильтров * Очищает все query параметры и возвращает к состоянию по умолчанию @@ -129,12 +141,14 @@ export class ProjectsFilterComponent implements OnInit { this.router .navigate([], { queryParams: { - search: undefined, + is_rated_by_expert: undefined, }, relativeTo: this.route, queryParamsHandling: "merge", }) .then(() => console.log("Query change from ProjectsComponent")); + + this.clear.emit(); } private initializeFilterForm(): void { @@ -147,6 +161,17 @@ export class ProjectsFilterComponent implements OnInit { formControls[field.name] = new FormControl(initialValue, validators); }); + if (this.listType === "rating") { + const isRatedByExpert = + this.route.snapshot.queryParams["is_rated_by_expert"] === "true" + ? true + : this.route.snapshot.queryParams["is_rated_by_expert"] === "false" + ? false + : null; + + formControls["is_rated_by_expert"] = new FormControl(isRatedByExpert); + } + this.filterForm = this.fb.group(formControls); } @@ -179,8 +204,8 @@ export class ProjectsFilterComponent implements OnInit { Object.keys(formValue).forEach(fieldName => { const value = formValue[fieldName]; - const field = this.filters()?.find(f => f.name === fieldName); + const field = this.filters()?.find(f => f.name === fieldName); if (this.shouldAddToQueryParams(value, field?.fieldType)) { currentParams[fieldName] = value; } else { diff --git a/projects/social_platform/src/app/office/program/models/project-rate.ts b/projects/social_platform/src/app/office/program/models/project-rate.ts index 733dcff5..d66ab8e5 100644 --- a/projects/social_platform/src/app/office/program/models/project-rate.ts +++ b/projects/social_platform/src/app/office/program/models/project-rate.ts @@ -1,5 +1,6 @@ /** @format */ +import { User } from "projects/ui/src/lib/models/user.model"; import { ProjectRatingCriterion } from "./project-rating-criterion"; // Assuming this is where ProjectRatingCriterion is declared /** @@ -32,7 +33,10 @@ export interface ProjectRate { region: string; viewsCount: number; industry: number; - criterias: ProjectRatingCriterion[]; scored: boolean; scoredExpertId: number | null; + ratedExperts: User[]; + ratedCount: number; + maxRates: number; + criterias: ProjectRatingCriterion[]; } diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts b/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts index 016a0a2c..26b98394 100644 --- a/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts +++ b/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts @@ -25,4 +25,5 @@ export interface ProjectRatingCriterion { minValue: number | null; maxValue: number | null; value: string | number; + expertId: number; } diff --git a/projects/social_platform/src/app/office/program/services/project-rating.service.ts b/projects/social_platform/src/app/office/program/services/project-rating.service.ts index ef14c158..e30cb490 100644 --- a/projects/social_platform/src/app/office/program/services/project-rating.service.ts +++ b/projects/social_platform/src/app/office/program/services/project-rating.service.ts @@ -43,24 +43,22 @@ export class ProjectRatingService { constructor(private readonly apiService: ApiService) {} - getAll( - id: number, - skip: number, - take: number, - isRatedByExpert?: boolean, - nameContains?: string + getAll(programId: number, params?: HttpParams): Observable> { + return this.apiService.get(`${this.RATE_PROJECT_URL}/${programId}`, params); + } + + postFilters( + programId: number, + filters: Record, + params?: HttpParams ): Observable> { - return this.apiService.get( - `${this.RATE_PROJECT_URL}/${id}`, - new HttpParams({ - fromObject: { - ...(nameContains !== undefined && { name__contains: nameContains }), - ...(isRatedByExpert !== undefined && { is_rated_by_expert: isRatedByExpert }), - limit: take, - offset: skip, - }, - }) - ); + let url = `${this.RATE_PROJECT_URL}/${programId}`; + + if (params) { + url += `?${params.toString()}`; + } + + return this.apiService.post(url, { filters: filters }); } rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable { diff --git a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.html b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.html index 5ebd8c9e..9d2b0c0a 100644 --- a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.html +++ b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.html @@ -60,10 +60,15 @@ } + +
+

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

+ +
-
+

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

@@ -71,7 +76,12 @@ @if (form | controlError: "required"; as error) {
@@ -87,19 +97,13 @@ customTypographyClass="text-body-12" size="big" [loader]="submitLoading() || confirmLoading()" - [color]="showRatedStatus ? 'green' : 'primary'" - [disabled]="programDateFinished()" - [appearance]="programDateFinished() ? 'outline' : 'inline'" - [style.opacity]="showConfirmedState ? '0.5' : '1'" - (click)="showRatingForm ? showConfirmRateModal.set(true) : null" + [disabled]="isButtonDisabled" + [style.opacity]="buttonOpacity" + [color]="buttonColor" + [title]="buttonTooltip" + (click)="handleRateButtonClick()" > - {{ - programDateFinished() - ? "проект оценен" - : showRatingForm - ? "оценить проект" - : "проект оценен" - }} + {{ rateButtonText }} @if (showEditButton) { @@ -110,7 +114,7 @@ class="card__rated--icon" (click)="redoRating()" > - + }
@@ -135,7 +139,7 @@ }
diff --git a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.scss b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.scss index 48c50c4d..ec49a83a 100644 --- a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.scss +++ b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.scss @@ -77,6 +77,16 @@ } } + &__experts { + display: flex; + gap: 5px; + align-items: center; + + p { + color: var(--grey-for-text); + } + } + &__lower { display: flex; flex-direction: column; diff --git a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts index d61afa80..8d55f75d 100644 --- a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts +++ b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts @@ -5,11 +5,9 @@ import { ChangeDetectorRef, Component, ElementRef, - EventEmitter, Input, OnDestroy, OnInit, - Output, signal, ViewChild, } from "@angular/core"; @@ -39,8 +37,8 @@ import { TagComponent } from "@ui/components/tag/tag.component"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { ProgramDataService } from "@office/program/services/program-data.service"; import { AuthService } from "@auth/services"; -import { User } from "@auth/models/user.model"; import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { HttpResponse } from "@angular/common/http"; /** * Компонент карточки оценки проекта @@ -50,25 +48,14 @@ import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; * * Принимает: * @Input project: ProjectRate | null - Текущий проект для оценки - * @Input projects: ProjectRate[] | null - Список всех проектов - * @Input currentIndex: number - Индекс текущего проекта в списке - * - * Генерирует: - * @Output onNext: EventEmitter - Событие перехода к следующему проекту - * @Output onPrev: EventEmitter - Событие перехода к предыдущему проекту - * - * Зависимости: - * @param {IndustryService} industryService - Сервис для работы с отраслями - * @param {ProjectRatingService} projectRatingService - Сервис оценки проектов - * @param {BreakpointObserver} breakpointObserver - Для адаптивного дизайна - * @param {ChangeDetectorRef} cdRef - Для ручного обновления представления * * Функциональность: * - Отображение информации о проекте (название, описание, изображения) * - Форма оценки с различными типами критериев * - Возможность развернуть/свернуть описание проекта - * - Навигация между проектами * - Отправка оценки и обработка результата + * - Поддержка переоценки для пользователей, которые уже оценили + * - Блокировка оценки при достижении лимита (только для тех, кто не оценивал) * - Адаптивный дизайн для мобильных устройств * * Состояния: @@ -78,7 +65,8 @@ import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; * @property {Signal} descriptionExpandable - Можно ли развернуть описание * @property {Signal} projectRated - Оценен ли проект (временно) * @property {Signal} projectConfirmed - Подтверждена ли оценка окончательно - * @property {Signal} confirmLoading - Состояние загрузки при подтверждении + * @property {Signal} locallyRatedByCurrentUser - Оценил ли пользователь локально + * @property {Signal} ratedCount - Количество оценок проекта */ @Component({ selector: "app-rating-card", @@ -91,7 +79,6 @@ import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; AvatarComponent, IconComponent, ButtonComponent, - AvatarComponent, ParseLinksPipe, ParseBreaksPipe, ProjectRatingComponent, @@ -127,7 +114,7 @@ export class RatingCardComponent implements OnInit, AfterViewInit, OnDestroy { _currentIndex = signal(0); _projects = signal([]); - profile = signal(null); + profile = signal(null); form = new FormControl(); @@ -147,6 +134,7 @@ export class RatingCardComponent implements OnInit, AfterViewInit, OnDestroy { locallyRatedByCurrentUser = signal(false); isProjectCriterias = signal(0); + ratedCount = signal(0); programDateFinished = signal(false); @@ -161,6 +149,7 @@ export class RatingCardComponent implements OnInit, AfterViewInit, OnDestroy { const isScored = this.project?.scored || false; this.projectConfirmed.set(isScored); this.projectRated.set(isScored); + this.ratedCount.set(this.project.ratedCount); } const program$ = this.programDataService.program$ @@ -214,6 +203,9 @@ export class RatingCardComponent implements OnInit, AfterViewInit, OnDestroy { this.readFullDescription.set(!isExpanded); } + /** + * Подтверждение оценки проекта + */ confirmRateProject(): void { this.form.markAsTouched(); if (this.form.invalid) return; @@ -229,18 +221,55 @@ export class RatingCardComponent implements OnInit, AfterViewInit, OnDestroy { .pipe(finalize(() => this.submitLoading.set(false))) .subscribe({ next: () => { + const profile = this.profile(); + const project = this.project as ProjectRate; + this.locallyRatedByCurrentUser.set(true); this.projectRated.set(true); this.projectConfirmed.set(true); + + let isFirstTimeRating = false; + + if (profile) { + if (!Array.isArray(project.ratedExperts)) { + project.ratedExperts = []; + } + + // Проверяем, первый ли раз пользователь оценивает + if (!project.ratedExperts.includes(profile.id)) { + project.ratedExperts = [...project.ratedExperts, profile.id]; + isFirstTimeRating = true; + } + } + + // Увеличиваем счетчик только при первой оценке + if (isFirstTimeRating) { + this.ratedCount.update(count => count + 1); + } + + this._project.set({ ...project }); this.showConfirmRateModal.set(false); }, + error: err => { + if (err instanceof HttpResponse) { + if (err.status === 400) { + console.error("Ошибка: достигнут максимальный лимит оценок"); + } + } + }, }); } + /** + * Переоценка проекта + * Сбрасываем статусы, но НЕ удаляем пользователя из списка оценивших + * После этого пользователь может заново оценить проект + */ redoRating(): void { this.projectRated.set(false); this.projectConfirmed.set(false); - this.locallyRatedByCurrentUser.set(false); + // locallyRatedByCurrentUser остается true, так как пользователь уже в списке оценивших + // После сброса статусов кнопка станет "оценить проект" и откроет модалку } openPresentation(url: string) { @@ -267,19 +296,158 @@ export class RatingCardComponent implements OnInit, AfterViewInit, OnDestroy { return isExpertFromBackend || isExpertLocally; } + /** + * Проверяет, может ли пользователь оценить проект + * Условия: + * 1. Программа не завершена + * 2. Либо лимит не достигнут, либо пользователь уже оценивал (может переоценить) + */ + get canRate(): boolean { + if (this.programDateFinished()) return false; + + // Если лимит достигнут, но пользователь уже оценивал - разрешаем переоценку + if (this.isLimitReached && !this.userRatedThisProject) return false; + + return true; + } + + /** + * Текст кнопки в зависимости от состояния + */ + get rateButtonText(): string { + if (this.programDateFinished()) return "программа завершена"; + if (this.projectConfirmed() && this.userRatedThisProject) return "проект оценен"; + if (this.isLimitReached && !this.userRatedThisProject) return "лимит оценок достигнут"; + + return "оценить проект"; + } + + /** + * Показывать ли форму оценки + */ get showRatingForm(): boolean { return !this.projectRated() && this.canEdit; } + /** + * Показывать ли статус "оценено" + */ get showRatedStatus(): boolean { return this.projectRated() || this.projectConfirmed(); } + /** + * Показывать ли кнопку редактирования + * Только если пользователь оценил проект, программа не завершена + */ get showEditButton(): boolean { - return this.showRatedStatus && this.canEdit && this.isCurrentUserExpert; + return this.projectConfirmed() && !this.programDateFinished() && this.userRatedThisProject; } + /** + * Проверяет, можно ли открыть модальное окно оценки + * Модальное окно открывается только для: + * 1. Первой оценки (когда пользователь не оценивал и лимит не превышен) + * 2. Переоценки (когда пользователь нажал кнопку редактирования) + * + * НЕ открывается когда проект уже оценен и пользователь просто кликает на зеленую кнопку + */ + get canOpenModal(): boolean { + // Если проект подтвержден и оценен - НЕ открываем модалку по клику на кнопку + if (this.projectConfirmed() && this.userRatedThisProject) return false; + + // В остальных случаях проверяем canRate + return this.canRate; + } + + /** + * Проверяет, оценил ли текущий пользователь этот проект + */ + get userRatedThisProject(): boolean { + const profile = this.profile(); + const project = this.project; + + if (!profile || !project) return false; + + return ( + this.locallyRatedByCurrentUser() || + (Array.isArray(project.ratedExperts) && project.ratedExperts.includes(profile.id)) + ); + } + + /** + * Должна ли кнопка быть неактивной + */ + get isButtonDisabled(): boolean { + // Если лимит достигнут и пользователь не оценивал - блокируем + if (this.isLimitReached && !this.userRatedThisProject) return true; + + // Если программа завершена - блокируем + if (this.programDateFinished()) return true; + + // В остальных случаях проверяем canRate + return !this.canRate; + } + + /** + * Цвет кнопки + */ + get buttonColor(): "green" | "primary" { + if (this.userRatedThisProject) return "green"; + return "primary"; + } + + /** + * Прозрачность кнопки + */ + get buttonOpacity(): string { + return this.isButtonDisabled ? "0.5" : "1"; + } + + /** + * Проверяет, достигнут ли лимит оценок + */ + get isLimitReached(): boolean { + return !!this.project && this.project.ratedCount >= this.project.maxRates; + } + + /** + * Показывать ли состояние "подтверждено" + */ get showConfirmedState(): boolean { - return this.projectConfirmed() && !this.canEdit; + return ( + (this.projectConfirmed() && !this.canEdit) || + (this.isLimitReached && !this.userRatedThisProject) + ); + } + + /** + * Обработка клика по кнопке оценки + */ + handleRateButtonClick(): void { + // Открываем модальное окно только если можно оценить + if (this.canOpenModal) { + this.showConfirmRateModal.set(true); + } + } + + /** + * Дополнительная проверка для визуального состояния кнопки + */ + get buttonTooltip(): string { + if (this.programDateFinished()) return "Программа завершена"; + if (this.isLimitReached && !this.userRatedThisProject) { + return "Достигнут максимальный лимит оценок"; + } + if (this.userRatedThisProject) return "Нажмите для переоценки"; + return "Нажмите для оценки проекта"; + } + + /** + * Должна ли форма в модалке быть отключена + * Форма отключена для просмотра, пользователь подтверждает без изменений + */ + get isModalFormDisabled(): boolean { + return true; // Всегда disabled в модалке для подтверждения } } diff --git a/projects/social_platform/src/app/office/services/subscription.service.ts b/projects/social_platform/src/app/office/services/subscription.service.ts index 920b17fd..16785f7c 100644 --- a/projects/social_platform/src/app/office/services/subscription.service.ts +++ b/projects/social_platform/src/app/office/services/subscription.service.ts @@ -57,7 +57,7 @@ export class SubscriptionService { * @returns Observable> - объект с массивом проектов и метаданными пагинации */ getSubscriptions(userId: number, params?: HttpParams): Observable> { - return this.apiService.get(`${this.AUTH_USERS_URL}/${userId}/subscribed_projects`, params); + return this.apiService.get(`${this.AUTH_USERS_URL}/${userId}/subscribed_projects/`, params); } /**