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 feee42090..776f1cb5e 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 @@ -17,6 +17,7 @@ export interface ExpertWorkspace { requiredEvaluators: number; managedEvaluation: boolean; submittedProjects: number; + projectsMissingExperts?: number; missingAssignments: number; readyProjects: number; experts: ExpertRow[]; @@ -49,6 +50,8 @@ export interface AllocationPlan { export interface QuotaPreview { revision: string; requiredEvaluators: number; + submittedProjects?: number; + projectsMissingExperts?: number; readyBefore: number; readyAfter: number; missingAssignments: number; diff --git a/projects/social_platform/src/app/domain/program/program-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-analytics.model.ts index b2629be65..83ddbf0e9 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics.model.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics.model.ts @@ -28,6 +28,7 @@ export interface ProgramAnalyticsOverview { evaluationStatus: { mode: ProgramEvaluationMode; maxEvaluationsPerProject: number | null; + requiredEvaluators?: number | null; assignments: { total: number; pending: number; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html index 5caa4e23b..502fbe41a 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html @@ -246,9 +246,11 @@
Каждую оценку выставляет отдельный эксперт.
- {{ state.submittedProjects }} сданных проектов · не хватает - {{ state.missingAssignments }} назначений + Сдано проектов: {{ state.submittedProjects }} · Полностью оценено: + {{ state.readyProjects }}
+ @if (state.missingAssignments) { ++ Ещё нужно выбрать экспертов{{ + state.projectsMissingExperts === undefined + ? " для проектов." + : " для " + + state.projectsMissingExperts + + " из " + + state.submittedProjects + + " проектов." + }} + Можно распределить автоматически или назначить вручную. +
+ } @else if (state.submittedProjects) { +Для всех сданных проектов выбран нужный состав экспертов.
+ }+ Отправка письма занимает больше времени. Можно проверить статус снова или отправить + приглашение повторно. + +
+ }+ {{ deliveryLabel(expert) }} +
+ } +Проект готов, когда получено нужное число полных оценок от разных экспертов.
++ Проект считается полностью оценённым, когда нужное число разных экспертов сохранили свои + оценки по всем критериям. +
{{ shortage.projectName }} — не хватает {{ shortage.missing }} экспертов
++ {{ shortage.projectName }} — нужно выбрать ещё {{ shortage.missing }} + {{ shortage.missing | pluralize: ["эксперта", "экспертов", "экспертов"] }} +
+ } + @if (!preview.assignments.length && !preview.shortages.length) { ++ Для всех проектов уже выбран нужный состав экспертов. Добавлять никого не нужно. +
}- Система дополнит назначения до {{ snapshot()?.requiredEvaluators }} оценщиков на каждый - проект. -
- -Выбрано {{ chosenExperts().size }} · приглашённые не участвуют
+Выберите экспертов — система предложит, кто оценит каждый сданный проект.
++ Нужно подобрать экспертов для {{ snapshot()?.projectsMissingExperts }} из + {{ snapshot()?.submittedProjects }} проектов. +
+ } @else { +Для всех сданных проектов уже выбран нужный состав экспертов.
+ } + } +Выбрано: {{ chosenExperts().size }}. Приглашённые появятся здесь после принятия.
Пока никто не принял приглашение. Пригласите экспертов и дождитесь принятия.
}- Учитываем оставшуюся нагрузку и текущие назначения. Проекты собственной команды - недоступны. + Учитываем нагрузку и сохраняем текущие назначения. Эксперт не получает проект своей + команды.
+Сначала покажем план. Назначим проекты после вашего подтверждения.
} } 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 index 373f08965..528b8931d 100644 --- 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 @@ -165,6 +165,25 @@ color: var(--grey-for-text); } +.experts__table .experts__delivery { + margin-top: 4px; + font-size: 10px; +} + +.experts__scope { + padding: 16px; + background: var(--light-gray); + border-radius: 12px; +} + +.experts__quota p { + margin-top: 6px; +} + +.experts__quota > button { + flex-shrink: 0; +} + .experts__selection { padding: 16px; margin: 16px 0; 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 index 8a61dcb4d..ea5b41055 100644 --- 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 @@ -8,6 +8,108 @@ import { ManagementExpertsComponent } from "./experts.component"; import { ExpertWorkspace, AssignmentOptions } from "@domain/program/evaluation-workspace.model"; describe("Управление экспертами", () => { + it("различает нехватку состава экспертов и полученные оценки", () => { + const { fixture, component } = setup(); + component.snapshot.update(state => ({ + ...state!, + requiredEvaluators: 1, + submittedProjects: 2, + projectsMissingExperts: 1, + missingAssignments: 1, + readyProjects: 0, + })); + fixture.detectChanges(); + const summary = fixture.nativeElement.querySelector(".experts__quota").textContent; + expect(summary).toContain("Оценок для каждого проекта: 1"); + expect(summary).toContain("для 1 из 2 проектов"); + expect(summary).toContain("Полностью оценено: 0"); + expect(summary).not.toContain("назначений"); + }); + it("обновляет отправку письма без сброса поиска и прекращает проверку после sent", async () => { + vi.useFakeTimers(); + try { + const { fixture, component, api } = setup(); + const queued = structuredClone(snapshot); + queued.experts[1].deliveryStatus = "queued"; + api.workspace.mockReturnValue(of(queued)); + component.load(); + fixture.detectChanges(); + const search = fixture.nativeElement.querySelector("input[type=search]") as HTMLInputElement; + search.focus(); + search.value = "pending"; + search.dispatchEvent(new Event("input")); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("Письмо в очереди"); + api.workspace.mockReturnValue(of(structuredClone(snapshot))); + await vi.advanceTimersByTimeAsync(3000); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("Письмо отправлено · ждём принятия"); + expect(search.value).toBe("pending"); + expect(document.activeElement).toBe(search); + const calls = api.workspace.mock.calls.length; + await vi.advanceTimersByTimeAsync(10000); + expect(api.workspace).toHaveBeenCalledTimes(calls); + fixture.destroy(); + } finally { + vi.useRealTimers(); + } + }); + it("отменяет фоновую проверку письма при уходе со страницы", async () => { + vi.useFakeTimers(); + try { + const { fixture, component, api } = setup(); + const queued = structuredClone(snapshot); + queued.experts[1].deliveryStatus = "queued"; + api.workspace.mockReturnValue(of(queued)); + component.load(); + fixture.detectChanges(); + fixture.destroy(); + const calls = api.workspace.mock.calls.length; + await vi.advanceTimersByTimeAsync(15000); + expect(api.workspace).toHaveBeenCalledTimes(calls); + } finally { + vi.useRealTimers(); + } + }); + it("останавливает долгую проверку и убирает предупреждение после получения статуса", async () => { + vi.useFakeTimers(); + try { + const { fixture, component, api } = setup(); + const queued = structuredClone(snapshot); + queued.experts[1].deliveryStatus = "queued"; + api.workspace.mockReturnValue(of(queued)); + component.load(); + fixture.detectChanges(); + await vi.advanceTimersByTimeAsync(123000); + fixture.detectChanges(); + expect(component.deliveryRefreshPaused()).toBe(true); + const calls = api.workspace.mock.calls.length; + await vi.advanceTimersByTimeAsync(15000); + expect(api.workspace).toHaveBeenCalledTimes(calls); + api.workspace.mockReturnValue(of(structuredClone(snapshot))); + component.load(); + fixture.detectChanges(); + expect(component.deliveryRefreshPaused()).toBe(false); + fixture.destroy(); + } finally { + vi.useRealTimers(); + } + }); + it("предлагает только принявших приглашение и не применяет план при предпросмотре", () => { + const { fixture, component, api } = setup(); + component.open("auto"); + fixture.detectChanges(); + api.allocation.mockReturnValue( + of({ revision: "v1", planToken: "signed", assignments: [], shortages: [], load: [] }), + ); + component.previewAllocation(); + expect(api.allocation).toHaveBeenCalledWith(42, { expertIds: [1] }); + expect(api.applyAllocation).not.toHaveBeenCalled(); + expect(component.label(snapshot.experts[1])).toBe("Приглашён"); + expect(component.deliveryLabel({ ...snapshot.experts[1], deliveryStatus: "failed" })).toBe( + "Не удалось отправить письмо", + ); + }); it("отправляет приглашение через native submit и предотвращает перезагрузку", async () => { const { fixture, component, api } = setup(); component.open("invite"); 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 index db9c26ff0..5f7ac5f75 100644 --- 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 @@ -13,7 +13,18 @@ import { } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { Observable, finalize } from "rxjs"; +import { + Observable, + catchError, + exhaustMap, + filter, + finalize, + of, + take, + timeout, + timer, +} from "rxjs"; +import { PluralizePipe } from "@corelib"; import { AlertDirective, AvatarComponent, @@ -44,6 +55,7 @@ import { presentationHref } from "@utils/program-case-analytics"; ChoiceDirective, FieldDirective, ModalComponent, + PluralizePipe, ], templateUrl: "./experts.component.html", styleUrl: "./experts.component.scss", @@ -62,6 +74,7 @@ export class ManagementExpertsComponent { readonly error = signal