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 @@

Статус оценивания

Статус оценивания categoryLabel="Показатель" definitionsLabel="Данные и определения показателей" /> - @if (overview.evaluationStatus.maxEvaluationsPerProject !== null) { + @if ( + overview.evaluationStatus.requiredEvaluators != null || + overview.evaluationStatus.maxEvaluationsPerProject != null + ) {
- Максимум экспертов на проект: - {{ overview.evaluationStatus.maxEvaluationsPerProject }} + {{ + overview.evaluationStatus.requiredEvaluators != null + ? "Оценок для каждого проекта:" + : "Максимум экспертов на проект:" + }} + {{ + overview.evaluationStatus.requiredEvaluators ?? + overview.evaluationStatus.maxEvaluationsPerProject + }} { ).toHaveLength(2); }); + it("показывает обязательное число оценок вместо прежнего максимума в управляемом режиме", () => { + const model = overview(); + model.evaluationStatus.requiredEvaluators = 1; + model.evaluationStatus.maxEvaluationsPerProject = 3; + data.set(model); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const limit = fixture.nativeElement.querySelector('[data-testid="evaluation-limit"]'); + expect(limit.textContent).toContain("Оценок для каждого проекта:"); + expect(limit.querySelector("strong").textContent.trim()).toBe("1"); + expect(limit.textContent).not.toContain("Максимум"); + }); + it("не выдаёт отсутствие новой метрики посещения за ноль", () => { const model = overview(); delete model.participantFunnel.visitedParticipants; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts index 3eeddcebf..f1d30abb5 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts @@ -288,7 +288,9 @@ export class ProgramAnalyticsComponent implements OnInit { "evaluation-partial", "Частично оценено", status.projects.partiallyEvaluated, - "Хотя бы один назначенный эксперт полностью оценил проект, но не все назначенные эксперты завершили оценивание.", + status.requiredEvaluators != null + ? "Есть полная оценка хотя бы одного эксперта, но нужное число оценок ещё не получено." + : "Хотя бы один назначенный эксперт полностью оценил проект, но не все назначенные эксперты завершили оценивание.", ), ); } @@ -298,9 +300,11 @@ export class ProgramAnalyticsComponent implements OnInit { "evaluation-evaluated", "Оценено", status.projects.evaluated, - status.mode === "open" - ? "Проекты, получившие хотя бы одну оценку эксперта." - : "Проекты, по которым выполнены все назначения экспертов.", + status.requiredEvaluators != null + ? "Проекты, получившие нужное число полных оценок от разных экспертов." + : status.mode === "open" + ? "Проекты, получившие хотя бы одну оценку эксперта." + : "Проекты, по которым выполнены все назначения экспертов.", ), ); return metrics; @@ -477,6 +481,8 @@ export class ProgramAnalyticsComponent implements OnInit { } private awaitingEvaluationTooltip(mode: "open" | "distributed"): string { + if (this.data()?.evaluationStatus.requiredEvaluators != null) + return "Сданные проекты, которые ещё не получили нужное число полных оценок от разных экспертов."; return mode === "open" ? "Сданные проекты, которые ещё не получили ни одной оценки эксперта." : "Сданные проекты без выполненных назначений или с выполненной только частью назначений."; 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 index 0a11144ed..d3ad30a9c 100644 --- 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 @@ -30,16 +30,40 @@

Эксперты

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

Каждую оценку выставляет отдельный эксперт.

- {{ state.submittedProjects }} сданных проектов · не хватает - {{ state.missingAssignments }} назначений + Сдано проектов: {{ state.submittedProjects }} · Полностью оценено: + {{ state.readyProjects }}

+ @if (state.missingAssignments) { +

+ Ещё нужно выбрать экспертов{{ + state.projectsMissingExperts === undefined + ? " для проектов." + : " для " + + state.projectsMissingExperts + + " из " + + state.submittedProjects + + " проектов." + }} + Можно распределить автоматически или назначить вручную. +

+ } @else if (state.submittedProjects) { +

Для всех сданных проектов выбран нужный состав экспертов.

+ }
}
+ @if (deliveryRefreshPaused()) { +

+ Отправка письма занимает больше времени. Можно проверить статус снова или отправить + приглашение повторно. + +

+ }
{{ expert.email }} - {{ label(expert) }} + + {{ label(expert) }} + @if (expert.status === "pending") { +

+ {{ deliveryLabel(expert) }} +

+ } + {{ expert.assigned ?? "—" }} {{ expert.completed ?? "—" }} {{ expert.remaining ?? "—" }} @@ -233,9 +264,12 @@

