From e1530940ca84884b30a619a69dbc452cafb1da13 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 24 Oct 2026 17:09:11 +0300 Subject: [PATCH] feat: launch assignment-first expert cabinet --- .../program-detail-list-info.service.ts | 2 + .../program/use-cases/evaluation.use-case.ts | 9 +- .../program/evaluation-workspace.model.ts | 28 ++ .../ports/evaluation.repository.port.ts | 3 + .../program/evaluation-http.adapter.spec.ts | 27 ++ .../program/evaluation-http.adapter.ts | 13 + .../program/evaluation.repository.ts | 5 +- .../evaluation-programs.component.html | 312 ++++++++++++------ .../evaluation-programs.component.scss | 311 +++++++++++++---- .../evaluation-programs.component.spec.ts | 236 +++++++++++++ .../programs/evaluation-programs.component.ts | 216 +++++++++--- .../ui/pages/office/nav/nav.component.html | 2 +- .../app/ui/pages/office/nav/nav.component.ts | 2 +- .../app/ui/pages/office/office.component.ts | 2 +- .../program/detail/list/list.component.html | 4 + .../program/detail/list/list.component.ts | 14 +- 16 files changed, 982 insertions(+), 204 deletions(-) create mode 100644 projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.spec.ts diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts index ddf64ce3e..ef811d19e 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts @@ -408,6 +408,8 @@ export class ProgramDetailListInfoService { const extraParams: Record = {}; Object.keys(q).forEach(key => { + // Navigation context is not a program application field. + if (this.listType() === "rating" && key === "returnUrl") return; const value = q[key]; if (value === undefined || value === "" || value === null) return; 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 index 7b6dee839..ee269dcc4 100644 --- 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 @@ -3,7 +3,11 @@ 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, ExpertsBulkWrite } from "@domain/program/evaluation-workspace.model"; +import { + ExpertCabinetQuery, + ExpertRegistration, + ExpertsBulkWrite, +} from "@domain/program/evaluation-workspace.model"; import { tap } from "rxjs"; @Injectable({ providedIn: "root" }) export class EvaluationUseCase { @@ -17,6 +21,9 @@ export class EvaluationUseCase { }); } readonly programs = this.repository.programs.bind(this.repository); + cabinet(query: ExpertCabinetQuery) { + return this.repository.cabinet(query); + } readonly workspace = this.repository.workspace.bind(this.repository); bulkExperts(id: number, data: ExpertsBulkWrite) { return this.repository.bulkExperts(id, data); 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 index 203fadfb7..79f8a0459 100644 --- a/projects/social_platform/src/app/domain/program/evaluation-workspace.model.ts +++ b/projects/social_platform/src/app/domain/program/evaluation-workspace.model.ts @@ -144,6 +144,34 @@ export interface EvaluationPrograms { results: EvaluationProgram[]; hasAccess: boolean; } +export interface ExpertCabinetQuery { + status: "pending" | "completed"; + q?: string; + programId?: number; + limit: number; + offset: number; +} +export interface ExpertCabinetProject { + assignmentId: number; + projectId: number; + programId: number; + name: string; + caseName: string | null; + isCompleted: boolean; + canRate: boolean; + blockedReason: string | null; +} +export interface ExpertCabinet extends EvaluationPrograms { + projects: { + count: number; + limit: number; + offset: number; + pending: number; + completed: number; + unavailable: number; + results: ExpertCabinetProject[]; + }; +} export interface ExpertInvitation { id: number; email: string; 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 index 352611600..c28cf906f 100644 --- 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 @@ -2,6 +2,8 @@ import { Observable } from "rxjs"; import { EvaluationPrograms, + ExpertCabinet, + ExpertCabinetQuery, ExpertWorkspace, AssignmentOptions, AllocationQuery, @@ -17,6 +19,7 @@ import { } from "@domain/program/evaluation-workspace.model"; export abstract class EvaluationRepositoryPort { abstract programs(): Observable; + abstract cabinet(query: ExpertCabinetQuery): Observable; abstract workspace(id: number): Observable; abstract bulkExperts(id: number, data: ExpertsBulkWrite): Observable; abstract saveCaseScope( diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.spec.ts new file mode 100644 index 000000000..2a7cfc0bf --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/evaluation-http.adapter.spec.ts @@ -0,0 +1,27 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; +import { ApiService } from "@corelib"; +import { EvaluationHttpAdapter } from "./evaluation-http.adapter"; + +describe("Expert cabinet HTTP contract", () => { + it("encodes the additive server query without changing the legacy summary request", () => { + const api = { get: vi.fn((_url: string) => of({})) }; + TestBed.configureTestingModule({ providers: [{ provide: ApiService, useValue: api }] }); + const adapter = TestBed.inject(EvaluationHttpAdapter); + adapter + .cabinet({ status: "completed", q: "A&B / Кейс", programId: 7, limit: 20, offset: 40 }) + .subscribe(); + const url = new URL(api.get.mock.calls[0][0] as string, "https://example.test"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + include: "projects", + status: "completed", + q: "A&B / Кейс", + program_id: "7", + limit: "20", + offset: "40", + }); + adapter.programs().subscribe(); + expect(api.get).toHaveBeenLastCalledWith("/evaluation/programs/"); + }); +}); 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 index 62812d614..ac60b0693 100644 --- 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 @@ -4,6 +4,8 @@ import { ApiService } from "@corelib"; import { Observable } from "rxjs"; import { EvaluationPrograms, + ExpertCabinet, + ExpertCabinetQuery, ExpertWorkspace, AssignmentOptions, AllocationQuery, @@ -27,6 +29,17 @@ export class EvaluationHttpAdapter implements EvaluationRepositoryPort { programs(): Observable { return this.api.get("/evaluation/programs/"); } + cabinet(query: ExpertCabinetQuery): Observable { + const params = new URLSearchParams({ + include: "projects", + status: query.status, + limit: String(query.limit), + offset: String(query.offset), + }); + if (query.q) params.set("q", query.q); + if (query.programId) params.set("program_id", String(query.programId)); + return this.api.get(`/evaluation/programs/?${params}`); + } workspace(id: number): Observable { return this.api.get(`${this.root(id)}/experts/`); } 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 index 5a1a457dd..c0db0bac2 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/evaluation.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/evaluation.repository.ts @@ -2,11 +2,14 @@ import { inject, Injectable } from "@angular/core"; import { EvaluationHttpAdapter } from "../../adapters/program/evaluation-http.adapter"; import { EvaluationRepositoryPort } from "@domain/program/ports/evaluation.repository.port"; -import { ExpertsBulkWrite } from "@domain/program/evaluation-workspace.model"; +import { ExpertCabinetQuery, ExpertsBulkWrite } from "@domain/program/evaluation-workspace.model"; @Injectable({ providedIn: "root" }) export class EvaluationRepository implements EvaluationRepositoryPort { private readonly adapter = inject(EvaluationHttpAdapter); readonly programs = this.adapter.programs.bind(this.adapter); + cabinet(query: ExpertCabinetQuery) { + return this.adapter.cabinet(query); + } readonly workspace = this.adapter.workspace.bind(this.adapter); bulkExperts(id: number, data: ExpertsBulkWrite) { return this.adapter.bulkExperts(id, data); diff --git a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.html b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.html index 0b81dcdeb..d7aa3b181 100644 --- a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.html +++ b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.html @@ -1,101 +1,218 @@ - -
-

Оценивание

-

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

-

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

- +
+
+

Кабинет эксперта

+

Все проекты, назначенные вам для оценки

+
+ +
+ @if (tab() !== "invitations") { + + + + } + +
@if (accepted()) { -

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

+ Приглашение принято } - @if (state.failed()) { - - } @else if (state.pending() && !state.programs().length) { -

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

+ @if (failed()) { + + } @else if (loading()) { +

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

+ } @else if (tab() === "invitations") { + @for (p of invitations(); track p.invitationId) { +
+
+ +
+

{{ p.name }}

+

Приглашение для {{ p.email }}

+
+
+ +
+ } @empty { +
+

Нет новых приглашений

+

Приглашения от организаторов появятся здесь.

+
+ } } @else { -

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

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

{{ program.name }}

- {{ - program.status === "joined" ? "Эксперт программы" : "Приглашение эксперта" - }}
- @if (program.status === "joined") { - К проектам - } @else { - - } + @for (group of groups(); track group.program.id) { +
+
+ {{ group.program.name }} +
+ {{ tab() === "completed" ? "Оценено" : "Осталось" }} + {{ tab() === "completed" ? group.program.completed : group.program.remaining }} из + {{ group.program.assigned }}{{ + group.program.datetimeEvaluationEnds + ? "До " + (group.program.datetimeEvaluationEnds | date: "d MMMM, HH:mm") + : "Без срока" + }} +
- @if (program.status === "joined") { -
-
-
Назначено
-
{{ program.assigned }}
+ @for (project of group.projects; track project.assignmentId) { +
+
+

{{ project.name }}

+ @if (project.caseName) { +

{{ project.caseName }}

+ } + @if (!project.canRate && !project.isCompleted) { +

{{ project.blockedReason }}

+ }
-
-
Оценено
-
{{ program.completed }}
-
-
-
Осталось
-
{{ program.remaining }}
-
-
-

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

- } @else { -

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

+ @if (project.canRate || project.isCompleted) { + {{ project.isCompleted ? "Посмотреть" : "Оценить" }} + } @else { + + } +
} - + } @empty { -
-

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

-

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

+
+ @if (filtered()) { +

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

+

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

+ + } @else if (query().offset > 0) { +

На этой странице нет проектов

+ + } @else if (allCompleted() && tab() === "pending") { +

Все проекты оценены

+

Спасибо за вашу работу!

+ + } @else if (tab() === "completed") { +

Пока нет оцененных проектов

+

После отправки оценки проект появится здесь.

+ } @else if (!data()?.hasAccess) { +

Пока нет программ для оценки

+

Когда организатор пригласит вас экспертом, приглашение появится здесь.

+ } @else if (data()?.projects?.unavailable) { +

Пока нет доступных проектов

+

Некоторые назначения недоступны. Уточните у организатора программы.

+ } @else { +

Пока нет назначенных проектов

+

Когда организатор назначит вам проекты, они появятся здесь.

+ @if (invitations().length) { + + } + }
} + @if ((data()?.projects?.count ?? 0) > 20 || query().offset > 0) { + + } }
{{ query() ? "Программы не найдены" : "У вас пока (openChange)="close()" labelledBy="evaluation-accept-title" bodyClass="modal__body--no-flex evaluation-accept-modal" - >
-

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

+ >
+

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

{{ selected()?.name }} -

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

+

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

@if (error()) {

{{ error() }}

} -
-
.ui-button { + min-width: 170px; +} + +.cabinet__tabs > .cabinet__invitations { + min-width: 156px; + margin-left: auto; } -.evaluation-programs__card { +.cabinet__toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + min-width: 0; +} + +.cabinet__search { + flex: 1; + min-width: 0; +} + +select.cabinet__program { + flex: 0 1 190px; + width: 190px; + padding-right: 36px; + background-image: url("/assets/icons/svg/chevron-down.svg"); + background-position: right 12px center; + background-size: 16px 16px; +} + +.cabinet__search-toggle, +.cabinet__invitations--mobile { + display: none; +} + +.cabinet__group { display: grid; gap: 16px; min-width: 0; - padding: 24px; + padding: 12px; background: var(--light-white); border-radius: 15px; } -.evaluation-programs__heading { +.cabinet__group-heading { display: flex; - gap: 16px; - align-items: flex-start; + gap: 12px; + align-items: center; justify-content: space-between; + padding: 8px 12px; } -.evaluation-programs__heading > .ui-button { - min-width: 200px; +.cabinet__group-heading > a { + min-width: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; + color: var(--black); + text-decoration: none; + overflow-wrap: anywhere; } -.evaluation-programs__identity { +.cabinet__meta { display: flex; - gap: 12px; + flex-shrink: 0; + gap: 16px; align-items: center; - min-width: 0; - color: inherit; - text-decoration: none; + color: var(--grey-for-text); } -.evaluation-programs__identity app-avatar { - flex-shrink: 0; +.cabinet__deadline { + color: var(--black); +} + +.cabinet__project { + display: flex; + gap: 12px; + align-items: center; + min-width: 0; + padding: 12px; } -.evaluation-programs__identity > span { +.cabinet__project-info { display: grid; - gap: 6px; + flex: 1; + gap: 4px; min-width: 0; overflow-wrap: anywhere; } -.evaluation-programs__stats { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 24px; +.cabinet__project h3 { margin: 0; + font-size: 14px; + font-weight: 400; + line-height: 21px; +} + +.cabinet__project p { + color: var(--grey-for-text); +} + +.cabinet__project > .ui-button { + flex-shrink: 0; + width: 128px; } -.evaluation-programs__stats div { +.cabinet__project .cabinet__blocked { + color: var(--black); +} + +.cabinet__empty { display: grid; - gap: 6px; + gap: 16px; + justify-items: start; + padding: 32px 24px; + background: var(--light-white); + border-radius: 15px; } -.evaluation-programs__stats dt, -.evaluation-programs__count, -.evaluation-programs__deadline { +.cabinet__empty p { color: var(--grey-for-text); } -.evaluation-programs__stats dd { - margin: 0; - font-size: 16px; - font-weight: 600; +.cabinet__pagination { + display: flex; + gap: 16px; + align-items: center; + justify-content: flex-end; + color: var(--grey-for-text); +} + +.cabinet__invitation { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + min-width: 0; + padding: 24px; + background: var(--light-white); + border-radius: 15px; +} + +.cabinet__identity { + display: flex; + gap: 12px; + align-items: center; + min-width: 0; + overflow-wrap: anywhere; } -.evaluation-programs__dialog { +.cabinet__identity app-avatar { + flex-shrink: 0; +} + +.cabinet__identity p { + margin-top: 8px; + color: var(--grey-for-text); +} + +.cabinet__dialog { display: grid; gap: 20px; font-size: 14px; } -.evaluation-programs__actions { +.cabinet__actions { display: flex; flex-wrap: wrap; gap: 12px; + justify-content: flex-end; +} + +.cabinet__sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); } ::ng-deep .modal .modal__body.evaluation-accept-modal { @@ -146,30 +260,115 @@ } @media (max-width: 999px) { - .evaluation-programs { + .cabinet { margin: 24px 20px 40px; } } @media (max-width: 700px) { - .evaluation-programs { + .cabinet { + gap: 12px; margin-top: 20px; } - .evaluation-programs__card { - padding: 20px; + .cabinet__title p { + display: none; } - .evaluation-programs__heading { - flex-direction: column; - align-items: stretch; + .cabinet__tabs > .ui-button { + flex: 1; + min-width: 0; + padding: 0 8px; + } + + .cabinet__invitations--desktop { + display: none; } - .evaluation-programs__stats { + .cabinet__toolbar { gap: 8px; } - .evaluation-programs__actions { + .cabinet__search-toggle, + .cabinet__toolbar .cabinet__invitations--mobile { + display: inline-flex; + } + + .cabinet__toolbar .ui-button { + padding: 0 8px; + } + + .cabinet__search-toggle { + flex: 0 0 64px; + } + + .cabinet__invitations--mobile { + flex: 0 0 108px; + font-size: 11px; + } + + select.cabinet__program { + flex: 1; + width: 0; + min-width: 0; + padding-right: 28px; + padding-left: 12px; + font-size: 11px; + background-position: right 10px center; + background-size: 12px; + } + + .cabinet__search { + display: none; + flex-basis: 100%; + order: 1; + } + + .cabinet__search--expanded { + display: block; + } + + .cabinet__group { + gap: 12px; + padding: 4px; + } + + .cabinet__group-heading { + display: grid; + gap: 8px; + padding: 12px; + } + + .cabinet__group-heading > a { + font-size: 14px; + line-height: 18px; + } + + .cabinet__meta { + gap: 8px; + justify-content: space-between; + } + + .cabinet__project > .ui-button { + width: 104px; + } + + .cabinet__invitation { flex-direction: column; + align-items: stretch; + padding: 16px; + } + + .cabinet__actions { + flex-direction: column-reverse; + } + + .cabinet__empty { + padding: 24px 16px; + } + + .cabinet__pagination { + gap: 8px; + justify-content: center; } } diff --git a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.spec.ts b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.spec.ts new file mode 100644 index 000000000..a96129a47 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.spec.ts @@ -0,0 +1,236 @@ +/** @format */ +import { signal } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { ActivatedRoute, convertToParamMap, provideRouter, Router } from "@angular/router"; +import { BehaviorSubject, of, Subject, throwError } from "rxjs"; +import { EvaluationUseCase } from "@api/program/use-cases/evaluation.use-case"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; +import { ExpertCabinet } from "@domain/program/evaluation-workspace.model"; +import { EvaluationProgramsComponent } from "./evaluation-programs.component"; + +const payload = (): ExpertCabinet => ({ + hasAccess: true, + results: [ + { + id: 7, + name: "Первая программа", + imageAddress: null, + datetimeEvaluationEnds: null, + status: "joined", + assigned: 2, + remaining: 1, + completed: 1, + }, + { + id: 8, + name: "Приглашение", + imageAddress: null, + datetimeEvaluationEnds: null, + status: "pending", + email: "expert@example.test", + invitationId: 19, + }, + ], + projects: { + count: 1, + limit: 20, + offset: 0, + pending: 1, + completed: 1, + unavailable: 0, + results: [ + { + assignmentId: 1, + projectId: 42, + programId: 7, + name: "Проект Альфа", + caseName: "Кейс А", + isCompleted: false, + canRate: true, + blockedReason: null, + }, + ], + }, +}); +function setup(initial: Record = {}, response = of(payload())) { + const params = new BehaviorSubject(convertToParamMap(initial)); + const profile = signal({ id: 10 }); + const profiles = { profile, applyProfileUpdated: vi.fn(), invalidateProfile: vi.fn() }; + const api = { + cabinet: vi.fn(() => response), + acceptOwn: vi.fn(() => of({ accepted: true, programId: 8 })), + }; + const auth = { fetchProfile: vi.fn(() => of({ id: 10 })) }; + TestBed.configureTestingModule({ + imports: [EvaluationProgramsComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { queryParamMap: params, snapshot: { queryParamMap: params.value } }, + }, + { provide: EvaluationUseCase, useValue: api }, + { provide: ProfileInfoService, useValue: profiles }, + { provide: AuthInfoService, useValue: auth }, + ], + }); + const router = TestBed.inject(Router); + const navigate = vi.spyOn(router, "navigate").mockResolvedValue(true); + const fixture = TestBed.createComponent(EvaluationProgramsComponent); + fixture.detectChanges(); + return { fixture, c: fixture.componentInstance, api, params, profile, profiles, auth, navigate }; +} + +describe("Кабинет эксперта: только реальные назначения", () => { + it.each([ + [false, 0, "Пока нет программ для оценки"], + [true, 0, "Все проекты оценены"], + ])("различает отсутствие доступа и завершение работы (%s)", (hasAccess, unavailable, text) => { + const data = payload(); + data.hasAccess = hasAccess; + data.projects = { + ...data.projects, + results: [], + count: 0, + pending: 0, + completed: hasAccess ? 1 : 0, + unavailable, + }; + if (!hasAccess) data.results = []; + const { fixture } = setup({}, of(data)); + expect(fixture.nativeElement.textContent).toContain(text); + }); + it("открывает конкретный назначенный проект в существующей форме и выводит серверные счетчики", () => { + const { fixture, api } = setup(); + expect(api.cabinet).toHaveBeenCalledWith({ + status: "pending", + q: "", + programId: undefined, + limit: 20, + offset: 0, + }); + const link = fixture.nativeElement.querySelector(".cabinet__project a") as HTMLAnchorElement; + expect(link.getAttribute("href")).toContain("/office/program/7/projects-rating?project_id=42"); + expect(fixture.nativeElement.textContent).toContain("Осталось 1 из 2"); + expect(fixture.nativeElement.textContent).not.toContain("Продолжить"); + }); + it("серверные поиск, программа, страница и завершенные проекты восстанавливаются из URL", () => { + const { c, api } = setup({ tab: "completed", q: "Альфа", program: "7", page: "3" }); + expect(api.cabinet).toHaveBeenCalledWith({ + status: "completed", + q: "Альфа", + programId: 7, + limit: 20, + offset: 40, + }); + expect(c.search.value).toBe("Альфа"); + expect(c.program.value).toBe("7"); + }); + it("смена фильтра отменяет старый запрос и не принимает устаревший результат", () => { + const first = new Subject(); + const { c, api, params, fixture } = setup({}, first); + const next = payload(); + next.projects.results[0].name = "Новый результат"; + api.cabinet.mockReturnValue(of(next)); + params.next(convertToParamMap({ q: "Новый" })); + fixture.detectChanges(); + expect(first.observed).toBe(false); + first.next(payload()); + fixture.detectChanges(); + expect(c.data()?.projects.results[0].name).toBe("Новый результат"); + }); + it("при смене аккаунта немедленно скрывает чужие данные и отменяет запрос", () => { + const { c, api, profile, fixture } = setup(); + const delayed = new Subject(); + api.cabinet.mockReturnValue(delayed); + profile.set({ id: 20 }); + expect(c.data()).toBeNull(); + fixture.detectChanges(); + expect(c.selected()).toBeNull(); + profile.set({ id: 0 }); + fixture.detectChanges(); + expect(delayed.observed).toBe(false); + expect(c.data()).toBeNull(); + }); + it("не предлагает оценивать просроченный проект, но разрешает просмотр завершенного", () => { + const data = payload(); + data.projects.results[0].canRate = false; + data.projects.results[0].blockedReason = "Срок оценивания завершен"; + const { fixture, params, api } = setup({}, of(data)); + expect(fixture.nativeElement.querySelector(".cabinet__project button").disabled).toBe(true); + expect(fixture.nativeElement.textContent).toContain("Срок оценивания завершен"); + data.projects.results[0].isCompleted = true; + api.cabinet.mockReturnValue(of(data)); + params.next(convertToParamMap({ tab: "completed" })); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector(".cabinet__project a").textContent).toContain( + "Посмотреть", + ); + }); + it("не показывает все оценено, если часть назначений недоступна", () => { + const data = payload(); + data.projects = { ...data.projects, results: [], count: 0, pending: 0, unavailable: 1 }; + const { fixture } = setup({}, of(data)); + expect(fixture.nativeElement.textContent).toContain("Пока нет доступных проектов"); + expect(fixture.nativeElement.textContent).not.toContain("Все проекты оценены"); + }); + it("сбой или старый API не превращает в успешный пустой кабинет", () => { + const { fixture } = setup( + {}, + of({ results: [], hasAccess: false } as unknown as ExpertCabinet), + ); + expect(fixture.nativeElement.textContent).toContain("Не удалось загрузить кабинет"); + expect(fixture.nativeElement.textContent).not.toContain("Пока нет назначенных проектов"); + }); + it("401 ведет на вход с возвратом, другие ошибки можно повторить", () => { + const { fixture } = setup( + {}, + throwError(() => ({ status: 401 })), + ); + expect(fixture.nativeElement.querySelector('a[href^="/auth/login"]')).toBeTruthy(); + }); + it("приглашение открывается по ссылке, отправляется однократно и обновляет профиль", () => { + const { c, api, auth, profiles, fixture } = setup({ invitation: "19" }); + expect(c.selected()?.invitationId).toBe(19); + const request = new Subject<{ accepted: boolean; programId: number }>(); + api.acceptOwn.mockReturnValue(request); + c.accept(); + c.accept(); + expect(api.acceptOwn).toHaveBeenCalledTimes(1); + c.close(); + expect(c.selected()).not.toBeNull(); + request.next({ accepted: true, programId: 8 }); + request.complete(); + fixture.detectChanges(); + expect(c.selected()).toBeNull(); + expect(auth.fetchProfile).toHaveBeenCalledOnce(); + expect(profiles.applyProfileUpdated).toHaveBeenCalledWith({ id: 10 }); + }); + it("не открывает чужое приглашение; ошибка принятия остается в модалке", () => { + const { c, api } = setup({ invitation: "999" }); + expect(c.selected()).toBeNull(); + c.open(payload().results[1]); + api.acceptOwn.mockReturnValue(throwError(() => ({ status: 409 }))); + c.accept(); + expect(c.error()).toBeTruthy(); + expect(c.busy()).toBe(false); + expect(c.selected()).not.toBeNull(); + }); + it("смена вкладки и программы сбрасывает страницу, сохраняя остальные параметры", () => { + const { c, navigate } = setup({ page: "3", q: "Альфа" }); + c.setTab("completed"); + expect(navigate).toHaveBeenLastCalledWith( + [], + expect.objectContaining({ + queryParams: { tab: "completed", page: null }, + queryParamsHandling: "merge", + }), + ); + c.program.setValue("7"); + expect(navigate).toHaveBeenLastCalledWith( + [], + expect.objectContaining({ queryParams: { program: "7", page: null } }), + ); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.ts b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.ts index 5e79ce660..1a3e1ba26 100644 --- a/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.ts +++ b/projects/social_platform/src/app/ui/pages/evaluation/programs/evaluation-programs.component.ts @@ -2,30 +2,41 @@ import { ChangeDetectionStrategy, Component, - OnInit, DestroyRef, + ElementRef, computed, effect, inject, signal, untracked, + viewChild, } 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 { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { debounceTime, distinctUntilChanged, 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 { TransientNoticeComponent } from "@ui/primitives/transient-notice/transient-notice.component"; import { EvaluationUseCase } from "@api/program/use-cases/evaluation.use-case"; -import { EvaluationProgram } from "@domain/program/evaluation-workspace.model"; +import { + EvaluationProgram, + ExpertCabinet, + ExpertCabinetProject, +} 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"; +type CabinetTab = "pending" | "completed" | "invitations"; +const positive = (value: string | null) => { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +}; + @Component({ selector: "app-evaluation-programs", imports: [ @@ -37,57 +48,170 @@ import { presentationHref } from "@utils/program-case-analytics"; ButtonDirective, FieldDirective, ModalComponent, + TransientNoticeComponent, ], templateUrl: "./evaluation-programs.component.html", styleUrl: "./evaluation-programs.component.scss", changeDetection: ChangeDetectionStrategy.OnPush, }) -export class EvaluationProgramsComponent implements OnInit { - readonly state = inject(EvaluationStateService); +export class EvaluationProgramsComponent { 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); + readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + private readonly params = toSignal(this.route.queryParamMap, { + initialValue: this.route.snapshot.queryParamMap, + }); + private readonly accountId = computed(() => this.profile.profile()?.id); + private readonly snapshot = signal<{ accountId: number; data: ExpertCabinet } | null>(null); + private readonly revision = signal(0); + private handledInvitation: string | null = null; + readonly data = computed(() => + this.snapshot()?.accountId === this.accountId() ? (this.snapshot()?.data ?? null) : null, + ); + readonly tab = computed(() => { + const tab = this.params().get("tab"); + return tab === "completed" || tab === "invitations" ? tab : "pending"; + }); + readonly query = computed( + () => ({ + status: this.tab() === "completed" ? ("completed" as const) : ("pending" as const), + q: (this.params().get("q") ?? "").trim().slice(0, 200), + programId: positive(this.params().get("program")), + limit: 20, + offset: ((positive(this.params().get("page")) ?? 1) - 1) * 20, + }), + { equal: (a, b) => JSON.stringify(a) === JSON.stringify(b) }, + ); readonly search = new FormControl("", { nonNullable: true }); - readonly query = signal(""); + readonly program = new FormControl("", { nonNullable: true }); + readonly searchExpanded = signal(false); + readonly searchInput = viewChild>("searchInput"); readonly selected = signal(null); + readonly loading = signal(true); + readonly failed = signal(null); + readonly unauthorized = signal(false); readonly busy = signal(false); readonly error = signal(null); - readonly accepted = signal(false); + readonly accepted = signal(false, { equal: () => 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 programs = computed(() => this.data()?.results.filter(p => p.status === "joined") ?? []); + readonly invitations = computed( + () => this.data()?.results.filter(p => p.status === "pending") ?? [], ); - readonly filtered = computed(() => - this.state - .programs() - .filter(p => - p.name.toLocaleLowerCase("ru").includes(this.query().trim().toLocaleLowerCase("ru")), - ), + readonly filtered = computed(() => !!this.query().q || !!this.query().programId); + readonly rangeStart = computed(() => (this.data()?.projects.count ? this.query().offset + 1 : 0)); + readonly rangeEnd = computed(() => + Math.min(this.query().offset + 20, this.data()?.projects.count ?? 0), ); - private handledInvitation: number | null = null; - ngOnInit(): void { - if (!this.state.pending()) this.state.reload(); - } + readonly groups = computed(() => { + const programs = new Map(this.programs().map(p => [p.id, p])); + const groups = new Map< + number, + { program: EvaluationProgram; projects: ExpertCabinetProject[] } + >(); + for (const project of this.data()?.projects.results ?? []) { + const program = programs.get(project.programId); + if (!program) continue; + if (!groups.has(program.id)) groups.set(program.id, { program, projects: [] }); + groups.get(program.id)!.projects.push(project); + } + return [...groups.values()]; + }); + readonly allCompleted = computed(() => { + const p = this.data()?.projects; + return !!p && !p.pending && !!p.completed && !p.unavailable; + }); + constructor() { - this.search.valueChanges.pipe(takeUntilDestroyed()).subscribe(v => this.query.set(v)); + this.search.valueChanges + .pipe(debounceTime(250), distinctUntilChanged(), takeUntilDestroyed()) + .subscribe(q => this.navigate({ q: q.trim() || null, page: null })); + this.program.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(program => this.navigate({ program: program || null, page: null })); + effect(() => { + const q = this.query(); + untracked(() => { + this.search.setValue(q.q, { emitEvent: false }); + this.program.setValue(q.programId?.toString() ?? "", { emitEvent: false }); + }); + }); + effect(() => { + this.accountId(); + untracked(() => { + this.selected.set(null); + this.busy.set(false); + this.error.set(null); + this.accepted.set(false); + this.handledInvitation = null; + }); + }); + effect(onCleanup => { + const accountId = this.accountId(); + const query = this.query(); + this.revision(); + this.failed.set(null); + this.unauthorized.set(false); + if (!accountId) { + this.snapshot.set(null); + this.loading.set(false); + return; + } + this.loading.set(true); + const request = this.api.cabinet(query).subscribe({ + next: data => { + if (!data.projects || !Array.isArray(data.projects.results)) { + this.snapshot.set(null); + this.failed.set("Кабинет временно недоступен. Попробуйте обновить страницу."); + } else this.snapshot.set({ accountId, data }); + this.loading.set(false); + }, + error: e => { + this.snapshot.set(null); + this.unauthorized.set(e?.status === 401); + this.failed.set(evaluationError(e)); + this.loading.set(false); + }, + }); + onCleanup(() => request.unsubscribe()); + }); 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"); + const id = positive(this.params().get("invitation")); + const key = `${this.accountId()}:${id}`; + if (!id || key === this.handledInvitation) return; + const invitation = this.invitations().find(p => p.invitationId === id); if (invitation) untracked(() => { - this.handledInvitation = id; + this.handledInvitation = key; this.open(invitation); }); }); + effect(() => { + if (this.searchExpanded()) this.searchInput()?.nativeElement.focus(); + }); + } + reload(): void { + this.revision.update(n => n + 1); + } + navigate(queryParams: Record): void { + void this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: "merge", + }); + } + setTab(tab: CabinetTab): void { + this.navigate({ tab: tab === "pending" ? null : tab, page: null }); + } + resetFilters(): void { + this.navigate({ q: null, program: null, page: null }); + } + page(delta: number): void { + this.navigate({ page: Math.max(1, this.query().offset / 20 + 1 + delta) }); } initials(name: string): string { return name @@ -105,38 +229,44 @@ export class EvaluationProgramsComponent implements OnInit { close(): void { if (this.busy()) return; this.selected.set(null); - void this.router.navigate([], { - relativeTo: this.route, - queryParams: { invitation: null }, - queryParamsHandling: "merge", - replaceUrl: true, - }); + this.navigate({ invitation: null }); } accept(): void { const id = this.selected()?.invitationId; - if (!id || this.busy()) return; + const accountId = this.accountId(); + if (!id || !accountId || this.busy()) return; this.busy.set(true); this.error.set(null); this.api .acceptOwn(id) .pipe( - finalize(() => this.busy.set(false)), + finalize(() => { + if (accountId === this.accountId()) this.busy.set(false); + }), takeUntilDestroyed(this.destroyRef), ) .subscribe({ next: () => { + if (accountId !== this.accountId()) return; this.selected.set(null); this.accepted.set(true); - this.state.reload(); + this.navigate({ invitation: null }); + this.reload(); this.auth .fetchProfile() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: profile => this.profile.applyProfileUpdated(profile), - error: () => this.profile.invalidateProfile(), + next: profile => { + if (accountId === this.accountId()) this.profile.applyProfileUpdated(profile); + }, + error: () => { + if (accountId === this.accountId()) this.profile.invalidateProfile(); + }, }); }, - error: e => this.error.set(evaluationError(e)), + error: e => { + if (accountId === this.accountId()) 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 bbae91a12..986e48d2c 100644 --- a/projects/social_platform/src/app/ui/pages/office/nav/nav.component.html +++ b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.html @@ -129,7 +129,7 @@ routerLink="/office/evaluation" routerLinkActive="nav__link--active" > } diff --git a/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts index 487e2391a..3c72a81c5 100644 --- a/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts +++ b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts @@ -141,7 +141,7 @@ export class NavComponent implements OnInit, OnDestroy { projects: "Проекты", members: "Участники", program: "Программы", - evaluation: "Оценивание", + evaluation: "Кабинет эксперта", courses: "Курсы", vacancies: "Вакансии", profile: "Профиль", diff --git a/projects/social_platform/src/app/ui/pages/office/office.component.ts b/projects/social_platform/src/app/ui/pages/office/office.component.ts index 658ae990d..250d7fc90 100644 --- a/projects/social_platform/src/app/ui/pages/office/office.component.ts +++ b/projects/social_platform/src/app/ui/pages/office/office.component.ts @@ -77,7 +77,7 @@ export class OfficeComponent implements OnInit { const items = [...this.officeUIInfoService.navItems()]; if (this.profile()?.relations.hasEvaluationAccess) { const index = items.findIndex(item => item.link === "program"); - items.splice(index + 1, 0, { name: "оценивание", icon: "check", link: "evaluation" }); + items.splice(index + 1, 0, { name: "кабинет эксперта", icon: "check", link: "evaluation" }); } return items; }); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html index f4a8e61db..722f67b6b 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html @@ -1,5 +1,9 @@ +@if (cabinetReturnUrl()) { + В кабинет эксперта +} +
{ + const value = this.routeQuery().get("returnUrl"); + return value && /^\/office\/evaluation(?:\?|$)/.test(value) + ? this.router.parseUrl(value) + : null; + }); readonly listRoot = viewChild | undefined>("listRoot"); readonly filterBody = viewChild>("filterBody");