Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.m
import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case";
import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case";
import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case";
import { GetProgramManagerParticipantsWithoutOnboardingUseCase } from "@api/program/use-cases/get-program-manager-participants-without-onboarding.use-case";
import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case";
import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case";
import {
Expand All @@ -27,6 +28,7 @@ import {

/** Root-списки внимания используют тот же modal context, не вложенную модалку. */
export type AnalyticsAttentionView =
| "participants-without-onboarding"
| "participants-without-team"
| "projects-awaiting-evaluation"
| "projects-not-submitted";
Expand Down Expand Up @@ -58,6 +60,9 @@ export class ProgramAnalyticsDrilldownService {
private readonly getAssignments = inject(GetProgramManagerAssignmentsUseCase);
private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase);
private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase);
private readonly getWithoutOnboarding = inject(
GetProgramManagerParticipantsWithoutOnboardingUseCase,
);
private readonly getProjects = inject(GetProgramManagerProjectsAwaitingEvaluationUseCase);
private readonly getNotSubmitted = inject(GetProgramManagerProjectsNotSubmittedUseCase);
private readonly destroyRef = inject(DestroyRef);
Expand Down Expand Up @@ -89,14 +94,19 @@ export class ProgramAnalyticsDrilldownService {
readonly notSubmittedPage = signal<ProgramAnalyticsNotSubmittedProjectsPage | null>(null);
readonly attentionPending = signal(false);
readonly attentionError = signal<ProgramAnalyticsError | null>(null);
readonly isAttentionView = computed(
readonly isParticipantView = computed(
() =>
this.view() === "participants-without-team" ||
this.view() === "participants-without-onboarding",
);
readonly isAttentionView = computed(
() =>
this.isParticipantView() ||
this.view() === "projects-awaiting-evaluation" ||
this.view() === "projects-not-submitted",
);
readonly attentionPage = computed(() =>
this.view() === "participants-without-team"
this.isParticipantView()
? this.participantsPage()
: this.view() === "projects-not-submitted"
? this.notSubmittedPage()
Expand Down Expand Up @@ -167,8 +177,12 @@ export class ProgramAnalyticsDrilldownService {
limit: this.attentionLimit,
offset: this.attentionOffset(),
};
if (this.view() === "participants-without-team") {
this.getParticipants
if (this.isParticipantView()) {
const useCase =
this.view() === "participants-without-onboarding"
? this.getWithoutOnboarding
: this.getParticipants;
useCase
.execute(this.programId, query)
.pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
notSubmittedPage,
} from "@domain/program/program-analytics-attention.fixture";
import { GetProgramManagerParticipantsWithoutTeamUseCase } from "./get-program-manager-participants-without-team.use-case";
import { GetProgramManagerParticipantsWithoutOnboardingUseCase } from "./get-program-manager-participants-without-onboarding.use-case";
import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "./get-program-manager-projects-awaiting-evaluation.use-case";
import { GetProgramManagerProjectsNotSubmittedUseCase } from "./get-program-manager-projects-not-submitted.use-case";

Expand All @@ -28,6 +29,12 @@ describe("Attention: adapter → repository → use cases", () => {
});

for (const [method, endpoint, useCase, page] of [
[
"getManagerParticipantsWithoutOnboarding",
"participants-without-onboarding",
GetProgramManagerParticipantsWithoutOnboardingUseCase,
participantsPage(),
],
[
"getManagerProjectsNotSubmitted",
"projects-not-submitted",
Expand All @@ -50,6 +57,7 @@ describe("Attention: adapter → repository → use cases", () => {
const injectUseCase = () =>
TestBed.inject<
| GetProgramManagerParticipantsWithoutTeamUseCase
| GetProgramManagerParticipantsWithoutOnboardingUseCase
| GetProgramManagerProjectsAwaitingEvaluationUseCase
| GetProgramManagerProjectsNotSubmittedUseCase
>(useCase);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/** @format */
import { inject, Injectable } from "@angular/core";
import { HttpErrorResponse } from "@angular/common/http";
import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port";
import {
ProgramAnalyticsAttentionPage,
ProgramAnalyticsAttentionParticipant,
ProgramAnalyticsAttentionQuery,
} from "@domain/program/program-analytics-attention.model";
import { ProgramAnalyticsError } from "@domain/program/program-analytics.model";
import { fail, ok, Result } from "@domain/shared/result.type";
import { catchError, map, Observable, of } from "rxjs";

/** Уникальные участники программы с незавершённым онбордингом платформы. */
@Injectable({ providedIn: "root" })
export class GetProgramManagerParticipantsWithoutOnboardingUseCase {
private readonly repository = inject(ProgramRepositoryPort);

execute(
programId: number,
query: ProgramAnalyticsAttentionQuery,
): Observable<
Result<
ProgramAnalyticsAttentionPage<ProgramAnalyticsAttentionParticipant>,
ProgramAnalyticsError
>
> {
return this.repository.getManagerParticipantsWithoutOnboarding(programId, query).pipe(
map(value => ok(value)),
catchError((error: unknown) => {
const status = error instanceof HttpErrorResponse ? error.status : 0;
const kind: ProgramAnalyticsError["kind"] =
status === 401
? "unauthorized"
: status === 403
? "forbidden"
: status === 404
? "not_found"
: "network";
return of(fail({ kind }));
}),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export abstract class ProgramRepositoryPort {

abstract getManagerOverview(programId: number): Observable<ProgramAnalyticsOverview>;

abstract getManagerParticipantsWithoutOnboarding(
programId: number,
query: ProgramAnalyticsAttentionQuery,
): Observable<ProgramAnalyticsAttentionPage<ProgramAnalyticsAttentionParticipant>>;

/** Уникальные участники без команды текущей программы, серверный поиск/пагинация. */
abstract getManagerParticipantsWithoutTeam(
programId: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type ProgramAnalyticsAttentionPage<T> = Omit<ApiPagination<T>, "next" | "
previous: string | null;
};

/** Уникальный участник без команды в этой программе, не признак «ищет команду».
/** Уникальный участник списка внимания программы, не признак «ищет команду».
* registeredAt — регистрация в программе, city сохраняет legacy-значения backend.
*/
export interface ProgramAnalyticsAttentionParticipant {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface ProgramAnalyticsOverview {
};
};
attention: {
/** Absent until the API deployment provides the onboarding attention list. */
participantsWithoutOnboarding?: number;
participantsWithoutTeam: number;
projectsAwaitingEvaluation: number;
/** Несданные связи: требование сдачи применимо только к конкурсной программе. */
Expand Down Expand Up @@ -158,17 +160,17 @@ export interface ProgramAnalyticsAssignmentScoreDetail extends ProgramAnalyticsA
scores: ProgramAnalyticsAssignmentCriterion[];
}

/** Severity и порядок authoritative: warning — >=2 ожиданий 24ч, critical — >=1 48ч.
* Заглавная H соответствует преобразованию overdue_24h/overdue_48h interceptor-ом.
/** Backend включает эксперта за 5 суток до дедлайна: warning до него, critical после.
* overdue24H/overdue48H сохранены для совместимости и не определяют сигнал.
*/
export interface ProgramAnalyticsDelayedExpert extends ProgramAnalyticsAssignmentExpert {
assignmentsTotal: number;
completed: number;
pending: number;
overdue24H: number;
overdue48H: number;
oldestWaitingSince: string;
oldestWaitingSeconds: number;
oldestWaitingSince: string | null;
oldestWaitingSeconds: number | null;
severity: "warning" | "critical";
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ export class ProgramHttpAdapter {
return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/manager-overview/`);
}

/** Без автозагрузки остальных страниц; camelcase преобразует общий interceptor. */
getManagerParticipantsWithoutOnboarding(
programId: number,
query: ProgramAnalyticsAttentionQuery,
): Observable<ProgramAnalyticsAttentionPage<ProgramAnalyticsAttentionParticipant>> {
return this.apiService.get(
`${this.PROGRAMS_URL}/${programId}/manager-overview/participants-without-onboarding/`,
this.attentionParams(query),
);
}

/** Без автозагрузки остальных страниц; camelcase преобразует общий interceptor. */
getManagerParticipantsWithoutTeam(
programId: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ export class ProgramRepository implements ProgramRepositoryPort {
return this.programAdapter.getManagerOverview(programId);
}

/** Свежий manager-список при каждом открытии/поиске, без EntityCache. */
getManagerParticipantsWithoutOnboarding(
programId: number,
query: ProgramAnalyticsAttentionQuery,
): Observable<ProgramAnalyticsAttentionPage<ProgramAnalyticsAttentionParticipant>> {
return this.programAdapter.getManagerParticipantsWithoutOnboarding(programId, query);
}

/** Свежий manager-список при каждом открытии/поиске, без EntityCache. */
getManagerParticipantsWithoutTeam(
programId: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,11 @@ <h2 class="text-body-12">Требует внимания</h2>
[disabled]="item.value === 0"
(click)="
drilldown.openAttention(
item.key === 'without-team'
? 'participants-without-team'
: 'projects-awaiting-evaluation',
item.key === 'without-onboarding'
? 'participants-without-onboarding'
: item.key === 'without-team'
? 'participants-without-team'
: 'projects-awaiting-evaluation',
attentionTrigger
)
"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,64 @@ describe("ProgramAnalyticsComponent", () => {
expect(open).toHaveBeenCalledTimes(1);
});

it("onboarding attention uses the API count, opens its list, and disables zero", () => {
const base = overview();
data.set({
...base,
attention: { ...base.attention, participantsWithoutOnboarding: 7 },
});
const fixture = TestBed.createComponent(ProgramAnalyticsComponent);
fixture.detectChanges();
const drilldown = fixture.debugElement.query(By.directive(AnalyticsDrilldownComponent))
.componentInstance as AnalyticsDrilldownComponent;
const open = vi.spyOn(drilldown, "openAttention").mockImplementation(() => {});
const trigger = Array.from(
fixture.nativeElement.querySelectorAll(".attention__action") as NodeListOf<HTMLButtonElement>,
).find(button => button.textContent?.includes("Участники не завершили онбординг"))!;
expect(trigger.querySelector("strong")?.textContent).toBe("7");
expect(trigger.disabled).toBe(false);
trigger.click();
expect(open).toHaveBeenCalledExactlyOnceWith("participants-without-onboarding", trigger);
data.set({ ...base, attention: { ...base.attention, participantsWithoutOnboarding: 0 } });
fixture.detectChanges();
expect(trigger.disabled).toBe(true);
trigger.click();
expect(open).toHaveBeenCalledTimes(1);
});

it("does not invent an onboarding attention count when the API field is absent", () => {
const fixture = TestBed.createComponent(ProgramAnalyticsComponent);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('[data-testid="attention-block"]').textContent,
).not.toContain("Участники не завершили онбординг");
});

it("unfinished onboarding alone prevents the all-clear attention state", () => {
const base = overview();
data.set({
...base,
attention: {
participantsWithoutOnboarding: 1,
participantsWithoutTeam: 0,
projectsAwaitingEvaluation: 0,
projectsNotSubmitted: { applicable: false, total: 0 },
delayedExperts: { total: 0, items: [] },
},
});
const fixture = TestBed.createComponent(ProgramAnalyticsComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector(".attention__empty")).toBeNull();
data.update(current => ({
...current!,
attention: { ...current!.attention, participantsWithoutOnboarding: 0 },
}));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector(".attention__empty")?.textContent).toContain(
"Ничего не требует внимания",
);
});

it("не дублирует счётчик несданных проектов в блоке внимания", () => {
const base = overview();
data.set({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,20 @@ export class ProgramAnalyticsComponent implements OnInit {
protected readonly attention = computed<AnalyticsMetric[]>(() => {
const overview = this.data();
if (!overview) return [];
const onboarding: AnalyticsMetric[] =
overview.attention.participantsWithoutOnboarding == null
? []
: [
{
key: "without-onboarding",
label: "Участники не завершили онбординг",
value: overview.attention.participantsWithoutOnboarding,
tooltip:
"Зарегистрированные участники программы, которые ещё не завершили стартовый онбординг платформы. Участие в команде и подтверждение почты не заменяют онбординг.",
},
];
return [
...onboarding,
{
key: "without-team",
label: "Участники без команды",
Expand All @@ -337,7 +350,7 @@ export class ProgramAnalyticsComponent implements OnInit {
label: "Эксперты задерживают оценивание",
value: overview.attention.delayedExperts.total,
tooltip:
"Эксперты с просроченными назначениями: минимум два проекта ожидают оценки 24 часа или хотя бы один проект — 48 часов.",
"Появляется за 5 суток до окончания оценивания, если у эксперта есть незавершённая оценка сданной работы. После дедлайна остаётся до завершения оценок. Без даты окончания оценивания сигнал не появляется.",
},
];
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { GetProgramManagerOverviewUseCase } from "@api/program/use-cases/get-pro
import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case";
import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case";
import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case";
import { GetProgramManagerParticipantsWithoutOnboardingUseCase } from "@api/program/use-cases/get-program-manager-participants-without-onboarding.use-case";
import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case";
import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case";
import { ProgramAnalyticsInfoService } from "@api/program/facades/detail/program-analytics-info.service";
Expand Down Expand Up @@ -56,6 +57,7 @@ class RouteHost {

describe("Attention SPA: observable route → resolver → overview", () => {
it.each([
"participants-without-onboarding",
"participants-without-team",
"projects-awaiting-evaluation",
"projects-not-submitted",
Expand All @@ -76,6 +78,7 @@ describe("Attention SPA: observable route → resolver → overview", () => {
{ provide: ActivatedRoute, useValue: { parent: { paramMap: params.asObservable() } } },
{ provide: GetProgramManagerOverviewUseCase, useValue: overview },
{ provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: attention },
{ provide: GetProgramManagerParticipantsWithoutOnboardingUseCase, useValue: attention },
{ provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: attention },
{ provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: attention },
{ provide: GetProgramManagerAssignmentsUseCase, useValue: { execute: vi.fn() } },
Expand All @@ -98,7 +101,7 @@ describe("Attention SPA: observable route → resolver → overview", () => {
await fixture.whenStable();
old.next(
ok(
view === "participants-without-team"
view === "participants-without-team" || view === "participants-without-onboarding"
? participantsPage()
: view === "projects-not-submitted"
? notSubmittedPage()
Expand Down
Loading