{{ dialogTitle() }}

} @case ("quota") { -

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

+

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

-

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

+

Кто будет оценивать проекты

@for (assignment of preview.assignments; track $index) {
@@ -304,7 +345,15 @@

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

} @for (shortage of preview.shortages; track shortage.projectId) { -

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

+

+ {{ shortage.projectName }} — нужно выбрать ещё {{ shortage.missing }} + {{ shortage.missing | pluralize: ["эксперта", "экспертов", "экспертов"] }} +

+ } + @if (!preview.assignments.length && !preview.shortages.length) { +

+ Для всех проектов уже выбран нужный состав экспертов. Добавлять никого не нужно. +

}
} @else { -

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

- -

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

-

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

+

Выберите экспертов — система предложит, кто оценит каждый сданный проект.

+
+ Экспертов на проект: {{ snapshot()?.requiredEvaluators }} · Сданных проектов: + {{ snapshot()?.submittedProjects }} + @if (snapshot()?.projectsMissingExperts !== undefined) { + @if (snapshot()?.projectsMissingExperts) { +

+ Нужно подобрать экспертов для {{ snapshot()?.projectsMissingExperts }} из + {{ snapshot()?.submittedProjects }} проектов. +

+ } @else { +

Для всех сданных проектов уже выбран нужный состав экспертов.

+ } + } +
+

1. Выберите экспертов

+

Выбрано: {{ chosenExperts().size }}. Приглашённые появятся здесь после принятия.

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

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

}

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

+

2. Проверьте результат

+

Сначала покажем план. Назначим проекты после вашего подтверждения.

} } 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(null); readonly dialogError = signal(null); readonly message = signal(null); + readonly deliveryRefreshPaused = signal(false); readonly selected = signal(null); readonly modal = signal<"invite" | "manual" | "assignments" | "auto" | "quota" | "revoke" | null>( null, @@ -96,6 +109,9 @@ export class ManagementExpertsComponent { readonly joined = computed( () => this.snapshot()?.experts.filter(e => e.status === "joined") ?? [], ); + private readonly hasQueuedInvitations = computed(() => + this.snapshot()?.experts.some(e => e.status === "pending" && e.deliveryStatus === "queued"), + ); readonly filtered = computed(() => (this.snapshot()?.experts ?? []).filter( e => @@ -137,7 +153,7 @@ export class ManagementExpertsComponent { this.modal() === "invite" ? "Пригласить эксперта" : this.modal() === "quota" - ? "Число оценщиков на проект" + ? "Сколько оценок нужно каждому проекту" : this.modal() === "manual" ? "Назначить проекты" : this.modal() === "assignments" @@ -160,6 +176,35 @@ export class ManagementExpertsComponent { this.load(id); }); }); + effect(onCleanup => { + const id = this.programId(); + this.deliveryRefreshPaused.set(false); + if (!this.hasQueuedInvitations() || this.busy()) return; + const subscription = timer(3000, 5000) + .pipe( + take(24), + filter(() => !this.busy()), + exhaustMap(() => + this.api.workspace(id).pipe( + timeout(10000), + catchError(() => of(null)), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: data => { + if (!data || this.programId() !== id || this.busy()) return; + // Обновляем данные без пересоздания формы, сброса поиска и открытого редактора. + this.snapshot.set(data); + this.selected.update(row => + row ? (data.experts.find(e => e.id === row.id) ?? null) : null, + ); + }, + complete: () => this.deliveryRefreshPaused.set(!!this.hasQueuedInvitations()), + }); + onCleanup(() => subscription.unsubscribe()); + }); effect(() => this.statusChange.emit({ dirty: this.dirty(), busy: this.busy() })); this.search.valueChanges.pipe(takeUntilDestroyed()).subscribe(v => { this.query.set(v); @@ -223,11 +268,14 @@ export class ManagementExpertsComponent { if (row.status === "joined") return "В программе"; if (row.status === "expired") return "Истекло"; if (row.status === "revoked") return "Отозвано"; - return row.deliveryStatus === "failed" - ? "Не доставлено" - : row.deliveryStatus === "queued" - ? "Отправляется" - : "Приглашён"; + return "Приглашён"; + } + deliveryLabel(row: ExpertRow): string { + if (row.status !== "pending") return ""; + if (row.deliveryStatus === "failed") return "Не удалось отправить письмо"; + if (row.deliveryStatus === "queued") return "Письмо в очереди"; + if (row.deliveryStatus === "sent") return "Письмо отправлено · ждём принятия"; + return "Статус отправки неизвестен"; } initials(row: ExpertRow): string { return (row.name || row.email) @@ -266,7 +314,7 @@ export class ManagementExpertsComponent { } this.request(this.api.invite(this.programId(), this.email.value.trim()), () => { this.modal.set(null); - this.message.set("Приглашение передано на отправку. Статус доставки появится в списке."); + this.message.set("Приглашение создано. Статус отправки письма обновится автоматически."); this.load(); }); }