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

# Вакансии проекта: активные и архивные

Дизайн согласован пользователем 21.10.2026 сообщением «по размерам UI KIT, 26–27 px … можно в дев».
[Figma](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=776-5414).
Высота обеих вкладок — 27 px, без общей серой подложки; Mont в приложении.

## Исходное и целевое поведение

Angular dev cc95cda8: общий список вакансий, native confirm удаления, ошибка API не показывается.
API dev 1c424ff: PATCH is_active существует, но не завершает ожидающие отклики; DELETE не защищает историю.
В Figma зафиксированы два списка, перенос, возобновление набора и модалки с последствиями.
Архив соответствует is_active=false, отдельная сущность не нужна.
В API добавлен внутренний resume_on_publish для отличия архива от временного скрытия вместе с черновиком.
Создание и редактирование сохраняют существующую форму, навыки и серверные проверки.

## Роли и правила

Изменения доступны руководителю проекта и администрации; сервер повторно проверяет право под блокировкой.
Закрытие отклоняет только ожидающие отклики с email-уведомлением после commit. История сохраняется.
Возобновление допустимо только для опубликованного публичного проекта; прежние решения не меняются.
По ответу пользователя 21.10.2026 архивные вакансии остаются в архиве до ручного возврата,
в том числе при повторной публикации проекта. Новые вакансии черновика публикуются вместе с проектом.
Наличие любых откликов запрещает удаление, включая архивные вакансии.
Блокировки согласованы с откликами: Project → Vacancy → VacancyResponse.

## Контракт

PATCH /vacancies/{id}/ с is_active=false/true, DELETE /vacancies/{id}/ сохраняются.
Добавляется readonly total_response_count во все представления вакансии;
response_count сохраняет прежнюю семантику ожидающих откликов.
DELETE с историей: 400 с code=vacancy_has_responses и detail; чужой объект — прежний отказ permissions.
PATCH закрытия атомарен с завершением откликов. Запрос без is_active не меняет жизненный цикл.
Frontend использует существующий UpdateVacancyUseCase и не заменяет server-side checks.

## Acceptance criteria

- AC1: руководитель переключает активные/архивные; счетчики соответствуют данным проекта, вкладки высотой 27 px.
- AC2: после подтвержденного успешного закрытия карточка переходит в архив; до успеха список не меняется.
- AC3: возобновление возвращает карточку в активные; прежние решения сохраняются; непубличный/черновой проект получает объяснение отказа.
- AC4: удаление без откликов требует подтверждения и обновляет список только после успеха.
- AC5: с любыми откликами удаление запрещено и через прямой API; UI объясняет причину и предлагает архив для активной вакансии.
- AC6: ошибки оставляют модалку открытой, повтор доступен, двойная отправка блокируется. Ошибочный запрос не отклоняет отклики и не удаляет навыки.
- AC7: пустые списки, неизвестное число откликов, мобильный экран 320/390, keyboard tabs, modal Escape/trap/return focus не теряют контекст и данные формы.
- AC8: повторная публикация проекта сохраняет архив, дату закрытия и историю. Ручной возврат разрешен после публикации; новые вакансии черновика продолжают публиковаться вместе с проектом.

## План и границы

1. API: атомарный lifecycle, защита удаления, общий счетчик истории, regression tests.
2. Angular: состояния действий в фасаде, фильтрация и вкладки, существующие Card/Modal/Button/State, интеграция с use cases.
3. Проверки: API/Angular targeted tests, build, browser fixtures для desktop/mobile и ошибок, review от AC, связанные PR в dev.

Backend с миграцией vacancy.0011 должен попасть в dev первым. PR frontend зависит от backend.
Старые закрытые вакансии, в том числе в черновиках, остаются закрытыми: причина прежнего закрытия не сохранялась.
Миграция добавляет внутренний флаг с default=false и не меняет статусы/даты; нужные старые записи можно вернуть вручную.
Изменения React, production, глобального UI Kit и соседних страниц не входят в задачу.
Данные browser fixtures не подтверждают работу deployed API; это отдельное ограничение отчета.
Binary file added docs/vacancy-archive/archive-modal-1440.png
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/vacancy-archive/archive-modal-390.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
218 changes: 218 additions & 0 deletions docs/vacancy-archive/browser-acceptance.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/** @format */

