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
47 changes: 47 additions & 0 deletions docs/feature-specs/project-program-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!-- @format -->

# Программа и статусы проекта

Пользователь утвердил макет плашек 04.10.2026 и попросил показывать, к какой программе относится проект. Цель — изменение Angular/API в dev, без изменения правил сдачи и доступа.

## Текущее поведение

«Мои проекты» показывает четыре состояния с приоритетом submitted > program > draft > published. Привязка отображается как «В программе» фиолетовым; сдача — золотым. Название программы уже есть в `GET /auth/users/projects/` и `/auth/users/projects/leader/`, но не выводится. `GET /projects/{id}/` возвращает выбранную legacy-связь с минимальным PK без названия программы.

## Целевое поведение и роли

- Привязка: «Привязан к программе», нейтральная плашка с существующей иконкой `program`.
- Сдача: «Сдан в программу», `green-dark` на `green-light`, существующая иконка `lock`.
- «Черновик» и «Опубликован» сохраняются; не появляется состояние «Не сдан» или «На проверке».
- В карточках «Моих проектов» при известном названии есть строка «Программа: …». Длинная строка сокращается многоточием, полное название есть в title и доступном названии ссылки проекта.
- В детали проекта в существующем блоке «Основное» есть поле «Программа»: полное название с переносом и ссылкой на `/office/program/{program_id}`. Оно доступно тем же пользователям, которые уже могут читать эту деталь.
- Карточка остаётся 156×180, аватар 70, действие «Открыть» и нижний блок доступа сохраняются. При добавлении строки программы описание занимает одну строку вместо двух.
- Роль, права редактирования, приоритет статусов, выбранная legacy-связь, фильтры и маршруты не меняются. Неизвестный `is_submitted` не даёт права редактирования.

## Контракт API и совместимость

`GET /projects/{id}/` (а также ответы уже использующего этот serializer CRUD) добавляет read-only `partner_program.name` из `PartnerProgram.name`. Существующие `program_id`, `program_link_id`, `is_submitted`, `can_submit`, поля заявки и `null` сохраняются. Выбор связи остаётся по минимальному PK. Request, validation, permissions и errors не меняются; миграции не нужны.

Angular DTO/model получает optional `name`, чтобы старый сервер оставался совместимым. Если имени в старом ответе нет, статус и доступ сохраняются; строка карточки отсутствует, в детали явно показано «Название не указано». Ссылка использует `program_id`, а не ID связи или проекта. Backend следует выпустить раньше Angular, чтобы имя сразу было доступно на обоих экранах. React не изменяется: добавление read-only поля не меняет его существующий контракт.

## Acceptance criteria

| AC | Предусловие и действие | Наблюдаемый результат / проверка |
| --- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| AC1 | Лидер или участник открывает «Мои проекты» с привязанным/сданным проектом | Согласованные подписи, разные иконки; lifecycle и права остаются независимыми. Component/HTTP integration tests и browser fixture. |
| AC2 | Ответ списка содержит название программы | Карточка показывает именно это название; весь проект остаётся одной ссылкой без вложенных ссылок/кнопок. Integration/component tests. |
| AC3 | Пользователь с действующим read access открывает деталь проекта со связью | Полное название программы и переход по program_id в «Основное». Component/API tests и browser fixture. |
| AC4 | Нет связи или имя отсутствует в legacy payload | Нет выдуманной программы или ошибок; прежние статус, доступ и маршрут работают. Component/API tests. |
| AC5 | У проекта несколько legacy-связей или программа переименована | Имя соответствует той же минимальной по PK связи, что статус и IDs; чтение не создаёт дополнительных запросов. Backend contract tests. |
| AC6 | Длинные имена/названия, ширина 320 и desktop | Нет наложений, размеры карточки/CTA сохранены; название на детали переносится. Browser geometry checks. |

## План реализации и источники

1. API: расширить `PartnerProgramProjectSerializer`, проверить parity list/detail, rename, выбор связи и существующие permissions/query-count tests (AC3–AC5).
2. Angular: DTO/model; `InfoCardComponent` и fixtures; поле в `ProjectsLeftSideComponent` (AC1–AC4).
3. Выполнить targeted tests, Angular build, проверки изменённых TS/SCSS и browser fixture реальных компонентов на representative widths (AC6).
4. Проверить финальный diff и подготовить отдельные API/Angular PR в dev.

