diff --git a/docs/feature-specs/vacancy-archive-20261021.md b/docs/feature-specs/vacancy-archive-20261021.md new file mode 100644 index 000000000..a1ddb0767 --- /dev/null +++ b/docs/feature-specs/vacancy-archive-20261021.md @@ -0,0 +1,58 @@ + + +# Вакансии проекта: активные и архивные + +Дизайн согласован пользователем 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; это отдельное ограничение отчета. diff --git a/docs/vacancy-archive/archive-modal-1440.png b/docs/vacancy-archive/archive-modal-1440.png new file mode 100644 index 000000000..0a453baf8 Binary files /dev/null and b/docs/vacancy-archive/archive-modal-1440.png differ diff --git a/docs/vacancy-archive/archive-modal-390.png b/docs/vacancy-archive/archive-modal-390.png new file mode 100644 index 000000000..09942f3eb Binary files /dev/null and b/docs/vacancy-archive/archive-modal-390.png differ diff --git a/docs/vacancy-archive/browser-acceptance.cjs b/docs/vacancy-archive/browser-acceptance.cjs new file mode 100644 index 000000000..22ad6c6d2 --- /dev/null +++ b/docs/vacancy-archive/browser-acceptance.cjs @@ -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); +}); diff --git a/docs/vacancy-archive/browser-results.json b/docs/vacancy-archive/browser-results.json new file mode 100644 index 000000000..883b2ee7d --- /dev/null +++ b/docs/vacancy-archive/browser-results.json @@ -0,0 +1,6 @@ +{ + "checks": 60, + "errors": [], + "widths": [1440, 768, 390, 320], + "scope": "local Angular browser fixtures; no live API mutations" +} diff --git a/docs/vacancy-archive/verification.md b/docs/vacancy-archive/verification.md new file mode 100644 index 000000000..6ea1ecf60 --- /dev/null +++ b/docs/vacancy-archive/verification.md @@ -0,0 +1,60 @@ + + +# Проверка архива вакансий — 21.10.2026 + +Дизайн: [согласованный макет](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=776-5414). +Вкладки в Figma и Angular имеют высоту 27 px, без общей серой подложки. +Использованы существующие Card, Button, Modal, State, VacancySkills и VacancyStatus; Mont загружен в браузере. + +## Выполненные проверки + +| Уровень | Evidence | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Source | UpdateVacancyUseCase → PATCH /vacancies/{id}/, DeleteVacancyUseCase → DELETE. Состояние списка меняется после успешного ответа; форма не сбрасывается при изменении соседней карточки. | +| Angular tests | `npm run test:ci -- projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.spec.ts projects/social_platform/src/app/api/project/facades/edit/ui/project-vacancy-ui.service.spec.ts projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.spec.ts` — 3 файла, 46 тестов passed. | +| Build | `npm run build:pr` — exit 0. Preview build с существующим entry point `docs/vacancy-redesign/preview/main.ts` — exit 0. Sass deprecation warnings относятся к существующим стилям. | +| Browser | `node docs/vacancy-archive/browser-acceptance.cjs` с локальным preview на 4371 — 60 проверок, JS errors []. Ширины 1440/768/390/320; Mont; touch; tabs keyboard; модалки; счетчики; отмена; busy; повтор после ошибки; появление отклика перед удалением; пустые списки; объяснение отказа возврата в непубличном/черновом проекте. | +| Static | ESLint измененных TS и Stylelint двух измененных SCSS — exit 0. | +| API | Связанная API-ветка от dev 1c424ff: `manage.py test vacancy.tests projects.tests.test_project_signals projects.tests.test_program_project_detail_access --noinput --keepdb` — 119 tests passed на PostgreSQL 18.1, Python 3.11.15 / Django 4.2.11. Models check и makemigrations --check --dry-run — exit 0; миграция vacancy.0011 проверена upgrade-тестом на старых записях. | + +[Результаты браузера](browser-results.json), [desktop](archive-modal-1440.png), [mobile](archive-modal-390.png). +Browser использует fixtures и не выполняет мутации live API или отправку писем. + +## Requirement audit + +| AC | Verdict | Evidence | +| --- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AC1 | PASS | height=27, счетчики и фильтрация проверены в браузере на четырех ширинах; геометрия карточек на desktop — две колонки по 554 px в preview. | +| AC2 | PASS | API-тест атомарного закрытия и уведомления ожидающих; browser проверяет ожидание ответа и успешный перенос. | +| AC3 | PASS | Сервер отклоняет возобновление в draft/private проекте; история решений сохраняется. UI показывает причину из API и для строки, и для массива; browser проверяет отказ и повтор. | +| AC4 | PASS | Подтверждение и успешное удаление проверены API, unit и browser. | +| AC5 | PASS | API запрещает удаление во всех состояниях откликов и обеих вкладках; UI предлагает архив. | +| AC6 | PASS | Ошибка и повтор, защита двойного запроса, появление отклика после открытия модалки; invalid skills не закрывают вакансию и не удаляют навыки. | +| AC7 | PASS в проверенном контуре | Empty, unknown count, 320/390, keyboard tabs, Escape и focus после переноса, сохранение формы проверены. Полный accessibility audit не выполнялся. | +| AC8 | PASS | API и signal tests: архив сохраняется при повторной публикации до ручного возврата; даты/решения сохранены. Новая вакансия черновика публикуется; миграция не меняет старые статусы/даты. | + +## Review и внедрение + +По итоговому diff проверены permissions, порядок блокировок Project → Vacancy → VacancyResponse, +сохранение response_count как числа ожидающих, отсутствие N+1 в каталоге, readonly total_response_count, +контекст проекта, server errors, Mont, фокус и отсутствие переполнения. +Кодовые замечания в проверенном контуре исправлены; связанный frontend/API diff требует совместного внедрения. + +Пользователь подтвердил: «Оставлять архивные вакансии в архиве до ручного возврата». +Сервер отличает архив от вакансий, временно скрытых вместе с черновиком. Архив при публикации не меняется. +Миграция vacancy.0011 добавляет внутренний resume_on_publish с default=false; старые закрытые записи, +включая черновики, сохраняются в архиве, поскольку причина закрытия ранее не фиксировалась. + +Не проверены deployed dev, реальная доставка email и полный frontend suite. Эти проверки не объявляются пройденными. +Полный backend suite подтвержден [PostgreSQL CI](https://github.com/PROCOLLAB-github/api/actions/runs/39598299814) +на финальном runtime SHA c92d997: 1296 tests, OK; отдельные 39 locking/response tests — OK; +миграции применены к пустой PostgreSQL 15. Порядок внедрения: API + миграция → Angular → ручная dev acceptance. + +Дополнительный локальный полный прогон с --keepdb завершился `1296 tests, 1 error` в неизмененном +`users.tests.test_university_import`: в сохраненной базе после предыдущих TransactionTestCase не осталось +справочника, который этот тест ожидает от data migration. Отдельный повтор этого теста на новой изолированной +PostgreSQL прошел (`1 test, OK`); полный CI на чистой базе прошел (`1296 tests, OK`). +Локальный полный прогон не объявляется успешным. Оба остальных API CI checks (Tests и Lint) — success. + +Итог review: требования AC1–AC8 подтверждены в указанном контуре; открытых BLOCKER/MAJOR нет. +Оба PR направлены в dev и готовы к merge в указанном порядке; merge/deploy выполняет пользователь. diff --git a/docs/vacancy-redesign/preview/main.ts b/docs/vacancy-redesign/preview/main.ts index c28d91bc7..9ca96b2af 100644 --- a/docs/vacancy-redesign/preview/main.ts +++ b/docs/vacancy-redesign/preview/main.ts @@ -46,6 +46,7 @@ import { ok, fail } from "@domain/shared/result.type"; import { success } from "@domain/shared/async-state"; const params = new URLSearchParams(location.search); +const archiveMode = params.has("archive"); const stress = params.has("stress"); const skills = [ "TypeScript", @@ -78,6 +79,7 @@ const vacancy = (id: number, overrides: Partial = {}) => id, role: "Frontend-разработчик Angular", isActive: true, + ...(archiveMode ? { totalResponseCount: id === 10 ? 5 : id === 12 ? 8 : 0 } : {}), project, requiredSkills: skills, description: @@ -159,6 +161,9 @@ let responses = [response(1, null), response(2, true), response(3, false)]; let request = new Subject(); let calls = 0; let lastCreatePayload: any; +let actionRequest = new Subject(); +let actionCalls = 0; +let lastAction: { id: number; payload?: Partial } | null = null; const previewEditProvider = { provide: ProjectsEditInfoService, useFactory: () => { @@ -342,8 +347,14 @@ TestBed.configureTestingModule({ { provide: UpdateVacancyUseCase, useValue: { - execute: (id: number, payload: any) => - of( + execute: (id: number, payload: any) => { + if (archiveMode && "isActive" in payload) { + actionCalls++; + lastAction = { id, payload }; + actionRequest = new Subject(); + return actionRequest; + } + return of( ok( vacancy(id, { ...payload, @@ -352,10 +363,22 @@ TestBed.configureTestingModule({ ), }), ), - ), + ); + }, + }, + }, + { + provide: DeleteVacancyUseCase, + useValue: { + execute: (id: number) => { + if (!archiveMode) return of(ok(undefined)); + actionCalls++; + lastAction = { id }; + actionRequest = new Subject(); + return actionRequest; + }, }, }, - { provide: DeleteVacancyUseCase, useValue: { execute: () => of(ok(undefined)) } }, { provide: GetVacanciesUseCase, useValue: { execute: () => of(ok(vacancies)) } }, { provide: SendVacancyResponseUseCase, useValue: { execute: () => of(ok(undefined)) } }, { provide: GetVacancyResponsesUseCase, useValue: { execute: () => of(ok(responses)) } }, @@ -416,6 +439,37 @@ TestBed.compileComponents().then(async () => { list, detail, projectUi, + lifecycle: { + get calls() { + return actionCalls; + }, + succeed() { + const source = projectUi.vacancies().find(v => v.id === lastAction?.id); + actionRequest.next( + ok( + lastAction?.payload + ? { + ...source, + ...lastAction.payload, + datetimeClosed: lastAction.payload.isActive ? null : "2026-10-21T12:00:00Z", + } + : undefined, + ), + ); + actionRequest.complete(); + }, + fail(status = 500, code?: string, isActiveError?: string) { + actionRequest.next( + fail({ + cause: { + status, + error: isActiveError ? { is_active: isActiveError } : code ? { code: [code] } : {}, + }, + }), + ); + actionRequest.complete(); + }, + }, rootSearch: TestBed.inject(SearchesService), fill() { TestBed.inject(ToggleFieldsInfoService).showFields(); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.spec.ts index c94cc3b2d..02c9b44b3 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.spec.ts @@ -22,6 +22,7 @@ describe("ProjectVacancyService", () => { let uiService: ProjectVacancyUIService; let postVacancy: ReturnType; let updateVacancy: ReturnType; + let deleteVacancy: ReturnType; let editIndex: WritableSignal; const skill = { @@ -34,6 +35,7 @@ describe("ProjectVacancyService", () => { beforeEach(() => { postVacancy = vi.fn(() => of(ok(Object.assign(new Vacancy(), { id: 10 })))); updateVacancy = vi.fn(() => of(ok(Object.assign(new Vacancy(), { id: 10 })))); + deleteVacancy = vi.fn(() => of(ok(undefined))); editIndex = signal(null); TestBed.configureTestingModule({ @@ -56,7 +58,7 @@ describe("ProjectVacancyService", () => { }, { provide: PostVacancyUseCase, useValue: { execute: postVacancy } }, { provide: UpdateVacancyUseCase, useValue: { execute: updateVacancy } }, - { provide: DeleteVacancyUseCase, useValue: { execute: vi.fn() } }, + { provide: DeleteVacancyUseCase, useValue: { execute: deleteVacancy } }, ], }); @@ -76,6 +78,124 @@ describe("ProjectVacancyService", () => { }); } + function seedActionVacancy(isActive = true, totalResponseCount = 0): Vacancy { + const vacancy = Object.assign(new Vacancy(), { + id: 11, + role: "Тестировщик платформы", + isActive, + totalResponseCount, + }); + uiService.applySetVacancies([vacancy]); + return vacancy; + } + + it("фильтрует списки и меняет статус только после ответа, защищая двойное подтверждение", () => { + const vacancy = seedActionVacancy(true, 3); + const request = new Subject(); + updateVacancy.mockReturnValue(request); + uiService.vacancyForm.patchValue({ role: "Несохраненный текст" }); + service.changeVacancyStatus(11); + service.confirmAction(); + service.confirmAction(); + service.closeAction(); + expect(updateVacancy).toHaveBeenCalledTimes(1); + expect(updateVacancy).toHaveBeenCalledWith(11, { isActive: false }); + expect(service.activeVacancies()).toHaveLength(1); + expect(service.archivedVacancies()).toHaveLength(0); + expect(service.action()).not.toBeNull(); + request.next(ok({ ...vacancy, isActive: false })); + request.complete(); + expect(service.activeVacancies()).toHaveLength(0); + expect(service.archivedVacancies()).toHaveLength(1); + expect(service.action()).toBeNull(); + expect(service.actionBusy()).toBe(false); + expect(uiService.vacancyForm.value.role).toBe("Несохраненный текст"); + service.activeTab.set("archived"); + expect(service.visibleVacancies()).toHaveLength(1); + }); + + it("предлагает архив вместо удаления с обработанными откликами", () => { + seedActionVacancy(true, 2); + service.removeVacancy(11); + expect(service.action()?.kind).toBe("blocked-delete"); + service.confirmAction(); + expect(deleteVacancy).not.toHaveBeenCalled(); + expect(updateVacancy).toHaveBeenCalledWith(11, { isActive: false }); + }); + + it("закрывает объяснение удаления архивной вакансии без запроса", () => { + seedActionVacancy(false, 2); + service.removeVacancy(11); + expect(service.actionLabel()).toBe("Понятно"); + service.confirmAction(); + expect(deleteVacancy).not.toHaveBeenCalled(); + expect(updateVacancy).not.toHaveBeenCalled(); + expect(service.action()).toBeNull(); + }); + + it("показывает отказ сервера при отклике, появившемся после открытия модалки", () => { + seedActionVacancy(); + deleteVacancy.mockReturnValue( + of(fail({ cause: { status: 400, error: { code: ["vacancy_has_responses"] } } })), + ); + service.removeVacancy(11); + service.confirmAction(); + expect(service.action()?.kind).toBe("blocked-delete"); + expect(uiService.vacancies()).toHaveLength(1); + expect(service.actionSuccess()).toBe(""); + expect(service.actionBusy()).toBe(false); + }); + + it.each(["result", "network"])( + "сохраняет карточку и позволяет повторить удаление после %s", + kind => { + seedActionVacancy(); + deleteVacancy.mockReturnValue( + kind === "result" + ? of(fail({ cause: { status: 500 } })) + : throwError(() => new Error("network")), + ); + service.removeVacancy(11); + expect(deleteVacancy).not.toHaveBeenCalled(); + service.confirmAction(); + expect(service.actionError()).toContain("повторите"); + expect(service.action()).not.toBeNull(); + expect(uiService.vacancies()).toHaveLength(1); + deleteVacancy.mockReturnValue(of(ok(undefined))); + service.confirmAction(); + expect(uiService.vacancies()).toHaveLength(0); + expect(service.actionSuccess()).toBe("Вакансия удалена"); + }, + ); + + it.each(["Сначала опубликуйте проект", ["Сначала опубликуйте проект"]])( + "возобновляет набор и показывает объяснение непубличного проекта: %s", + detail => { + const vacancy = seedActionVacancy(false, 5); + updateVacancy.mockReturnValue( + of(fail({ cause: { status: 400, error: { is_active: detail } } })), + ); + service.changeVacancyStatus(11); + service.confirmAction(); + expect(service.actionError()).toBe("Сначала опубликуйте проект"); + expect(service.archivedVacancies()).toHaveLength(1); + updateVacancy.mockReturnValue(of(ok({ ...vacancy, isActive: true }))); + service.confirmAction(); + expect(updateVacancy).toHaveBeenLastCalledWith(11, { isActive: true }); + expect(service.activeVacancies()).toHaveLength(1); + }, + ); + + it("сохраняет идентичность редактируемой карточки при удалении соседней", () => { + const first = seedActionVacancy(); + uiService.applySetVacancies([first, Object.assign(new Vacancy(), { id: 12, isActive: true })]); + editIndex.set(1); + service.removeVacancy(11); + service.confirmAction(); + expect(editIndex()).toBe(0); + expect(uiService.vacancies()[0].id).toBe(12); + }); + it("добавляет вакансию и открывает подтверждение только после успешного ответа", () => { const request = new Subject(); postVacancy.mockReturnValue(request); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.ts index 8bb5c051b..ad24dff08 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.ts @@ -1,6 +1,6 @@ /** @format */ -import { DestroyRef, inject, Injectable } from "@angular/core"; +import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core"; import { Validators } from "@angular/forms"; import { ValidationService } from "@corelib"; import { ProjectVacancyUIService } from "./ui/project-vacancy-ui.service"; @@ -11,6 +11,15 @@ import { PostVacancyUseCase } from "../../../vacancy/use-cases/post-vacancy.use- import { DeleteVacancyUseCase } from "../../../vacancy/use-cases/delete-vacancy.use-case"; import { failure, initial, loading } from "@domain/shared/async-state"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize, Observable } from "rxjs"; +import { Result } from "@domain/shared/result.type"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; + +type VacancyActionKind = "archive" | "restore" | "delete" | "blocked-delete"; +interface VacancyAction { + kind: VacancyActionKind; + vacancy: Vacancy; +} /** Сервис для управления вакансиями проекта. */ @Injectable() @@ -31,6 +40,40 @@ export class ProjectVacancyService { private readonly vacancyIsSubmitting = this.projectVacancyUIService.vacancyIsSubmitting; private readonly vacancySubmitInitiated = this.projectVacancyUIService.vacancySubmitInitiated; + readonly action = signal(null); + readonly actionBusy = signal(false); + readonly actionError = signal(null); + readonly actionSuccess = signal(""); + readonly activeTab = signal<"active" | "archived">("active"); + readonly activeVacancies = computed(() => + this.projectVacancyUIService.vacancies().filter(v => v.isActive), + ); + readonly archivedVacancies = computed(() => + this.projectVacancyUIService.vacancies().filter(v => !v.isActive), + ); + readonly visibleVacancies = computed(() => + this.activeTab() === "active" ? this.activeVacancies() : this.archivedVacancies(), + ); + readonly actionTitle = computed(() => { + switch (this.action()?.kind) { + case "archive": + return "Перенести вакансию в архив?"; + case "restore": + return "Вернуть вакансию в активные?"; + case "delete": + return "Удалить вакансию?"; + default: + return "Вакансию нельзя удалить"; + } + }); + readonly actionLabel = computed(() => { + const action = this.action(); + if (action?.kind === "restore") return "Вернуть в активные"; + if (action?.kind === "delete") return "Удалить вакансию"; + if (action?.kind === "blocked-delete" && !action.vacancy.isActive) return "Понятно"; + return "Перенести в архив"; + }); + constructor() { this.vacancyForm .get("skills") @@ -41,7 +84,7 @@ export class ProjectVacancyService { } public submitVacancy(projectId: number) { - if (this.projectVacancyUIService.vacancyIsSubmittingFlag()) return; + if (this.projectVacancyUIService.vacancyIsSubmittingFlag() || this.actionBusy()) return; // Настройка валидаторов для обязательных полей this.vacancyForm.get("role")?.setValidators([Validators.required]); this.vacancyForm.get("skills")?.setValidators([Validators.required]); @@ -126,14 +169,107 @@ export class ProjectVacancyService { } public removeVacancy(vacancyId: number): void { - if (!confirm("Вы точно хотите удалить вакансию?")) return; - this.deleteVacancyUseCase - .execute(vacancyId) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(result => { - if (!result.ok) return; + this.openAction(vacancyId, "delete"); + } + + changeVacancyStatus(vacancyId: number): void { + const vacancy = this.projectVacancyUIService.vacancies().find(v => v.id === vacancyId); + if (vacancy) this.openAction(vacancyId, vacancy.isActive ? "archive" : "restore"); + } - this.projectVacancyUIService.applyRemoveVacancy(vacancyId); + closeAction(): void { + if (this.actionBusy()) return; + this.action.set(null); + this.actionError.set(null); + } + + private openAction(vacancyId: number, kind: VacancyActionKind): void { + if (this.actionBusy() || this.projectVacancyUIService.vacancyIsSubmittingFlag()) return; + const vacancy = this.projectVacancyUIService.vacancies().find(v => v.id === vacancyId); + if (!vacancy) return; + const hasResponses = (vacancy.totalResponseCount ?? vacancy.responseCount ?? 0) > 0; + this.action.set({ kind: kind === "delete" && hasResponses ? "blocked-delete" : kind, vacancy }); + this.actionError.set(null); + this.actionSuccess.set(""); + } + + confirmAction(): void { + const action = this.action(); + if (!action || this.actionBusy() || this.projectVacancyUIService.vacancyIsSubmittingFlag()) + return; + if (action.kind === "blocked-delete" && !action.vacancy.isActive) { + this.closeAction(); + return; + } + const current = this.projectVacancyUIService.vacancies().find(v => v.id === action.vacancy.id); + if (!current) { + this.actionError.set("Вакансия больше недоступна. Обновите страницу проекта."); + return; + } + if ( + action.kind === "delete" && + (current.totalResponseCount ?? current.responseCount ?? 0) > 0 + ) { + this.action.set({ kind: "blocked-delete", vacancy: current }); + return; + } + const kind = action.kind === "blocked-delete" ? "archive" : action.kind; + this.actionBusy.set(true); + this.actionError.set(null); + const request: Observable> = + kind === "delete" + ? this.deleteVacancyUseCase.execute(current.id) + : this.updateVacancyUseCase.execute(current.id, { isActive: kind === "restore" }); + request + .pipe( + finalize(() => this.actionBusy.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: result => { + if (!result.ok) { + this.handleActionError(result.error.cause, kind); + return; + } + if (kind === "delete") this.projectVacancyUIService.applyRemoveVacancy(current.id); + else this.projectVacancyUIService.applyLifecycleVacancy(result.value as Vacancy); + this.actionSuccess.set( + kind === "delete" + ? "Вакансия удалена" + : kind === "restore" + ? "Вакансия возвращена в активные" + : "Вакансия перенесена в архив. История откликов сохранена.", + ); + this.action.set(null); + }, + error: error => this.handleActionError(error, kind), }); } + + private handleActionError(error: unknown, kind: string): void { + const response = error as { status?: number; error?: Record } | null; + const body = response?.error; + const code = body?.["code"]; + if ( + kind === "delete" && + (code === "vacancy_has_responses" || + (Array.isArray(code) && code.includes("vacancy_has_responses"))) + ) { + const current = this.action(); + if (current) this.action.set({ ...current, kind: "blocked-delete" }); + return; + } + const detail = body?.["is_active"]; + this.actionError.set( + response?.status === 403 + ? "Нет доступа к управлению этой вакансией. Обратитесь к руководителю проекта." + : response?.status === 404 + ? "Вакансия больше недоступна. Обновите страницу проекта." + : typeof detail === "string" + ? detail + : Array.isArray(detail) && typeof detail[0] === "string" + ? detail[0] + : "Не удалось выполнить действие. Вакансия не изменена. Проверьте соединение и повторите попытку.", + ); + } } diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/project-vacancy-ui.service.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/project-vacancy-ui.service.ts index d14e349f1..c81125877 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/ui/project-vacancy-ui.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-vacancy-ui.service.ts @@ -154,7 +154,19 @@ export class ProjectVacancyUIService { } applyRemoveVacancy(vacancyId: number): void { + const editIndex = this.projectFormService.editIndex(); + const editedId = editIndex === null ? null : this.vacancies()[editIndex]?.id; this.vacancies.update(list => list.filter(v => v.id !== vacancyId)); + if (editedId === vacancyId) this.applyResetVacancyForm(); + else if (editedId !== null && editedId !== undefined) { + const nextIndex = this.vacancies().findIndex(v => v.id === editedId); + this.projectFormService.editIndex.set(nextIndex >= 0 ? nextIndex : null); + } + } + + applyLifecycleVacancy(vacancy: Vacancy): void { + // Изменение статуса соседней карточки не сбрасывает несохраненную форму. + this.vacancies.update(list => list.map(item => (item.id === vacancy.id ? vacancy : item))); } applySubmitVacancy(vacancy: Vacancy): void { diff --git a/projects/social_platform/src/app/domain/vacancy/vacancy.model.ts b/projects/social_platform/src/app/domain/vacancy/vacancy.model.ts index c6ff05ed0..736f70a18 100644 --- a/projects/social_platform/src/app/domain/vacancy/vacancy.model.ts +++ b/projects/social_platform/src/app/domain/vacancy/vacancy.model.ts @@ -19,6 +19,10 @@ export class Vacancy { specialization?: string; datetimeCreated!: string; datetimeUpdated!: string; + datetimeClosed?: string | null; + /** Ожидающие решения отклики; полная история приходит отдельным полем. */ + responseCount?: number; + totalResponseCount?: number; hasResponded = false; canRespond = false; canManageResponses = false; diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.html index 784a93155..7e2f60509 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.html @@ -1,234 +1,406 @@ -
-
- @if (showInputFields()) { -
- @if (role; as role) { -
- - - @if ((role | controlError: "required") && vacancySubmitInitiated()) { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } - @if (description) { -
- - - @if ((description | controlError: "required") && vacancySubmitInitiated()) { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } - -
-
- @if (requiredExperience; as requiredExperience) { -
- - - @if (requiredExperience | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } - @if (workFormat; as workFormat) { -
- - - @if (workFormat | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- - @if (isCityVisible()) { - @if (city; as city) { -
- - - @if ((city | controlError: "required") && vacancySubmitInitiated()) { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } +
+
+

Вакансии проекта

+

Управляйте набором в команду и сохраняйте историю откликов.

+
+ @if (!showInputFields()) { + + Создать вакансию + + } +
+
+ + +
+

+ {{ + vacancyActions.activeTab() === "active" + ? "Активные вакансии принимают отклики в опубликованном публичном проекте." + : "Набор закрыт. Здесь хранятся вакансии и история откликов." + }} +

+@if (vacancyActions.actionSuccess()) { +

+ {{ vacancyActions.actionSuccess() }} +

+} + +
+
+
+ @if (showInputFields()) { + + @if (role; as role) { +
+ + + @if ((role | controlError: "required") && vacancySubmitInitiated()) { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } + @if (description) { +
+ + + @if ((description | controlError: "required") && vacancySubmitInitiated()) { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
} -
- @if (salary; as salary) { -
- - - @if ((salary | controlError: "required") && vacancySubmitInitiated()) { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } - @if (workSchedule; as workSchedule) { -
- - - @if (workSchedule | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
+
+
+ @if (requiredExperience; as requiredExperience) { +
+ + + @if (requiredExperience | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } + @if (workFormat; as workFormat) { +
+ + + @if (workFormat | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } +
+ + @if (isCityVisible()) { + @if (city; as city) { +
+ + + @if ((city | controlError: "required") && vacancySubmitInitiated()) { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } } -
-
- - @if (skills; as skills) { -
- - @if (skills | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } + + + @if (skills; as skills) { +
+ + @if (skills | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } + + } + + {{ + isEditing() + ? "Сохранить изменения" + : showInputFields() + ? "Добавить вакансию" + : "Создать вакансию" + }} + + + @if (vacancySubmitFailed()) { + + } +
+ +
+
    + @for (vacancy of visibleVacancies(); track vacancy.id) { +
  • + +
  • } - - } - - {{ - isEditing() - ? "Сохранить изменения" - : showInputFields() - ? "Добавить вакансию" - : "Создать вакансию" - }} - - - @if (vacancySubmitFailed()) { +
+
+ + @if (!visibleVacancies().length) { + class="vacancy-empty" + kind="empty" + [title]=" + vacancyActions.activeTab() === 'active' ? 'Нет активных вакансий' : 'В архиве пока пусто' + " + > + {{ + vacancyActions.activeTab() === "active" + ? "Создайте вакансию или верните существующую из архива." + : "Вакансии и история откликов появятся здесь после переноса в архив." + }} + }
+
-
-
    - @for (vacancy of vacancies(); track vacancy.id) { -
  • - -
  • +@if (vacancyActions.action(); as action) { + +
    + +

    {{ vacancyActions.actionTitle() }}

    +
    +

    {{ action.vacancy.role }}

    + @switch (action.kind) { + @case ("restore") { +

    + Вакансия снова будет принимать новые отклики. Возобновить набор можно только в + опубликованном публичном проекте. +

    +

    История и прежние решения сохранятся. Отклоненные отклики не вернутся в ожидание.

    + } + @case ("delete") { +

    + Вакансия будет удалена без возможности восстановления. Если хотите временно завершить + набор, перенесите ее в архив. +

    +

    Удалить вакансию можно только при отсутствии откликов.

    + } + @default { + @if (action.kind === "blocked-delete") { +

    + На вакансию уже есть отклики. Чтобы сохранить историю кандидатов, удаление + недоступно, даже после переноса в архив. +

    + } + @if (action.vacancy.isActive) { +

    + Вы закроете набор. Вакансия перестанет принимать новые отклики. История сохранится, + вернуться к набору можно позже. +

    +

    + Ожидающие решения отклики будут отклонены. Кандидаты получат уведомления по + электронной почте. +

    + } + } + } +
    + @if (vacancyActions.actionError(); as error) { + } -
-
- - @if (!vacancies().length) { - - } -
+
+ Отмена + {{ + vacancyActions.actionBusy() ? "Выполняем…" : vacancyActions.actionLabel() + }} +
+ + +} @if (createdVacancyId(); as vacancyId) { >(ElementRef); private readonly injector = inject(Injector); private readonly skillSearch = viewChild(AutoCompleteInputComponent); private readonly vacancySubmitButton = viewChild>( @@ -112,6 +114,38 @@ export class ProjectVacancyStepComponent { protected readonly vacancySubmitFailed = computed(() => isFailure(this.projectVacancyUIService.vacancyIsSubmitting()), ); + protected readonly vacancyActions = this.projectVacancyInfoService; + protected readonly visibleVacancies = this.projectVacancyInfoService.visibleVacancies; + + constructor() { + effect(() => { + if (!this.vacancyActions.actionSuccess()) return; + afterNextRender( + () => { + this.element.nativeElement + .querySelector('[role="tab"][aria-selected="true"]') + ?.focus(); + }, + { injector: this.injector }, + ); + }); + } + + onVacancyTabKey(event: KeyboardEvent): void { + const keys = ["ArrowLeft", "ArrowRight", "Home", "End"]; + if (!keys.includes(event.key)) return; + event.preventDefault(); + const tab = + event.key === "Home" + ? "active" + : event.key === "End" + ? "archived" + : this.vacancyActions.activeTab() === "active" + ? "archived" + : "active"; + this.vacancyActions.activeTab.set(tab); + this.element.nativeElement.querySelector(`#vacancy-tab-${tab}`)?.focus(); + } protected readonly inlineSkills = this.searchesService.inlineSkills; protected readonly projectId = this.projectsEditInfoService.profileId; diff --git a/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.html b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.html index 2c18bf3a7..3a32309be 100644 --- a/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.html +++ b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.html @@ -5,7 +5,7 @@

{{ item.role }}

@@ -13,10 +13,33 @@

{{ item.role }}

Ищем: {{ item.specialization }}

} + @if (manageLifecycle()) { + + {{ + item.totalResponseCount === undefined + ? "Вакансия и отклики" + : "Отклики · " + item.totalResponseCount + }} + — {{ item.role }} + + @if (!item.isActive && item.datetimeClosed) { +

В архиве с {{ item.datetimeClosed | date: "dd.MM.yyyy" }}

+ } + }
Редактировать + @if (manageLifecycle()) { + + {{ item.isActive ? "В архив" : "Вернуть в активные" }} + + } { let component: VacancyCardComponent; @@ -14,6 +15,7 @@ describe("VacancyCardComponent", () => { await TestBed.configureTestingModule({ imports: [VacancyCardComponent], providers: [ + provideRouter([]), provideNgxMask(), { provide: AuthRepositoryPort, @@ -43,6 +45,27 @@ describe("VacancyCardComponent", () => { }; } + it("показывает архив, историю и действие возврата в управляемой карточке", () => { + fixture.componentRef.setInput("manageLifecycle", true); + fixture.componentRef.setInput("vacancy", { ...createVacancy(false), totalResponseCount: 4 }); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("В архиве"); + expect(fixture.nativeElement.textContent).toContain("Вернуть в активные"); + expect(fixture.nativeElement.querySelector(".vacancy__responses").textContent).toContain("4"); + const changed = vi.fn(); + component.changeStatus.subscribe(changed); + fixture.nativeElement.querySelectorAll(".vacancy__actions button")[1].click(); + expect(changed).toHaveBeenCalledWith(1); + }); + + it("не подменяет неизвестное число откликов нулем", () => { + fixture.componentRef.setInput("manageLifecycle", true); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector(".vacancy__responses").textContent).toContain( + "Вакансия и отклики", + ); + }); + it("should create", () => { expect(component).toBeTruthy(); }); diff --git a/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.ts b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.ts index 28843d704..a3bb8918d 100644 --- a/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.ts +++ b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.ts @@ -8,6 +8,9 @@ import { Vacancy } from "@domain/vacancy/vacancy.model"; import { IconComponent, ButtonComponent } from "@ui/primitives"; import { VacancyStatusComponent } from "../vacancy-status/vacancy-status.component"; import { VacancySkillsComponent } from "../vacancy-skills/vacancy-skills.component"; +import { DatePipe } from "@angular/common"; +import { RouterLink } from "@angular/router"; +import { AppRoutes } from "@api/paths/app-routes"; @Component({ selector: "app-vacancy-card", @@ -19,15 +22,26 @@ import { VacancySkillsComponent } from "../vacancy-skills/vacancy-skills.compone ButtonComponent, VacancyStatusComponent, VacancySkillsComponent, + DatePipe, + RouterLink, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class VacancyCardComponent { readonly vacancy = input(); readonly disabled = input(false); + readonly manageLifecycle = input(false); + readonly changeStatus = output(); + protected readonly AppRoutes = AppRoutes; readonly remove = output(); readonly edit = output(); + onChangeStatus(event: MouseEvent): void { + event.stopPropagation(); + event.preventDefault(); + if (!this.disabled() && this.vacancy()) this.changeStatus.emit(this.vacancy()!.id); + } + onRemove(event: MouseEvent): void { event.stopPropagation(); event.preventDefault();