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 a4780d2f6..d724de699 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 @@ -81,6 +81,8 @@ export interface AllocationQuery { projectIds?: number[]; casePools?: CaseExpertPool[]; revision?: string; + seed?: number; + assignmentOverrides?: { projectId: number; expertIds: number[] }[]; } export interface AllocationPlan { revision: string; 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 1e21e4fab..7164a720a 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 @@ -236,7 +236,6 @@

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

Оценок для каждого проекта: {{ state.requiredEvaluators }} -

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

+ @if (attentionProjects().length) { + + }
-

- Всего проектов на оценку: {{ allProjects().length }}. Показано - {{ filteredDistribution().length }} - {{ filteredDistribution().length | pluralize: ["проект", "проекта", "проектов"] }} - @if (filteredMissing()) { - · Экспертов нужно добавить в {{ filteredMissing() }} - {{ filteredMissing() | pluralize: ["проект", "проекта", "проектов"] }} - } @else if (filteredDistribution().length) { - · Эксперты назначены - } -

- @if (state.unavailableProjects?.length) { -
- Сданные черновики: {{ state.unavailableProjects!.length }} -

Они станут доступны экспертам после публикации автором.

- @for (project of state.unavailableProjects; track project.projectId) { -

{{ project.projectName }} — черновик

- } -
- }

Автоматически

@@ -632,7 +630,7 @@

Автоматически

appField [formControl]="quota" /> -

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

+

Изменение применяется ко всей программе.

@if (quotaPreview(); as preview) {
Автоматически } @case ("auto") { @if (plan(); as preview) { - Шаг 2 из 2. Проверьте назначения +

Шаг 2 из 2 · Проверьте составы

+ По {{ snapshot()?.requiredEvaluators }} + {{ + snapshot()?.requiredEvaluators ?? 1 | pluralize: ["эксперту", "эксперта", "экспертов"] + }} + на проект @if (preview.shortages.length) {

- Не хватает подходящих экспертов. Вернитесь назад и выберите больше экспертов для - указанных кейсов или пригласите новых. + Не хватает экспертов. Измените состав у проектов или вернитесь к выбору экспертов для + кейсов.

- } @else if (preview.assignments.length) { -

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

} -

Проекты и выбранные эксперты

-
- @for (assignment of preview.assignments; track $index) { -
- {{ assignment.projectName }}{{ caseLabel(assignment.caseName ?? null) }}{{ assignment.expertName }} -
- } - @for (shortage of preview.shortages; track shortage.projectId) { -

- {{ shortage.projectName }} · {{ caseLabel(shortage.caseName ?? null) }} — нужно еще - {{ shortage.missing }} - {{ shortage.missing | pluralize: ["эксперт", "эксперта", "экспертов"] }} -

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

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

+
+ @for (project of allocationProjects(); track project.id) { +
+
+

{{ project.name }}

+ {{ project.experts.length + project.additions.length }} из + {{ snapshot()?.requiredEvaluators }} + +
+
    + @for (expert of project.experts; track expert.expertId) { +
  • + {{ expert.name || expert.email }}Уже назначен +
  • + } + @for (expert of project.additions; track expert.expertId) { +
  • + {{ expert.expertName }}Добавится +
  • + } +
+ @if (project.missing) { +

Не хватает: {{ project.missing }}

+ } +
+ } @empty { +

Все проекты уже распределены.

}
-

Нагрузка по всем кейсам

-

Проекты, которые еще нужно оценить

-
- - - - - - - - - - - @for (expert of preview.load; track expert.expertId) { +
+ Нагрузка экспертов +
+
ЭкспертСейчасДобавитсяВсего
+ - - - - + + + - } - -
{{ expert.name }}{{ expert.before }}+{{ expert.after - expert.before }}{{ expert.after }}ЭкспертСейчасПосле распределения
-
-

Текущие назначения и выставленные оценки сохранятся.

-
+ + + @for (expert of preview.load; track expert.expertId) { + + {{ expert.name }} + {{ expert.before }} + {{ expert.after }} + + } + + +
+ + @if (allocationHint()) { +

{{ allocationHint() }}

+ } +
+
} @else { - Шаг 1 из 2. Выберите экспертов для кейсов -

- Система добавит экспертов проектам, где их не хватает: по - {{ snapshot()?.requiredEvaluators }} на проект. Сначала вы проверите результат, затем - подтвердите назначения. -

+

Шаг 1 из 2 · Эксперты для кейсов

+ По {{ snapshot()?.requiredEvaluators }} + {{ + snapshot()?.requiredEvaluators ?? 1 | pluralize: ["эксперту", "эксперта", "экспертов"] + }} + на проект
- @for (group of autoGroups(); track group.name) { -
-
-

- {{ caseLabel(group.name) }} · {{ group.count }} - {{ group.count | pluralize: ["проект", "проекта", "проектов"] }} -

- -
- @if (!group.missing) { -

Нужный состав экспертов уже назначен.

- } @else { + @for (group of pendingAutoGroups(); track group.name) { +
+
+

{{ caseLabel(group.name) }}

- Нужно дополнить состав для {{ group.missing }} - {{ group.missing | pluralize: ["проекта", "проектов", "проектов"] }}. -

- @if (poolExperts(group.name).length) { -

{{ poolNames(group.name) }}

- } -

- {{ - poolExperts(group.name).length - ? "Выбрано " + poolExperts(group.name).length - : "Выберите экспертов кнопкой выше" - }} - @if (poolExperts(group.name).length) { - {{ - poolExperts(group.name).length - | pluralize: ["эксперт", "эксперта", "экспертов"] - }} - } + Нужно дополнить {{ group.missing }} + {{ group.missing | pluralize: ["проект", "проекта", "проектов"] }}

+
+ + @if (!poolExperts(group.name).length) { +

Выберите экспертов для этого кейса

}
} @empty { -

Сданных опубликованных проектов пока нет.

+

Все проекты уже распределены.

}
-

Эксперты могут участвовать в нескольких кейсах. Нагрузка учитывается суммарно.

- + + @if (pendingAutoGroups().length) { + + }
} - @if ((plan()?.unavailableProjects ?? snapshot()?.unavailableProjects)?.length) { -
- Некоторые проекты пока недоступны экспертам - @for ( - project of plan()?.unavailableProjects ?? snapshot()?.unavailableProjects; - track project.projectId - ) { -

{{ project.projectName }} — черновик. Автору нужно опубликовать проект.

- } -
+ } + @case ("allocation-project") { +

{{ allocationProject()?.name }}

+ @if (allocationProject()?.experts?.length) { +

Уже назначенные эксперты останутся в составе.

} + +

Выбрано {{ chosenExperts().size }} из {{ snapshot()?.requiredEvaluators }}

+
+ + +
} @case ("project") { {{ targetNames() }} 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 f06497599..3a1bf9a3b 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 @@ -918,6 +918,200 @@ opacity: 1; } +.experts__floating--projects { + display: grid; + grid-template-columns: minmax(0, 1fr) auto 32px; + background: var(--light-white); + border: 1px solid var(--medium-grey-for-outline); + + > span { + grid-column: 1 / -1; + } + + .ui-button--primary { + min-width: 0; + } + + .ui-button:disabled { + color: var(--grey-for-text); + background: var(--medium-grey-for-outline); + border-color: var(--medium-grey-for-outline); + opacity: 1; + } +} + +.experts__project-summary { + display: grid; + gap: 4px; + min-width: 0; + overflow-wrap: anywhere; + + span { + color: var(--grey-for-text); + } +} + +.experts__floating--projects .experts__project-clear { + align-self: center; + width: 32px; + min-width: 32px; + min-height: 32px; + color: var(--grey-for-text); + background: var(--light-gray); + border-color: var(--light-gray); +} + +.experts__attention { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 16px; + font: inherit; + color: var(--black); + text-align: left; + cursor: pointer; + background: color-mix(in srgb, var(--gold) 28%, var(--light-white)); + border: 1px solid transparent; + border-radius: 8px; + + > span:first-child { + display: grid; + gap: 4px; + } + + > span:last-child { + flex-shrink: 0; + color: var(--accent-dark); + } + + &:hover, + &--active { + border-color: var(--gold); + } + + &:focus-visible { + outline: 2px solid var(--accent-dark); + outline-offset: 3px; + } +} + +.experts__allocation-project { + display: grid; + gap: 12px; + padding: 16px 0; + border-bottom: 1px solid var(--medium-grey-for-outline); +} + +.experts__allocation-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 12px; + align-items: center; + + h3 { + font-size: 14px; + overflow-wrap: anywhere; + } + + > span { + white-space: nowrap; + } +} + +.experts__allocation-members { + display: grid; + gap: 8px; + padding: 0; + margin: 0; + list-style: none; + + li { + display: flex; + gap: 12px; + justify-content: space-between; + } + + li > span:first-child { + min-width: 0; + overflow-wrap: anywhere; + } + + li > span:last-child { + flex-shrink: 0; + color: var(--grey-for-text); + } + + li > .experts__allocation-new:last-child { + color: var(--accent-dark); + } +} + +.experts__dialog p.experts__allocation-shortage { + font-weight: 600; + color: var(--black); +} + +.experts__allocation-case { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + + > p { + grid-column: 1 / -1; + } +} + +.experts__allocation-load summary { + padding-block: 8px; + cursor: pointer; +} + +.experts__allocation-actions { + justify-content: space-between; +} + +@media (max-width: 999px) { + .experts:not(.experts--people) { + padding-bottom: 144px; + } + + .experts__floating--projects { + grid-template-columns: minmax(0, 1fr) 32px; + + > .ui-button--primary { + grid-row: 2; + grid-column: 1 / -1; + } + + > .experts__project-clear { + grid-row: 1; + grid-column: 2; + } + } +} + +@media (max-width: 599px) { + .experts__allocation-heading { + grid-template-columns: minmax(0, 1fr) auto; + + button { + grid-column: 1 / -1; + } + } + + .experts__allocation-case { + grid-template-columns: minmax(0, 1fr); + } + + .experts__allocation-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + } +} + @media (max-width: 999px) { .experts__mobile-tools { display: block; 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 1caa9013c..ddddc6230 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 @@ -96,11 +96,13 @@ describe("Управление экспертами", () => { const { fixture, component, api } = setup(); component.open("auto"); fixture.detectChanges(); - expect(component.canPreviewAllocation()).toBe(false); + expect(component.canPreviewAllocation()).toBe(true); component.openPool("Сбер"); + component.chosenExperts.set(new Set()); component.toggleCandidate(snapshot.experts[0]); component.savePool(); component.openPool("Т-Банк"); + component.chosenExperts.set(new Set()); component.toggleCandidate(snapshot.experts[0]); component.savePool(); api.allocation.mockReturnValue( @@ -108,6 +110,7 @@ describe("Управление экспертами", () => { ); component.previewAllocation(); expect(api.allocation).toHaveBeenCalledWith(42, { + seed: 0, expertIds: [1], revision: "v1", casePools: [ @@ -139,6 +142,115 @@ describe("Управление экспертами", () => { caseNames: ["Сбер"], }); }); + it("показывает внимание только при нехватке и включает фильтр по всем проблемным проектам", () => { + const { component } = setup(); + expect(component.attentionProjects().length).toBe(3); + component.showAttentionProjects(); + expect(component.distributionStatus.value).toBe("missing"); + expect(component.attentionActive()).toBe(true); + component.showAttentionProjects(); + expect(component.distributionStatus.value).toBe("all"); + component.snapshot.update(s => ({ + ...s!, + projects: s!.projects!.map(p => ({ + ...p, + experts: [projects[0].experts[0], { ...projects[0].experts[0], expertId: 8 }], + })), + })); + expect(component.attentionProjects()).toEqual([]); + expect(component.pendingAutoGroups()).toEqual([]); + }); + it("в предпросмотре показывает существующих экспертов вместе с добавляемыми", async () => { + const { component, fixture } = setup(); + component.plan.set({ + revision: "v1", + planToken: "signed", + assignments: [ + { projectId: 10, projectName: "Тест", expertId: 3, expertName: "Новый эксперт" }, + ], + shortages: [], + load: [], + }); + component.modal.set("auto"); + fixture.detectChanges(); + await new Promise(resolve => setTimeout(resolve, 0)); + fixture.detectChanges(); + const card = document.querySelector(".experts__allocation-project")!; + expect(card.textContent).toContain("2 из 2"); + expect(card.textContent).toContain("Уже назначен"); + expect(card.textContent).toContain("Новый эксперт"); + expect(card.textContent).toContain("Добавится"); + }); + it("другой вариант меняет seed и не применяет план", () => { + const { component, api } = setup(); + component.open("auto"); + const plan = { revision: "v1", planToken: "signed", assignments: [], shortages: [], load: [] }; + component.plan.set(plan); + component.allocationOverrides.set(new Map([[10, [3]]])); + api.allocation.mockReturnValue(of(plan)); + component.rerollAllocation(); + expect(api.allocation.mock.calls[0][1].seed).toBe(1); + expect(api.allocation.mock.calls[0][1].assignmentOverrides).toBeUndefined(); + expect(component.allocationOverrides().size).toBe(0); + expect(component.allocationHint()).toContain("тот же вариант"); + expect(api.applyAllocation).not.toHaveBeenCalled(); + }); + it("редактор меняет только новые назначения и получает новый подписанный предпросмотр", () => { + const { component, api } = setup(); + const extra = [3, 4].map(id => ({ + ...snapshot.experts[0], + id, + userId: 100 + id, + name: `Эксперт ${id}`, + })); + component.snapshot.update(s => ({ ...s!, experts: [...s!.experts, ...extra] })); + component.open("auto"); + const plan = { + revision: "v1", + planToken: "old", + assignments: [{ projectId: 10, projectName: "Тест", expertId: 3, expertName: "Эксперт 3" }], + shortages: [], + load: [], + }; + component.plan.set(plan); + component.editAllocationProject(10); + expect(component.candidateReason(snapshot.experts[0])).toContain("Уже назначен"); + component.toggleCandidate(extra[0]); + component.toggleCandidate(extra[1]); + api.allocation.mockReturnValue( + of({ ...plan, planToken: "new", assignments: [{ ...plan.assignments[0], expertId: 4 }] }), + ); + component.saveAllocationProject(); + expect(api.allocation.mock.calls[0][1].assignmentOverrides).toEqual([ + { projectId: 10, expertIds: [4] }, + ]); + expect(component.plan()?.planToken).toBe("new"); + expect(component.modal()).toBe("auto"); + expect(api.applyAllocation).not.toHaveBeenCalled(); + }); + it("ошибка пересчета сохраняет старый план и выбор в редакторе", () => { + const { component, api } = setup(); + component.snapshot.update(s => ({ + ...s!, + experts: [...s!.experts, { ...snapshot.experts[0], id: 3, userId: 103 }], + })); + component.open("auto"); + const plan = { + revision: "v1", + planToken: "old", + assignments: [{ projectId: 10, projectName: "Тест", expertId: 3, expertName: "Эксперт 3" }], + shortages: [], + load: [], + }; + component.plan.set(plan); + component.editAllocationProject(10); + api.allocation.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409 }))); + component.saveAllocationProject(); + expect(component.plan()?.planToken).toBe("old"); + expect(component.modal()).toBe("allocation-project"); + expect(component.chosenExperts().has(3)).toBe(true); + expect(component.allocationOverrides().size).toBe(0); + }); const snapshot: ExpertWorkspace = { revision: "v1", requiredEvaluators: 2, @@ -266,7 +378,7 @@ describe("Управление экспертами", () => { it("pending исключён из распределения, план с нехваткой нельзя применить", () => { const { component, api } = setup(); component.open("auto"); - expect(component.poolExperts("Сбер")).toEqual([]); + expect(component.poolExperts("Сбер").map(e => e.id)).toEqual([1]); component.plan.set({ revision: "v1", planToken: "signed", @@ -354,13 +466,15 @@ describe("Управление экспертами", () => { const restricted = { ...snapshot.experts[0], id: 3, allCases: false, caseNames: ["Сбер"] }; component.snapshot.update(s => ({ ...s!, experts: [...s!.experts, restricted] })); component.open("auto"); - expect(component.poolExperts("Сбер")).toEqual([]); + expect(component.poolExperts("Сбер").map(e => e.id)).toEqual([1, 3]); component.openPool("Сбер"); + component.chosenExperts.set(new Set()); component.toggleCandidate(snapshot.experts[0]); component.toggleCandidate(restricted); component.savePool(); expect(component.poolExperts("Сбер").map(e => e.id)).toEqual([1, 3]); component.openPool("Т-Банк"); + component.chosenExperts.set(new Set()); component.toggleCandidate(snapshot.experts[0]); component.toggleCandidate(restricted); expect(component.chosenExperts().has(3)).toBe(false); @@ -371,9 +485,11 @@ describe("Управление экспертами", () => { const { component, api } = setup(); component.open("auto"); component.openPool("Сбер"); + component.chosenExperts.set(new Set()); component.toggleCandidate(snapshot.experts[0]); component.savePool(); component.openPool("Т-Банк"); + component.chosenExperts.set(new Set()); component.toggleCandidate(snapshot.experts[0]); component.savePool(); api.allocation.mockReturnValue( @@ -381,6 +497,7 @@ describe("Управление экспертами", () => { ); component.previewAllocation(); expect(api.allocation).toHaveBeenCalledWith(42, { + seed: 0, expertIds: [1], revision: "v1", casePools: [ @@ -518,6 +635,7 @@ describe("Управление экспертами", () => { })); component.open("auto"); component.openPool("Сбер"); + component.chosenExperts.set(new Set()); expect(component.chosenExperts().size).toBe(0); expect(component.candidateReason(extra)).toBeNull(); component.toggleCandidate(snapshot.experts[0]); 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 247fd9c47..3c4595d0d 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 @@ -118,9 +118,39 @@ export class ManagementExpertsComponent { readonly bulkScope = signal(false); readonly bulkRevoke = signal(false); readonly modal = signal< - "invite" | "auto" | "quota" | "revoke" | "method" | "project" | "scope" | "pool" | null + | "invite" + | "auto" + | "quota" + | "revoke" + | "method" + | "project" + | "scope" + | "pool" + | "allocation-project" + | null >(null); readonly plan = signal(null); + readonly allocationSeed = signal(0); + readonly allocationOverrides = signal(new Map()); + readonly allocationHint = signal(null); + readonly allocationProjectId = signal(null); + readonly allocationProject = computed(() => + this.allProjects().find(p => p.id === this.allocationProjectId()), + ); + readonly allocationProjects = computed(() => { + const plan = this.plan(); + return this.allProjects() + .filter( + p => + plan?.assignments.some(a => a.projectId === p.id) || + plan?.shortages.some(s => s.projectId === p.id), + ) + .map(project => ({ + ...project, + additions: plan!.assignments.filter(a => a.projectId === project.id), + missing: plan!.shortages.find(s => s.projectId === project.id)?.missing ?? 0, + })); + }); readonly quotaPreview = signal(null); readonly chosenExperts = signal(new Set()); readonly search = this.fb.control(""); @@ -204,6 +234,10 @@ export class ManagementExpertsComponent { (this.projectPage() + 1) * this.pageSize, ), ); + readonly attentionProjects = computed(() => + this.allProjects().filter(p => p.experts.length < (this.snapshot()?.requiredEvaluators ?? 1)), + ); + readonly attentionActive = computed(() => this.distributionStatusValue() === "missing"); readonly selection = computed(() => this.allProjects().filter(p => this.selectedProjects().has(p.id)), ); @@ -247,6 +281,7 @@ export class ManagementExpertsComponent { .filter(g => g.missing) .every(g => this.poolExperts(g.name).length), ); + readonly pendingAutoGroups = computed(() => this.autoGroups().filter(g => g.missing)); readonly candidateExperts = computed(() => (this.snapshot()?.experts ?? []).filter( e => @@ -308,6 +343,8 @@ export class ManagementExpertsComponent { return this.bulkScope() ? "Кейсы выбранных экспертов" : "Кейсы эксперта"; case "pool": return "Эксперты для кейса «" + this.caseLabel(this.poolCase()) + "»"; + case "allocation-project": + return "Эксперты проекта"; case "project": return this.targets().length > 1 ? "Назначить экспертов нескольким проектам" @@ -697,16 +734,26 @@ export class ManagementExpertsComponent { } if (kind === "quota") this.quota.setValue(this.snapshot()?.requiredEvaluators ?? 1); if (kind === "auto") { + this.allocationOverrides.set(new Map()); + this.allocationSeed.set(0); + this.allocationHint.set(null); this.casePools.set( this.autoGroups().map(g => ({ caseName: g.name, - expertIds: [], + expertIds: this.joined() + .filter(e => this.eligibleFor(e, g.name)) + .map(e => Number(e.id)), })), ); } } async close(): Promise { if (this.busy()) return; + if (this.modal() === "allocation-project") { + this.modal.set("auto"); + this.dialogError.set(null); + return; + } if (this.dirty() && !(await this.confirmDiscard()())) return; this.modal.set(null); this.dialogError.set(null); @@ -761,7 +808,23 @@ export class ManagementExpertsComponent { } previewAllocation(): void { if (!this.canPreviewAllocation()) return; + this.calculateAllocation(this.allocationOverrides(), this.allocationSeed()); + } + rerollAllocation(): void { + if (this.busy() || !this.canPreviewAllocation()) return; + this.calculateAllocation(new Map(), (this.allocationSeed() + 1) % 2147483648, true); + } + private calculateAllocation( + overrides: Map, + seed: number, + reroll = false, + ): void { const pools = this.casePools(); + const before = JSON.stringify( + this.plan() + ?.assignments.map(a => `${a.projectId}:${a.expertId}`) + .sort(), + ); this.request( this.api.allocation( this.programId(), @@ -770,12 +833,64 @@ export class ManagementExpertsComponent { expertIds: [...new Set(pools.flatMap(p => p.expertIds))], casePools: pools, revision: this.snapshot()!.revision, + seed, + ...(overrides.size + ? { + assignmentOverrides: [...overrides].map(([projectId, expertIds]) => ({ + projectId, + expertIds, + })), + } + : {}), } : { expertIds: [...this.chosenExperts()] }, ), - plan => this.plan.set(plan), + plan => { + this.allocationOverrides.set(overrides); + this.allocationSeed.set(seed); + this.allocationHint.set( + reroll && + before === + JSON.stringify(plan.assignments.map(a => `${a.projectId}:${a.expertId}`).sort()) + ? "Получился тот же вариант. Можно изменить состав у проекта или выбрать других экспертов для кейса." + : null, + ); + this.plan.set(plan); + this.modal.set("auto"); + }, ); } + editAllocationProject(id: number): void { + if (this.busy()) return; + const project = this.allProjects().find(p => p.id === id); + if (!project || !this.plan()) return; + this.allocationProjectId.set(id); + this.chosenExperts.set( + new Set([ + ...project.experts.map(e => e.expertId), + ...this.plan()! + .assignments.filter(a => a.projectId === id) + .map(a => a.expertId), + ]), + ); + this.expertSearch.reset(""); + this.dialogError.set(null); + this.modal.set("allocation-project"); + } + saveAllocationProject(): void { + const project = this.allocationProject(); + if (!project || this.busy()) return; + const limit = this.snapshot()?.requiredEvaluators ?? 1; + if (this.chosenExperts().size !== limit) return; + const overrides = new Map( + this.allocationProjects().map(p => [p.id, p.additions.map(e => e.expertId)]), + ); + overrides.set( + project.id, + [...this.chosenExperts()].filter(id => !project.experts.some(e => e.expertId === id)), + ); + this.calculateAllocation(overrides, this.allocationSeed()); + } applyAllocation(): void { const plan = this.plan(); if (!plan || plan.shortages.length) return; @@ -818,6 +933,12 @@ export class ManagementExpertsComponent { distributionCaseKey(name: string | null): string { return JSON.stringify(name); } + showAttentionProjects(): void { + const active = this.attentionActive(); + this.distributionSearch.reset(""); + this.distributionCase.setValue("all"); + this.distributionStatus.setValue(active ? "all" : "missing"); + } scopeLabel(expert: ExpertRow): string { return (expert.allCases ?? true) ? "Все кейсы" @@ -881,6 +1002,23 @@ export class ManagementExpertsComponent { candidateReason(expert: ExpertRow): string | null { if (expert.status !== "joined") return "Приглашение еще не принято"; const id = Number(expert.id); + if (this.modal() === "allocation-project") { + const project = this.allocationProject(); + if (!project) return "Проект не найден"; + if (project.experts.some(e => e.expertId === id)) return "Уже назначен — останется в составе"; + if (!this.poolExperts(project.caseName).some(e => Number(e.id) === id)) + return "Не выбран для этого кейса"; + if ( + expert.userId && + (project.leaderId === expert.userId || project.teamUserIds.includes(expert.userId)) + ) + return "Проект собственной команды"; + if ( + !this.chosenExperts().has(id) && + this.chosenExperts().size >= (this.snapshot()?.requiredEvaluators ?? 1) + ) + return "Сначала снимите другого эксперта"; + } if (this.modal() === "project") { const targets = this.targets(); if (targets.length === 1 && targets[0].experts.some(e => e.expertId === id && e.protected)) @@ -997,6 +1135,8 @@ export class ManagementExpertsComponent { ), ); this.plan.set(null); + this.allocationOverrides.set(new Map()); + this.allocationHint.set(null); this.modal.set("auto"); } }