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
42 changes: 42 additions & 0 deletions docs/modules/program-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<!-- @format -->

# Управление программой · V1

Дизайн утверждён пользователем 17.10.2026 («В целом да»).
[Figma V1](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=201-5390).

Организатору доступны два входа вокруг аватара программы: «Оценка проектов» и «Управление программой». Последний открывает участников. Старые отдельные кнопки участников и проектов у организатора убраны. Эксперт без роли организатора сохраняет прежний маршрут оценивания.

## Границы

- Участники: таблица ФИО, почты, города и даты регистрации именно в программе; только чтение, поиск по имени/почте, серверная пагинация.
- Проекты: сдача и оценивание раздельно, кейс, руководитель и прямая ссылка на презентацию. Выбор одной строки открывает одну панель с просмотром команды и переходом к оценке.
- Команда: состав и роли; desktop side panel, mobile full screen. Закрытие возвращает фокус; возврат из оценки восстанавливает выбор и query списка.
- Оценивание: чтение оценок выбранного проекта. Существующий эксперт дополнительно получает переход в прежнюю форму с точным `project_id`. Роль организатора сама по себе не даёт право выставлять оценки. Несданное решение и неконкурсная программа блокируют переход из списка.
- Аналитика: существующий компонент и API, без изменения определений показателей.
- Настройки: Основное, Сроки, Материалы. Явное сохранение, предупреждение об уходе, сохранение введённых значений при ошибке, optimistic concurrency. Даты явно в Москве UTC+3. Повторное открытие истёкшего срока требует подтверждения сервера.
- Материалы: файл через существующий FileService либо URL; изменение и удаление записи программы сохраняют исходный UserFile.

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

## Интеграция

Новый приватный port/use case/repository/HTTP adapter использует `/programs/:id/management/`. CamelcaseInterceptor сохраняет существующее преобразование JSON. Почта и прочие приватные данные не попадают в общий EntityCache. После записи событие ProgramUpdated очищает кеш страницы и списка программ, включая persistent cache; старый ответ списка не заменяет новые данные.

Backend проверяет manager именно этой программы либо существующий staff/superuser scope. Прямые чужие ID и эксперт без manager не получают доступ. Полный контракт находится в backend `docs/modules/program-management.md`. Backend PR должен попасть в dev раньше UI.

Native adapters Button/Field/Choice/Table, существующие Modal/Icon/Pagination/Alert и Mont сохраняют PROCOLLAB DS. Foundation подключена локально также на desktop; глобальные компоненты не менялись.

## Verification · 17.10.2026

52 теста Vitest в семи файлах: management facade, компонент, HTTP contract, утилиты дат/статусов, ProgramRepository, ProgramShellInfoService и существующий FileService. Проверены отмена устаревших запросов и сброс приватных данных при 403, выбор, dirty confirmation, версия записи, загрузка multipart через FileService и кеш после сохранения.

Полный настроенный Vitest-прогон: **2117 PASS / 416 файлов**. Ожидание ссылки organizer role widget обновлено на `/management/analytics`; прежний экспертный маршрут сохранён. Общая проверка Prettier проходит с `--end-of-line auto` для Windows checkout с CRLF; изменённые файлы отдельно проходят обычный `--check`.

`ng build social_platform --configuration=production` проходит. Сохраняются предупреждения Sass/CommonJS и budget warnings; ошибок сборки нет. Новая SCSS укладывается в error budget. Targeted ESLint, Stylelint и форматирование проверены отдельно.

Headless Chrome, реальная production-сборка + локальный Django/PostgreSQL: участники, почта, no-results/reset, выбор и команда с возвратом фокуса, возврат из оценки, readonly manager, settings save/error/dirty leave, обновление публичной страницы, повторное открытие срока (cancel/save), material URL CRUD, аналитика, 403. Проверены desktop1440/mobile390/360; переполнения нет. Часовой пояс браузера America/Los_Angeles подтверждает независимость московских полей.

Снимки локальной проверки: [участники desktop](../qa/program-management-v1/participants-desktop.png), [проекты mobile](../qa/program-management-v1/projects-mobile.png), [команда desktop](../qa/program-management-v1/team-desktop.png). Данные демонстрационные; внешний placeholder аватара заблокирован в тестовом браузере.

Реальная загрузка в Selectel CDN, dev после merge и production не проверялись. FileService не менялся; ownership, переключение file→URL и сохранность исходного файла проверены backend tests. Нужна обычная приёмка на dev после обоих merge; merge/deploy выполняет пользователь.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/program-management-v1/team-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions projects/social_platform/src/app/api/paths/app-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export const AppRoutes = {
},