Визуальные источники: утверждённый макет текущего чата, Angular `_colors.scss`/Mont, существующий `InfoCard` и `ProjectsLeftSide`, `procollab-design-system-v1/docs/design-system/components/status.md`. Новые DS tokens/components не вводятся. Дополнение имени использует существующее metadata field и сохраняет композицию, поэтому не является отдельным редизайном.

Не проверяется в этом scope: live production/deployment и изменение правил редактирования/сдачи. Loading/error/empty сохраняются в существующих контейнерах; новая metadata не вызывает дополнительных frontend запросов.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { Resource } from "./resource.model";

export class PartnerProgramInfo {
id!: number;
/** Имя из list/detail API; optional для совместимости со старым сервером. */
name?: string;
programLinkId!: number;
programId!: number;
isSubmitted!: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

export interface PartnerProgramInfoDto {
id: number;
name?: string;
programLinkId: number;
programId: number;
isSubmitted: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ <h3 class="text-body-12 lists__title">основное</h3>
</div>

<ul class="lists__list">
@if (project()?.partnerProgram; as program) {
<li class="lists__item lists__item--program" lang="ru">
<i
appIcon
icon="program"
class="lists__icon"
appWidth="10"
appHeight="10"
appViewBox="0 0 21 16"
aria-hidden="true"
></i>
<div class="lists__item-content">
<span class="text-body-10 lists__label">Программа</span>
@if (program.programId > 0) {
<a
class="text-body-10 lists__value lists__value--link"
[routerLink]="AppRoutes.program.detail(program.programId)"
>{{ program.name?.trim() || "Название не указано" }}</a
>
} @else {
<p class="text-body-10 lists__value">
{{ program.name?.trim() || "Название не указано" }}
</p>
}
</div>
</li>
}
<li class="lists__item">
<i appIcon icon="label" class="lists__icon" appSquare="10"></i>
<div class="lists__item-content">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@
align-items: flex-start;
min-width: 0;

// Длинное имя программы использует всю ширину существующего metadata блока.
&--program {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
gap: 4px 6px;
align-items: center;

.lists__item-content {
display: contents;
}

.lists__value {
grid-column: 1 / -1;
hyphens: auto;
word-break: normal;
}
}

&--status {
padding: 8px;
border: 0.5px solid var(--medium-grey-for-outline);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,36 @@ describe("ProjectsLeftSideComponent", () => {
expect(text).toContain("Иванов Иван");
expect(text).not.toContain(">0<");
});

it("показывает полное имя и открывает именно программу, а не связь или проект", () => {
fixture.componentRef.setInput("project", {
industry: 1,
leader: 42,
partnerProgram: {
name: "Акселератор студенческих технологических проектов",
programId: 12,
programLinkId: 120,
},
});
fixture.detectChanges();
const field = fixture.nativeElement.querySelector(".lists__item--program");
expect(field.textContent).toContain("Программа");
expect(field.querySelector("a").textContent.trim()).toBe(
"Акселератор студенческих технологических проектов",
);
expect(field.querySelector("a").getAttribute("href")).toBe("/office/program/12");
});

it("не добавляет программу без связи и явно обозначает отсутствующее legacy имя", () => {
expect(fixture.nativeElement.querySelector(".lists__item--program")).toBeNull();
fixture.componentRef.setInput("project", {
industry: 1,
leader: 42,
partnerProgram: { programId: 12 },
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector(".lists__item--program").textContent).toContain(
"Название не указано",
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe("Мои проекты: HTTP list → преобразование ко
expect(project).toBeInstanceOf(Project);
expect(project.shortDescription).toBe("Описание проекта");
expect(project.partnerProgram?.isSubmitted).toBe(submitted);
expect(project.partnerProgram?.name).toBe("Программа");
if (submitted !== undefined) {
expect(project.partnerProgram?.programLinkId).toBe(ids.programLinkId);
expect(project.partnerProgram?.programId).toBe(ids.programId);
Expand All @@ -112,16 +113,17 @@ describe("Мои проекты: HTTP list → преобразование ко
it.each([
{ submitted: true, draft: false, status: "Сдан в программу" },
{ submitted: true, draft: true, status: "Сдан в программу" },
{ submitted: false, draft: false, status: "В программе" },
{ submitted: false, draft: true, status: "В программе" },
{ submitted: undefined, draft: false, status: "В программе" },
{ submitted: undefined, draft: true, status: "В программе" },
{ submitted: false, draft: false, status: "Привязан к программе" },
{ submitted: false, draft: true, status: "Привязан к программе" },
{ submitted: undefined, draft: false, status: "Привязан к программе" },
{ submitted: undefined, draft: true, status: "Привязан к программе" },
])("is_submitted=$submitted, draft=$draft: lifecycle и доступ независимы", async item => {
await loadProject(item.submitted, item.draft);
for (const userId of [7, 99, undefined]) {
fixture.componentRef.setInput("loggedUserId", userId);
fixture.detectChanges();
expect(text(".card__status")).toBe(item.status);
expect(text(".card__program-name")).toBe("Программа: Программа");
expect(text(".card__role")).toBe(userId === 7 ? "Лидер" : "Участник");
expect(text(".card__access-label")).toBe(
userId === 7 && item.submitted === false ? "можно редактировать" : "только просмотр",
Expand All @@ -143,7 +145,7 @@ describe("Мои проекты: HTTP list → преобразование ко
});
expect(project.id).toBe(286);
expect(project.draft).toBe(true);
expect(text(".card__status")).toBe("В программе");
expect(text(".card__status")).toBe("Привязан к программе");
expect(fixture.nativeElement.querySelectorAll(".card__status")).toHaveLength(1);
expect(text(".card__role")).toBe("Лидер");
expect(text(".card__access-label")).toBe("можно редактировать");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@
<a
class="card__project-link"
[routerLink]="AppRoutes.projects.detail(info()?.id)"
[attr.aria-label]="'Открыть проект «' + info()?.name + '»'"
[attr.aria-label]="
'Открыть проект «' +
info()?.name +
'»' +
(programName() ? '. Программа: ' + programName() : '')
"
>
<ng-container *ngTemplateOutlet="cardContent"></ng-container>
</a>
Expand All @@ -57,6 +62,25 @@
<div class="card__content" [class.card__content--empty]="appereance() === 'empty'">
@if (myProjectPresentation(); as presentation) {
<p [class]="'card__context card__status card__status--' + presentation.lifecycle">
@if (presentation.lifecycle === "program") {
<i
appIcon
icon="program"
appWidth="12"
appHeight="12"
appViewBox="0 0 21 16"
aria-hidden="true"
></i>
} @else if (presentation.lifecycle === "submitted") {
<i
appIcon
icon="lock"
appWidth="12"
appHeight="12"
appViewBox="0 0 11 12"
aria-hidden="true"
></i>
}
{{ presentation.statusLabel }}
</p>
}
Expand All @@ -67,6 +91,9 @@
} @else {
<ng-container *ngTemplateOutlet="regularCardHeader"></ng-container>
}
@if (programName(); as name) {
<p class="card__program-name" [title]="'Программа: ' + name">Программа: {{ name }}</p>
}
</div>
</div>

Expand Down Expand Up @@ -136,6 +163,7 @@
<div
class="card__description"
[class.card__description--project]="type() === 'projects'"
[class.card__description--with-program]="programName() !== null"
[style.max-height]="
isProjectCard() || type() === 'invite' ? null : type() === 'members' ? '50px' : '24px'
"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,26 @@
line-height: 12px;
-webkit-line-clamp: 2;
}

&--with-program {
flex-basis: 12px;
height: 12px;
min-height: 12px;

p {
-webkit-line-clamp: 1;
}
}
}

.card__program-name {
width: 100%;
overflow: hidden;
font-size: 9px;
line-height: 12px;
color: var(--grey-for-text);
text-overflow: ellipsis;
white-space: nowrap;
}

.card__footer {
Expand Down Expand Up @@ -464,6 +484,15 @@
}

&__status {
gap: 4px;
padding-inline: 5px;
color: var(--black);

i {
display: flex;
flex-shrink: 0;
}

&::before {
flex: 0 0 6px;
width: 6px;
Expand All @@ -479,13 +508,18 @@
}

&--program {
color: var(--accent-dark);
background: var(--accent-light);
&::before {
display: none;
}
}

&--submitted {
color: var(--gold-dark);
background: var(--gold-light);
color: var(--green-dark);
background: var(--green-light);

&::before {
display: none;
}
}
}
}
Expand Down
Loading