diff --git a/docs/expert-workspace.md b/docs/expert-workspace.md new file mode 100644 index 000000000..ccb3aa38d --- /dev/null +++ b/docs/expert-workspace.md @@ -0,0 +1,80 @@ + + +# Эксперты и личное оценивание + +Организатор получает пятую вкладку «Эксперты» в управлении программой: приглашения +по email, состав и нагрузку, ручное назначение проектов, предпросмотр и применение +автораспределения, изменение необходимого числа оценщиков. + +Макеты: + +- [Управление экспертами](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=293-3220) +- [Путь приглашённого эксперта](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=314-3220) +- [Оценивание — Мои программы](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=331-3220) + +## Переходы и роли + +`/office/evaluation` всегда открывает «Мои программы», даже с одной программой. +Кнопка программы «Оценка проектов» открывает непосредственно её +`/office/program/{id}/projects-rating`; карточка в кабинете ведёт туда же. + +Меню доступно принявшим приглашение экспертам и пользователям с действующим +приглашением на их email. При нуле назначений вход остаётся, показано пустое +состояние. Обычный участник не видит оценивание; организатор видит управление; +эксперт — оценку; совмещающий обе роли — обе кнопки. Роль проверяется в текущей +программе, глобальный user_type не является источником разрешения. + +Права и ограничения повторно проверяются backend при прямом запросе. +Принятие приглашения сбрасывает кеш сведений о программе и обновляет профиль. +Возврат в кабинет после оценки обновляет личные счётчики. + +## Приглашение + +`/expert-invitation/{token}` находится вне Office, чтобы новый пользователь мог +пройти путь до входа. Открытие ссылки ничего не создаёт и не принимает. + +Новый аккаунт: фиксированный email, имя, фамилия, пароль/повтор и действующие +согласия платформы (не отмечены заранее). Даты рождения в этом сценарии нет. +Создание и принятие атомарны; повторное подтверждение email не отправляется. +Обязательный onboarding не перекрывает переход к оценке, но остальные сценарии +платформы сохраняют свои требования. + +Существующий аккаунт: вход, затем отдельное принятие приглашения. Другая почта +показывает объяснение и смену аккаунта. Восстановление пароля сохраняет контекст +приглашения, после смены пароля возвращает на вход, не авторизует автоматически. +Ссылка из письма открывает `/expert-password-reset/` в той же Auth-оболочке; +этот SPA route не пересекается с API `/auth/` на совмещённом DEV-host. +Просроченная/отозванная ссылка и повторное принятие имеют отдельные состояния. +На странице приглашения действует no-referrer; ссылка не передаётся в Referer. + +## UI и ограничения + +Используются существующие Modal/CDK focus trap, Button/Field/Choice/Avatar/Alert, +Mont, семантические токены и текущая оболочка. Все новые модалки центрированы, +высота ограничена viewport, длинные списки прокручиваются внутри. Desktop — +компактная таблица, 390/360 — карточки. Выбор строки/кружка повторным нажатием +снимается. Поиск сохраняет фокус и текст; выбранный кейс остаётся в select. + +Защищённые назначения с оценками не снимаются. При 409 выбор остаётся в редакторе; +нужно обновить данные перед повторной записью. Pending не доступен для назначения. +Автораспределение заполняет дефицит до N, исключает свои команды, сохраняет текущие +назначения и показывает нагрузку до/после. Нехватка кандидатов блокирует применение. +Изменение N предварительно показывает влияние на готовность; оценки не удаляются. + +## API и rollout + +Новый `EvaluationRepositoryPort` → HTTP adapter/repository → use case → UI; доменная +логика не размещается в компонентах. Используется существующее преобразование +snake_case/camelCase. Контракт и миграции — `docs/expert-workspace.md` в API repo. +Backend должен быть выложен раньше Angular. Новая таблица приглашений и nullable +birthday обязательны до открытия новых маршрутов. + +Новый кабинет показывает назначения (`assigned_only=1`), поэтому существующим +экспертам перед началом работы следует назначить проекты через новую вкладку. +Backend сохраняет прежний open mode для старых клиентов до явного включения +управляемого режима организатором. Production/React rollout в эту задачу не входит. + +AC1–AC9 и правила объединены в implementation spec. Feature tests находятся рядом +с ExpertsComponent и ExpertInvitationComponent; рейтинг проверяется существующим +RatingCardService suite. Browser acceptance использует настоящий изолированный +Django/PostgreSQL, синтетические аккаунты и locmem mail; реальных писем не отправляет. diff --git a/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts b/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts index 8a8f51347..ce313336e 100644 --- a/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts +++ b/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts @@ -87,6 +87,13 @@ export class AuthPasswordService { .pipe( tap(result => { if (result.ok) { + const invitation = this.route.snapshot.queryParamMap.get("expert_invitation"); + if (invitation && /^[A-Za-z0-9_-]{20,100}$/.test(invitation)) { + void this.router.navigate(["/expert-invitation", invitation], { + queryParams: { mode: "login" }, + }); + return; + } this.router .navigateByUrl(AppRoutes.auth.login()) .then(() => this.logger.debug("SetPasswordComponent")); diff --git a/projects/social_platform/src/app/api/office/facades/office-info.service.ts b/projects/social_platform/src/app/api/office/facades/office-info.service.ts index 79e945a07..4ae810deb 100644 --- a/projects/social_platform/src/app/api/office/facades/office-info.service.ts +++ b/projects/social_platform/src/app/api/office/facades/office-info.service.ts @@ -70,11 +70,16 @@ export class OfficeInfoService { ) .subscribe(profile => { this.officeUIInfoService.applyCreateNavItems(profile!.id); - if (!profile!.doesCompleted()) { + const expertRoute = + this.router.url.startsWith("/office/evaluation") || + /^\/office\/program\/\d+\/projects-rating(?:[/?]|$)/.test(this.router.url); + const expertEntry = expertRoute && profile!.relations.hasEvaluationAccess === true; + if (!profile!.doesCompleted() && !expertEntry) { this.router .navigateByUrl(AppRoutes.onboarding.root()) .then(() => this.logger.debug("Route changed from OfficeComponent")); } else if ( + !expertEntry && profile!.relations.verificationDate === null && profile!.relations.verificationNoticeAcknowledgedAt === null ) { diff --git a/projects/social_platform/src/app/api/program/facades/evaluation-state.service.ts b/projects/social_platform/src/app/api/program/facades/evaluation-state.service.ts new file mode 100644 index 000000000..11f5a205e --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/evaluation-state.service.ts @@ -0,0 +1,52 @@ +/** @format */ +import { DestroyRef, Injectable, computed, effect, inject, signal, untracked } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { EvaluationUseCase } from "../use-cases/evaluation.use-case"; +import { EvaluationProgram } from "@domain/program/evaluation-workspace.model"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; + +@Injectable({ providedIn: "root" }) +export class EvaluationStateService { + private readonly api = inject(EvaluationUseCase); + private readonly profiles = inject(ProfileInfoService); + private readonly destroyRef = inject(DestroyRef); + readonly programs = signal([]); + readonly pending = signal(false); + readonly failed = signal(false); + readonly hasAccess = computed(() => this.programs().length > 0); + private accountId: number | null = null; + private sequence = 0; + constructor() { + effect(() => { + const id = this.profiles.profile()?.id ?? null; + untracked(() => { + if (id === this.accountId) return; + this.accountId = id; + this.sequence++; + this.programs.set([]); + this.failed.set(false); + if (id) this.reload(); + }); + }); + } + reload(): void { + const sequence = ++this.sequence; + this.pending.set(true); + this.failed.set(false); + this.api + .programs() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: data => { + if (sequence !== this.sequence) return; + this.programs.set(data.results); + this.pending.set(false); + }, + error: () => { + if (sequence !== this.sequence) return; + this.failed.set(true); + this.pending.set(false); + }, + }); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/evaluation.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/evaluation.use-case.ts new file mode 100644 index 000000000..7d7ee68c0 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/evaluation.use-case.ts @@ -0,0 +1,40 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { EvaluationRepositoryPort } from "@domain/program/ports/evaluation.repository.port"; +import { EventBus } from "@domain/shared/event-bus"; +import { ProgramUpdated } from "@domain/program/program-updated.event"; +import { ExpertRegistration } from "@domain/program/evaluation-workspace.model"; +import { tap } from "rxjs"; +@Injectable({ providedIn: "root" }) +export class EvaluationUseCase { + private readonly repository = inject(EvaluationRepositoryPort); + private readonly events = inject(EventBus); + private updated(programId: number): void { + this.events.emit({ + type: "ProgramUpdated", + payload: { programId }, + occurredAt: new Date(), + }); + } + readonly programs = this.repository.programs.bind(this.repository); + readonly workspace = this.repository.workspace.bind(this.repository); + readonly invite = this.repository.invite.bind(this.repository); + readonly invitationAction = this.repository.invitationAction.bind(this.repository); + readonly assignments = this.repository.assignments.bind(this.repository); + readonly saveAssignments = this.repository.saveAssignments.bind(this.repository); + readonly allocation = this.repository.allocation.bind(this.repository); + readonly applyAllocation = this.repository.applyAllocation.bind(this.repository); + readonly quota = this.repository.quota.bind(this.repository); + readonly saveQuota = this.repository.saveQuota.bind(this.repository); + readonly invitation = this.repository.invitation.bind(this.repository); + accept(token: string) { + return this.repository.accept(token).pipe(tap(r => this.updated(r.programId))); + } + acceptOwn(id: number) { + return this.repository.acceptOwn(id).pipe(tap(r => this.updated(r.programId))); + } + register(token: string, data: ExpertRegistration) { + return this.repository.register(token, data).pipe(tap(r => this.updated(r.programId))); + } + readonly resetPassword = this.repository.resetPassword.bind(this.repository); +} diff --git a/projects/social_platform/src/app/app.config.ts b/projects/social_platform/src/app/app.config.ts index a1d970480..6ecfc2ef6 100644 --- a/projects/social_platform/src/app/app.config.ts +++ b/projects/social_platform/src/app/app.config.ts @@ -15,6 +15,7 @@ import { BrowserModule } from "@angular/platform-browser"; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; import { provideRouter, withRouterConfig } from "@angular/router"; import { APP_ROUTES } from "./app.routes"; +import { EVALUATION_PROVIDERS } from "./infrastructure/di/program/evaluation.providers"; import { API_URL, BearerTokenInterceptor, @@ -95,6 +96,7 @@ export const APP_CONFIG: ApplicationConfig = { ), provideAnimations(), ...AUTH_PROVIDERS, + ...EVALUATION_PROVIDERS, ...FEED_PROVIDERS, ...INDUSTRY_PROVIDERS, ...INVITE_PROVIDERS, diff --git a/projects/social_platform/src/app/app.routes.ts b/projects/social_platform/src/app/app.routes.ts index d114f479c..e5915755a 100644 --- a/projects/social_platform/src/app/app.routes.ts +++ b/projects/social_platform/src/app/app.routes.ts @@ -15,6 +15,26 @@ import { AuthRequiredGuard } from "@core/lib/guards/auth/auth-required.guard"; */ export const APP_ROUTES: Routes = [ + { + path: "expert-password-reset", + loadComponent: () => import("./ui/pages/auth/auth.component").then(c => c.AuthComponent), + children: [ + { + path: "", + loadComponent: () => + import("./ui/pages/auth/set-password/set-password.component").then( + c => c.SetPasswordComponent, + ), + }, + ], + }, + { + path: "expert-invitation/:token", + loadComponent: () => + import("./ui/pages/evaluation/invitation/expert-invitation.component").then( + c => c.ExpertInvitationComponent, + ), + }, { path: "", component: AppComponent, diff --git a/projects/social_platform/src/app/domain/auth/user.model.ts b/projects/social_platform/src/app/domain/auth/user.model.ts index 196f5bb38..be0ffdf12 100644 --- a/projects/social_platform/src/app/domain/auth/user.model.ts +++ b/projects/social_platform/src/app/domain/auth/user.model.ts @@ -65,6 +65,7 @@ export class UserSubscription { } export class UserRelations { + hasEvaluationAccess?: boolean; education!: Education[]; userLanguages!: UserLanguages[]; workExperience!: WorkExperience[]; diff --git a/projects/social_platform/src/app/domain/program/evaluation-workspace.model.ts b/projects/social_platform/src/app/domain/program/evaluation-workspace.model.ts new file mode 100644 index 000000000..feee42090 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/evaluation-workspace.model.ts @@ -0,0 +1,100 @@ +/** @format */ +export interface ExpertRow { + id: number | string; + userId?: number; + invitationId?: number; + name: string | null; + email: string; + avatar?: string | null; + status: "joined" | "pending" | "expired" | "revoked"; + deliveryStatus?: "queued" | "sent" | "failed"; + assigned: number | null; + completed: number | null; + remaining: number | null; +} +export interface ExpertWorkspace { + revision: string; + requiredEvaluators: number; + managedEvaluation: boolean; + submittedProjects: number; + missingAssignments: number; + readyProjects: number; + experts: ExpertRow[]; +} +export interface AssignmentProject { + id: number; + name: string; + caseName: string | null; + submitted: boolean; + assigned: boolean; + protected: boolean; + completed: boolean; + ownTeam: boolean; +} +export interface AssignmentOptions { + revision: string; + projects: AssignmentProject[]; +} +export interface AllocationQuery { + expertIds: number[]; + projectIds?: number[]; +} +export interface AllocationPlan { + revision: string; + planToken: string; + assignments: { projectId: number; projectName: string; expertId: number; expertName: string }[]; + shortages: { projectId: number; projectName: string; missing: number }[]; + load: { expertId: number; name: string; before: number; after: number }[]; +} +export interface QuotaPreview { + revision: string; + requiredEvaluators: number; + readyBefore: number; + readyAfter: number; + missingAssignments: number; + surplusAssignments: number; +} +export interface EvaluationProgram { + id: number; + name: string; + imageAddress: string | null; + imageFraming?: import("./program-image-framing.model").ProgramImageFraming; + datetimeEvaluationEnds: string | null; + status: "joined" | "pending"; + email?: string; + invitationId?: number; + assigned?: number; + completed?: number; + remaining?: number; +} +export interface EvaluationPrograms { + results: EvaluationProgram[]; + hasAccess: boolean; +} +export interface ExpertInvitation { + id: number; + email: string; + status: "pending" | "accepted" | "expired" | "revoked"; + expiresAt: string; + program: EvaluationProgram; + accountExists: boolean; + loginEmail?: string; + matchesAccount: boolean; +} +export interface ExpertRegistration { + firstName: string; + lastName: string; + password: string; + repeatPassword: string; + acceptPolicy: boolean; + acceptAge: boolean; +} +export interface InvitationAccepted { + programId: number; + accepted: boolean; +} +export interface ExpertRegistered { + access: string; + refresh: string; + programId: number; +} diff --git a/projects/social_platform/src/app/domain/program/ports/evaluation.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/evaluation.repository.port.ts new file mode 100644 index 000000000..8fd4f73c9 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/ports/evaluation.repository.port.ts @@ -0,0 +1,44 @@ +/** @format */ +import { Observable } from "rxjs"; +import { + EvaluationPrograms, + ExpertWorkspace, + AssignmentOptions, + AllocationQuery, + AllocationPlan, + QuotaPreview, + ExpertInvitation, + InvitationAccepted, + ExpertRegistration, + ExpertRegistered, +} from "@domain/program/evaluation-workspace.model"; +export abstract class EvaluationRepositoryPort { + abstract programs(): Observable; + abstract workspace(id: number): Observable; + abstract invite(id: number, email: string): Observable; + abstract invitationAction( + id: number, + invitationId: number, + action: "resend" | "revoke", + ): Observable; + abstract assignments(id: number, expertId: number): Observable; + abstract saveAssignments( + id: number, + expertId: number, + revision: string, + projectIds: number[], + ): Observable; + abstract allocation(id: number, query: AllocationQuery): Observable; + abstract applyAllocation(id: number, planToken: string): Observable; + abstract quota(id: number, requiredEvaluators: number): Observable; + abstract saveQuota( + id: number, + requiredEvaluators: number, + revision: string, + ): Observable; + abstract invitation(token: string): Observable; + abstract accept(token: string): Observable; + abstract acceptOwn(id: number): Observable; + abstract register(token: string, data: ExpertRegistration): Observable; + abstract resetPassword(token: string, email: string): Observable; +} diff --git a/projects/social_platform/src/app/domain/program/program-management.model.ts b/projects/social_platform/src/app/domain/program/program-management.model.ts index 7b1781939..fda76f0e8 100644 --- a/projects/social_platform/src/app/domain/program/program-management.model.ts +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -5,6 +5,7 @@ import { ProgramImageFraming } from "./program-image-framing.model"; export type ManagementSection = | "participants" | "projects" + | "experts" | "analytics" | "settings" | "evaluation"; diff --git a/projects/social_platform/src/app/domain/program/program.model.ts b/projects/social_platform/src/app/domain/program/program.model.ts index 499255db2..a98012cfb 100644 --- a/projects/social_platform/src/app/domain/program/program.model.ts +++ b/projects/social_platform/src/app/domain/program/program.model.ts @@ -36,6 +36,7 @@ export class Program { isUserLiked!: boolean; isUserManager!: boolean; isUserExpert?: boolean; + pendingExpertInvitationId?: number | null; isUserMember!: boolean; currentApplication!: ProgramCurrentApplication | null; welcomeAcknowledgedAt!: string | null; 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 d6091f2ec..9a5942612 100644 --- a/projects/social_platform/src/app/domain/project/project-rate.ts +++ b/projects/social_platform/src/app/domain/project/project-rate.ts @@ -5,6 +5,7 @@ import { ProjectRatingCriterion } from "./project-rating-criterion"; // Assuming /** Интерфейс проекта для оценки */ export interface ProjectRate { + managedEvaluation?: boolean; criteriaRevision?: string; id: number; name: string; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.ts new file mode 100644 index 000000000..300cd3681 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.ts @@ -0,0 +1,81 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { + EvaluationPrograms, + ExpertWorkspace, + AssignmentOptions, + AllocationQuery, + AllocationPlan, + QuotaPreview, + ExpertInvitation, + InvitationAccepted, + ExpertRegistration, + ExpertRegistered, +} from "@domain/program/evaluation-workspace.model"; +import { EvaluationRepositoryPort } from "@domain/program/ports/evaluation.repository.port"; +@Injectable({ providedIn: "root" }) +export class EvaluationHttpAdapter implements EvaluationRepositoryPort { + private readonly api = inject(ApiService); + private root(id: number) { + return `/programs/${id}/management`; + } + programs(): Observable { + return this.api.get("/evaluation/programs/"); + } + workspace(id: number): Observable { + return this.api.get(`${this.root(id)}/experts/`); + } + invite(id: number, email: string): Observable { + return this.api.post(`${this.root(id)}/experts/`, { email }); + } + invitationAction( + id: number, + invitationId: number, + action: "resend" | "revoke", + ): Observable { + return this.api.post(`${this.root(id)}/expert-invitations/${invitationId}/${action}/`, {}); + } + assignments(id: number, expertId: number): Observable { + return this.api.get(`${this.root(id)}/experts/${expertId}/assignments/`); + } + saveAssignments( + id: number, + expertId: number, + revision: string, + projectIds: number[], + ): Observable { + return this.api.put(`${this.root(id)}/experts/${expertId}/assignments/`, { + revision, + projectIds, + }); + } + allocation(id: number, query: AllocationQuery): Observable { + return this.api.post(`${this.root(id)}/expert-allocation/`, query); + } + applyAllocation(id: number, planToken: string): Observable { + return this.api.post(`${this.root(id)}/expert-allocation/apply/`, { planToken }); + } + quota(id: number, requiredEvaluators: number): Observable { + return this.api.post(`${this.root(id)}/evaluators/`, { requiredEvaluators }); + } + saveQuota(id: number, requiredEvaluators: number, revision: string): Observable { + return this.api.put(`${this.root(id)}/evaluators/`, { requiredEvaluators, revision }); + } + invitation(token: string): Observable { + return this.api.get(`/evaluation/invitations/${encodeURIComponent(token)}/`); + } + accept(token: string): Observable { + return this.api.post(`/evaluation/invitations/${encodeURIComponent(token)}/accept/`, {}); + } + acceptOwn(id: number): Observable { + return this.api.post(`/evaluation/own-invitations/${id}/accept/`, {}); + } + register(token: string, data: ExpertRegistration): Observable { + return this.api.post(`/evaluation/invitations/${encodeURIComponent(token)}/register/`, data); + } + resetPassword(token: string, email: string): Observable { + return this.api.post("/auth/reset_password/", { email, expertInvitationToken: token }); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.spec.ts index b043223ab..77a4f3e00 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.spec.ts @@ -42,7 +42,10 @@ describe("ProjectRatingHttpAdapter", () => { adapter.getAll(5, params).subscribe(); - expect(api.get).toHaveBeenCalledExactlyOnceWith("/rate-project/5", params); + expect(api.get).toHaveBeenCalledExactlyOnceWith("/rate-project/5", expect.any(HttpParams)); + const sent = api.get.mock.calls[0][1] as HttpParams; + expect(sent.get("limit")).toBe("10"); + expect(sent.get("assigned_only")).toBe("1"); }); it("postFilters идёт в POST /rate-project/:programId c query и body", () => { @@ -52,18 +55,20 @@ describe("ProjectRatingHttpAdapter", () => { adapter.postFilters(5, { status: ["open"] }, params).subscribe(); - expect(api.post).toHaveBeenCalledExactlyOnceWith("/rate-project/5?limit=10", { + expect(api.post).toHaveBeenCalledExactlyOnceWith("/rate-project/5?limit=10&assigned_only=1", { filters: { status: ["open"] }, }); }); - it("postFilters без params не добавляет query", () => { + it("postFilters без params всё равно ограничен своими назначениями", () => { setup(); api.post.mockReturnValue(of(pagination())); adapter.postFilters(5, {}).subscribe(); - expect(api.post).toHaveBeenCalledExactlyOnceWith("/rate-project/5", { filters: {} }); + expect(api.post).toHaveBeenCalledExactlyOnceWith("/rate-project/5?assigned_only=1", { + filters: {}, + }); }); it("rate идёт в POST /rate-project/rate/:projectId c массивом оценок", () => { diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts index 34da3e225..34485d595 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts @@ -15,7 +15,10 @@ export class ProjectRatingHttpAdapter { private readonly apiService = inject(ApiService); getAll(programId: number, params?: HttpParams): Observable> { - return this.apiService.get(`${this.RATE_PROJECT_URL}/${programId}`, params); + return this.apiService.get( + `${this.RATE_PROJECT_URL}/${programId}`, + (params ?? new HttpParams()).set("assigned_only", "1"), + ); } postFilters( @@ -24,6 +27,7 @@ export class ProjectRatingHttpAdapter { params?: HttpParams, ): Observable> { let url = `${this.RATE_PROJECT_URL}/${programId}`; + params = (params ?? new HttpParams()).set("assigned_only", "1"); if (params) { url += `?${params.toString()}`; } diff --git a/projects/social_platform/src/app/infrastructure/di/program/evaluation.providers.ts b/projects/social_platform/src/app/infrastructure/di/program/evaluation.providers.ts new file mode 100644 index 000000000..2cdff9672 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/program/evaluation.providers.ts @@ -0,0 +1,7 @@ +/** @format */ +import { Provider } from "@angular/core"; +import { EvaluationRepositoryPort } from "@domain/program/ports/evaluation.repository.port"; +import { EvaluationRepository } from "../../repository/program/evaluation.repository"; +export const EVALUATION_PROVIDERS: Provider[] = [ + { provide: EvaluationRepositoryPort, useExisting: EvaluationRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/repository/program/evaluation.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/evaluation.repository.ts new file mode 100644 index 000000000..f3977c7c3 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/program/evaluation.repository.ts @@ -0,0 +1,23 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { EvaluationHttpAdapter } from "../../adapters/program/evaluation-http.adapter"; +import { EvaluationRepositoryPort } from "@domain/program/ports/evaluation.repository.port"; +@Injectable({ providedIn: "root" }) +export class EvaluationRepository implements EvaluationRepositoryPort { + private readonly adapter = inject(EvaluationHttpAdapter); + readonly programs = this.adapter.programs.bind(this.adapter); + readonly workspace = this.adapter.workspace.bind(this.adapter); + readonly invite = this.adapter.invite.bind(this.adapter); + readonly invitationAction = this.adapter.invitationAction.bind(this.adapter); + readonly assignments = this.adapter.assignments.bind(this.adapter); + readonly saveAssignments = this.adapter.saveAssignments.bind(this.adapter); + readonly allocation = this.adapter.allocation.bind(this.adapter); + readonly applyAllocation = this.adapter.applyAllocation.bind(this.adapter); + readonly quota = this.adapter.quota.bind(this.adapter); + readonly saveQuota = this.adapter.saveQuota.bind(this.adapter); + readonly invitation = this.adapter.invitation.bind(this.adapter); + readonly accept = this.adapter.accept.bind(this.adapter); + readonly acceptOwn = this.adapter.acceptOwn.bind(this.adapter); + readonly register = this.adapter.register.bind(this.adapter); + readonly resetPassword = this.adapter.resetPassword.bind(this.adapter); +} diff --git a/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.html b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.html new file mode 100644 index 000000000..30fc80801 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.html @@ -0,0 +1,232 @@ + + +
+ +
+ @if (loading()) { +

Загружаем приглашение…

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

{{ error() }}

+ } + @if (invitation(); as invite) { +
+

Приглашение эксперта

+

{{ invite.program.name }}

+
+ @if (mode() === "joined") { +

Вы присоединились к программе

+

Теперь вы эксперт программы. Организатор сможет назначить вам проекты на оценку.

+ Перейти к проектамМои программы + } @else if ( + invite.status === "accepted" && + mode() !== "login" && + mode() !== "reset" && + mode() !== "sent" + ) { +

Приглашение уже принято

+

Войдите с почтой {{ invite.email }}, чтобы открыть свои программы.

+ @if (invite.matchesAccount) { + Перейти к проектам + } @else { + + } + } @else if (hasSession() && !invite.matchesAccount) { +

Приглашение для другой почты

+

+ Эта ссылка предназначена для {{ invite.email }}. Чтобы принять приглашение, войдите в + соответствующий аккаунт. +

+ + } @else if (hasSession() && invite.matchesAccount) { +

Стать экспертом программы

+

Приглашение для {{ invite.email }}. Ваш аккаунт и другие роли сохранятся.

+ Вернуться к программам + } @else { + @switch (mode()) { + @case ("register") { +

Создание аккаунта эксперта

+ +
+
+ +
+ + + Соглашение и политика обработки данных + +
+ Компания, должность и фото — позже в профиле. + } + @case ("login") { +

Вход для принятия приглашения

+

Войдите с почтой из приглашения. Затем вы сможете присоединиться к программе.

+
+ +
+ } + @case ("reset") { +

Восстановление пароля

+

+ Отправим письмо для восстановления на {{ invite.email }}. После смены пароля вы + вернётесь к приглашению. +

+ + } + @case ("sent") { +

Проверьте почту

+

+ Если аккаунт с почтой {{ invite.email }} существует, письмо для восстановления пароля + отправлено. +

+

Перейдите по ссылке в письме. После смены пароля войдите и примите приглашение.

+ + } + @default { +

Вас пригласили стать экспертом

+

+ Приглашение для {{ invite.email }}. После принятия организатор сможет назначить вам + проекты на оценку. +

+ @if (invite.accountExists) { + + } @else { + + } + } + } + } + } @else if (!loading()) { +

Приглашение недоступно

+ Войти на платформу + } +
+
diff --git a/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.scss b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.scss new file mode 100644 index 000000000..7f27d01f4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.scss @@ -0,0 +1,129 @@ +/** @format */ + +@use "styles/ui-foundation"; + +:host { + display: block; + min-height: 100dvh; +} + +.ui-button { + @include ui-foundation.button; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; +} + +.ui-choice { + @include ui-foundation.choice; +} + +.expert-invitation { + display: flex; + flex-direction: column; + gap: 92px; + align-items: center; + min-height: 100dvh; + padding: 32px 20px 48px; + color: var(--black); +} + +.expert-invitation__logo { + display: block; + object-fit: contain; +} + +.expert-invitation__card { + display: grid; + gap: 16px; + width: min(600px, 100%); + min-width: 0; + padding: 32px; + font-size: 14px; + background: var(--light-white); + border-radius: 15px; +} + +.expert-invitation h1, +.expert-invitation h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 27px; + overflow-wrap: anywhere; +} + +.expert-invitation p { + margin: 0; + line-height: 1.5; +} + +.expert-invitation__eyebrow { + margin-bottom: 6px !important; + font-size: 12px; + color: var(--accent); +} + +.expert-invitation form, +.expert-invitation label { + display: grid; + gap: 16px; + min-width: 0; +} + +.expert-invitation label { + gap: 6px; + font-size: 12px; + font-weight: 600; +} + +.expert-invitation__names { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.expert-invitation .expert-invitation__choice { + display: flex; + gap: 12px; + align-items: center; + min-height: 44px; + font-weight: 400; +} + +.expert-invitation__choice input { + flex-shrink: 0; +} + +.expert-invitation small, +.expert-invitation__choice { + font-size: 12px; + line-height: 1.5; + color: var(--grey-for-text); +} + +.expert-invitation__link { + display: flex; + align-items: center; + min-height: 44px; + font-size: 12px; + color: var(--accent); +} + +@media (max-width: 650px) { + .expert-invitation { + gap: 40px; + padding-top: 24px; + } + + .expert-invitation__card { + padding: 20px; + } + + .expert-invitation__names { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.spec.ts b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.spec.ts new file mode 100644 index 000000000..9705064f2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.spec.ts @@ -0,0 +1,170 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ActivatedRoute, convertToParamMap, provideRouter } from "@angular/router"; +import { of, throwError } from "rxjs"; +import { HttpErrorResponse } from "@angular/common/http"; +import { TokenService } from "@corelib"; +import { EvaluationUseCase } from "@api/program/use-cases/evaluation.use-case"; +import { LoginUseCase } from "@api/auth/use-cases/login.use-case"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { ExpertInvitationComponent } from "./expert-invitation.component"; + +describe("Путь приглашённого эксперта", () => { + it("не передаёт ссылку приглашения в Referer и восстанавливает политику после ухода", () => { + const { fixture } = setup(); + expect(document.querySelector('meta[name="referrer"]')?.getAttribute("content")).toBe( + "no-referrer", + ); + fixture.destroy(); + expect(document.querySelector('meta[name="referrer"]')).toBeNull(); + }); + it("вход через форму предотвращает перезагрузку и вызывает авторизацию", () => { + const { fixture, component, login } = setup(); + component.changeMode("login"); + component.password.setValue("password"); + fixture.detectChanges(); + const form = fixture.nativeElement.querySelector("form") as HTMLFormElement; + const event = new Event("submit", { bubbles: true, cancelable: true }); + form.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(login.execute).toHaveBeenCalledOnce(); + }); + function setup() { + let session: { access: string; refresh: string } | null = null; + const token = "an-invitation-token-0123456789012345"; + const invitation = { + id: 1, + email: "expert@example.com", + status: "pending", + accountExists: false, + matchesAccount: false, + program: { id: 7, name: "Программа" }, + }; + const api = { + invitation: vi.fn(() => of(invitation)), + register: vi.fn(() => of({ access: "access", refresh: "refresh", programId: 7 })), + accept: vi.fn(() => of({ programId: 7, accepted: true })), + resetPassword: vi.fn(() => of(undefined)), + }; + const tokens = { + getTokens: vi.fn(() => session), + memTokens: vi.fn(v => (session = v)), + clearTokens: vi.fn(() => (session = null)), + }; + const profiles = { invalidateProfile: vi.fn(), ensureProfileLoaded: vi.fn() }; + const login = { + execute: vi.fn(() => + of({ ok: true, value: { tokens: { access: "access", refresh: "refresh" } } }), + ), + }; + TestBed.configureTestingModule({ + imports: [ExpertInvitationComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ token })), + snapshot: { queryParamMap: convertToParamMap({}) }, + }, + }, + { provide: EvaluationUseCase, useValue: api }, + { provide: TokenService, useValue: tokens }, + { provide: LoginUseCase, useValue: login }, + { provide: ProfileInfoService, useValue: profiles }, + ], + }); + const fixture = TestBed.createComponent(ExpertInvitationComponent); + fixture.detectChanges(); + return { + fixture, + component: fixture.componentInstance, + api, + tokens, + profiles, + login, + token, + invitation, + }; + } + it("переход по ссылке не создаёт аккаунт и не принимает приглашение", () => { + const { api } = setup(); + expect(api.invitation).toHaveBeenCalledOnce(); + expect(api.register).not.toHaveBeenCalled(); + expect(api.accept).not.toHaveBeenCalled(); + }); + it("короткая регистрация не содержит birthday, согласия исходно сняты", () => { + const { component, api } = setup(); + expect(component.registration.contains("birthday")).toBe(false); + expect(component.registration.controls.acceptPolicy.value).toBe(false); + component.register(); + expect(api.register).not.toHaveBeenCalled(); + }); + it("создание передаёт только разрешённые поля, сохраняет сессию и не отправляет второе письмо", () => { + const { component, api, tokens, token } = setup(); + component.registration.setValue({ + firstName: "Анна", + lastName: "Волкова", + password: "Password123!", + repeatPassword: "Password123!", + acceptPolicy: true, + acceptAge: true, + }); + component.register(); + expect(api.register).toHaveBeenCalledWith( + token, + expect.objectContaining({ firstName: "Анна", acceptPolicy: true }), + ); + expect(api.register.mock.calls[0][1]).not.toHaveProperty("birthday"); + expect(api.register.mock.calls[0][1]).not.toHaveProperty("email"); + expect(tokens.memTokens).toHaveBeenCalledOnce(); + expect(component.mode()).toBe("joined"); + }); + it("успешный вход ещё не принимает приглашение", () => { + const { component, api, login, tokens } = setup(); + component.password.setValue("existing-password"); + component.signIn(); + expect(login.execute).toHaveBeenCalledWith({ + email: "expert@example.com", + password: "existing-password", + }); + expect(tokens.memTokens).toHaveBeenCalled(); + expect(api.accept).not.toHaveBeenCalled(); + expect(component.mode()).toBe("intro"); + }); + it("ошибка регистрации сохраняет введённые данные", () => { + const { component, api } = setup(); + api.register.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409 }))); + component.registration.setValue({ + firstName: "Анна", + lastName: "Волкова", + password: "Password123!", + repeatPassword: "Password123!", + acceptPolicy: true, + acceptAge: true, + }); + component.register(); + expect(component.registration.controls.firstName.value).toBe("Анна"); + expect(component.error()).toBeTruthy(); + expect(component.busy()).toBe(false); + }); + it("восстановление отправляет контекст именно этого приглашения", () => { + const { component, api, token } = setup(); + component.resetPassword(); + expect(api.resetPassword).toHaveBeenCalledWith(token, "expert@example.com"); + expect(component.mode()).toBe("sent"); + }); + it("принятое приглашение позволяет восстановить пароль после выхода", () => { + const { component, api, fixture, invitation } = setup(); + api.invitation.mockReturnValue(of({ ...invitation, status: "accepted", accountExists: true })); + component.load(); + component.changeMode("reset"); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("Восстановление пароля"); + expect(fixture.nativeElement.textContent).not.toContain("Приглашение уже принято"); + component.resetPassword(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("Проверьте почту"); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.ts b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.ts new file mode 100644 index 000000000..48c9650a6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.ts @@ -0,0 +1,205 @@ +/** @format */ +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from "@angular/core"; +import { ActivatedRoute, RouterLink } from "@angular/router"; +import { Meta } from "@angular/platform-browser"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; +import { AlertDirective, ButtonDirective, ChoiceDirective, FieldDirective } from "@uilib"; +import { TokenService } from "@corelib"; +import { EvaluationUseCase } from "@api/program/use-cases/evaluation.use-case"; +import { LoginUseCase } from "@api/auth/use-cases/login.use-case"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { ExpertInvitation } from "@domain/program/evaluation-workspace.model"; +import { evaluationError } from "@utils/evaluation-workspace"; +import { AppRoutes } from "@api/paths/app-routes"; +import { EventBus } from "@domain/shared/event-bus"; +import { loggedOut } from "@domain/auth/events/logged-out.event"; + +@Component({ + selector: "app-expert-invitation", + imports: [ + RouterLink, + ReactiveFormsModule, + AlertDirective, + ButtonDirective, + ChoiceDirective, + FieldDirective, + ], + templateUrl: "./expert-invitation.component.html", + styleUrl: "./expert-invitation.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExpertInvitationComponent { + private readonly api = inject(EvaluationUseCase); + private readonly login = inject(LoginUseCase); + private readonly events = inject(EventBus); + private readonly tokens = inject(TokenService); + private readonly profile = inject(ProfileInfoService); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + private readonly fb = inject(FormBuilder).nonNullable; + private readonly meta = inject(Meta); + readonly paths = AppRoutes; + readonly invitation = signal(null); + readonly loading = signal(false); + readonly busy = signal(false); + readonly error = signal(null); + readonly hasSession = signal(!!this.tokens.getTokens()); + readonly mode = signal<"intro" | "register" | "login" | "reset" | "sent" | "joined">("intro"); + readonly showPassword = signal(false); + readonly password = this.fb.control("", Validators.required); + readonly registration = this.fb.group({ + firstName: ["", [Validators.required, Validators.pattern(/\S/), Validators.maxLength(255)]], + lastName: ["", [Validators.required, Validators.pattern(/\S/), Validators.maxLength(255)]], + password: ["", [Validators.required, Validators.minLength(8), Validators.maxLength(128)]], + repeatPassword: ["", Validators.required], + acceptPolicy: [false, Validators.requiredTrue], + acceptAge: [false, Validators.requiredTrue], + }); + private token = ""; + private sequence = 0; + constructor() { + const previous = this.meta.getTag('name="referrer"')?.content; + this.meta.updateTag({ name: "referrer", content: "no-referrer" }); + this.destroyRef.onDestroy(() => { + if (previous !== undefined) this.meta.updateTag({ name: "referrer", content: previous }); + else this.meta.removeTag('name="referrer"'); + }); + this.route.paramMap.pipe(takeUntilDestroyed()).subscribe(params => { + this.token = params.get("token") ?? ""; + this.mode.set(this.route.snapshot.queryParamMap.get("mode") === "login" ? "login" : "intro"); + this.load(); + }); + } + load(): void { + const seq = ++this.sequence; + this.loading.set(true); + this.error.set(null); + this.invitation.set(null); + this.api + .invitation(this.token) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: data => { + if (seq !== this.sequence) return; + this.invitation.set(data); + this.hasSession.set(!!this.tokens.getTokens()); + this.loading.set(false); + }, + error: e => { + if (seq !== this.sequence) return; + this.error.set(evaluationError(e)); + this.loading.set(false); + }, + }); + } + changeMode(mode: "intro" | "register" | "login" | "reset"): void { + if (this.busy()) return; + this.error.set(null); + this.mode.set(mode); + this.password.reset(""); + } + switchAccount(): void { + this.tokens.clearTokens(); + this.events.emit(loggedOut()); + this.profile.invalidateProfile(); + this.hasSession.set(false); + this.mode.set("login"); + this.load(); + } + signIn(): void { + const invitation = this.invitation(); + if (!invitation || this.password.invalid || this.busy()) { + this.password.markAsTouched(); + return; + } + this.busy.set(true); + this.error.set(null); + this.login + .execute({ email: invitation.loginEmail ?? invitation.email, password: this.password.value }) + .pipe( + finalize(() => this.busy.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(result => { + if (!result.ok) { + this.error.set("Не удалось войти. Проверьте пароль и повторите попытку."); + return; + } + this.tokens.memTokens(result.value.tokens); + this.profile.invalidateProfile(); + this.profile.ensureProfileLoaded(); + this.password.reset(""); + this.mode.set("intro"); + this.load(); + }); + } + register(): void { + if (this.busy()) return; + this.registration.markAllAsTouched(); + const data = this.registration.getRawValue(); + if (this.registration.invalid) { + this.error.set("Заполните обязательные поля и подтвердите согласия."); + return; + } + if (data.password !== data.repeatPassword) { + this.error.set("Пароли не совпадают."); + return; + } + this.busy.set(true); + this.error.set(null); + this.api + .register(this.token, data) + .pipe( + finalize(() => this.busy.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: result => { + this.tokens.memTokens(result); + this.registration.reset(); + this.hasSession.set(true); + this.profile.invalidateProfile(); + this.profile.ensureProfileLoaded(); + this.mode.set("joined"); + }, + error: e => this.error.set(evaluationError(e)), + }); + } + accept(): void { + if (this.busy()) return; + this.busy.set(true); + this.error.set(null); + this.api + .accept(this.token) + .pipe( + finalize(() => this.busy.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: () => { + this.profile.invalidateProfile(); + this.profile.ensureProfileLoaded(); + this.mode.set("joined"); + }, + error: e => this.error.set(evaluationError(e)), + }); + } + resetPassword(): void { + const invitation = this.invitation(); + if (!invitation || this.busy()) return; + this.busy.set(true); + this.error.set(null); + this.api + .resetPassword(this.token, invitation.email) + .pipe( + finalize(() => this.busy.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: () => this.mode.set("sent"), + error: e => this.error.set(evaluationError(e)), + }); + } +} 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 new file mode 100644 index 000000000..0b81dcdeb --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.html @@ -0,0 +1,124 @@ + + +
+

Оценивание

+

Мои программы

+

+ Ваши программы для оценки проектов и приглашения эксперта. +

+ + @if (accepted()) { +

+ Приглашение принято. Организатор сможет назначить вам проекты. +

+ } + @if (state.failed()) { + + } @else if (state.pending() && !state.programs().length) { +

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

+ } @else { +

+ Программ: {{ joinedCount() }} · приглашений: {{ pendingCount() }} +

+ @for (program of filtered(); track program.id) { +
+
+

{{ program.name }}

+ {{ + program.status === "joined" ? "Эксперт программы" : "Приглашение эксперта" + }}
+ @if (program.status === "joined") { + К проектам + } @else { + + } +
+ @if (program.status === "joined") { +
+
+
Назначено
+
{{ program.assigned }}
+
+
+
Оценено
+
{{ program.completed }}
+
+
+
Осталось
+
{{ program.remaining }}
+
+
+

+ {{ + program.datetimeEvaluationEnds + ? "Срок оценки: до " + (program.datetimeEvaluationEnds | date: "d MMMM, HH:mm") + : "Срок оценки не задан" + }} +

+ } @else { +

+ Приглашение для {{ program.email }}. Проекты станут доступны после принятия. +

+ } +
+ } @empty { +
+

{{ query() ? "Программы не найдены" : "У вас пока нет программ для оценивания" }}

+

+ {{ + query() + ? "Попробуйте изменить поисковый запрос." + : "Когда организатор пригласит вас экспертом, программа появится здесь." + }} +

+
+ } + } +
+
+

Принять приглашение эксперта

+ {{ selected()?.name }} +

+ Вы присоединитесь к программе как эксперт с почтой {{ selected()?.email }}. Ваши другие роли + сохранятся. +

+ @if (error()) { +

{{ error() }}

+ } +
+ +
+
diff --git a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.scss b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.scss new file mode 100644 index 000000000..66f5e9e67 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.scss @@ -0,0 +1,175 @@ +/** @format */ + +@use "styles/ui-foundation"; + +:host { + display: block; + min-width: 0; +} + +.ui-button { + @include ui-foundation.button; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; +} + +.evaluation-programs { + display: grid; + gap: 20px; + max-width: 863px; + margin: 24px auto 48px; + font-size: 12px; + color: var(--black); +} + +.evaluation-programs h1 { + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 27px; +} + +.evaluation-programs h2, +.evaluation-programs__dialog h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +.evaluation-programs p, +.evaluation-programs__dialog p { + margin: 0; + line-height: 1.5; +} + +.evaluation-programs__section, +.evaluation-programs__expert { + color: var(--accent); +} + +.evaluation-programs__intro, +.evaluation-programs__invitation { + font-size: 14px; + color: var(--grey-for-text); +} + +.evaluation-programs__search { + display: grid; + gap: 6px; + font-weight: 600; +} + +.evaluation-programs__card { + display: grid; + gap: 16px; + min-width: 0; + padding: 24px; + background: var(--light-white); + border-radius: 15px; +} + +.evaluation-programs__heading { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: space-between; +} + +.evaluation-programs__heading > .ui-button { + min-width: 200px; +} + +.evaluation-programs__identity { + display: flex; + gap: 12px; + align-items: center; + min-width: 0; + color: inherit; + text-decoration: none; +} + +.evaluation-programs__identity app-avatar { + flex-shrink: 0; +} + +.evaluation-programs__identity > span { + display: grid; + gap: 6px; + min-width: 0; + overflow-wrap: anywhere; +} + +.evaluation-programs__stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; + margin: 0; +} + +.evaluation-programs__stats div { + display: grid; + gap: 6px; +} + +.evaluation-programs__stats dt, +.evaluation-programs__count, +.evaluation-programs__deadline { + color: var(--grey-for-text); +} + +.evaluation-programs__stats dd { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.evaluation-programs__dialog { + display: grid; + gap: 20px; + font-size: 14px; +} + +.evaluation-programs__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +::ng-deep .modal .modal__body.evaluation-accept-modal { + width: 600px; + max-width: calc(100vw - 40px); +} + +@media (max-width: 999px) { + .evaluation-programs { + margin: 24px 20px 40px; + } +} + +@media (max-width: 700px) { + .evaluation-programs { + margin-top: 20px; + } + + .evaluation-programs__card { + padding: 20px; + } + + .evaluation-programs__heading { + flex-direction: column; + align-items: stretch; + } + + .evaluation-programs__stats { + gap: 8px; + } + + .evaluation-programs__actions { + flex-direction: column; + } +} 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 new file mode 100644 index 000000000..5e79ce660 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.ts @@ -0,0 +1,142 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + OnInit, + DestroyRef, + computed, + effect, + inject, + signal, + untracked, +} from "@angular/core"; +import { DatePipe } from "@angular/common"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; +import { AlertDirective, AvatarComponent, ButtonDirective, FieldDirective } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { EvaluationStateService } from "@api/program/facades/evaluation-state.service"; +import { EvaluationUseCase } from "@api/program/use-cases/evaluation.use-case"; +import { EvaluationProgram } from "@domain/program/evaluation-workspace.model"; +import { AppRoutes } from "@api/paths/app-routes"; +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"; + +@Component({ + selector: "app-evaluation-programs", + imports: [ + RouterLink, + ReactiveFormsModule, + DatePipe, + AlertDirective, + AvatarComponent, + ButtonDirective, + FieldDirective, + ModalComponent, + ], + templateUrl: "./evaluation-programs.component.html", + styleUrl: "./evaluation-programs.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EvaluationProgramsComponent implements OnInit { + readonly state = inject(EvaluationStateService); + private readonly api = inject(EvaluationUseCase); + private readonly profile = inject(ProfileInfoService); + private readonly auth = inject(AuthInfoService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + readonly search = new FormControl("", { nonNullable: true }); + readonly query = signal(""); + readonly selected = signal(null); + readonly busy = signal(false); + readonly error = signal(null); + readonly accepted = signal(false); + readonly paths = AppRoutes; + readonly href = presentationHref; + readonly joinedCount = computed( + () => this.state.programs().filter(p => p.status === "joined").length, + ); + readonly pendingCount = computed( + () => this.state.programs().filter(p => p.status === "pending").length, + ); + readonly filtered = computed(() => + this.state + .programs() + .filter(p => + p.name.toLocaleLowerCase("ru").includes(this.query().trim().toLocaleLowerCase("ru")), + ), + ); + private handledInvitation: number | null = null; + ngOnInit(): void { + if (!this.state.pending()) this.state.reload(); + } + constructor() { + this.search.valueChanges.pipe(takeUntilDestroyed()).subscribe(v => this.query.set(v)); + effect(() => { + const programs = this.state.programs(); + const id = Number(this.route.snapshot.queryParamMap.get("invitation")); + if (!id || id === this.handledInvitation) return; + const invitation = programs.find(p => p.invitationId === id && p.status === "pending"); + if (invitation) + untracked(() => { + this.handledInvitation = id; + this.open(invitation); + }); + }); + } + initials(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(n => n[0]) + .join("") + .toUpperCase(); + } + open(program: EvaluationProgram): void { + this.selected.set(program); + this.error.set(null); + } + close(): void { + if (this.busy()) return; + this.selected.set(null); + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { invitation: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + accept(): void { + const id = this.selected()?.invitationId; + if (!id || this.busy()) return; + this.busy.set(true); + this.error.set(null); + this.api + .acceptOwn(id) + .pipe( + finalize(() => this.busy.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: () => { + this.selected.set(null); + this.accepted.set(true); + this.state.reload(); + this.auth + .fetchProfile() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: profile => this.profile.applyProfileUpdated(profile), + error: () => this.profile.invalidateProfile(), + }); + }, + error: e => this.error.set(evaluationError(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 6284c811f..a2c4b8134 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 @@ -123,6 +123,16 @@ программы + @if (authRepository.profile()?.relations?.hasEvaluationAccess) { + + } + } @empty { + @if (listType() === "rating") { +
  • +

    + {{ + !canEvaluate() + ? "У вас нет доступа к оцениванию этой программы" + : searchForm.get("search")?.value + ? "Проекты не найдены" + : "Проекты пока не назначены" + }} +

    +

    + {{ + !canEvaluate() + ? "Этот раздел доступен экспертам программы." + : searchForm.get("search")?.value + ? "Попробуйте изменить поиск или фильтры." + : "Когда организатор назначит вам проекты, они появятся здесь." + }} +

    +
    Мои программы +
  • + } } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts index 6122912d9..b128d3f44 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts @@ -25,6 +25,7 @@ import { IconComponent, FormLayoutDirective } from "@uilib"; import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; import { tagsFilter } from "@core/consts/filters/tags-filter.const"; import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; import { ProgramDetailListInfoService } from "@api/program/facades/detail/program-detail-list-info.service"; import { ExportFileInfoService } from "@api/export-file/facades/export-file-info.service"; import { SwipeService } from "@api/swipe/swipe.service"; @@ -82,6 +83,8 @@ export class ProgramListComponent implements OnInit, AfterViewInit { protected readonly searchForm = this.programDetailListUIInfoService.searchForm; protected readonly listType = this.programDetailListUIInfoService.listType; + private readonly programUI = inject(ProgramDetailMainUIInfoService, { optional: true }); + protected readonly canEvaluate = computed(() => this.programUI?.program()?.isUserExpert === true); protected readonly searchedList = this.programDetailListUIInfoService.searchedList; protected readonly profileProjSubsIds = this.programDetailListUIInfoService.profileProjSubsIds; 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 6929f0212..550150bd3 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 @@ -197,6 +197,13 @@ describe("RatingCardService evaluation modes", () => { expect(service.canOpenModal()).toBe(false); }); + it("allows an assigned evaluator to submit after the managed minimum is reached", () => { + service.initProject({ ...project(), ratedCount: 2, managedEvaluation: true }); + expect(service.canRate()).toBe(true); + expect(service.isButtonDisabled()).toBe(false); + expect(service.rateButtonText()).toBe("оценить проект"); + }); + it("does not submit an empty configured criterion set", () => { service.initProject({ ...project(), criteriaRevision: "empty" }); expect(service.rateButtonText()).toBe("критерии не настроены"); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.ts index 8fcbe15c2..8854ab1fd 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 @@ -72,7 +72,7 @@ export class RatingCardService { readonly isLimitReached = computed(() => { const p = this.project(); - return !!p && p.ratedCount >= p.maxRates; + return !!p && !p.managedEvaluation && p.ratedCount >= p.maxRates; }); readonly canEdit = computed(() => !this.evaluationDateExpired()); diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html new file mode 100644 index 000000000..0a11144ed --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.html @@ -0,0 +1,457 @@ + + +
    +
    +
    +

    Эксперты

    +

    Приглашайте экспертов и распределяйте проекты на оценку.

    +
    +
    + +
    +
    + @if (message()) { +

    {{ message() }}

    + } + @if (error()) { +
    + {{ error() }} +
    + } + @if (snapshot(); as state) { +
    +
    + На каждый проект: {{ state.requiredEvaluators }} оценщика +

    + {{ state.submittedProjects }} сданных проектов · не хватает + {{ state.missingAssignments }} назначений +

    +
    + +
    + } +
    +
    + +
    + @if (selected(); as expert) { +
    +
    +

    Выбран эксперт

    + {{ expert.name || expert.email }} +
    +
    + @if (expert.status === "joined") { + + } @else { + + @if (expert.status !== "revoked") { + + } + } + +
    +
    + } + @if (loading() && !snapshot()) { +

    Загружаем экспертов…

    + } @else { + + + + + + + + + + + + + @for (expert of visible(); track expert.id) { + + + + + + + + + } @empty { + + + + } + +
    ЭкспертEmailСтатусНазначеноОцененоОсталось
    +
    + {{ expert.name || "Имя пока не указано" }} +
    +
    {{ label(expert) }}{{ expert.assigned ?? "—" }}{{ expert.completed ?? "—" }}{{ expert.remaining ?? "—" }}
    + {{ + search.value || status.value !== "all" + ? "Эксперты не найдены. Измените поиск или статус." + : "Экспертов пока нет. Пригласите первого эксперта по email." + }} +
    +
    + {{ filtered().length ? page() * pageSize + 1 : 0 }}–{{ + page() * pageSize + visible().length + }} + из {{ filtered().length }} +
    + } +
    +
    + +
    +
    +

    {{ dialogTitle() }}

    + +
    + @if (dialogError()) { +

    {{ dialogError() }}

    + } + @switch (modal()) { + @case ("invite") { +

    Приглашение даёт право оценивать проекты этой программы.

    +
    + +

    Если аккаунта ещё нет, эксперт сможет зарегистрироваться по ссылке из письма.

    +
    + +
    +
    + } + @case ("revoke") { +

    Отозвать приглашение для {{ selected()?.email }}? Эта ссылка перестанет действовать.

    +
    + +
    + } + @case ("quota") { +

    Проект готов, когда получено нужное число полных оценок от разных экспертов.

    + +

    + Сейчас: {{ snapshot()?.requiredEvaluators }}. Изменение применяется ко всей программе. +

    + @if (quotaPreview(); as preview) { +
    +
    + После пересчёта готово {{ preview.readyAfter }} проектов вместо + {{ preview.readyBefore }} +

    Не хватает {{ preview.missingAssignments }} назначений.

    +
    +
    +

    + Выставленные оценки сохранятся. Избыточные назначения ({{ preview.surplusAssignments }}) + можно снять вручную, если оценок ещё нет. +

    + + } @else { + + } + } + @case ("auto") { + @if (plan(); as preview) { + @if (preview.shortages.length) { +

    + Не хватает подходящих экспертов. Измените состав распределения или пригласите новых. +

    + } @else { +

    + Добавим {{ preview.assignments.length }} назначений. Текущие назначения и сохранённые + оценки остаются. +

    + } +

    Нагрузка экспертов

    +
    + @for (expert of preview.load; track expert.expertId) { +
    + {{ expert.name }}Осталось: {{ expert.before }} → {{ expert.after }} +
    + } +
    +

    Назначения по проектам

    +
    + @for (assignment of preview.assignments; track $index) { +
    + {{ assignment.projectName }}{{ assignment.expertName }} +
    + } + @for (shortage of preview.shortages; track shortage.projectId) { +

    {{ shortage.projectName }} — не хватает {{ shortage.missing }} экспертов

    + } +
    +
    + +
    + } @else { +

    + Система дополнит назначения до {{ snapshot()?.requiredEvaluators }} оценщиков на каждый + проект. +

    + +

    Эксперты программы

    +

    Выбрано {{ chosenExperts().size }} · приглашённые не участвуют

    +
    + @for (expert of snapshot()?.experts; track expert.id) { + + } +
    +

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

    + + } + } + @default { +

    {{ selected()?.name }} · {{ selected()?.email }}

    +
    + +
    + @if (busy() && !options()) { +

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

    + } +
    + @for (project of visibleProjects(); track project.id) { + + } @empty { + @if (options()) { +

    Проекты не найдены.

    + } + } +
    +

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

    +
    + @if (modal() === "manual") { + + } @else { + + } + +
    + } + } +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.scss b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.scss new file mode 100644 index 000000000..373f08965 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.scss @@ -0,0 +1,325 @@ +/** @format */ + +@use "styles/ui-foundation"; + +:host { + display: block; + min-width: 0; +} + +.ui-button { + @include ui-foundation.button; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; +} + +.ui-choice { + @include ui-foundation.choice; +} + +.experts, +.experts__dialog, +.experts__dialog form { + display: grid; + gap: 20px; + min-width: 0; + font-size: 12px; +} + +.experts h2, +.experts h3, +.experts__dialog h2, +.experts__dialog h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +.experts p, +.experts__dialog p { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--grey-for-text); +} + +.experts__heading, +.experts__actions, +.experts__quota, +.experts__selection { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + min-width: 0; +} + +.experts__heading > div { + min-width: 0; +} + +.experts__heading p { + margin-top: 6px; +} + +.experts__actions { + flex-wrap: wrap; + justify-content: flex-start; +} + +.experts__card { + min-width: 0; + padding: 24px; + background: var(--light-white); + border-radius: 15px; +} + +.experts__filters { + display: grid; + grid-template-columns: 3fr 2fr; + gap: 12px; + margin-bottom: 16px; +} + +.experts label, +.experts__dialog label { + display: grid; + gap: 6px; + min-width: 0; + font-size: 12px; +} + +.experts__table { + width: 100%; + font-size: 12px; + line-height: 1.4; + table-layout: fixed; + border-collapse: collapse; +} + +.experts__table th { + padding: 12px 4px; + font-weight: 400; + color: var(--grey-for-text); + text-align: left; +} + +.experts__table td { + padding: 16px 4px; + overflow-wrap: anywhere; + border-bottom: 1px solid var(--medium-grey-for-outline); +} + +.experts__table th:first-child { + width: 30%; +} + +.experts__table th:nth-child(2) { + width: 26%; +} + +.experts__table th:nth-child(3) { + width: 15%; +} + +.experts__table tr[tabindex] { + cursor: pointer; +} + +.experts__row--selected { + background: var(--light-gray); +} + +.experts__person { + display: flex; + gap: 8px; + align-items: center; + min-width: 0; +} + +.experts__person app-avatar, +.experts__person input { + flex-shrink: 0; +} + +.experts__person > .ui-choice { + width: 16px; + height: 16px; + appearance: none; + border: 1px solid var(--grey-for-text); + border-radius: 50%; +} + +.experts__person > .ui-choice:checked { + background: var(--accent); + border-color: var(--accent); + box-shadow: inset 0 0 0 3px var(--light-white); +} + +.experts__email { + color: var(--grey-for-text); +} + +.experts__selection { + padding: 16px; + margin: 16px 0; + background: var(--light-gray); + border-radius: 12px; +} + +.experts__pagination { + display: flex; + gap: 12px; + align-items: center; + justify-content: flex-end; + margin-top: 12px; + font-size: 12px; + color: var(--grey-for-text); +} + +.experts__dialog .experts__filters { + margin: 0; +} + +.experts__scroll { + max-height: 285px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.experts__scroll--projects { + max-height: min(390px, 40dvh); +} + +.experts__dialog .experts__choice { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 4px; + cursor: pointer; + border-bottom: 1px solid var(--medium-grey-for-outline); +} + +.experts__choice > span { + display: grid; + gap: 6px; + min-width: 0; + overflow-wrap: anywhere; +} + +.experts__choice input { + flex-shrink: 0; +} + +.experts__choice span span { + color: var(--grey-for-text); +} + +.experts__load { + display: flex; + gap: 12px; + justify-content: space-between; + padding: 12px 0; + font-size: 12px; + border-bottom: 1px solid var(--medium-grey-for-outline); +} + +.experts__load > * { + min-width: 0; + overflow-wrap: anywhere; +} + +::ng-deep .modal .modal__body.experts-modal { + width: 760px; + max-width: calc(100vw - 40px); + max-height: calc(var(--visual-viewport-height, 100dvh) - 40px); + padding: 24px; + overflow-y: auto; +} + +@media (max-width: 700px) { + .experts__heading, + .experts__quota, + .experts__selection { + flex-direction: column; + align-items: stretch; + } + + .experts__actions > button { + flex: 1 1 auto; + min-height: 44px; + } + + .experts__card { + padding: 20px; + } + + .experts__filters { + grid-template-columns: minmax(0, 1fr); + } + + .experts__table thead { + display: none; + } + + .experts__table, + .experts__table tbody { + display: block; + } + + .experts__table tr { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 12px 0; + border-bottom: 1px solid var(--medium-grey-for-outline); + } + + .experts__table td { + padding: 0; + border: 0; + } + + .experts__table td:nth-child(-n + 3), + .experts__table td[colspan] { + grid-column: 1 / -1; + } + + .experts__table td:nth-child(2), + .experts__table td:nth-child(3) { + padding-left: 40px; + } + + .experts__table td[data-label] { + display: grid; + gap: 6px; + padding-top: 8px; + } + + .experts__table td[data-label]::before { + color: var(--grey-for-text); + content: attr(data-label); + } + + .experts__dialog .experts__heading { + flex-direction: row; + align-items: center; + } + + .experts__dialog .experts__heading button { + flex-shrink: 0; + } + + .experts__load { + flex-direction: column; + } + + ::ng-deep .modal .modal__body.experts-modal { + padding: 20px; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.spec.ts new file mode 100644 index 000000000..8a61dcb4d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.spec.ts @@ -0,0 +1,189 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { of, throwError } from "rxjs"; +import { HttpErrorResponse } from "@angular/common/http"; +import { EvaluationUseCase } from "@api/program/use-cases/evaluation.use-case"; +import { ManagementExpertsComponent } from "./experts.component"; +import { ExpertWorkspace, AssignmentOptions } from "@domain/program/evaluation-workspace.model"; + +describe("Управление экспертами", () => { + it("отправляет приглашение через native submit и предотвращает перезагрузку", async () => { + const { fixture, component, api } = setup(); + component.open("invite"); + fixture.detectChanges(); + await new Promise(resolve => setTimeout(resolve, 0)); + fixture.detectChanges(); + component.email.setValue("expert@example.com"); + const form = document.querySelector(".experts__dialog form") as HTMLFormElement; + expect(form).toBeTruthy(); + const event = new Event("submit", { bubbles: true, cancelable: true }); + form.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(api.invite).toHaveBeenCalledWith(42, "expert@example.com"); + }); + const snapshot: ExpertWorkspace = { + revision: "v1", + requiredEvaluators: 2, + managedEvaluation: true, + submittedProjects: 3, + missingAssignments: 3, + readyProjects: 0, + experts: [ + { + id: 1, + name: "Мария Иванова", + email: "maria@example.com", + status: "joined", + assigned: 1, + completed: 0, + remaining: 1, + }, + { + id: "invite-2", + invitationId: 2, + name: null, + email: "pending@example.com", + status: "pending", + deliveryStatus: "sent", + assigned: null, + completed: null, + remaining: null, + }, + ], + }; + const options: AssignmentOptions = { + revision: "v1", + projects: [ + { + id: 10, + name: "С оценкой", + caseName: "Первый", + submitted: true, + assigned: true, + protected: true, + completed: true, + ownTeam: false, + }, + { + id: 11, + name: "Новый проект", + caseName: "Второй", + submitted: true, + assigned: false, + protected: false, + completed: false, + ownTeam: false, + }, + { + id: 12, + name: "Своя команда", + caseName: null, + submitted: true, + assigned: false, + protected: false, + completed: false, + ownTeam: true, + }, + ], + }; + function setup() { + const api = { + workspace: vi.fn(() => of(structuredClone(snapshot))), + assignments: vi.fn(() => of(options)), + saveAssignments: vi.fn(() => of(options)), + invite: vi.fn(() => of({})), + allocation: vi.fn(), + applyAllocation: vi.fn(), + quota: vi.fn(), + saveQuota: vi.fn(), + }; + TestBed.configureTestingModule({ + imports: [ManagementExpertsComponent], + providers: [{ provide: EvaluationUseCase, useValue: api }], + }); + const fixture = TestBed.createComponent(ManagementExpertsComponent); + fixture.componentRef.setInput("programId", 42); + fixture.componentRef.setInput("confirmDiscard", () => Promise.resolve(false)); + fixture.detectChanges(); + return { fixture, component: fixture.componentInstance, api }; + } + it("поиск сохраняет значение и фокус при обновлении состава", () => { + const { fixture, component } = setup(); + const search = fixture.nativeElement.querySelector("input[type=search]") as HTMLInputElement; + search.focus(); + search.value = "Мария"; + search.dispatchEvent(new Event("input")); + fixture.detectChanges(); + component.load(); + fixture.detectChanges(); + expect(component.filtered().length).toBe(1); + expect(search.value).toBe("Мария"); + expect(document.activeElement).toBe(search); + }); + it("повторный выбор строки снимает выделение", () => { + const { fixture, component } = setup(); + const row = fixture.nativeElement.querySelector("tbody tr"); + row.click(); + fixture.detectChanges(); + expect(component.selected()?.id).toBe(1); + row.click(); + fixture.detectChanges(); + expect(component.selected()).toBeNull(); + }); + it("сохранённые оценки и собственная команда защищены, разрешённый проект назначается", () => { + const { component, api } = setup(); + component.select(snapshot.experts[0]); + component.openAssignments(); + component.toggleProject(options.projects[0]); + component.toggleProject(options.projects[2]); + expect([...component.chosenProjects()]).toEqual([10]); + component.toggleProject(options.projects[1]); + component.saveAssignments(); + expect(api.saveAssignments).toHaveBeenCalledWith(42, 1, "v1", [10, 11]); + }); + it("409 оставляет выбор и открытый редактор, фильтр кейса сохраняет значение", () => { + const { component, api } = setup(); + component.select(snapshot.experts[0]); + component.openAssignments(); + component.caseFilter.setValue("case:Второй"); + expect(component.visibleProjects().map(p => p.id)).toEqual([11]); + component.toggleProject(options.projects[1]); + api.saveAssignments.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409 }))); + component.saveAssignments(); + expect(component.modal()).toBe("manual"); + expect(component.chosenProjects().has(11)).toBe(true); + expect(component.caseFilter.value).toBe("case:Второй"); + expect(component.dialogError()).toBeTruthy(); + }); + it("pending исключён из распределения, план с нехваткой нельзя применить", () => { + const { component, api } = setup(); + component.open("auto"); + expect([...component.chosenExperts()]).toEqual([1]); + component.plan.set({ + revision: "v1", + planToken: "signed", + assignments: [], + shortages: [{ projectId: 10, projectName: "P", missing: 1 }], + load: [], + }); + component.applyAllocation(); + expect(api.applyAllocation).not.toHaveBeenCalled(); + }); + it("новый ввод N сбрасывает старый предпросмотр и запрещает 0", () => { + const { component } = setup(); + component.open("quota"); + component.quotaPreview.set({ + revision: "v1", + requiredEvaluators: 2, + readyBefore: 0, + readyAfter: 0, + missingAssignments: 0, + surplusAssignments: 0, + }); + component.quota.setValue(3); + expect(component.quotaPreview()).toBeNull(); + component.quota.setValue(0); + expect(component.quota.invalid).toBe(true); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.ts b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.ts new file mode 100644 index 000000000..db9c26ff0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/experts/experts.component.ts @@ -0,0 +1,367 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal, + untracked, +} from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Observable, finalize } from "rxjs"; +import { + AlertDirective, + AvatarComponent, + ButtonDirective, + ChoiceDirective, + FieldDirective, +} from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { EvaluationUseCase } from "@api/program/use-cases/evaluation.use-case"; +import { + AllocationPlan, + AssignmentOptions, + AssignmentProject, + ExpertRow, + ExpertWorkspace, + QuotaPreview, +} from "@domain/program/evaluation-workspace.model"; +import { evaluationError } from "@utils/evaluation-workspace"; +import { presentationHref } from "@utils/program-case-analytics"; + +@Component({ + selector: "app-management-experts", + imports: [ + ReactiveFormsModule, + AlertDirective, + AvatarComponent, + ButtonDirective, + ChoiceDirective, + FieldDirective, + ModalComponent, + ], + templateUrl: "./experts.component.html", + styleUrl: "./experts.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManagementExpertsComponent { + readonly programId = input.required(); + readonly confirmDiscard = input.required<() => Promise>(); + readonly statusChange = output<{ dirty: boolean; busy: boolean }>(); + private readonly api = inject(EvaluationUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly fb = inject(FormBuilder).nonNullable; + readonly snapshot = signal(null); + readonly loading = signal(false); + readonly busy = signal(false); + readonly error = signal(null); + readonly dialogError = signal(null); + readonly message = signal(null); + readonly selected = signal(null); + readonly modal = signal<"invite" | "manual" | "assignments" | "auto" | "quota" | "revoke" | null>( + null, + ); + readonly plan = signal(null); + readonly quotaPreview = signal(null); + readonly options = signal(null); + readonly chosenProjects = signal(new Set()); + readonly chosenExperts = signal(new Set()); + readonly search = this.fb.control(""); + readonly status = this.fb.control("all"); + readonly projectSearch = this.fb.control(""); + readonly caseFilter = this.fb.control("all"); + readonly email = this.fb.control("", [ + Validators.required, + Validators.email, + Validators.maxLength(254), + ]); + readonly quota = this.fb.control(1, [ + Validators.required, + Validators.min(1), + Validators.max(1000), + Validators.pattern(/^\d+$/), + ]); + private readonly query = signal(""); + private readonly statusValue = signal("all"); + private readonly projectQuery = signal(""); + private readonly caseValue = signal("all"); + readonly page = signal(0); + readonly pageSize = 20; + readonly href = presentationHref; + readonly joined = computed( + () => this.snapshot()?.experts.filter(e => e.status === "joined") ?? [], + ); + readonly filtered = computed(() => + (this.snapshot()?.experts ?? []).filter( + e => + (this.statusValue() === "all" || e.status === this.statusValue()) && + `${e.name ?? ""} ${e.email}` + .toLocaleLowerCase("ru") + .includes(this.query().trim().toLocaleLowerCase("ru")), + ), + ); + readonly visible = computed(() => + this.filtered().slice(this.page() * this.pageSize, (this.page() + 1) * this.pageSize), + ); + readonly cases = computed(() => [ + ...new Set( + this.options() + ?.projects.map(p => p.caseName) + .filter((v): v is string => !!v), + ), + ]); + readonly visibleProjects = computed(() => + (this.options()?.projects ?? []).filter( + p => + (this.modal() !== "assignments" || p.assigned) && + p.name + .toLocaleLowerCase("ru") + .includes(this.projectQuery().trim().toLocaleLowerCase("ru")) && + (this.caseValue() === "all" || + (this.caseValue() === "none" ? !p.caseName : `case:${p.caseName}` === this.caseValue())), + ), + ); + readonly manualDirty = computed( + () => + !!this.options() && + this.modal() === "manual" && + this.options()!.projects.some(p => p.assigned !== this.chosenProjects().has(p.id)), + ); + readonly dirty = computed(() => this.manualDirty()); + readonly dialogTitle = computed(() => + this.modal() === "invite" + ? "Пригласить эксперта" + : this.modal() === "quota" + ? "Число оценщиков на проект" + : this.modal() === "manual" + ? "Назначить проекты" + : this.modal() === "assignments" + ? "Назначения эксперта" + : this.modal() === "revoke" + ? "Отозвать приглашение" + : this.plan() + ? this.plan()!.shortages.length + ? "Не хватает экспертов" + : "План распределения" + : "Распределить проекты", + ); + private loadSequence = 0; + constructor() { + effect(() => { + const id = this.programId(); + untracked(() => { + this.snapshot.set(null); + this.selected.set(null); + this.load(id); + }); + }); + effect(() => this.statusChange.emit({ dirty: this.dirty(), busy: this.busy() })); + this.search.valueChanges.pipe(takeUntilDestroyed()).subscribe(v => { + this.query.set(v); + this.page.set(0); + this.selected.set(null); + }); + this.status.valueChanges.pipe(takeUntilDestroyed()).subscribe(v => { + this.statusValue.set(v); + this.page.set(0); + this.selected.set(null); + }); + this.projectSearch.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(v => this.projectQuery.set(v)); + this.caseFilter.valueChanges.pipe(takeUntilDestroyed()).subscribe(v => this.caseValue.set(v)); + this.quota.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => this.quotaPreview.set(null)); + } + load(id = this.programId()): void { + const seq = ++this.loadSequence; + this.loading.set(true); + this.error.set(null); + this.api + .workspace(id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: data => { + if (seq !== this.loadSequence) return; + this.snapshot.set(data); + this.loading.set(false); + this.selected.set(data.experts.find(e => e.id === this.selected()?.id) ?? null); + }, + error: e => { + if (seq !== this.loadSequence) return; + this.error.set(evaluationError(e)); + this.loading.set(false); + }, + }); + } + private request(request: Observable, done: (data: T) => void): void { + if (this.busy()) return; + this.busy.set(true); + this.dialogError.set(null); + this.error.set(null); + request + .pipe( + finalize(() => this.busy.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: done, + error: e => { + if (this.modal()) this.dialogError.set(evaluationError(e)); + else this.error.set(evaluationError(e)); + }, + }); + } + select(row: ExpertRow): void { + this.selected.set(this.selected()?.id === row.id ? null : row); + } + label(row: ExpertRow): string { + if (row.status === "joined") return "В программе"; + if (row.status === "expired") return "Истекло"; + if (row.status === "revoked") return "Отозвано"; + return row.deliveryStatus === "failed" + ? "Не доставлено" + : row.deliveryStatus === "queued" + ? "Отправляется" + : "Приглашён"; + } + initials(row: ExpertRow): string { + return (row.name || row.email) + .split(/\s+/) + .slice(0, 2) + .map(s => s[0]) + .join("") + .toUpperCase(); + } + open(kind: "invite" | "auto" | "quota" | "revoke"): void { + this.dialogError.set(null); + this.message.set(null); + this.modal.set(kind); + this.plan.set(null); + this.quotaPreview.set(null); + if (kind === "invite") this.email.reset(""); + if (kind === "quota") this.quota.setValue(this.snapshot()?.requiredEvaluators ?? 1); + if (kind === "auto") this.chosenExperts.set(new Set(this.joined().map(e => Number(e.id)))); + } + async close(): Promise { + if (this.busy()) return; + if (this.dirty() && !(await this.confirmDiscard()())) return; + this.modal.set(null); + this.options.set(null); + this.dialogError.set(null); + } + discard(): void { + this.modal.set(null); + this.options.set(null); + } + sendInvite(): void { + if (this.email.invalid) { + this.email.markAsTouched(); + this.dialogError.set("Введите корректный email эксперта."); + return; + } + this.request(this.api.invite(this.programId(), this.email.value.trim()), () => { + this.modal.set(null); + this.message.set("Приглашение передано на отправку. Статус доставки появится в списке."); + this.load(); + }); + } + invitationAction(action: "resend" | "revoke"): void { + const id = this.selected()?.invitationId; + if (!id) return; + this.request(this.api.invitationAction(this.programId(), id, action), () => { + this.modal.set(null); + this.message.set( + action === "resend" + ? "Приглашение передано на повторную отправку." + : "Приглашение отозвано.", + ); + this.selected.set(null); + this.load(); + }); + } + openAssignments(readonly = false): void { + const expert = this.selected(); + if (!expert || expert.status !== "joined") return; + this.options.set(null); + this.projectSearch.reset(""); + this.caseFilter.reset("all"); + this.modal.set(readonly ? "assignments" : "manual"); + this.request(this.api.assignments(this.programId(), Number(expert.id)), data => { + this.options.set(data); + this.chosenProjects.set(new Set(data.projects.filter(p => p.assigned).map(p => p.id))); + }); + } + toggleProject(project: AssignmentProject): void { + if ( + this.busy() || + project.protected || + (!project.assigned && (!project.submitted || project.ownTeam)) + ) + return; + const values = new Set(this.chosenProjects()); + values.has(project.id) ? values.delete(project.id) : values.add(project.id); + this.chosenProjects.set(values); + } + saveAssignments(): void { + const expert = this.selected(), + options = this.options(); + if (!expert || !options) return; + this.request( + this.api.saveAssignments(this.programId(), Number(expert.id), options.revision, [ + ...this.chosenProjects(), + ]), + () => { + this.modal.set(null); + this.options.set(null); + this.message.set("Назначения сохранены."); + this.load(); + }, + ); + } + toggleExpert(id: number | string): void { + const values = new Set(this.chosenExperts()); + values.has(Number(id)) ? values.delete(Number(id)) : values.add(Number(id)); + this.chosenExperts.set(values); + } + previewAllocation(): void { + this.request( + this.api.allocation(this.programId(), { expertIds: [...this.chosenExperts()] }), + plan => this.plan.set(plan), + ); + } + applyAllocation(): void { + const plan = this.plan(); + if (!plan || plan.shortages.length) return; + this.request(this.api.applyAllocation(this.programId(), plan.planToken), data => { + this.snapshot.set(data); + this.modal.set(null); + this.message.set("Проекты распределены."); + }); + } + previewQuota(): void { + if (this.quota.invalid) { + this.quota.markAsTouched(); + return; + } + this.request(this.api.quota(this.programId(), this.quota.value), data => + this.quotaPreview.set(data), + ); + } + saveQuota(): void { + const preview = this.quotaPreview(); + if (!preview || preview.requiredEvaluators !== this.quota.value) return; + this.request( + this.api.saveQuota(this.programId(), preview.requiredEvaluators, preview.revision), + data => { + this.snapshot.set(data); + this.modal.set(null); + this.message.set("Число оценщиков изменено, готовность проектов пересчитана."); + }, + ); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.html b/projects/social_platform/src/app/ui/pages/program/management/management.component.html index 1cb1ea9fc..9872d8ddf 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.html @@ -35,6 +35,13 @@

    {{ requestError() }}

    Загружаем данные программы… } @else if (state.program(); as program) { + @if (section() === "experts") { + + } @if (section() === "participants" || section() === "projects") {

    {{ section() === "participants" ? "Участники" : "Проекты" }}

    diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts index ac53dcbe2..5f35cbe3a 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts @@ -52,6 +52,7 @@ import { ProgramAnalyticsComponent } from "../detail/analytics/analytics.compone import { ProgramBrandingPreviewComponent } from "./branding-preview/branding-preview.component"; import { ManagementProjectFormComponent } from "./project-form/project-form.component"; import { ManagementCriteriaComponent } from "./criteria/criteria.component"; +import { ManagementExpertsComponent } from "./experts/experts.component"; import { presentationHref } from "@utils/program-case-analytics"; import { evaluationLabel, fromMoscowInput, toMoscowInput } from "@utils/program-management"; import { DEFAULT_IMAGE_FRAME, programImageFraming } from "@utils/program-branding"; @@ -79,6 +80,7 @@ import { ProgramImageFraming, ProgramImageKey } from "@domain/program/program-im ProgramBrandingPreviewComponent, ManagementProjectFormComponent, ManagementCriteriaComponent, + ManagementExpertsComponent, ], providers: [ ProgramManagementService, @@ -116,6 +118,8 @@ export class ProgramManagementComponent implements OnInit { readonly projectFormStatus = signal({ dirty: false, busy: false }); readonly criteriaForm = viewChild(ManagementCriteriaComponent); readonly criteriaStatus = signal({ dirty: false, busy: false }); + readonly expertsStatus = signal({ dirty: false, busy: false }); + readonly expertsForm = viewChild(ManagementExpertsComponent); readonly confirmProjectFormDiscard = () => this.confirm( "Есть несохранённые изменения", @@ -142,6 +146,7 @@ export class ProgramManagementComponent implements OnInit { readonly tabs = [ { id: "participants", label: "Участники" }, { id: "projects", label: "Проекты" }, + { id: "experts", label: "Эксперты" }, { id: "analytics", label: "Аналитика" }, { id: "settings", label: "Настройки" }, ]; @@ -248,7 +253,8 @@ export class ProgramManagementComponent implements OnInit { this.saving() || this.uploading() || this.projectFormStatus().busy || - this.criteriaStatus().busy + this.criteriaStatus().busy || + this.expertsStatus().busy ) { event.preventDefault(); event.returnValue = ""; @@ -271,6 +277,7 @@ export class ProgramManagementComponent implements OnInit { const section = [ "participants", "projects", + "experts", "analytics", "settings", "evaluation", @@ -393,7 +400,8 @@ export class ProgramManagementComponent implements OnInit { this.datesForm.dirty || this.materialForm.dirty || this.projectFormStatus().dirty || - this.criteriaStatus().dirty + this.criteriaStatus().dirty || + this.expertsStatus().dirty ); } async canLeave(): Promise { @@ -401,7 +409,8 @@ export class ProgramManagementComponent implements OnInit { this.saving() || this.uploading() || this.projectFormStatus().busy || - this.criteriaStatus().busy + this.criteriaStatus().busy || + this.expertsStatus().busy ) return false; if (!this.dirty()) return true; @@ -420,6 +429,8 @@ export class ProgramManagementComponent implements OnInit { this.projectFormStatus.set({ dirty: false, busy: false }); this.criteriaForm()?.discard(); this.criteriaStatus.set({ dirty: false, busy: false }); + this.expertsForm()?.discard(); + this.expertsStatus.set({ dirty: false, busy: false }); } return yes; } diff --git a/projects/social_platform/src/app/ui/routes/office/office.routes.ts b/projects/social_platform/src/app/ui/routes/office/office.routes.ts index 358de3b57..8a2a62e5f 100644 --- a/projects/social_platform/src/app/ui/routes/office/office.routes.ts +++ b/projects/social_platform/src/app/ui/routes/office/office.routes.ts @@ -16,6 +16,13 @@ export const OFFICE_ROUTES: Routes = [ path: "", component: OfficeComponent, children: [ + { + path: "evaluation", + loadComponent: () => + import("../../pages/evaluation/programs/evaluation-programs.component").then( + c => c.EvaluationProgramsComponent, + ), + }, { path: "", pathMatch: "full", diff --git a/projects/social_platform/src/app/ui/widgets/detail/detail.component.html b/projects/social_platform/src/app/ui/widgets/detail/detail.component.html index 088edf65f..3b29d7979 100644 --- a/projects/social_platform/src/app/ui/widgets/detail/detail.component.html +++ b/projects/social_platform/src/app/ui/widgets/detail/detail.component.html @@ -140,7 +140,12 @@

    {{ info().name }}

    } @else {
    - @if (!isUserMember() && !isUserManager()) { + @if ( + !isUserMember() && + !isUserManager() && + !isUserExpert() && + !info().pendingExpertInvitationId + ) { @if (getProgramRegistrationLink(info()); as registrationLink) { {{ info().name }} } } - @if (isUserMember() && !isUserManager() && !isUserExpert()) { + @if ( + isUserMember() && + !isUserManager() && + !isUserExpert() && + !info().pendingExpertInvitationId + ) { {{ info().name }} } - @if (isUserManager() || (isUserExpert() && isUserMember())) { + @if (info().pendingExpertInvitationId && !isUserExpert()) { Принять приглашение + } + @if (isUserExpert()) { + + typeof value === "string" + ? [value] + : Array.isArray(value) + ? value.filter(v => typeof v === "string") + : [], + ); + if (messages.length) return messages.join(" "); + } + if (error.status === 409) return "Данные изменились. Обновите список и повторите действие."; + if (error.status === 400) return "Проверьте заполненные поля и повторите действие."; + } + return "Не удалось выполнить действие. Попробуйте ещё раз."; +} +export function expertInvitationReturn(value: string | null | undefined): string | null { + return value && /^\/expert-invitation\/[A-Za-z0-9_-]{20,100}$/.test(value) ? value : null; +} diff --git a/projects/social_platform/src/app/utils/userRaw.ts b/projects/social_platform/src/app/utils/userRaw.ts index 59b916156..83264b81f 100644 --- a/projects/social_platform/src/app/utils/userRaw.ts +++ b/projects/social_platform/src/app/utils/userRaw.ts @@ -31,6 +31,7 @@ export function userFromRaw(raw: UserInput): User { }; user.relations = { + hasEvaluationAccess: raw.relations?.hasEvaluationAccess ?? raw.hasEvaluationAccess ?? false, education: raw.relations?.education ?? raw.education ?? [], userLanguages: raw.relations?.userLanguages ?? raw.userLanguages ?? [], workExperience: raw.relations?.workExperience ?? raw.workExperience ?? [], diff --git a/projects/ui/src/lib/components/primitives/avatar/avatar.component.html b/projects/ui/src/lib/components/primitives/avatar/avatar.component.html index 07453c3cb..dbc9652d5 100644 --- a/projects/ui/src/lib/components/primitives/avatar/avatar.component.html +++ b/projects/ui/src/lib/components/primitives/avatar/avatar.component.html @@ -17,7 +17,16 @@ >
    } - @if (imageFraming()) { + @if (!url() && fallbackLabel()) { + + } @else if (imageFraming()) {
    (null); /** URL изображения аватара */ url = input.required(); + /** Optional initials used by the expert workspace; existing placeholders stay unchanged. */ + readonly fallbackLabel = input(); /** Размер аватара в пикселях */ size = input(50); diff --git a/projects/ui/src/lib/components/primitives/button/button.directive.ts b/projects/ui/src/lib/components/primitives/button/button.directive.ts index 95740d350..7bbc0c8d3 100644 --- a/projects/ui/src/lib/components/primitives/button/button.directive.ts +++ b/projects/ui/src/lib/components/primitives/button/button.directive.ts @@ -1,17 +1,24 @@ /** @format */ -import { booleanAttribute, Directive, input } from "@angular/core"; +import { booleanAttribute, Directive, HostListener, input } from "@angular/core"; /** Native adapter for the same Button foundation; keeps form semantics and HTMLElement refs. */ @Directive({ - selector: "button[appButton]", + selector: "button[appButton], a[appButton]", host: { "[class]": "'ui-button ui-button--' + appButton()", "[disabled]": "disabled() || appButtonLoading()", "[attr.aria-busy]": "appButtonLoading() || null", + "[attr.aria-disabled]": "disabled() || appButtonLoading() || null", }, }) export class ButtonDirective { + @HostListener("click", ["$event"]) onClick(event: MouseEvent): void { + if (this.disabled() || this.appButtonLoading()) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + } readonly appButton = input<"primary" | "secondary" | "danger" | "icon" | "text">("secondary"); readonly disabled = input(false, { transform: booleanAttribute }); readonly appButtonLoading = input(false, { transform: booleanAttribute });