program: {
management: (programId: number | string, section: string = "participants") =>
`/office/program/${programId}/management/${section}`,
root: () => "/office/program",
list: () => "/office/program/all",
detail: (programId: number | string) => `/office/program/${programId}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,11 @@ export class ProgramDetailListInfoService {
return;
}

if (this.listType() === "rating" && key === "project_id") {
extraParams["project_id"] = value;
return;
}

if (this.listType() === "rating" && key === "is_rated_by_expert") {
extraParams["is_rated_by_expert"] = value;
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/** @format */

import { TestBed } from "@angular/core/testing";
import { HttpErrorResponse } from "@angular/common/http";
import { Subject, of, throwError } from "rxjs";
import { ProgramManagementService, ManagementContext } from "./program-management.service";
import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case";
import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service";
import {
ManagementProject,
ManagementTeam,
ProgramManagement,
} from "@domain/program/program-management.model";

const program = { id: 1, name: "Программа", materials: [] } as unknown as ProgramManagement;
const first = { id: 11, projectId: 91, name: "Первый" } as ManagementProject;
const second = { id: 12, projectId: 92, name: "Второй" } as ManagementProject;
const page = (results: ManagementProject[]) => ({
results,
count: results.length,
next: null,
previous: null,
caseOptions: [],
isCompetitive: true,
});
const context = (id = 1): ManagementContext => ({
programId: id,
section: "projects",
query: { search: "", limit: 25, offset: 0, submission: "all", caseScope: "all" },
});

describe("ProgramManagementService", () => {
function setup() {
const api = {
get: vi.fn(() => of(program)),
projects: vi.fn(() => of(page([first, second]))),
participants: vi.fn(),
team: vi.fn(),
evaluation: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
ProgramManagementService,
ProgramDetailMainUIInfoService,
{ provide: ProgramManagementUseCase, useValue: api },
],
});
return { api, service: TestBed.inject(ProgramManagementService) };
}
it("одновременно выбирает только один проект", () => {
const { service } = setup();
service.load(context());
service.select(first);
service.select(second);
expect(service.selected()?.id).toBe(second.id);
});
it("очищает выбор, если проект исчез после фильтрации", () => {
const { service, api } = setup();
service.load(context());
service.select(first);
api.projects.mockReturnValue(of(page([second])));
service.load({ ...context(), query: { ...context().query, search: "Второй" } });
expect(service.selected()).toBeNull();
});
it("сохраняет выбор при обновлении той же выдачи", () => {
const { service } = setup();
service.load(context());
service.select(first);
service.retry();
expect(service.selected()?.id).toBe(first.id);
});
it("не применяет поздний ответ предыдущей программы", () => {
const { service, api } = setup();
const old = new Subject<ProgramManagement>();
api.get.mockReturnValueOnce(old);
service.load(context());
api.get.mockReturnValueOnce(of({ ...program, id: 2 }));
service.load(context(2));
old.next(program);
old.complete();
expect(service.program()?.id).toBe(2);
});
it("не показывает команду прошлого выбора", () => {
const { service, api } = setup();
const team = new Subject<ManagementTeam>();
api.team.mockReturnValue(team);
service.load(context());
service.select(first);
service.openTeam();
service.select(second);
team.next({ projectId: 91, programLinkId: 11, name: "Первый", members: [] });
expect(service.team()).toBeNull();
expect(service.teamOpen()).toBe(false);
});
it("команда использует id связи программы, а не глобальный projectId", () => {
const { service, api } = setup();
api.team.mockReturnValue(of({ members: [] }));
service.load(context());
service.select(first);
service.openTeam();
expect(api.team).toHaveBeenCalledWith(1, 11);
});
it("после 403 удаляет ранее загруженные приватные данные", () => {
const { service, api } = setup();
service.load(context());
service.select(first);
api.get.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 403 })));
service.retry();
expect(service.program()).toBeNull();
expect(service.projects()).toBeNull();
expect(service.selected()).toBeNull();
expect(service.error()?.status).toBe(403);
});
it("восстанавливает выбор после возврата из оценок", () => {
const { service } = setup();
service.load({ ...context(), selectedId: second.id });
expect(service.selected()?.id).toBe(second.id);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/** @format */

import { DestroyRef, Injectable, inject, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { HttpErrorResponse } from "@angular/common/http";
import { Subject, catchError, forkJoin, map, of, switchMap, takeUntil, tap } from "rxjs";
import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case";
import {
ManagementEvaluation,
ManagementPage,
ManagementParticipant,
ManagementProject,
ManagementProjectsPage,
ManagementQuery,
ManagementSection,
ManagementTeam,
ProgramManagement,
} from "@domain/program/program-management.model";
import { Program } from "@domain/program/program.model";
import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service";

export interface ManagementContext {
programId: number;
section: ManagementSection;
query: ManagementQuery;
linkId?: number;
selectedId?: number;
}
interface Content {
participants?: ManagementPage<ManagementParticipant>;
projects?: ManagementProjectsPage;
evaluation?: ManagementEvaluation;
}

@Injectable()
export class ProgramManagementService {
readonly api = inject(ProgramManagementUseCase);
private readonly destroyRef = inject(DestroyRef);
private readonly programUI = inject(ProgramDetailMainUIInfoService);
private readonly requests = new Subject<ManagementContext>();
private readonly cancelTeam = new Subject<void>();
readonly program = signal<ProgramManagement | null>(null);
readonly participants = signal<ManagementPage<ManagementParticipant> | null>(null);
readonly projects = signal<ManagementProjectsPage | null>(null);
readonly evaluation = signal<ManagementEvaluation | null>(null);
readonly pending = signal(false);
readonly error = signal<HttpErrorResponse | null>(null);
readonly selected = signal<ManagementProject | null>(null);
readonly team = signal<ManagementTeam | null>(null);
readonly teamPending = signal(false);
readonly teamError = signal<HttpErrorResponse | null>(null);
readonly teamOpen = signal(false);
context: ManagementContext | null = null;

constructor() {
this.requests
.pipe(
tap(ctx => {
if (this.context?.programId !== ctx.programId) this.program.set(null);
if (this.context?.programId !== ctx.programId || this.context?.section !== ctx.section)
this.clearSelection();
this.context = ctx;
this.pending.set(true);
this.error.set(null);
this.closeTeam();
}),
switchMap(ctx => {
const content =
ctx.section === "participants"
? this.api
.participants(ctx.programId, ctx.query)
.pipe(map(participants => ({ participants }) as Content))
: ctx.section === "projects"
? this.api
.projects(ctx.programId, ctx.query)
.pipe(map(projects => ({ projects }) as Content))
: ctx.section === "evaluation" && ctx.linkId
? this.api
.evaluation(ctx.programId, ctx.linkId)
.pipe(map(evaluation => ({ evaluation }) as Content))
: of({} as Content);
return forkJoin({ program: this.api.get(ctx.programId), content }).pipe(
tap(({ program, content }) => {
this.program.set(program);
this.participants.set(content.participants ?? null);
this.projects.set(content.projects ?? null);
this.evaluation.set(content.evaluation ?? null);
const selectedId = ctx.selectedId ?? this.selected()?.id;
this.selected.set(content.projects?.results.find(p => p.id === selectedId) ?? null);
this.programUI.program.set(
Object.assign(new Program(), Program.default(), program, { isUserManager: true }),
);
}),
catchError((error: HttpErrorResponse) => {
this.error.set(error);
this.program.set(null);
this.participants.set(null);
this.projects.set(null);
this.evaluation.set(null);
this.clearSelection();
return of(null);
}),
tap(() => this.pending.set(false)),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
load(ctx: ManagementContext): void {
this.requests.next(ctx);
}
retry(): void {
if (this.context) this.load(this.context);
}
select(project: ManagementProject): void {
this.closeTeam();
this.selected.set(project);
}
clearSelection(): void {
this.closeTeam();
this.selected.set(null);
}
openTeam(): void {
const project = this.selected(),
ctx = this.context;
if (!project || !ctx) return;
this.cancelTeam.next();
this.team.set(null);
this.teamError.set(null);
this.teamOpen.set(true);
this.teamPending.set(true);
this.api
.team(ctx.programId, project.id)
.pipe(takeUntil(this.cancelTeam), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: team => {
this.team.set(team);
this.teamPending.set(false);
},
error: (error: HttpErrorResponse) => {
this.teamError.set(error);
this.teamPending.set(false);
},
});
}
closeTeam(): void {
this.cancelTeam.next();
this.teamOpen.set(false);
this.teamPending.set(false);
this.team.set(null);
this.teamError.set(null);
}
}
Loading