const { chromium } = require(process.env.PLAYWRIGHT_MODULE || "playwright");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const output = process.env.QA_OUTPUT || "tmp/vacancy-archive-qa";
fs.mkdirSync(output, { recursive: true });

(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROME_PATH,
});
const page = await browser.newPage({ hasTouch: true });
const errors = [];
page.on("pageerror", error => errors.push(error.message));
let checks = 0;
const check = (value, message) => {
assert.ok(value, message);
checks++;
};
const root = "http://127.0.0.1:4371/office/projects/5/edit?archive";
const open = async (width = 1440, stress = false) => {
await page.setViewportSize({ width, height: 1000 });
await page.goto(root + (stress ? "&stress" : ""));
await page.waitForSelector("app-vacancy-card");
await page.evaluate(() => document.fonts.ready);
};
const confirm = () => page.getByRole("dialog");
const calls = () => page.evaluate(() => window.__vacancyPreview.lifecycle.calls);
const succeed = () => page.evaluate(() => window.__vacancyPreview.lifecycle.succeed());
const fail = (status, code, detail) =>
page.evaluate(
([s, c, d]) => window.__vacancyPreview.lifecycle.fail(s, c, d),
[status, code, detail],
);
for (const width of [1440, 768, 390, 320]) {
await open(width, width === 320);
const geometry = await page.getByRole("tab").evaluateAll(nodes =>
nodes.map(n => ({
height: n.getBoundingClientRect().height,
right: n.getBoundingClientRect().right,
width: n.getBoundingClientRect().width,
})),
);
check(
geometry.every(r => r.height === 27),
`tabs are 27px at ${width}`,
);
check(
geometry.every(r => r.right <= width + 1),
`tabs within viewport ${width}`,
);
check(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
`no horizontal overflow ${width}`,
);
check(
await page.evaluate(() =>
[...document.fonts].some(f => /Mont/.test(f.family) && f.status === "loaded"),
),
"Mont is loaded",
);
check((await page.getByRole("tab", { name: "Активные · 2" }).count()) === 1, "active count");
check((await page.getByRole("tab", { name: "Архивные · 2" }).count()) === 1, "archived count");
const active = page.getByRole("tab", { name: "Активные · 2" });
await active.focus();
if (width <= 390) {
const bounds = await page.locator("#vacancy-tab-archived").boundingBox();
await page.touchscreen.tap(bounds.x + bounds.width / 2, bounds.y + bounds.height + 4);
await page.locator('#vacancy-tab-archived[aria-selected="true"]').waitFor();
check(true, "touch target extends beyond the 27px surface");
await page.locator("#vacancy-tab-archived").press("Home");
await page.locator('#vacancy-tab-active[aria-selected="true"]').waitFor();
}
await active.press("ArrowRight");
await page.locator('#vacancy-tab-archived[aria-selected="true"]').waitFor();
check(
(await page.getByRole("tab", { name: "Архивные · 2" }).getAttribute("aria-selected")) ===
"true",
"keyboard selection",
);
check(
(await page.getByText("Вернуть в активные", { exact: true }).count()) === 2,
"restore actions",
);
await page.getByRole("tab", { name: "Архивные · 2" }).press("Home");
await page.locator('#vacancy-tab-active[aria-selected="true"]').waitFor();
const card = page.locator("app-vacancy-card").first();
await card.getByRole("button", { name: "Удалить", exact: true }).click();
await confirm().waitFor();
check(
await confirm()
.innerText()
.then(t => t.includes("нельзя удалить") && t.includes("Ожидающие решения")),
"delete restriction and consequences",
);
check(
await confirm().evaluate(n => {
const r = n.getBoundingClientRect();
return r.x >= 0 && r.right <= innerWidth + 1;
}),
`modal fits ${width}`,
);
await page.screenshot({ path: `${output}/archive-modal-${width}.png`, fullPage: true });
await page.keyboard.press("Escape");
await confirm().waitFor({ state: "detached" });
check((await calls()) === 0, "cancel never calls API");
}
await open();
await page
.locator("app-vacancy-card")
.first()
.getByRole("button", { name: "В архив", exact: true })
.click();
await confirm().getByRole("button", { name: "Перенести в архив", exact: true }).click();
check((await calls()) === 1, "archive calls API once");
check(
(await page.getByRole("tab", { name: "Активные · 2" }).count()) === 1,
"count waits for API",
);
await fail(500);
await confirm().getByRole("alert").waitFor();
check(
(await page.getByRole("tab", { name: "Архивные · 2" }).count()) === 1,
"failure preserves lists",
);
await confirm().getByRole("button", { name: "Перенести в архив", exact: true }).click();
await succeed();
await confirm().waitFor({ state: "detached" });
await page.getByRole("tab", { name: "Активные · 1" }).waitFor();
check(
(await page.getByRole("tab", { name: "Архивные · 3" }).count()) === 1,
"archive counts after success",
);
check(
await page.locator("#vacancy-tab-active").evaluate(n => document.activeElement === n),
"focus returns to surviving tab",
);
await page.getByRole("tab", { name: "Архивные · 3" }).click();
const restored = page.locator("app-vacancy-card").first();
await restored.getByRole("button", { name: "Вернуть в активные", exact: true }).click();
check(
await confirm()
.innerText()
.then(t => t.includes("Отклоненные отклики не вернутся")),
"restore preserves past decisions",
);
await confirm().getByRole("button", { name: "Вернуть в активные", exact: true }).click();
const unpublished = "Возобновить набор можно только в опубликованном публичном проекте.";
await fail(400, undefined, unpublished);
await confirm().getByRole("alert").waitFor();
check(
(await confirm().getByRole("alert").innerText()) === unpublished,
"server reason is visible",
);
check(
(await page.getByRole("tab", { name: "Архивные · 3" }).count()) === 1,
"denial keeps archive",
);
await confirm().getByRole("button", { name: "Вернуть в активные", exact: true }).click();
await succeed();
await confirm().waitFor({ state: "detached" });
check(
(await page.getByRole("tab", { name: "Активные · 2" }).count()) === 1,
"restore active count",
);
await page.getByRole("tab", { name: "Активные · 2" }).click();
await page
.locator("app-vacancy-card")
.filter({ has: page.getByRole("heading", { name: "QA", exact: true }) })
.getByRole("button", { name: "Удалить", exact: true })
.click();
await confirm().getByRole("button", { name: "Удалить вакансию", exact: true }).click();
await fail(400, "vacancy_has_responses");
await confirm().getByRole("heading", { name: "Вакансию нельзя удалить", exact: true }).waitFor();
check(
(await confirm()
.getByRole("heading", { name: "Вакансию нельзя удалить", exact: true })
.count()) === 1,
"new response race is visible",
);
await confirm().getByRole("button", { name: "Отмена", exact: true }).click();
await confirm().waitFor({ state: "detached" });
await page
.locator("app-vacancy-card")
.filter({ has: page.getByRole("heading", { name: "QA", exact: true }) })
.getByRole("button", { name: "Удалить", exact: true })
.click();
await confirm().getByRole("button", { name: "Удалить вакансию", exact: true }).click();
await succeed();
await confirm().waitFor({ state: "detached" });
check((await page.getByRole("tab", { name: "Активные · 1" }).count()) === 1, "deletion count");
await page.evaluate(() => window.__vacancyPreview.projectUi.applySetVacancies([]));
await page.getByText("Нет активных вакансий", { exact: true }).waitFor();
await page.getByRole("tab", { name: "Архивные · 0" }).click();
await page.getByText("В архиве пока пусто", { exact: true }).waitFor();
checks += 2;
check(errors.length === 0, `no browser errors: ${errors.join("; ")}`);
fs.writeFileSync(
`${output}/browser-results.json`,
JSON.stringify(
{
checks,
errors,
widths: [1440, 768, 390, 320],
scope: "local Angular browser fixtures; no live API mutations",
},
null,
2,
),
);
await browser.close();
console.log(JSON.stringify({ checks, errors }));
})().catch(error => {
console.error(error);
process.exit(1);
});
6 changes: 6 additions & 0 deletions docs/vacancy-archive/browser-results.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"checks": 60,
"errors": [],
"widths": [1440, 768, 390, 320],
"scope": "local Angular browser fixtures; no live API mutations"
}
Loading