Compare commits

...

37 Commits

Author SHA1 Message Date
coopops 4a5866ea1b [562-32][@ant] refactor(capital): перестроить layout проекта, компонента и задачи на табы — чтобы освободить рабочую зону от постоянного сайдбара и унифицировать навигацию с empty-state
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:18:19 +00:00
coopops 4b16a18716 chore(release): publish
Release / trigger-mono-docs (push) Has been cancelled
Build bootstrap container / build (push) Has been cancelled
Release / release (push) Has been cancelled
Release / publish-packages (push) Has been cancelled
Release / trigger-coopenomics-docs (push) Has been cancelled
2026-07-23 17:28:42 +00:00
coopops 67121d51cd ci(release): pnpm@10.33.0 везде + skip docs-dispatch без токена
Build bootstrap container / build (push) Has been cancelled
Corepack в docker-сборке mono-base тянул pnpm@9.9.0 из sdk/docs и
падал на HTTP 504 registry.npmjs.org. Выравниваем packageManager на
корневой 10.33.0 (sdk/docs/boot/parser).

trigger-coopenomics-docs: без DOCS_DISPATCH_TOKEN — skip (GitHub-only);
с токеном бьём в Gitea host, не в github.server_url.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 17:23:48 +00:00
coopops 489492695b chore(release): publish
Build bootstrap container / build (push) Successful in 8m20s
Release / release (push) Successful in 1h40m17s
Release / publish-packages (push) Successful in 33m36s
Release / trigger-coopenomics-docs (push) Successful in 5s
Release / trigger-mono-docs (push) Successful in 5s
2026-07-23 16:29:15 +00:00
coopops 757c785ee6 fix(capital): pending_clearance только для appendix status=created
findCreatedByUsernameAndProjectHash снова фильтрует по created —
иначе confirmed-допуск давал «Запрос на рассмотрении» на всех проектах.
Parent lookup для документа компонента: confirmed ?? created (двойной отклик).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:24:38 +00:00
coopops a819b27fbe chore(release): publish
Build bootstrap container / build (push) Successful in 11m3s
Release / release (push) Successful in 1h33m0s
Release / publish-packages (push) Successful in 19m57s
Release / trigger-coopenomics-docs (push) Successful in 29s
Release / trigger-mono-docs (push) Successful in 4s
2026-07-23 11:49:22 +00:00
coopops 2b63f54117 feat(desktop): страница компонента на канон PageTabs + читаемый диалог отклика
Привести ComponentPage к тому же layout, что у проекта (PageTabs, действия в таббаре, без FAB), и исправить белый текст пути проекта в диалоге «Откликнуться на приглашение».

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:46:05 +00:00
ant 8d0e837bb9 Merge pull request 'Канон-перевод UI Благороста (capital) — зонтичный PR' (#124) from feat/capital-canon into dev
Reviewed-on: #124
2026-07-23 11:12:41 +00:00
coopops 70f2e8e38b Merge remote-tracking branch 'origin/dev' into feat/capital-canon
Typecheck / desktop (pull_request) Successful in 29m45s
Typecheck / controller (pull_request) Successful in 30m48s
2026-07-23 09:23:23 +00:00
coopops 5e58c173ee chore(release): publish
Build bootstrap container / build (push) Successful in 3m45s
Release / publish-packages (push) Successful in 29m23s
Release / release (push) Successful in 29m7s
Release / trigger-mono-docs (push) Successful in 2s
Release / trigger-coopenomics-docs (push) Successful in 2s
2026-07-21 20:19:26 +00:00
coopops c1ce7a095d [989-10][@ant] fix: убрать поле СФР и сделать поля организации/подписанта в форме нулёвых отчётов read-only
Поле «Регистрационный номер СФР» в форме ЕФС-1 не использовалось генератором вовсе (в документ и в имя файла идёт только pfrRegNumber) — убрано вместе с ложной проверкой в fss4.generator.ts, требовавшей его заполнения.

Остальные поля секций «Организация»/«Подписант» в ZeroReportEditor.vue (ФИО, СНИЛС, ИНН/КПП/ОКВЭД/ОГРН/ОКТМО, рег. номер ПФР, должность, тип подписанта) всегда приходят из Реквизитов/БД кооператива и правка через этот черновик никуда не сохранялась — редактируемые поля создавали иллюзию, что правка что-то меняет. Переведены в read-only (readonly+disable, как уже сделано для орг-полей в SettingsPage) — правка доступна только на странице Реквизитов. Раздел «Отчётный год/период/корректировка» остался редактируемым — он не приходит из Реквизитов, это per-документ данные.
2026-07-21 20:17:24 +00:00
coopops 995f65f524 [989-10][@ant] fix: убрать лишний поясняющий хинт у поля «Рег. номер ПФР» — по замечанию пользователя ни у одного другого поля в реквизитах/форме ЕФС-1 такого хинта нет, заполняющий и так знает, что это за номер 2026-07-21 19:12:09 +00:00
coopops 5298e55a7b [989-10][@ant] fix: ЕФС-1 (4-ФСС) — использовать рег. номер ПФР, а не СФР, в имени файла и <ЕФС8:РегНомер>
Сторонние бухгалтерские системы (СБИС и др.) при приёме ЕФС-1 отклоняли выгруженный XML: "Рег. номер отправителя из наименования файла не соответствует ни рег. номеру ПФР организации, ни рег. номеру СФР". Причина в двух местах:
1) generateGenericFileName для FSS4 хардкодил сегмент отправителя как "0000000000" — реальный номер туда не попадал вообще (тот же класс бага, что чинили в idFile для нулёвок ранее).
2) fss4.generator.ts писал в <ЕФС8:РегНомер> sfrRegNumber (10-значный унифицированный номер СФР) — но внешние системы сверяют этот тег с рег. номером ПФР организации (формат XXX-XXX-XXXXXX), который у них уже настроен через канал ФСС ЭДО, а не с номером СФР.

Добавлено отдельное обязательное поле pfrRegNumber (report_requisites.pfr_reg_number, XXX-XXX-XXXXXX) — не заменяет sfrRegNumber (оставлен, вдруг понадобится), а используется отдельно для генерации ЕФС-1: и в сегменте имени файла, и в теге <ЕФС8:РегНомер>. Заодно сузили маску sfrRegNumber до строго 10 цифр (раньше маска дублировала формат ПФР и два поля выглядели взаимозаменяемыми — по замечанию в ревью). Цепочка regen пройдена (generate-schema → generate-client → sdk build), controller tsc и desktop vue-tsc чистые, добавлены/обновлены unit-тесты Fss4Generator.
2026-07-21 18:45:12 +00:00
coopops db28ba1137 [989-10][@ant] fix: чинить генерацию имени файла нулёвых отчётов (idFile/ИдФайл) — терялся код формы ФНС/СФР
generateGenericFileName брал префикс как первый underscore-сегмент имени XSD (xsd.split('_')[0]), а для всех форм с xsdFile вида 'NO_<Форма>_...' первый сегмент всегда равен 'NO' — код формы (NDFL6.2, RASCHSV, PERSSVFL, USN, UVISCHSUMNAL) терялся из имени файла. Внешние системы (в частности приёмка Сбера) отклоняли выгруженный XML как нераспознанный по имени, а после ручного добавления '_NDFL6.2_' в имя падала уже сверка тега <ИдФайл> с фактическим именем файла — оба генерировались одной и той же испорченной функцией. Исправлено: префикс строится из первых двух сегментов XSD-имени (slice(0, 2)), что совпадает с реальным форматом имени файла ФНС/СФР и с эталонными фикстурами tests/fixtures/reports-references/*.
2026-07-21 16:23:21 +00:00
coopops 6a05e88ba1 [989-10][@ant] fix: убрать stack-раскладку (иконка над текстом) у кнопок панели действий в ReportEditorDialog расширения reports — canon-паддинг q-btn (0 14px, без вертикального зазора) при вертикальной раскладке icon+label прижимал иконку к верхней границе кнопки, из-за чего панель «Действия»/«Скачать»/«Отметить сданным» выглядела недоделанной; иконка возвращена в строку рядом с текстом 2026-07-21 15:56:09 +00:00
coopops 09eaa6192c [reports][@ant] fix: показывать серверные ошибки валидации формы отчёта для полей шапки и строк баланса — счётчик «Ошибок: N» был непрозрачным без единой подсказки
BuhotchEditor/ZeroReportEditor подключали fieldErrors только для части полей
(организация, часть подписанта). Поля шапки (reportYear/period/docDate/
correctionNumber) и все строки баланса (BalanceRowEditor — otch/prev/prePrev)
не имели inline-подсветки вообще, хотя backend (validateReportEdits) исправно
возвращал path+message. В проде это привело к невидимой ошибке
organization.ogrn — 12 vs 13 цифр (опечатка в реквизитах, отдельно исправлена
data-фиксом в organizations, не кодом).

- BalanceRowEditor: добавлены basePath/fieldErrors пропсы, подсветка otch/prev/prePrev.
- BuhotchEditor: прокинул field-errors во все 6 BalanceRowEditor, подключил
  header.reportYear/docDate/correctionNumber.
- ZeroReportEditor: подключил header.reportYear/period.
- ReportEditorDialog: добавлен явный список ошибок (path → человекочитаемое
  сообщение) в панели действий — страховка на случай ещё не подключённых полей.
2026-07-21 15:45:20 +00:00
coopops 81df662cfa chore(release): publish
Build bootstrap container / build (push) Successful in 46s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / release (push) Successful in 34m17s
Release / trigger-mono-docs (push) Successful in 2s
Release / publish-packages (push) Failing after 13m36s
2026-07-21 08:50:38 +00:00
coopops 845c13605d fix(reports): разрешить 10-значный рег.номер СФР наравне с XXX-XXX-XXXXXX
XSD-схема ЕФС-1 (ТипРегНомерОбщ) допускает регистрационный номер
страхователя в двух форматах: действующий XXX-XXX-XXXXXX (12 цифр,
3-3-6) или прежний — 10 цифр без разделителей. Валидация на бэке и
фронте (форма реквизитов кооператива + редактор нулевого отчёта
ЕФС-1) принимала только 12-значный формат с тире, из-за чего
кооперативы со старым 10-значным номером ФСС не могли его сохранить.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 08:43:46 +00:00
coopops f521d65a51 Merge branch 'dev' into feat/capital-canon
Typecheck / desktop (pull_request) Successful in 14m42s
Typecheck / controller (pull_request) Successful in 14m6s
Подтягиваем свежие изменения dev (в т.ч. капитал: CPP-template mapping,
onboarding widget canon). Единственный конфликт — дублирующее
добавление autogrow в BaseInputProps с обеих сторон, разрешено оставлением
варианта с doc-комментарием.
2026-07-20 15:56:12 +00:00
Alex Ant 76b4988da8 migrate 2026-07-20 11:34:49 +03:00
coopops 3db80a30e9 chore(release): publish
Release / release (push) Failing after 2m16s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
Build bootstrap container / build (push) Successful in 49s
2026-07-19 17:01:42 +00:00
coopops 1b457b8f87 fix(controller): stop migration failures being masked as success
Backfill-миграция V2.3.2 падала на testnet TS-ошибкой компиляции
(mongo.db возможно undefined, strictNullChecks), но deploy-скрипт
(blue-green-deploy.sh) рапортовал успех — 2.3.2 не появилась в
таблице migrations, extensions.capital_program_doc_data_hash осталась
пустой, а Semaphore-лог показывал "Database migrations completed
successfully".

Причина ложноположительного результата — не баг деплой-скрипта, а
Sentry: в production Sentry.init() (enabled: config.env==='production')
ставит process.on('unhandledRejection', ...) с дефолтным mode:'warn',
который перехватывает необработанный reject из bootstrap()'а --migrate
ветки и не роняет процесс — в отличие от штатного поведения голого
Node. В dev (Sentry выключен) тот же баг воспроизводимо валит процесс
exit 1; на testnet — exit 0. Подтверждено локальным воспроизведением
обеих версий на идентичном коде.

Фикс на двух уровнях:
1. index.ts: явный try/catch + process.exit(1) вокруг --migrate и
   --migrations-only веток. Не полагаемся на ambient unhandled-rejection
   поведение (Sentry/Node/версия) — exit code теперь детерминирован
   независимо от окружения.
2. V2.3.2: mongo.db действительно типизирован как возможно undefined —
   явная runtime-проверка с throw вместо каста.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 16:57:59 +00:00
coopops 710763f7db fix(desktop): страница проекта — скелетон загрузки, канон-фон и шрифты crepe-редактора
Typecheck / desktop (pull_request) Failing after 8m41s
Typecheck / controller (pull_request) Successful in 13m39s
Пустая страница на время загрузки проекта заменена skel-каркасом
(сайдбар + контент). Crepe-редактор: белый хардкод-фон → transparent
(наследует поверхность страницы), шрифты Noto Serif/Noto Sans/Space
Mono → канонные var(--p-sans)/var(--p-mono) в обеих темах.
ContributorSelector: проп loading подключён к q-select (игнорировался).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:46:47 +00:00
coopops 9ca51c13c8 feat(desktop): страница проекта — канонный PageTabs с контекстным действием вкладки, FAB убран
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Субменю проекта перенесено из шапки (RouteMenuButton/useHeaderActions)
в канонный PageTabs под заголовком. FAB с четырьмя действиями убран —
действие живёт в слоте actions активной вкладки: Компоненты → «+
Компонент», Артефакты → «+ Артефакт», Участники → «+ Соавтор», План →
«+ Инвестиция»; без допуска — «Принять участие» / чип «Запрос на
рассмотрении». Хоткеи сохранены через refs кнопок.

Попутно: PageTabs научен router-link-режиму (нейтрализация глобальных
стилей ссылок); AddAuthorButton/CreateProjectInvestButton переведены
на BaseButton/BaseForm/AmountInput; PendingClearanceButton — статус-чип
вместо чёрного disabled-fab «жидание»; MakeClearanceButton — primary
вместо accent-заливки вне Fab; новый CreateRequirementButton.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:24:35 +00:00
coopops 4c86c911c6 feat(desktop): Мастерская — панель фильтров скрыта со страницы, виджет канонизирован на будущее
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Фильтры пока не используются — ProjectsFilterPanel убран из
ProjectsListPage (виджет остаётся в проекте). Попутно панель приведена
к канону: ContributorSelector получил проп outlined (канон-вид как
BaseSelect вместо legacy standout), все четыре инпута панели одинаковые.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:07:56 +00:00
coopops 7e5ef8e928 feat(desktop): Мастерская — кнопка «Проект» в шапке вместо Fab, фильтры компактной панелью под шапкой
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Fab убран; создание проекта — BaseButton в header-actions (хоткей P
сохранён через колбэк exposeOpen). Диалог фильтров разобран:
ProjectsFilterPanel под шапкой — статусы/приоритеты компактными
multiple-селектами, исполнитель и мастер через ContributorSelector,
применение сразу без кнопки, сброс при активных фильтрах,
восстановление участников из сохранённых фильтров. Страница —
flex-колонка: скролл-зона списка не уезжает за вьюпорт.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:00:43 +00:00
coopops 572984e32c fix(desktop): Мастерская — выравнивание пустых аватар-слотов + скелетон при загрузке
Typecheck / desktop (pull_request) Failing after 8m14s
Typecheck / controller (pull_request) Successful in 13m18s
Пустая иконка «назначить исполнителя/мастера» центрировалась внутри
слота и выпадала из вертикали заполненных аватарок — прижата вправо
(margin-left: auto), центр иконки совпадает с центром аватара.

Пока исполнители/мастер грузятся, вместо вводящего в заблуждение
пустого плейсхолдера показывается скелетон-кружок той же геометрии;
при фоновом обновлении уже показанные аватарки не мигают.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:07:38 +00:00
coopops 6257730ccb feat(desktop): Мастерская — полоски «Добавить компонент/задачу» вместо плюсиков в строках
Typecheck / desktop (pull_request) Failing after 8m15s
Typecheck / controller (pull_request) Has been cancelled
Кнопки создания убраны из строк проектов/компонентов (hover-reveal
плюсики вываливались из сетки). Вместо них — полнострочные
muted-полоски в конце списков: «Добавить компонент» после всех
компонентов проекта (гейт can_edit_project), «Добавить задачу» после
всех задач компонента. В колонке действий строк остался только
аватар мастера.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:26:08 +00:00
coopops 641eaaad6d feat(desktop): Мастерская — аватар мастера на строках проектов/компонентов + единые шрифты
Typecheck / desktop (pull_request) Failing after 8m20s
Typecheck / controller (pull_request) Has been cancelled
Новый SetMasterAvatar (зеркало SetCreatorAvatars): аватар мастера в
правой колонке действий строки проекта и компонента, по клику q-menu
с ContributorSelector в одиночном выборе — мастер может быть только
один. Плейсхолдер manage_accounts при пустом мастере, права —
can_set_master. Шрифты выровнены: чип времени задачи переведён на
mono 12px (как метрики проектов), тайтлы всех уровней — --p-fs-body.
Аватарки исполнителей задач переведены с hex/--q-* на токены --p-*.
2026-06-10 17:07:27 +00:00
coopops 3b81f821c7 feat(desktop): Мастерская — Linear-style строки: плоские ID, фиксированные левые колонки, единый формат времени
Typecheck / desktop (pull_request) Failing after 8m16s
Typecheck / controller (pull_request) Successful in 13m13s
EntityIdBadge получил flat-вариант (muted mono-текст без плашки,
копи-иконка по hover) — серые коробки с двумя иконками убраны из строк.
Левые колонки зафиксированы для всех уровней: [chevron/приоритет 28px |
ID 96px | статус-иконка 22px | title] — заголовки уровня стартуют с
одной координаты. Чип времени задачи прижат к правому краю своей
колонки, формат часов синхронизирован («1ч / 4ч» и у проектов, и у
задач), правый отступ строк выровнен (--p-3 у всех уровней).
2026-06-10 16:44:51 +00:00
coopops 112d13114c feat(desktop): Мастерская — фиксированная колоночная сетка справа и hover-кнопки добавления
Typecheck / desktop (pull_request) Failing after 8m15s
Typecheck / controller (pull_request) Successful in 13m1s
Правая часть строк всех трёх уровней — общая сетка фиксированных
колонок [время 110px | статус/инвестиции 132px | действия 120px]:
часы проектов/компонентов и время задач в одной вертикали, статус
задач и инвестиции — в своей, люди/действия — в своей. Ничего не
плывёт при появлении значений. Безликие плюсики заменены подписанными
ghost-кнопками «+ Компонент» / «+ Задача», которые проявляются при
наведении на строку (на touch-устройствах видны всегда).
2026-06-10 15:54:26 +00:00
coopops e1405a3b3d feat(desktop): Мастерская — время задачи в правый блок строки
Typecheck / desktop (pull_request) Failing after 8m27s
Typecheck / controller (pull_request) Successful in 13m9s
IssueTimeChip переехал из мета-блока (между ID и заголовком, смещал
title) в правый actions-блок перед статусом — время задач теперь в
одной вертикали с метриками часов факт/план проектов и компонентов.
2026-06-10 14:53:58 +00:00
coopops 6d4809e8b6 feat(desktop): Мастерская — правки по ревью: пустые слоты, каскад отступов, метрики строк
Typecheck / desktop (pull_request) Failing after 9m1s
Typecheck / controller (pull_request) Successful in 14m0s
Дефолтный no-data q-table (чёрный треугольник) заменён тихим
канон-состоянием (inbox + muted-текст) во всех трёх списках.
Каскадный отступ вложенности задаёт родительский виджет через
слот-ячейку (--p-7, на мобильных --p-3) — сами виджеты при одиночном
использовании отступа не имеют. На строках проектов и компонентов —
компактные метрики: часы исполнителей факт/план и привлечённые
инвестиции факт/план (компактный формат, скрываются на мобильных,
инвестиции — только если ненулевые).
2026-06-10 12:31:19 +00:00
coopops edc339b673 feat(desktop): Мастерская Благороста на канон MONO Platform v2
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Дерево проекты→компоненты→задачи переведено на канон: убраны q-card
обёртки и WindowLoader (скелетон .skel вместо полноэкранного лоадера),
FontAwesome-иконки статусов заменены Material-эквивалентами, цвета
статусов/приоритетов задач и проектов — канон-палитра
(positive/negative/warning/info/primary/grey-6) вместо сырых
green/red/purple, кнопки «Компонент»/«Создать задачу» — BaseButton,
лейблы задач — BaseChip, попапы статуса и времени — токены --p-*
вместо white/grey hex (чинит тёмную тему), hover тайтлов — --p-primary
вместо accent, инлайн-px — классы и токены. Fab — primary вместо
accent-заливки (канон запрещает accent на кнопках).
2026-06-10 12:16:39 +00:00
coopops 9b26834033 feat(desktop): вернуть красный тик затухания энергии в бейдж уровня
Typecheck / desktop (pull_request) Failing after 9m1s
Typecheck / controller (pull_request) Has been cancelled
При канонизации ContributorGamificationWidget потерялся мигающий
красный arrow_left перед процентом энергии — индикатор того, что
энергия затухает со временем и взнос для следующего уровня растёт.
Возвращён внутрь канон-чипа BaseChip.
2026-06-10 11:55:03 +00:00
coopops ecf8d2b5de feat(desktop): профиль Благороста — правки по ревью
Typecheck / desktop (pull_request) Failing after 9m13s
Typecheck / controller (pull_request) Successful in 13m57s
Кошельки Генератор/Благорост — в одну строку по col-md-6 (на мобильных
во всю ширину). Карандаш редактирования — рядом с заголовком поля, а не
оторван справа. Параметры участия и взносы по ролям обогащены:
иконки-плитки на нейтральном фоне, hairline-разделители, выделенная
плашка итога взносов, крупные mono-значения.
2026-06-10 09:00:32 +00:00
coopops 14170e6d93 feat(desktop): профиль Благороста на канон MONO Platform v2
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Страница CapitalProfilePage и её виджеты переведены на базовые
компоненты канона: IdentityPanel + бейдж уровня, WalletCard для
кошельков Генератора/Благороста, BaseCard + DataRow для взносов,
редактируемые поля на BaseForm/BaseInput/AmountInput, кнопка
«Инвестировать» — в header actions. BaseInput расширен type=textarea.
Скелетон вместо спиннера, токены --p-* вместо hex/px.
2026-06-10 08:51:16 +00:00
137 changed files with 3621 additions and 2670 deletions
+19 -10
View File
@@ -408,15 +408,16 @@ jobs:
# ============================================================================
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
# Гейт: ветка main.
# Гейт: ветка main + задан secret DOCS_DISPATCH_TOKEN.
#
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
# Целевой workflow слушает workflow_dispatch с входами
# mono_sha/mono_ref.
# Dual-host: Actions могут бежать на GitHub или Gitea. Сайт доков живёт на
# Gitea (C9S/coopenomics), поэтому API всегда бьёт в GITEA_SERVER_URL
# (var, дефолт https://git.coopenomics.world) — НЕ в github.server_url
# (на GitHub Actions тот = github.com → 410).
#
# Без DOCS_DISPATCH_TOKEN job пропускается (GitHub-only режим: токен не
# задаём → релиз не валится). На Gitea задаём PAT (префикс GITEA_ у секретов
# зарезервирован, поэтому имя DOCS_DISPATCH_TOKEN; write на C9S/coopenomics).
# ============================================================================
trigger-coopenomics-docs:
needs: release
@@ -424,12 +425,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
env:
DOCS_DISPATCH_TOKEN: ${{ secrets.DOCS_DISPATCH_TOKEN }}
GITEA_SERVER_URL: ${{ vars.GITEA_SERVER_URL }}
run: |
if [ -z "$DOCS_DISPATCH_TOKEN" ]; then
echo "DOCS_DISPATCH_TOKEN не задан — skip (GitHub-only / без Gitea docs dispatch)"
exit 0
fi
GITEA_SERVER_URL="${GITEA_SERVER_URL:-https://git.coopenomics.world}"
curl -fsSL -X POST \
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
-H "Authorization: token ${DOCS_DISPATCH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
"${GITEA_SERVER_URL}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
# ============================================================================
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.7.19",
"version": "2026.7.23-2",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.7.19",
"version": "2026.7.23-2",
"private": true,
"packageManager": "pnpm@9.0.6",
"packageManager": "pnpm@10.33.0",
"description": "CLI-утилита инициализации блокчейна и кооператива",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.7.19",
"version": "2026.7.23-2",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.7.19",
"version": "2026.7.23-2",
"private": true,
"type": "module",
"scripts": {
@@ -72,6 +72,9 @@ export default {
try {
mongo = await mongoose.createConnection(config.mongoose.url).asPromise();
const db = mongo.db;
if (!db) {
throw new Error('Подключение к MongoDB установлено, но db не инициализирована');
}
const collection = db.collection(COLLECTION_NAME);
await collection.createIndex({ hash: 1 }, { unique: true });
await collection.updateOne(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.7.19",
"version": "2026.7.23-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.7.19",
"version": "2026.7.23-2",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.7.19",
"version": "2026.7.23-2",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
@@ -306,10 +306,11 @@ export class PermissionsService {
? await this.isProjectContributor(username, project.coopname, project.project_hash)
: false;
// Проверяем наличие pending clearance
const pending_clearance = project.coopname
? (await this.appendixRepository.findCreatedByUsernameAndProjectHash(username, project.project_hash)) !== null
: false;
// Запрос на рассмотрении — только status=created и только если допуска ещё нет
const pending_clearance =
!has_clearance && project.coopname
? (await this.appendixRepository.findCreatedByUsernameAndProjectHash(username, project.project_hash)) !== null
: false;
// Проверяем допуск к родителю — каскадно вниз по иерархии проектов:
// если есть допуск к корневому проекту, пользователь видит артефакты всех его компонентов.
@@ -345,7 +345,20 @@ export class ParticipationManagementInteractor {
);
}
// Проверяем, есть ли уже запрос на рассмотрении для данного пользователя и проекта
// Уже подтверждённый допуск — повторный отклик не нужен
const confirmedAppendix =
await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
data.username,
data.project_hash
);
if (confirmedAppendix) {
throw new HttpApiError(
httpStatus.CONFLICT,
'Вы уже являетесь участником этого проекта'
);
}
// Есть незакрытый запрос на рассмотрении
const existingAppendix =
await this.appendixRepository.findCreatedByUsernameAndProjectHash(
data.username,
@@ -612,11 +625,17 @@ export class ParticipationManagementInteractor {
);
}
// 6. Находим родительское приложение (appendix) к родительскому проекту
const parentAppendix = await this.appendixRepository.findCreatedByUsernameAndProjectHash(
data.username,
parentProject.project_hash
);
// 6. Допуск к родительскому проекту: confirmed (уже участник)
// или created (только что отправили отклик на родителя в том же диалоге)
const parentAppendix =
(await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
data.username,
parentProject.project_hash
)) ??
(await this.appendixRepository.findCreatedByUsernameAndProjectHash(
data.username,
parentProject.project_hash
));
if (!parentAppendix) {
throw new HttpApiError(
@@ -72,13 +72,15 @@ export class AppendixTypeormRepository
}
/**
* Найти созданное приложение по имени пользователя и хэшу проекта
* Найти приложение со статусом created (запрос на рассмотрении)
* по имени пользователя и хэшу проекта
*/
async findCreatedByUsernameAndProjectHash(username: string, projectHash: string): Promise<AppendixDomainEntity | null> {
const entity = await this.repository.findOne({
where: {
username,
username: username.toLowerCase(),
project_hash: projectHash.toLowerCase(),
status: AppendixStatus.CREATED,
},
});
@@ -1,6 +1,7 @@
import { ObjectType, Field, InputType, registerEnumType } from '@nestjs/graphql';
import { IsOptional, IsString, IsIn, Matches, MaxLength } from 'class-validator';
import { ReportType } from '../../domain/enums/report-type.enum';
import { SFR_REG_NUMBER_PATTERN, PFR_REG_NUMBER_PATTERN } from '../../domain/patterns';
export enum RequisiteSource {
DATABASE = 'database',
@@ -43,6 +44,7 @@ export class ReportRequisitesViewDTO {
@Field(() => RequisiteFieldViewDTO) oktmo!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) okpo!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) sfrRegNumber!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) pfrRegNumber!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) chairmanPosition!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) signerSnils!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) signerRepDoc!: RequisiteFieldViewDTO;
@@ -65,7 +67,10 @@ export class ReportRequisitesViewDTO {
* ОКТМО — `\d{8}` или `\d{11}`
* ОКПО — `\d{8}` или `\d{10}`
* СНИЛС — `\d{3}-\d{3}-\d{3} \d{2}` (14 симв.) или 11 цифр подряд
* РегНом СФР — `\d{3}-\d{3}-\d{6}` (14 симв. с тире)
* РегНом СФР — `\d{10}` (10 цифр подряд, без тире)
* РегНом ПФР — `\d{3}-\d{3}-\d{6}` (14 симв. с тире), только этот формат —
* отдельное поле от РегНом СФР, требуется для ЕФС-1 (внешние
* бухгалтерские системы сверяют его при приёме отчёта)
*/
@InputType('UpdateReportRequisitesInput')
export class UpdateReportRequisitesInputDTO {
@@ -99,12 +104,18 @@ export class UpdateReportRequisitesInputDTO {
@Matches(/^\d{8}(\d{2})?$/, { message: 'ОКПО — 8 или 10 цифр' })
okpo?: string | null;
@Field(() => String, { nullable: true, description: 'Рег. номер СФР — XXX-XXX-XXXXXX' })
@Field(() => String, { nullable: true, description: 'Рег. номер СФР — 10 цифр' })
@IsOptional()
@IsString()
@Matches(/^\d{3}-\d{3}-\d{6}$/, { message: 'Рег. номер СФР — XXX-XXX-XXXXXX (14 симв.)' })
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег. номер СФР — 10 цифр' })
sfrRegNumber?: string | null;
@Field(() => String, { nullable: true, description: 'Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР)' })
@IsOptional()
@IsString()
@Matches(PFR_REG_NUMBER_PATTERN, { message: 'Рег. номер ПФР — XXX-XXX-XXXXXX' })
pfrRegNumber?: string | null;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@@ -21,6 +21,7 @@ import {
OKTMO_PATTERN,
OKVED_PATTERN,
SFR_REG_NUMBER_PATTERN,
PFR_REG_NUMBER_PATTERN,
SNILS_PATTERN,
DATE_DDMMYYYY_PATTERN,
} from '../../domain/patterns';
@@ -200,9 +201,14 @@ export class ZeroReportSignerEditsInputDTO {
@Field(() => String, { nullable: true })
@IsOptional()
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег.номер СФР — XXX-XXX-XXXXXX' })
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег.номер СФР — 10 цифр' })
sfrRegNumber!: string | null;
@Field(() => String, { nullable: true })
@IsOptional()
@Matches(PFR_REG_NUMBER_PATTERN, { message: 'Рег.номер ПФР — XXX-XXX-XXXXXX' })
pfrRegNumber!: string | null;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@@ -219,6 +225,7 @@ export class ZeroReportSignerEditsDTO {
@Field(() => String, { nullable: true }) repDoc!: string | null;
@Field(() => String, { nullable: true }) snils!: string | null;
@Field(() => String, { nullable: true }) sfrRegNumber!: string | null;
@Field(() => String, { nullable: true }) pfrRegNumber!: string | null;
@Field(() => String, { nullable: true }) chairmanPosition!: string | null;
}
@@ -53,6 +53,7 @@ export class ReportRequisitesResolver {
oktmo: input.oktmo ?? undefined,
okpo: input.okpo ?? undefined,
sfr_reg_number: input.sfrRegNumber ?? undefined,
pfr_reg_number: input.pfrRegNumber ?? undefined,
chairman_position: input.chairmanPosition ?? undefined,
signer_snils: input.signerSnils ?? undefined,
signer_rep_doc: input.signerRepDoc ?? undefined,
@@ -109,6 +110,7 @@ function toView(m: MergedRequisites): ReportRequisitesViewDTO {
oktmo: f(m.oktmo),
okpo: f(m.okpo),
sfrRegNumber: f(m.sfrRegNumber),
pfrRegNumber: f(m.pfrRegNumber),
chairmanPosition: f(m.chairmanPosition),
signerSnils: f(m.signerSnils),
signerRepDoc: f(m.signerRepDoc),
@@ -51,6 +51,14 @@ export interface ZeroReportSignerShape {
snils: string | null;
/** Регистрационный номер в СФР — обязателен для ЕФС-1. */
sfrRegNumber: string | null;
/**
* Регистрационный номер в ПФР (XXX-XXX-XXXXXX) — обязателен для ЕФС-1,
* попадает и в <ЕФС8:РегНомер>, и в имя файла. Отдельное поле от
* sfrRegNumber: сторонние бухгалтерские системы (СБИС и др.) при приёме
* ЕФС-1 сверяют номер отправителя именно с рег. номером ПФР организации,
* не с 10-значным номером СФР.
*/
pfrRegNumber: string | null;
/** Должность председателя — для ЕФС-1 (<УТ8:Должность>). */
chairmanPosition: string | null;
}
@@ -47,7 +47,7 @@ export interface ReportInput {
correctionNumber?: number;
/** СНИЛС председателя (для ПСВ) */
signerSnils?: string;
/** Регистрационный номер страхователя в СФР/ПФР (для ЕФС-1). */
/** Регистрационный номер страхователя в СФР (для ЕФС-1). Legacy-путь — актуальные генераторы берут ZeroReportSignerShape.pfrRegNumber отдельно. */
sfrRegNumber?: string;
/** Должность руководителя для подачи в СФР (по умолчанию «Председатель Совета»). */
chairmanPosition?: string;
@@ -43,8 +43,19 @@ export const OKPO_PATTERN = /^\d{8}(\d{2})?$/;
// СНИЛС: XXX-XXX-XXX YY (с пробелом/тире) или 11 цифр подряд.
export const SNILS_PATTERN = /^(\d{3}-\d{3}-\d{3} \d{2}|\d{11})$/;
// Регистрационный номер СФР: XXX-XXX-XXXXXX (3-3-6).
export const SFR_REG_NUMBER_PATTERN = /^\d{3}-\d{3}-\d{6}$/;
// Регистрационный номер страхователя в СФР: только 10 цифр без разделителей
// (унифицированный номер СФР, ТипРегНомерОбщ в XSD ЕФС-1). Формат
// XXX-XXX-XXXXXX сюда больше не входит — он теперь отдельное поле
// PFR_REG_NUMBER_PATTERN, чтобы два похожих номера не путались в одной маске.
export const SFR_REG_NUMBER_PATTERN = /^\d{10}$/;
// Регистрационный номер страхователя в ПФР: только формат XXX-XXX-XXXXXX
// (3-3-6, 12 цифр с тире) — старый/«действующий» формат, который сверяют
// сторонние бухгалтерские системы (СБИС и др.) при приёме ЕФС-1. Это
// отдельное поле от sfrRegNumber (10-значный унифицированный номер СФР):
// внешние системы могут не знать организацию по 10-значному номеру, но
// знать по этому.
export const PFR_REG_NUMBER_PATTERN = /^\d{3}-\d{3}-\d{6}$/;
/** Дата в формате `DD.MM.YYYY` (XSD `xs:date`-совместимый ФНС-формат). */
export const DATE_DDMMYYYY_PATTERN = /^\d{2}\.\d{2}\.\d{4}$/;
@@ -10,6 +10,7 @@ export interface ReportRequisitesRecord {
oktmo: string | null;
okpo: string | null;
sfr_reg_number: string | null;
pfr_reg_number: string | null;
chairman_position: string | null;
signer_snils: string | null;
signer_rep_doc: string | null;
@@ -28,6 +29,7 @@ export interface UpsertReportRequisitesInput {
oktmo?: string | null;
okpo?: string | null;
sfr_reg_number?: string | null;
pfr_reg_number?: string | null;
chairman_position?: string | null;
signer_snils?: string | null;
signer_rep_doc?: string | null;
@@ -203,7 +203,12 @@ export class ReportEditsBuilderService {
return {
header: {
idFile: this.generateGenericFileName(reportType, merged.inn.value ?? '', merged.kpp.value ?? ''),
idFile: this.generateGenericFileName(
reportType,
merged.inn.value ?? '',
merged.kpp.value ?? '',
merged.pfrRegNumber.value,
),
versProgram: 'Платформа отчётности кооператива 1.0',
docDate: formatDate(new Date()),
reportYear: year,
@@ -230,6 +235,7 @@ export class ReportEditsBuilderService {
repDoc: merged.signerRepDoc.value,
snils: merged.signerSnils.value,
sfrRegNumber: merged.sfrRegNumber.value,
pfrRegNumber: merged.pfrRegNumber.value,
chairmanPosition:
merged.chairmanPosition.value || merged.chairmanPositionFromOrg.value,
},
@@ -243,19 +249,33 @@ export class ReportEditsBuilderService {
return 1;
}
private generateGenericFileName(reportType: ReportType, inn: string, kpp: string): string {
// Упрощённый префикс по типу отчёта — точный формат имени зависит от
// формы (NO_NDFL6.2_..., СФР_..._ЕФС-1_...), но для предварительного
// idFile достаточно. Генератор сам перезапишет его при необходимости.
private generateGenericFileName(
reportType: ReportType,
inn: string,
kpp: string,
pfrRegNumber?: string | null,
): string {
// Префикс = первые два underscore-сегмента имени XSD (например,
// 'NO_NDFL6.2_1_231_...xsd' → 'NO_NDFL6.2', 'UT_UVISCHSUMNAL_...xsd' →
// 'UT_UVISCHSUMNAL') — это и есть код формата в имени файла по формату
// ФНС/СФР. Брать только первый сегмент нельзя: для всех форм с 'NO_'
// в начале он всегда равен 'NO' и код формы (NDFL6.2/RASCHSV/PERSSVFL/
// USN) терялся — импорт стороннего ПО (Сбер и др.) отклонял файл как
// нераспознанный по имени.
const xsd = REPORT_CONFIG[reportType]?.xsdFile ?? 'REPORT';
const prefix = xsd.split('_')[0] ?? 'REPORT';
const prefix = xsd.split('_').slice(0, 2).join('_') || 'REPORT';
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const unit = `${inn}${kpp}`;
const tax = kpp.substring(0, 4);
const uuid = randomUUID();
if (reportType === ReportType.FSS4) {
return `СФР_0000000000_ЕФС-1_${dateStr}_${uuid}`;
// Сегмент отправителя в имени файла — рег. номер ПФР организации, не
// заглушка из нулей: сторонние бухгалтерские системы (СБИС и др.)
// сверяют этот сегмент со своим профилем организации и отклоняют файл,
// если он не совпадает ни с известным им номером ПФР, ни с номером
// СФР. См. также <ЕФС8:РегНомер> в fss4.generator.ts — то же значение.
return `СФР_${pfrRegNumber || '0000000000'}_ЕФС-1_${dateStr}_${uuid}`;
}
return `${prefix}_${tax}_${tax}_${unit}_${dateStr}_${uuid}`;
}
@@ -39,6 +39,8 @@ export interface MergedRequisites {
oktmo: RequisiteField;
okpo: RequisiteField;
sfrRegNumber: RequisiteField;
/** Рег. номер страхователя в ПФР (XXX-XXX-XXXXXX) — отдельный от sfrRegNumber, требуется для ЕФС-1. */
pfrRegNumber: RequisiteField;
chairmanPosition: RequisiteField;
signerSnils: RequisiteField;
signerRepDoc: RequisiteField;
@@ -94,6 +96,7 @@ const REQUIRED_BY_TYPE: Record<ReportType, RequiredFieldSpec[]> = {
[ReportType.FSS4]: [
{ key: 'oktmo', label: 'ОКТМО', source: 'manual' },
{ key: 'sfrRegNumber', label: 'Регистрационный номер в СФР', source: 'manual' },
{ key: 'pfrRegNumber', label: 'Регистрационный номер в ПФР', source: 'manual' },
{ key: 'chairmanPosition', label: 'Должность руководителя', source: 'manual' },
],
[ReportType.PSV]: [{ key: 'signerSnils', label: 'СНИЛС подписанта', source: 'manual' }],
@@ -152,6 +155,7 @@ export class ReportRequisitesService {
oktmo: mn(manual?.oktmo),
okpo: mn(manual?.okpo),
sfrRegNumber: mn(manual?.sfr_reg_number),
pfrRegNumber: mn(manual?.pfr_reg_number),
chairmanPosition: mn(manual?.chairman_position),
signerSnils: mn(manual?.signer_snils),
signerRepDoc: mn(manual?.signer_rep_doc),
@@ -218,6 +222,7 @@ export class ReportRequisitesService {
oktmo: input.oktmo ?? undefined,
okpo: input.okpo ?? undefined,
sfr_reg_number: input.sfr_reg_number ?? undefined,
pfr_reg_number: input.pfr_reg_number ?? undefined,
chairman_position: input.chairman_position ?? undefined,
signer_snils: input.signer_snils ?? undefined,
signer_rep_doc: input.signer_rep_doc ?? undefined,
@@ -5,6 +5,7 @@ import { Entity, Column, PrimaryColumn, UpdateDateColumn } from 'typeorm';
*
* Хранятся поля, которых нет в профиле организации (`IOrganizationData` в
* основной БД кооператива): ОКВЭД, ОКФС, ОКОПФ, ОКТМО, ОКПО, регномер СФР,
* регномер ПФР (для ЕФС-1 — отдельный от regномера СФР, см. patterns.ts),
* должность руководителя, СНИЛС подписанта, описание доверенности. Плюс
* возможные override'ы телефона и адреса (если в отчётности нужен не тот,
* что в профиле организации).
@@ -34,6 +35,9 @@ export class ReportRequisitesEntity {
@Column({ type: 'varchar', length: 32, nullable: true })
sfr_reg_number?: string | null;
@Column({ type: 'varchar', length: 32, nullable: true })
pfr_reg_number?: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
chairman_position?: string | null;
@@ -66,8 +66,14 @@ export class Fss4Generator implements IReportGenerator {
const edits = input as ZeroReportEditsShape;
const fileName = edits.header.idFile;
const errors: string[] = [];
if (!edits.signer.sfrRegNumber) {
errors.push('Для ЕФС-1 обязателен рег. номер страхователя в СФР (поле signer.sfrRegNumber)');
if (!edits.signer.pfrRegNumber) {
// В документ идёт только pfrRegNumber (см. strah.ele('ЕФС8:РегНомер')
// ниже) — sfrRegNumber в ЕФС-1 не используется вовсе, поэтому не
// проверяем его здесь, хотя в реквизитах кооператива оно и остаётся
// обязательным полем (может понадобиться другим формам/интеграциям).
errors.push('Для ЕФС-1 обязателен рег. номер страхователя в ПФР (поле signer.pfrRegNumber)');
}
if (errors.length) {
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
}
try {
@@ -100,8 +106,13 @@ export class Fss4Generator implements IReportGenerator {
const efs1 = edsfr.ele('ЕФС-1');
const strah = efs1.ele('Страхователь');
// sfrRegNumber гарантированно определён — guard в generate() выше.
strah.ele('ЕФС8:РегНомер').txt(signer.sfrRegNumber ?? '').up();
// Тег «Действующий регистрационный номер страхователя» (см. XSD
// efs-types.xsd/ТипСтрахователь) — сюда идёт номер ПФР, а не СФР:
// сторонние бухгалтерские системы (СБИС и др.) при приёме ЕФС-1 сверяют
// рег. номер отправителя в имени файла и в этом теге со своим профилем
// организации, где обычно заведён именно старый номер ПФР (XXX-XXX-XXXXXX).
// pfrRegNumber гарантированно определён — guard в generate() выше.
strah.ele('ЕФС8:РегНомер').txt(signer.pfrRegNumber ?? '').up();
strah.ele('ЕФС8:Наименование').txt(organization.orgName).up();
strah.ele('УТ8:ИНН').txt(organization.inn).up();
strah.ele('УТ8:КПП').txt(organization.kpp).up();
@@ -39,6 +39,7 @@ export class ReportRequisitesTypeormRepository implements ReportRequisitesReposi
if (input.oktmo !== undefined) row.oktmo = input.oktmo;
if (input.okpo !== undefined) row.okpo = input.okpo;
if (input.sfr_reg_number !== undefined) row.sfr_reg_number = input.sfr_reg_number;
if (input.pfr_reg_number !== undefined) row.pfr_reg_number = input.pfr_reg_number;
if (input.chairman_position !== undefined) row.chairman_position = input.chairman_position;
if (input.signer_snils !== undefined) row.signer_snils = input.signer_snils;
if (input.signer_rep_doc !== undefined) row.signer_rep_doc = input.signer_rep_doc;
@@ -66,6 +67,7 @@ export class ReportRequisitesTypeormRepository implements ReportRequisitesReposi
oktmo: entity.oktmo ?? null,
okpo: entity.okpo ?? null,
sfr_reg_number: entity.sfr_reg_number ?? null,
pfr_reg_number: entity.pfr_reg_number ?? null,
chairman_position: entity.chairman_position ?? null,
signer_snils: entity.signer_snils ?? null,
signer_rep_doc: entity.signer_rep_doc ?? null,
+23 -5
View File
@@ -62,15 +62,33 @@ async function bootstrap() {
// Проверяем, был ли запущен режим миграций
const args = process.argv.slice(2);
if (args.includes('--migrate')) {
await migrateData();
process.exit(0);
try {
await migrateData();
process.exit(0);
} catch (error) {
// migrateData() уже логирует причину; здесь важен только ненулевой
// exit code. В production Sentry.init() ставит свой глобальный
// process.on('unhandledRejection', ...) с дефолтным mode: 'warn' —
// он перехватывает необработанный reject этого await и НЕ роняет
// процесс (в отличие от штатного поведения Node без Sentry). Без
// явного process.exit(1) здесь deploy-скрипт получал ложноположительный
// exit code 0 при реально упавшей миграции (кейс V2.3.2 на testnet
// 2026-07-19 — TS-ошибка компиляции миграции проглатывалась именно так).
logger.error('Процесс миграции завершился с ошибкой, выходим с кодом 1', error);
process.exit(1);
}
}
// Проверяем, был ли запущен режим только миграций (для обратной совместимости)
if (args.includes('--migrations-only')) {
await migrateData();
logger.info('Режим только миграций - миграции выполнены, сервер не будет запущен');
process.exit(0);
try {
await migrateData();
logger.info('Режим только миграций - миграции выполнены, сервер не будет запущен');
process.exit(0);
} catch (error) {
logger.error('Процесс миграции завершился с ошибкой, выходим с кодом 1', error);
process.exit(1);
}
}
// Подключение к MongoDB
@@ -202,6 +202,7 @@ const zeroBaseEdits: ZeroReportEditsShape = {
repDoc: 'Доверенность №1 от 01.01.2024',
snils: '123-456-789 00',
sfrRegNumber: '7701234567',
pfrRegNumber: '087-701-579643',
chairmanPosition: 'Председатель Совета',
},
};
@@ -564,7 +565,7 @@ describe('ЕФС-1 (Fss4Generator, СФР)', () => {
expect(gen.reportType).toBe(ReportType.FSS4);
});
it('генерирует XML при переданном sfrRegNumber', () => {
it('генерирует XML при переданном pfrRegNumber', () => {
const result = gen.generate(withPeriod(1));
expect(result.isValid).toBe(true);
expect(result.xml).toContain('<?xml');
@@ -572,13 +573,27 @@ describe('ЕФС-1 (Fss4Generator, СФР)', () => {
expect(result.xml).toContain('<ЭДСФР');
});
it('возвращает ошибку без sfrRegNumber', () => {
it('генерируется без sfrRegNumber — ЕФС-1 его не использует', () => {
const result = gen.generate({
...withPeriod(1),
signer: { ...zeroBaseEdits.signer, sfrRegNumber: null },
});
expect(result.isValid).toBe(true);
});
it('возвращает ошибку без pfrRegNumber', () => {
const result = gen.generate({
...withPeriod(1),
signer: { ...zeroBaseEdits.signer, pfrRegNumber: null },
});
expect(result.isValid).toBe(false);
expect(result.errors.join('|')).toContain('sfrRegNumber');
expect(result.errors.join('|')).toContain('pfrRegNumber');
});
it('использует pfrRegNumber (не sfrRegNumber) в <ЕФС8:РегНомер> — сторонние бухгалтерские системы сверяют именно рег. номер ПФР', () => {
const result = gen.generate(withPeriod(1));
expect(result.xml).toContain('<ЕФС8:РегНомер>087-701-579643</ЕФС8:РегНомер>');
expect(result.xml).not.toContain('<ЕФС8:РегНомер>7701234567</ЕФС8:РегНомер>');
});
it('эхом возвращает header.idFile в result.fileName', () => {
+2
View File
@@ -4821,6 +4821,7 @@ export const ReturnTypes: Record<string,any> = {
oktmo:"RequisiteFieldView",
okved:"RequisiteFieldView",
orgName:"RequisiteFieldView",
pfrRegNumber:"RequisiteFieldView",
phone:"RequisiteFieldView",
sfrRegNumber:"RequisiteFieldView",
signerFirstName:"RequisiteFieldView",
@@ -5063,6 +5064,7 @@ export const ReturnTypes: Record<string,any> = {
firstName:"String",
lastName:"String",
middleName:"String",
pfrRegNumber:"String",
repDoc:"String",
sfrRegNumber:"String",
snils:"String",
+20 -4
View File
@@ -9043,6 +9043,7 @@ validateReportEdits?: [{ editsJson: string | Variable<any, string>, reportType:
oktmo?:ValueTypes["RequisiteFieldView"],
okved?:ValueTypes["RequisiteFieldView"],
orgName?:ValueTypes["RequisiteFieldView"],
pfrRegNumber?:ValueTypes["RequisiteFieldView"],
phone?:ValueTypes["RequisiteFieldView"],
sfrRegNumber?:ValueTypes["RequisiteFieldView"],
signerFirstName?:ValueTypes["RequisiteFieldView"],
@@ -10179,8 +10180,10 @@ validateReportEdits?: [{ editsJson: string | Variable<any, string>, reportType:
oktmo?: string | undefined | null | Variable<any, string>,
/** ОКВЭД — напр. 94.99, 46.73.7 */
okved?: string | undefined | null | Variable<any, string>,
/** Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР) */
pfrRegNumber?: string | undefined | null | Variable<any, string>,
phoneOverride?: string | undefined | null | Variable<any, string>,
/** Рег. номер СФР — XXX-XXX-XXXXXX */
/** Рег. номер СФР — 10 цифр */
sfrRegNumber?: string | undefined | null | Variable<any, string>,
signerRepDoc?: string | undefined | null | Variable<any, string>,
/** СНИЛС — XXX-XXX-XXX YY или 11 цифр */
@@ -10482,6 +10485,7 @@ validateReportEdits?: [{ editsJson: string | Variable<any, string>, reportType:
firstName?:boolean | `@${string}`,
lastName?:boolean | `@${string}`,
middleName?:boolean | `@${string}`,
pfrRegNumber?:boolean | `@${string}`,
repDoc?:boolean | `@${string}`,
sfrRegNumber?:boolean | `@${string}`,
snils?:boolean | `@${string}`,
@@ -18204,6 +18208,7 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo
oktmo?:ResolverInputTypes["RequisiteFieldView"],
okved?:ResolverInputTypes["RequisiteFieldView"],
orgName?:ResolverInputTypes["RequisiteFieldView"],
pfrRegNumber?:ResolverInputTypes["RequisiteFieldView"],
phone?:ResolverInputTypes["RequisiteFieldView"],
sfrRegNumber?:ResolverInputTypes["RequisiteFieldView"],
signerFirstName?:ResolverInputTypes["RequisiteFieldView"],
@@ -19317,8 +19322,10 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo
oktmo?: string | undefined | null,
/** ОКВЭД — напр. 94.99, 46.73.7 */
okved?: string | undefined | null,
/** Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР) */
pfrRegNumber?: string | undefined | null,
phoneOverride?: string | undefined | null,
/** Рег. номер СФР — XXX-XXX-XXXXXX */
/** Рег. номер СФР — 10 цифр */
sfrRegNumber?: string | undefined | null,
signerRepDoc?: string | undefined | null,
/** СНИЛС — XXX-XXX-XXX YY или 11 цифр */
@@ -19614,6 +19621,7 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo
firstName?:boolean | `@${string}`,
lastName?:boolean | `@${string}`,
middleName?:boolean | `@${string}`,
pfrRegNumber?:boolean | `@${string}`,
repDoc?:boolean | `@${string}`,
sfrRegNumber?:boolean | `@${string}`,
snils?:boolean | `@${string}`,
@@ -27847,6 +27855,7 @@ export type ModelTypes = {
oktmo: ModelTypes["RequisiteFieldView"],
okved: ModelTypes["RequisiteFieldView"],
orgName: ModelTypes["RequisiteFieldView"],
pfrRegNumber: ModelTypes["RequisiteFieldView"],
phone: ModelTypes["RequisiteFieldView"],
sfrRegNumber: ModelTypes["RequisiteFieldView"],
signerFirstName: ModelTypes["RequisiteFieldView"],
@@ -28930,8 +28939,10 @@ export type ModelTypes = {
oktmo?: string | undefined | null,
/** ОКВЭД — напр. 94.99, 46.73.7 */
okved?: string | undefined | null,
/** Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР) */
pfrRegNumber?: string | undefined | null,
phoneOverride?: string | undefined | null,
/** Рег. номер СФР — XXX-XXX-XXXXXX */
/** Рег. номер СФР — 10 цифр */
sfrRegNumber?: string | undefined | null,
signerRepDoc?: string | undefined | null,
/** СНИЛС — XXX-XXX-XXX YY или 11 цифр */
@@ -29214,6 +29225,7 @@ export type ModelTypes = {
firstName: string,
lastName: string,
middleName?: string | undefined | null,
pfrRegNumber?: string | undefined | null,
repDoc?: string | undefined | null,
sfrRegNumber?: string | undefined | null,
snils?: string | undefined | null,
@@ -37955,6 +37967,7 @@ export type GraphQLTypes = {
oktmo: GraphQLTypes["RequisiteFieldView"],
okved: GraphQLTypes["RequisiteFieldView"],
orgName: GraphQLTypes["RequisiteFieldView"],
pfrRegNumber: GraphQLTypes["RequisiteFieldView"],
phone: GraphQLTypes["RequisiteFieldView"],
sfrRegNumber: GraphQLTypes["RequisiteFieldView"],
signerFirstName: GraphQLTypes["RequisiteFieldView"],
@@ -39090,8 +39103,10 @@ export type GraphQLTypes = {
oktmo?: string | undefined | null,
/** ОКВЭД — напр. 94.99, 46.73.7 */
okved?: string | undefined | null,
/** Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР) */
pfrRegNumber?: string | undefined | null,
phoneOverride?: string | undefined | null,
/** Рег. номер СФР — XXX-XXX-XXXXXX */
/** Рег. номер СФР — 10 цифр */
sfrRegNumber?: string | undefined | null,
signerRepDoc?: string | undefined | null,
/** СНИЛС — XXX-XXX-XXX YY или 11 цифр */
@@ -39395,6 +39410,7 @@ export type GraphQLTypes = {
firstName: string,
lastName: string,
middleName?: string | undefined | null,
pfrRegNumber?: string | undefined | null,
repDoc?: string | undefined | null,
sfrRegNumber?: string | undefined | null,
snils?: string | undefined | null,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cooptypes",
"type": "module",
"version": "2026.7.19",
"version": "2026.7.23-2",
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
@@ -1,10 +1,12 @@
<template lang="pug">
q-select(
ref='selectRef'
standout="bg-teal text-white"
:standout='outlined ? false : "bg-teal text-white"'
:outlined='outlined'
:color='outlined ? "primary" : undefined'
v-model='selectedValue'
:options='filteredContributors'
:loading='isSearching'
:loading='isSearching || loading'
:label='label'
:placeholder='placeholder'
:multiple='multiSelect'
@@ -77,6 +79,8 @@ interface Props {
disable?: boolean;
readonly?: boolean;
loading?: boolean;
/** Канон-вид (как BaseSelect: outlined dense) вместо legacy standout */
outlined?: boolean;
}
interface Emits {
@@ -94,6 +98,7 @@ const props = withDefaults(defineProps<Props>(), {
disable: false,
readonly: false,
loading: false,
outlined: false,
});
const emit = defineEmits<Emits>();
@@ -1,44 +1,41 @@
<template lang="pug">
ColorCard
.card-label О себе
template(v-if="!isEditing")
.card-value(:class="{ 'cursor-pointer': isOwnProfile }" :style="{ 'white-space': 'pre-line' }" @click="isOwnProfile ? startEditing() : null")
template(v-if="isOwnProfile")
q-icon(
name="edit"
size="16px"
color="grey-6"
style="margin-right: 8px;"
)
| {{ hasAbout ? contributorStore.self?.about : 'Не указано' }}
template(v-else)
.q-pa-sm
q-input(
v-model="localAbout"
type="textarea"
label="Расскажите о себе и чем можете быть полезны кооперативу"
outlined
rows="3"
:rules="[val => !val || val.length <= 1000 || 'Максимум 1000 символов']"
dense
autogrow
)
.row.q-gutter-sm.q-mt-sm.justify-end
q-btn(
flat
dense
label="Отмена"
color="grey-7"
@click="cancelEditing"
)
q-btn(
color="primary"
dense
label="Сохранить"
:loading="isSaving"
:disable="!hasChanges"
@click="saveAbout"
)
.edit-field
.edit-field__view
span.edit-field__icon
q-icon(name='notes', size='20px')
.edit-field__main
template(v-if='!isEditing')
.edit-field__head
span.t-sm.t-muted О себе
BaseButton(
v-if='isOwnProfile',
variant='ghost',
size='sm',
icon-only,
aria-label='Редактировать информацию о себе',
@click='startEditing'
)
template(#icon-left)
q-icon(name='edit', size='16px')
.edit-field__value(:class='{ "t-muted": !hasAbout }') {{ hasAbout ? contributorStore.self?.about : 'Не указано' }}
template(v-else)
BaseForm(:loading='isSaving', @submit='saveAbout')
BaseInput(
v-model='localAbout',
type='textarea',
autogrow,
label='Расскажите о себе и чем можете быть полезны кооперативу',
:error='aboutError'
)
template(#footer)
BaseButton(variant='ghost', size='sm', @click='cancelEditing') Отмена
BaseButton(
variant='primary',
size='sm',
type='submit',
:loading='isSaving',
:disabled='!hasChanges || !!aboutError'
) Сохранить
</template>
<script setup lang="ts">
@@ -47,7 +44,7 @@ import { FailAlert, SuccessAlert } from 'src/shared/api';
import { useEditContributor } from '../model';
import { useContributorStore } from 'app/extensions/capital/entities/Contributor/model';
import { useSessionStore } from 'src/entities/Session/model';
import { ColorCard } from 'src/shared/ui';
import { BaseButton, BaseForm, BaseInput } from 'src/shared/ui/base';
const emit = defineEmits<{
'about-updated': [];
@@ -71,6 +68,11 @@ const hasAbout = computed(() => {
return contributorStore.self?.about && contributorStore.self.about.trim().length > 0;
});
// Валидация длины текста
const aboutError = computed(() =>
localAbout.value.length > 1000 ? 'Максимум 1000 символов' : undefined,
);
// Проверяем, есть ли изменения
const hasChanges = computed(() => {
const currentAbout = contributorStore.self?.about || '';
@@ -91,6 +93,7 @@ const cancelEditing = () => {
// Сохраняем изменения
const saveAbout = async () => {
if (aboutError.value) return;
try {
const aboutValue = localAbout.value.trim() || undefined;
@@ -121,16 +124,37 @@ watch(() => contributorStore.self?.about, (newAbout) => {
</script>
<style lang="scss" scoped>
.card-value {
font-size: 16px;
font-weight: 500;
color: var(--p-ink);
margin-bottom: var(--p-2);
.edit-field__view {
display: flex;
align-items: flex-start;
gap: var(--p-3);
}
.card-label {
font-size: var(--p-fs-body);
.edit-field__icon {
width: var(--p-8);
height: var(--p-8);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--p-r-sm);
background: var(--p-canvas-2);
color: var(--p-ink-2);
margin-bottom: var(--p-1);
}
.edit-field__main {
flex: 1;
min-width: 0;
}
.edit-field__head {
display: flex;
align-items: center;
gap: var(--p-1);
}
.edit-field__value {
white-space: pre-line;
font-weight: 500;
}
</style>
@@ -1,45 +1,40 @@
<template lang="pug">
ColorCard(color='blue' :transparent="true")
.card-label Часов в день
template(v-if="!isEditing")
.card-value(:class="{ 'cursor-pointer': isOwnProfile }" @click="isOwnProfile ? startEditing() : null")
template(v-if="isOwnProfile")
q-icon(
name="edit"
size="16px"
color="grey-6"
style="margin-right: 8px;"
)
| {{ hasHours ? contributorStore.self?.hours_per_day : 'Не указано' }}
template(v-else)
.q-pa-sm
q-input(
v-model.number="localHours"
type="number"
label="Часов в день"
outlined
min="1"
max="8"
step="1"
:rules="[val => !val || (val >= 1 && val <= 8) || 'От 1 до 24 часов']"
dense
)
.row.q-gutter-sm.q-mt-sm.justify-end
q-btn(
flat
dense
label="Отмена"
color="grey-7"
@click="cancelEditing"
)
q-btn(
color="primary"
dense
label="Сохранить"
:loading="isSaving"
:disable="!hasChanges"
@click="saveHours"
)
.edit-field
.edit-field__view
span.edit-field__icon
q-icon(name='schedule', size='20px')
.edit-field__main
template(v-if='!isEditing')
.edit-field__head
span.t-sm.t-muted Часов в день
BaseButton(
v-if='isOwnProfile',
variant='ghost',
size='sm',
icon-only,
aria-label='Редактировать количество часов в день',
@click='startEditing'
)
template(#icon-left)
q-icon(name='edit', size='16px')
.edit-field__value(:class='{ "t-muted": !hasHours }') {{ hasHours ? contributorStore.self?.hours_per_day : 'Не указано' }}
template(v-else)
BaseForm(:loading='isSaving', @submit='saveHours')
BaseInput(
v-model.number='localHours',
type='number',
label='Часов в день',
:error='hoursError'
)
template(#footer)
BaseButton(variant='ghost', size='sm', @click='cancelEditing') Отмена
BaseButton(
variant='primary',
size='sm',
type='submit',
:loading='isSaving',
:disabled='!hasChanges || !!hoursError'
) Сохранить
</template>
<script setup lang="ts">
@@ -48,7 +43,7 @@ import { FailAlert, SuccessAlert } from 'src/shared/api';
import { useEditContributor } from '../model';
import { useContributorStore } from 'app/extensions/capital/entities/Contributor/model';
import { useSessionStore } from 'src/entities/Session/model';
import { ColorCard } from 'src/shared/ui';
import { BaseButton, BaseForm, BaseInput } from 'src/shared/ui/base';
const emit = defineEmits<{
'hours-updated': [];
@@ -72,6 +67,14 @@ const hasHours = computed(() => {
return contributorStore.self?.hours_per_day && contributorStore.self.hours_per_day > 0;
});
// Валидация диапазона
const hoursError = computed(() => {
if (localHours.value === undefined || localHours.value === null) return undefined;
return localHours.value >= 1 && localHours.value <= 8
? undefined
: 'От 1 до 8 часов';
});
// Проверяем, есть ли изменения
const hasChanges = computed(() => {
const currentHours = contributorStore.self?.hours_per_day;
@@ -92,6 +95,7 @@ const cancelEditing = () => {
// Сохраняем изменения
const saveHours = async () => {
if (hoursError.value) return;
try {
// Отправляем все текущие значения из store + новое значение hours_per_day
await editContributor({
@@ -120,16 +124,37 @@ watch(() => contributorStore.self?.hours_per_day, (newHours) => {
</script>
<style lang="scss" scoped>
.card-value {
font-size: 16px;
font-weight: 500;
color: var(--p-ink);
margin-bottom: var(--p-2);
.edit-field__view {
display: flex;
align-items: flex-start;
gap: var(--p-3);
}
.card-label {
font-size: var(--p-fs-body);
.edit-field__icon {
width: var(--p-8);
height: var(--p-8);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--p-r-sm);
background: var(--p-canvas-2);
color: var(--p-ink-2);
margin-bottom: var(--p-1);
}
.edit-field__main {
flex: 1;
min-width: 0;
}
.edit-field__head {
display: flex;
align-items: center;
gap: var(--p-1);
}
.edit-field__value {
font-size: var(--p-fs-h3);
font-weight: 600;
}
</style>
@@ -1,47 +1,44 @@
<template lang="pug">
ColorCard(color='orange' :transparent="true")
.card-label Стоимость часа
template(v-if="!isEditing")
.card-value(:class="{ 'cursor-pointer': isOwnProfile }" @click="isOwnProfile ? startEditing() : null")
template(v-if="isOwnProfile")
q-icon(
name="edit"
size="16px"
color="grey-6"
style="margin-right: 8px;"
)
| {{ hasRate ? formattedRate : 'Не указано' }}
template(v-else)
.q-pa-sm
q-input(
v-model="localRate"
type="number"
label="Стоимость часа"
outlined
min="0"
max="3000"
step="0.01"
:rules="[val => !val || (val >= 0 && val <= 3000) || 'От 0 до 3000']"
dense
)
template(#append)
.text-body2 RUB
.row.q-gutter-sm.q-mt-sm.justify-end
q-btn(
flat
dense
label="Отмена"
color="grey-7"
@click="cancelEditing"
)
q-btn(
color="primary"
dense
label="Сохранить"
:loading="isSaving"
:disable="!hasChanges"
@click="saveRate"
)
.edit-field
.edit-field__view
span.edit-field__icon
q-icon(name='payments', size='20px')
.edit-field__main
template(v-if='!isEditing')
.edit-field__head
span.t-sm.t-muted Стоимость часа
BaseButton(
v-if='isOwnProfile',
variant='ghost',
size='sm',
icon-only,
aria-label='Редактировать стоимость часа',
@click='startEditing'
)
template(#icon-left)
q-icon(name='edit', size='16px')
.edit-field__value.t-mono(:class='{ "t-muted": !hasRate }') {{ hasRate ? formattedRate : 'Не указано' }}
template(v-else)
BaseForm(:loading='isSaving', @submit='saveRate')
AmountInput(
v-model='localRate',
label='Стоимость часа',
placeholder='0,00',
:symbol='governSymbol',
:precision='2',
:min='0',
:max='3000',
:error='rateError'
)
template(#footer)
BaseButton(variant='ghost', size='sm', @click='cancelEditing') Отмена
BaseButton(
variant='primary',
size='sm',
type='submit',
:loading='isSaving',
:disabled='!hasChanges || !!rateError'
) Сохранить
</template>
<script setup lang="ts">
@@ -51,7 +48,8 @@ import { useEditContributor } from '../model';
import { useContributorStore } from 'app/extensions/capital/entities/Contributor/model';
import { useSessionStore } from 'src/entities/Session/model';
import { useSystemStore } from 'src/entities/System/model';
import { ColorCard } from 'src/shared/ui';
import { BaseButton, BaseForm } from 'src/shared/ui/base';
import { AmountInput } from 'src/shared/ui/domain/AmountInput';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
const emit = defineEmits<{
@@ -64,7 +62,8 @@ const { info } = useSystemStore();
const { editContributor, isLoading } = useEditContributor();
const isEditing = ref(false);
const localRate = ref<string>('');
// AmountInput эмитит number | null
const localRate = ref<number | string | null>('');
const isSaving = computed(() => isLoading.value);
const governSymbol = computed(() => info.symbols.root_govern_symbol);
@@ -85,13 +84,21 @@ const formattedRate = computed(() => {
return formatAsset2Digits(contributorStore.self.rate_per_hour);
});
// Валидация диапазона
const rateError = computed(() => {
if (!localRate.value || !localRate.value.toString().trim()) return undefined;
const numericValue = parseFloat(localRate.value.toString());
if (isNaN(numericValue)) return 'Введите число';
return numericValue >= 0 && numericValue <= 3000 ? undefined : 'От 0 до 3000';
});
// Проверяем, есть ли изменения
const hasChanges = computed(() => {
if (!localRate.value.trim()) return false;
if (!localRate.value || !localRate.value.toString().trim()) return false;
const currentRate = contributorStore.self?.rate_per_hour || '';
const currentNumeric = currentRate.split(' ')[0];
const localNumeric = parseFloat(localRate.value.trim());
const localNumeric = parseFloat(localRate.value.toString().trim());
if (!currentNumeric || isNaN(localNumeric)) return true;
@@ -131,8 +138,9 @@ const formatRateForBackend = (rate: string): string | undefined => {
// Сохраняем изменения
const saveRate = async () => {
if (rateError.value) return;
try {
const formattedRate = formatRateForBackend(localRate.value);
const formattedRate = formatRateForBackend(String(localRate.value ?? ''));
// Отправляем все текущие значения из store + новое значение rate_per_hour
await editContributor({
@@ -162,16 +170,37 @@ watch(() => contributorStore.self?.rate_per_hour, (newRate) => {
</script>
<style lang="scss" scoped>
.card-value {
font-size: 16px;
font-weight: 500;
color: var(--p-ink);
margin-bottom: var(--p-2);
.edit-field__view {
display: flex;
align-items: flex-start;
gap: var(--p-3);
}
.card-label {
font-size: var(--p-fs-body);
.edit-field__icon {
width: var(--p-8);
height: var(--p-8);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--p-r-sm);
background: var(--p-canvas-2);
color: var(--p-ink-2);
margin-bottom: var(--p-1);
}
.edit-field__main {
flex: 1;
min-width: 0;
}
.edit-field__head {
display: flex;
align-items: center;
gap: var(--p-1);
}
.edit-field__value {
font-size: var(--p-fs-h3);
font-weight: 600;
}
</style>
@@ -1,29 +1,30 @@
<template lang="pug">
q-btn(
color="accent"
:color="fab ? 'accent' : 'primary'"
:label="buttonLabel"
@click="dialogRef?.openDialog()"
:fab="fab"
:disable="isSubmitting"
v-if="!project?.permissions?.pending_clearance"
).bg-fab-accent-radial
:class="{ 'bg-fab-accent-radial': fab }"
)
CreateDialog(
ref="dialogRef"
title="Откликнуться на приглашение"
submit-text="Отправить отклик"
dialog-style="width: 1000px; max-width: 100% !important;"
size="lg"
:is-submitting="isSubmitting"
:disabled="isSubmitDisabled"
@submit="handleConfirmRespond"
@dialog-closed="clear"
)
template(#title)
| Откликнуться на приглашение в:
ProjectPathWidget.ml-2(:project="project" text-color="white")
template(#form-fields)
.invite-dialog__target.q-mb-md
.text-caption.text-grey-7.q-mb-xs Куда отклик
ProjectPathWidget(:project="project")
.text-body2.q-mb-md
| Выберите роли участия:
.text-body2.text-weight-medium.q-mb-sm
| Выберите роли участия
RoleSelector(
v-model="selectedRoles"
@@ -1,11 +1,15 @@
<template lang="pug">
div
q-btn(
color='primary',
span
BaseButton(
variant='ghost',
size='sm',
:loading='isGenerating',
@click='showDialog = true',
label='Инвестировать в проект'
aria-label='Инвестировать в проект',
@click.stop='showDialog = true'
)
template(#icon-left)
q-icon(name='add', size='18px')
| Инвестиция
BaseDialog(
v-model='showDialog',
@@ -13,29 +17,30 @@ div
size='md',
@update:model-value='(v) => !v && clear()'
)
Form.q-pa-sm(
:handler-submit='handleInvest',
:is-submitting='isGenerating',
:button-cancel-txt='"Отменить"',
:button-submit-txt='"Инвестировать"',
@cancel='clear'
)
q-input(
BaseForm(:loading='isGenerating', @submit='handleInvest')
AmountInput(
v-model='quantity',
standout='bg-teal text-white',
placeholder='Введите сумму инвестиций',
type='number',
label='Сумма',
placeholder='0,00',
:symbol='currency',
:precision='2',
:min='0',
:rules='[(val) => val > 0 || "Сумма инвестиций должна быть положительной"]'
:error='amountError'
)
template(#append)
span.text-overline {{ currency }}
template(#footer)
BaseButton(variant='ghost', @click='clear') Отменить
BaseButton(
variant='primary',
type='submit',
:loading='isGenerating',
:disabled='!isValidAmount'
) Инвестировать
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { Form } from 'src/shared/ui/Form';
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
import { ref, computed } from 'vue';
import { BaseButton, BaseDialog, BaseForm } from 'src/shared/ui/base';
import { AmountInput } from 'src/shared/ui/domain/AmountInput';
import { useCreateProjectInvest } from '../model';
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
import { useSetPlan } from '../../../Project/SetPlan/model';
@@ -43,18 +48,33 @@ import type { IProject } from '../../../../entities/Project/model';
const props = defineProps<{ project: IProject | null | undefined }>();
const emit = defineEmits<{
actionCompleted: [];
}>();
const {
createProjectInvestWithGeneratedStatement,
isGenerating
} = useCreateProjectInvest();
const { governSymbol } = useSetPlan();
const quantity = ref();
const quantity = ref<number | string | null>(null);
const showDialog = ref(false);
const currency = computed(() => governSymbol.value);
const isValidAmount = computed(() => Number(quantity.value) > 0);
// Ошибку показываем только после ввода, чтобы пустая форма не краснела
const amountError = computed(() =>
quantity.value !== null && quantity.value !== '' && !isValidAmount.value
? 'Сумма инвестиций должна быть положительной'
: undefined,
);
const clear = (): void => {
showDialog.value = false;
quantity.value = 1000;
quantity.value = null;
};
// Обработка инвестирования (генерация + подпись + создание)
@@ -63,18 +83,18 @@ const handleInvest = async (): Promise<void> => {
FailAlert('Не указан проект');
return;
}
if (!isValidAmount.value) return;
isGenerating.value = true;
try {
// Создаем инвестицию с сгенерированным и подписанным заявлением
await createProjectInvestWithGeneratedStatement(
quantity.value.toString(),
String(quantity.value),
props.project.project_hash
);
// Показываем сообщение об успехе и закрываем диалог
SuccessAlert('Инвестиция принята');
clear();
emit('actionCompleted');
} catch (e: any) {
console.log('e.message', e.message);
FailAlert(e);
@@ -83,10 +103,9 @@ const handleInvest = async (): Promise<void> => {
}
};
const currency = computed(() => governSymbol.value);
// Инициализация данных для инвестирования при изменении проекта
watch(() => props.project, () => {
// Теперь инициализация происходит в createProjectInvestWithGeneratedStatement
}, { immediate: true });
defineExpose({
openDialog: () => {
showDialog.value = true;
},
});
</script>
@@ -1,13 +1,30 @@
<template lang="pug">
q-btn(
@click='dialogRef?.openDialog()',
:loading='loading',
:label='mini ? "" : "Создать задачу"',
:icon='mini ? "add" : "add"',
:size='mini ? "sm" : "md"',
flat,
@click.stop
)
span(:class='{ "create-host--row": row }')
//- Режим полнострочной «полоски-добавлялки» в конце списка (Linear-style)
.list-add-row(
v-if='row',
role='button',
tabindex='0',
aria-label='Добавить задачу',
@click.stop='dialogRef?.openDialog()',
@keydown.enter.prevent='dialogRef?.openDialog()'
)
q-icon(name='add', size='16px')
span Добавить задачу
BaseButton(
v-else,
variant='ghost',
:size='size ?? (mini ? "sm" : "md")',
:loading='loading',
:icon-only='mini',
aria-label='Создать задачу',
@click.stop='dialogRef?.openDialog()'
)
template(#icon-left)
q-icon(name='add', size='18px')
template(v-if='!mini', #default)
| {{ label ?? 'Создать задачу' }}
CreateIssueDialog(
ref='dialogRef',
@@ -18,11 +35,18 @@ q-btn(
<script setup lang="ts">
import { ref } from 'vue';
import { BaseButton } from 'src/shared/ui/base';
import { CreateIssueDialog } from './Dialog';
defineProps<{
mini?: boolean;
projectHash?: string;
/** Размер кнопки независимо от mini (для компактных строк списков) */
size?: 'sm' | 'md';
/** Короткая подпись для строк списков (default «Создать задачу») */
label?: string;
/** Полнострочная «полоска-добавлялка» в конце списка вместо кнопки */
row?: boolean;
}>();
const emit = defineEmits<{
@@ -35,4 +59,37 @@ const loading = ref(false);
const handleSuccess = () => {
emit('actionCompleted');
};
defineExpose({
openDialog: () => dialogRef.value?.openDialog(),
});
</script>
<style lang="scss" scoped>
.create-host--row {
display: block;
width: 100%;
}
// Полнострочная «полоска-добавлялка»: muted-текст, проявляется фоном по hover
.list-add-row {
display: flex;
align-items: center;
gap: var(--p-2);
width: 100%;
min-height: 40px;
padding: var(--p-2) var(--p-3);
box-sizing: border-box;
color: var(--p-ink-3);
font-size: var(--p-fs-body-sm);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
&:hover,
&:focus-visible {
background-color: var(--p-surface-2);
color: var(--p-ink-1);
outline: none;
}
}
</style>
@@ -180,7 +180,7 @@ const handleSubmit = async () => {
icon: 'launch',
handler: () => {
router.push({
name: 'component-issue',
name: 'component-issue-description',
params: {
coopname: system.info.coopname,
project_hash: currentProjectHash.value,
@@ -4,9 +4,13 @@
// По клику открывается q-menu с полным ContributorSelector — вся функциональность
// существующего SetCreatorButton сохраняется (поиск, мульти-выбор, дебаунс-сохранение).
.creators-trigger(
:class='{ readonly: !canAssign, empty: currentCreators.length === 0 }'
:class='{ readonly: !canAssign, empty: currentCreators.length === 0 && !isLoadingCreators }'
)
template(v-if='currentCreators.length > 0')
//- Пока исполнители грузятся — скелетон-кружок, а не «назначьте исполнителя»:
//- пустой плейсхолдер во время загрузки вводит в заблуждение
template(v-if='isLoadingCreators && currentCreators.length === 0')
.skel.skel--circle.avatar-skel
template(v-else-if='currentCreators.length > 0')
.avatar-stack
.creator-avatar(
v-for='(c, idx) in visibleCreators'
@@ -18,7 +22,7 @@
.creator-avatar.more-avatar(v-if='hiddenCount > 0')
span.creator-initial +{{ hiddenCount }}
template(v-else)
q-icon(name='person_add', size='18px', color='grey-6')
q-icon.empty-icon(name='person_add', size='18px', color='grey-6')
q-menu(
v-if='canAssign'
@@ -222,23 +226,37 @@ watch(
box-sizing: border-box;
cursor: pointer;
padding: 2px 4px;
border-radius: 12px;
border-radius: var(--p-r-md);
transition: background-color 0.15s ease;
&:hover:not(.readonly) {
background-color: rgba(0, 0, 0, 0.04);
background-color: var(--p-surface-2);
}
&.readonly {
cursor: default;
}
// Пустое состояние выравниваем так же вправо, как и заполненное: иначе
// иконка внутри фикс-ширины 72px визуально получала «отступ справа» и
// выпадала из вертикали аватарок соседних строк.
&.empty {
padding: 4px;
justify-content: center;
padding: 4px 9px 4px 4px; // 4 + (28 18) / 2 — центр иконки в центре колонки аватара
}
}
// margin-left: auto — жёсткая прижимка вправо независимо от justify-content
.empty-icon {
margin-left: auto;
}
// Скелетон на время загрузки исполнителей — той же геометрии, что аватар
.avatar-skel {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.avatar-stack {
display: inline-flex;
flex-direction: row;
@@ -252,9 +270,9 @@ watch(
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--q-primary, #1976d2);
background-color: var(--p-primary);
color: #fff;
border: 2px solid var(--q-color-white, #fff);
border: 2px solid var(--p-surface);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -267,8 +285,8 @@ watch(
}
&.more-avatar {
background-color: #e0e0e0;
color: #424242;
background-color: var(--p-surface-2);
color: var(--p-ink-2);
font-size: 11px;
font-weight: 500;
}
@@ -118,7 +118,7 @@ function formatHours(h: number | null | undefined): string {
const inlineLabel = computed(() => {
if (hasFact.value && hasEstimate.value) {
return `${formatHours(props.fact)}/${formatHours(props.estimate)}`;
return `${formatHours(props.fact)} / ${formatHours(props.estimate)}`;
}
if (hasFact.value) return formatHours(props.fact);
if (hasEstimate.value) return formatHours(props.estimate);
@@ -139,8 +139,8 @@ const progressColor = computed(() => {
if (!hasEstimate.value) return 'grey-6';
const fact = props.fact ?? 0;
const est = props.estimate ?? 0;
if (fact > est + 1e-6) return 'orange-7';
return 'teal-7';
if (fact > est + 1e-6) return 'warning';
return 'primary';
});
const progressLabel = computed(() => {
@@ -181,27 +181,28 @@ const saveEstimate = async () => {
align-items: center;
}
// Триггер фиксированной ширины — title не прыгает между задачами
// с разными значениями времени.
// Триггер живёт в фиксированной колонке строки (.cell-time) и
// прижимается к её правому краю — время всех уровней в одной вертикали.
.time-trigger {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 3px;
padding: 2px 6px;
height: 22px;
width: 86px;
box-sizing: border-box;
border-radius: 4px;
font-size: 11px;
font-family: var(--p-mono);
font-size: var(--p-fs-mono-sm, 12px);
font-weight: 500;
color: var(--q-grey-7, #616161);
color: var(--p-ink-2);
cursor: pointer;
transition: background-color 0.12s ease;
white-space: nowrap;
overflow: hidden;
&:hover:not(.readonly) {
background-color: rgba(0, 0, 0, 0.04);
background-color: var(--p-surface-2);
}
&.readonly {
@@ -209,7 +210,7 @@ const saveEstimate = async () => {
}
&.empty {
color: var(--q-grey-6, #757575);
color: var(--p-ink-3);
justify-content: flex-start;
}
}
@@ -229,14 +230,14 @@ const saveEstimate = async () => {
.time-popup {
min-width: 240px;
padding: 12px;
background-color: var(--q-color-white, #fff);
background-color: var(--p-surface);
border-radius: 8px;
}
.popup-header {
font-size: 11px;
font-weight: 600;
color: var(--q-grey-7, #616161);
color: var(--p-ink-2);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
@@ -252,7 +253,7 @@ const saveEstimate = async () => {
.popup-label {
width: 64px;
font-size: 12px;
color: var(--q-grey-7, #616161);
color: var(--p-ink-2);
}
.popup-input {
@@ -270,7 +271,7 @@ const saveEstimate = async () => {
flex: 1;
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--q-grey-8, #424242);
color: var(--p-ink);
}
.popup-progress {
@@ -280,14 +281,14 @@ const saveEstimate = async () => {
.popup-progress-text {
font-size: 11px;
color: var(--q-grey-6, #757575);
color: var(--p-ink-3);
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.popup-hint {
font-size: 10px;
color: var(--q-grey-6, #757575);
color: var(--p-ink-3);
line-height: 1.3;
font-style: italic;
}
@@ -178,14 +178,14 @@ const handleStatusChange = async (option: {
.status-menu {
min-width: 200px;
padding: 6px;
background-color: var(--q-color-white, #fff);
background-color: var(--p-surface);
border-radius: 8px;
}
.status-menu-header {
font-size: 11px;
font-weight: 500;
color: var(--q-grey-6, #757575);
color: var(--p-ink-3);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 8px 6px;
@@ -208,7 +208,7 @@ const handleStatusChange = async (option: {
}
&:hover {
background-color: rgba(0, 0, 0, 0.04);
background-color: var(--p-surface-2);
}
}
@@ -1,13 +1,10 @@
<template lang="pug">
div
q-btn(
color='primary'
flat
class='q-mt-sm full-width'
:loading='isGenerating'
BaseButton(
variant='primary',
:loading='isGenerating',
@click='showDialog = true'
label='Инвестировать'
)
) Инвестировать
BaseDialog(
v-model='showDialog',
@@ -15,29 +12,30 @@ div
size='md',
@update:model-value='(v) => !v && clear()'
)
Form.q-pa-sm(
:handler-submit='handleInvest',
:is-submitting='isGenerating',
:button-cancel-txt='"Отменить"',
:button-submit-txt='"Инвестировать"',
@cancel='clear'
)
q-input(
BaseForm(:loading='isGenerating', @submit='handleInvest')
AmountInput(
v-model='quantity',
standout='bg-teal text-white',
placeholder='Введите сумму',
type='number',
label='Сумма',
placeholder='0,00',
:symbol='currency',
:precision='2',
:min='0',
:rules='[(val) => val > 0 || "Сумма должна быть положительной"]'
:error='amountError'
)
template(#append)
span.text-overline {{ currency }}
template(#footer)
BaseButton(variant='ghost', @click='clear') Отменить
BaseButton(
variant='primary',
type='submit',
:loading='isGenerating',
:disabled='!isValidAmount'
) Инвестировать
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Form } from 'src/shared/ui/Form';
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
import { BaseButton, BaseDialog, BaseForm } from 'src/shared/ui/base';
import { AmountInput } from 'src/shared/ui/domain/AmountInput';
import { useCreateProgramInvest } from '../model';
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
import { useSystemStore } from 'src/entities/System/model';
@@ -46,19 +44,30 @@ const { createProgramInvestWithGeneratedStatement, isGenerating } =
useCreateProgramInvest();
const system = useSystemStore();
const quantity = ref<number | string>();
// AmountInput эмитит number | null
const quantity = ref<number | string | null>(null);
const showDialog = ref(false);
const currency = computed(
() => system.info?.symbols?.root_govern_symbol ?? 'GOV',
);
const isValidAmount = computed(() => Number(quantity.value) > 0);
// Ошибку показываем только после ввода, чтобы пустой диалог не открывался красным
const amountError = computed(() =>
quantity.value != null && quantity.value !== '' && !isValidAmount.value
? 'Сумма должна быть положительной'
: undefined,
);
const clear = (): void => {
showDialog.value = false;
quantity.value = '';
};
const handleInvest = async (): Promise<void> => {
if (!isValidAmount.value) return;
try {
await createProgramInvestWithGeneratedStatement(quantity.value!.toString());
SuccessAlert('Инвестиция принята');
@@ -1,12 +1,15 @@
<template lang="pug">
div
q-btn(
span
BaseButton(
variant='ghost',
size='sm',
color='primary',
@click='showDialog = true',
:loading='isSubmitting',
label='Добавить соавтора'
aria-label='Добавить соавтора',
@click.stop='showDialog = true'
)
template(#icon-left)
q-icon(name='add', size='18px')
| Соавтор
BaseDialog(
v-model='showDialog',
@@ -45,6 +48,7 @@ import { ref, watch } from 'vue';
import { useAddAuthor } from '../model';
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
import { ContributorSelector } from '../../../../entities/Contributor';
import { BaseButton } from 'src/shared/ui/base';
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
import { Form } from 'src/shared/ui/Form';
import type { IProject } from '../../../../entities/Project/model';
@@ -123,6 +127,12 @@ const handleAddAuthors = async () => {
isSubmitting.value = false;
}
};
defineExpose({
openDialog: () => {
showDialog.value = true;
},
});
</script>
<style lang="scss" scoped>
@@ -1,13 +1,30 @@
<template lang="pug">
q-btn(
@click.stop='handleButtonClick',
:loading='loading',
:label='mini ? "" : "Компонент"',
:icon='mini ? "add" : "add"',
:size='mini ? "sm" : "md"',
flat,
:dense="isMobile"
)
span(:class='{ "create-host--row": row }')
//- Режим полнострочной «полоски-добавлялки» в конце списка (Linear-style)
.list-add-row(
v-if='row',
role='button',
tabindex='0',
aria-label='Добавить компонент',
@click.stop='handleButtonClick',
@keydown.enter.prevent='handleButtonClick'
)
q-icon(name='add', size='16px')
span Добавить компонент
BaseButton(
v-else,
variant='ghost',
:size='size ?? (mini ? "sm" : "md")',
:loading='loading',
:icon-only='mini',
aria-label='Создать компонент',
@click.stop='handleButtonClick'
)
template(#icon-left)
q-icon(name='add', size='18px')
template(v-if='!mini', #default)
| Компонент
CreateComponentDialog(
ref="dialogRef"
@@ -20,14 +37,16 @@ q-btn(
<script setup lang="ts">
import { ref } from 'vue';
import type { IProject } from 'app/extensions/capital/entities/Project/model';
import { useWindowSize } from 'src/shared/hooks';
import { BaseButton } from 'src/shared/ui/base';
import { CreateComponentDialog } from './Dialog';
const { isMobile } = useWindowSize();
defineProps<{
project: IProject;
mini?: boolean;
/** Размер кнопки независимо от mini (для компактных строк списков) */
size?: 'sm' | 'md';
/** Полнострочная «полоска-добавлялка» в конце списка вместо кнопки */
row?: boolean;
}>();
const emit = defineEmits<{
@@ -47,4 +66,37 @@ const handleSuccess = () => {
// После успешного создания компонента отправляем событие для закрытия меню
emit('onClick');
};
defineExpose({
openDialog: () => dialogRef.value?.openDialog(),
});
</script>
<style lang="scss" scoped>
.create-host--row {
display: block;
width: 100%;
}
// Полнострочная «полоска-добавлялка»: muted-текст, проявляется фоном по hover
.list-add-row {
display: flex;
align-items: center;
gap: var(--p-2);
width: 100%;
min-height: 40px;
padding: var(--p-2) var(--p-3);
box-sizing: border-box;
color: var(--p-ink-3);
font-size: var(--p-fs-body-sm);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
&:hover,
&:focus-visible {
background-color: var(--p-surface-2);
color: var(--p-ink-1);
outline: none;
}
}
</style>
@@ -0,0 +1,45 @@
<template lang="pug">
div
BaseButton(
variant='primary',
aria-label='Создать проект',
@click='dialogRef?.openDialog()'
)
template(#icon-left)
q-icon(name='add', size='18px')
| Проект
CreateProjectDialog(
ref='dialogRef',
@success='handleSuccess'
)
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { BaseButton } from 'src/shared/ui/base';
import { CreateProjectDialog } from './Dialog';
const props = defineProps<{
/**
* Канал для хоткея страницы: header-action рендерится через
* useHeaderActions (markRaw-компонент, ref снаружи недоступен),
* поэтому страница получает openDialog через колбэк.
*/
exposeOpen?: (fn: () => void) => void;
}>();
const emit = defineEmits<{
actionCompleted: [];
}>();
const dialogRef = ref();
const handleSuccess = () => {
emit('actionCompleted');
};
onMounted(() => {
props.exposeOpen?.(() => dialogRef.value?.openDialog());
});
</script>
@@ -1,2 +1,3 @@
export { default as CreateProjectButton } from './CreateProjectButton.vue';
export { default as CreateProjectHeaderButton } from './CreateProjectHeaderButton.vue';
export * from './Fab';
@@ -0,0 +1,225 @@
<template lang="pug">
.set-master-avatar(@click.stop)
//- Триггер: аватарка мастера (или плейсхолдер «назначить»).
//- По клику открывается q-menu с ContributorSelector — мастер один,
//- поэтому выбор одиночный (без мульти-выбора).
.master-trigger(
:class='{ readonly: !canSet, empty: !currentMaster && !isLoadingMaster }'
)
//- Пока мастер грузится — скелетон-кружок, а не «мастер не назначен»:
//- пустой плейсхолдер во время загрузки вводит в заблуждение
template(v-if='isLoadingMaster && !currentMaster')
.skel.skel--circle.avatar-skel
template(v-else-if='currentMaster')
.master-avatar
span.master-initial {{ masterInitial }}
q-tooltip(anchor='bottom middle', self='top middle') Мастер: {{ currentMaster.display_name || currentMaster.username }}
template(v-else)
q-icon.empty-icon(name='manage_accounts', size='18px', color='grey-6')
q-tooltip(v-if='canSet', anchor='bottom middle', self='top middle') Назначить мастера
q-menu(
v-if='canSet'
anchor='bottom right'
self='top right'
:offset='[0, 4]'
)
.selector-popup
ContributorSelector(
v-model='selectedMaster'
:multi-select='false'
:dense='true'
:loading='loading'
:project-hash='project?.project_hash'
placeholder='поиск...'
label='Мастер'
autofocus
)
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useSetMaster } from '../model';
import { useContributorStore } from '../../../../entities/Contributor/model';
import { FailAlert } from 'src/shared/api/alerts';
import { ContributorSelector } from '../../../../entities/Contributor';
import type { IProject, IProjectComponent } from '../../../../entities/Project/model';
import type { IContributor } from '../../../../entities/Contributor/model';
interface Props {
project: IProject | IProjectComponent;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'master-set': [contributor: IContributor | null];
}>();
const { setMaster, setMasterInput } = useSetMaster();
const contributorStore = useContributorStore();
const loading = ref(false);
const selectedMaster = ref<IContributor | null>(null);
const currentMaster = ref<IContributor | null>(null);
const isSaving = ref(false);
const isLoadingMaster = ref(false);
const isProgrammaticChange = ref(false);
const canSet = computed(() => !!props.project?.permissions?.can_set_master);
const masterInitial = computed(() => {
const src =
currentMaster.value?.display_name || currentMaster.value?.username || '?';
return src.charAt(0).toUpperCase();
});
const loadMaster = async (masterUsername: string) => {
isProgrammaticChange.value = true;
isLoadingMaster.value = true;
try {
if (!masterUsername) {
currentMaster.value = null;
selectedMaster.value = null;
return;
}
const contributor = await contributorStore.loadContributor({
username: masterUsername,
});
currentMaster.value = contributor ?? null;
selectedMaster.value = contributor ?? null;
} catch (error) {
console.error('SetMasterAvatar: load master failed', error);
currentMaster.value = null;
selectedMaster.value = null;
} finally {
await nextTick();
isProgrammaticChange.value = false;
isLoadingMaster.value = false;
}
};
watch(
() => props.project,
async (newProject) => {
if (newProject) {
setMasterInput.value.project_hash = newProject.project_hash;
await loadMaster(newProject.master || '');
} else {
await loadMaster('');
}
},
{ immediate: true },
);
watch(selectedMaster, async (newMaster, oldMaster) => {
if (isProgrammaticChange.value) return;
if (isSaving.value) return;
if (!canSet.value) {
FailAlert('У вас нет прав на изменение мастера');
isProgrammaticChange.value = true;
selectedMaster.value = oldMaster ?? null;
await nextTick();
isProgrammaticChange.value = false;
return;
}
if ((newMaster?.username || '') === (currentMaster.value?.username || ''))
return;
isSaving.value = true;
loading.value = true;
try {
await setMaster({
coopname: setMasterInput.value.coopname,
project_hash: props.project.project_hash,
master: newMaster?.username || '',
});
currentMaster.value = newMaster ?? null;
emit('master-set', newMaster ?? null);
} catch (error) {
console.error('SetMasterAvatar: setMaster error', error);
FailAlert(error);
isProgrammaticChange.value = true;
selectedMaster.value = oldMaster ?? null;
await nextTick();
isProgrammaticChange.value = false;
} finally {
loading.value = false;
isSaving.value = false;
}
});
</script>
<style lang="scss" scoped>
.set-master-avatar {
display: inline-flex;
align-items: center;
}
// Та же геометрия, что у аватарок исполнителей задач (SetCreatorAvatars):
// колонка не прыгает между строками с мастером и без.
.master-trigger {
display: inline-flex;
align-items: center;
justify-content: flex-end;
box-sizing: border-box;
cursor: pointer;
padding: 2px 4px;
border-radius: var(--p-r-md);
transition: background-color 0.15s ease;
&:hover:not(.readonly) {
background-color: var(--p-surface-2);
}
&.readonly {
cursor: default;
}
&.empty {
padding: 4px 9px 4px 4px; // 4 + (28 18) / 2 — центр иконки в центре колонки аватара
}
}
// Скелетон на время загрузки мастера — той же геометрии, что аватар
.avatar-skel {
width: 28px;
height: 28px;
flex-shrink: 0;
}
// margin-left: auto — жёсткая прижимка вправо независимо от justify-content
.empty-icon {
margin-left: auto;
}
.master-avatar {
box-sizing: border-box;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--p-primary);
color: #fff;
border: 2px solid var(--p-surface);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
line-height: 1;
}
.master-initial {
display: block;
line-height: 1;
margin-top: 1px; // оптическая центровка под cap-height шрифта
}
.selector-popup {
padding: var(--p-3);
min-width: 260px;
max-width: 320px;
}
</style>
@@ -1 +1,2 @@
export { default as SetMasterButton } from './SetMasterButton.vue';
export { default as SetMasterAvatar } from './SetMasterAvatar.vue';
@@ -1,134 +1,42 @@
<template lang="pug">
div
q-btn(
v-if="props.project?.permissions?.can_set_plan"
size='sm',
color='primary',
@click='showDialog = true',
label='Установить план проекта'
span
BaseButton(
v-if="project?.permissions?.can_set_plan"
variant='ghost'
size='sm'
aria-label='Установить план'
@click.stop='dialogRef?.openDialog()'
)
template(#icon-left)
q-icon(name='edit', size='18px')
| Финплан
BaseDialog(
v-model='showDialog',
title='Установить план проекта',
size='md',
@update:model-value='(v) => !v && clear()'
SetPlanDialog(
ref='dialogRef'
:project='project'
@success='handleSuccess'
)
Form.q-pa-md(
:handler-submit='handleSetPlan',
:is-submitting='isSubmitting',
:button-submit-txt='"Установить план"',
:button-cancel-txt='"Отмена"',
@cancel='clear'
style="width: 600px; max-width: 100% !important;"
)
q-input(
v-model.number='formData.plan_creators_hours'
label='Плановое количество часов исполнителей'
type='number'
:rules='[(val) => val > 0 || "Количество часов должно быть больше 0"]'
outlined
)
template(#append)
span.text-grey-7 ч
q-input(
v-model='formData.plan_expenses'
label='Плановые расходы'
:rules='[(val) => !!val || "Плановые расходы обязательны"]'
outlined
)
template(#append)
span.text-grey-7 {{ governSymbol }}
q-input(
v-model='formData.plan_hour_cost'
label='Стоимость часа работы'
:rules='[(val) => !!val || "Стоимость часа работы обязательна"]'
outlined
)
template(#append)
span.text-grey-7 {{ governSymbol }}
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useSetPlan } from '../model';
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
import { Form } from 'src/shared/ui/Form';
import { ref } from 'vue';
import { BaseButton } from 'src/shared/ui/base';
import { SetPlanDialog } from './Dialog';
import type { IProject } from '../../../../entities/Project/model';
const props = defineProps<{ project: IProject | null | undefined }>();
defineProps<{ project: IProject | null | undefined }>();
const { setPlan, governSymbol, formatAmountForEOSIO } = useSetPlan();
const emit = defineEmits<{
actionCompleted: [];
}>();
const showDialog = ref(false);
const isSubmitting = ref(false);
const dialogRef = ref();
const formData = ref({
plan_creators_hours: 0,
plan_expenses: '',
plan_hour_cost: ''
const handleSuccess = () => {
emit('actionCompleted');
};
defineExpose({
openDialog: () => dialogRef.value?.openDialog(),
});
const clear = () => {
showDialog.value = false;
formData.value = {
plan_creators_hours: 0,
plan_expenses: '',
plan_hour_cost: ''
};
};
// Обновляем входные данные при изменении проекта
watch(
() => props.project,
(newProject) => {
if (newProject && newProject.is_planed) {
// Для запланированного проекта заполняем форму текущими значениями
formData.value.plan_creators_hours = newProject.plan.creators_hours || 0;
formData.value.plan_expenses = newProject.plan.target_expense_pool || '';
formData.value.plan_hour_cost = newProject.plan.hour_cost || '';
} else {
// Для нового проекта сбрасываем форму
formData.value = {
plan_creators_hours: 0,
plan_expenses: '',
plan_hour_cost: ''
};
}
},
{ immediate: true }
);
const handleSetPlan = async () => {
if (!props.project) return;
// Проверяем права доступа
if (!props.project.permissions?.can_set_plan) {
FailAlert('У вас нет прав на установку плана проекта');
return;
}
isSubmitting.value = true;
try {
const planData = {
coopname: props.project.coopname || '',
master: props.project.master || '',
project_hash: props.project.project_hash,
plan_creators_hours: formData.value.plan_creators_hours,
plan_expenses: formatAmountForEOSIO(formData.value.plan_expenses),
plan_hour_cost: formatAmountForEOSIO(formData.value.plan_hour_cost)
};
await setPlan(planData);
SuccessAlert('План проекта успешно установлен');
clear();
} catch (error) {
FailAlert(error);
} finally {
isSubmitting.value = false;
}
};
</script>
@@ -0,0 +1,57 @@
<template lang="pug">
span
BaseButton(
v-if='canCreateRequirement',
variant='ghost',
size='sm',
aria-label='Создать артефакт',
@click.stop='dialogRef?.openDialog()'
)
template(#icon-left)
q-icon(name='add', size='18px')
| Артефакт
CreateRequirementWithEditorDialog(
ref='dialogRef',
:filter='filter',
@success='handleSuccess'
)
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { BaseButton } from 'src/shared/ui/base';
import { CreateRequirementWithEditorDialog } from './Dialog';
import type { IIssuePermissions } from 'app/extensions/capital/entities/Issue/model';
import type { IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
const props = withDefaults(defineProps<{
filter?: {
project_hash?: string;
issue_hash?: string;
};
permissions?: IIssuePermissions | IProjectPermissions | null;
}>(), {
filter: undefined,
permissions: null,
});
const emit = defineEmits<{
actionCompleted: [];
}>();
const dialogRef = ref();
const canCreateRequirement = computed((): boolean => {
if (!props.permissions) return false;
return !!(props.permissions as IProjectPermissions).can_create_requirement;
});
const handleSuccess = () => {
emit('actionCompleted');
};
defineExpose({
openDialog: () => dialogRef.value?.openDialog(),
});
</script>
@@ -1,3 +1,4 @@
export { default as CreateStoryButton } from './CreateStoryButton.vue';
export { default as CreateRequirementButton } from './CreateRequirementButton.vue';
export { CreateRequirementFabAction } from './Fab';
export { CreateRequirementDialog, CreateRequirementWithEditorDialog } from './Dialog';
@@ -7,6 +7,10 @@ import { ProjectsListPage } from './pages/ProjectsListPage';
import { ProjectPage } from './pages/ProjectPage';
import { ComponentPage } from './pages/ComponentPage';
import { IssuePage } from './pages/IssuePage';
import { IssueDescriptionPage } from './pages/IssueDescriptionPage';
import { IssueRequirementsPage } from './pages/IssueRequirementsPage';
import { IssueCommitsPage } from './pages/IssueCommitsPage';
import { IssueHistoryPage } from './pages/IssueHistoryPage';
import { TrackerPage } from './pages';
import { ProjectsVotingPage } from './pages';
import { ProjectsResultsPage } from './pages';
@@ -557,7 +561,65 @@ export default async function (): Promise<IWorkspaceConfig[]> {
requiresAuth: true,
hidden: true,
},
children: [],
children: [
{
path: '',
name: 'component-issue-redirect',
redirect: { name: 'component-issue-description' },
},
{
path: 'description',
name: 'component-issue-description',
component: markRaw(IssueDescriptionPage),
meta: {
title: 'Описание задачи',
icon: 'fa-solid fa-file-alt',
roles: [],
agreements: agreementsBase,
requiresAuth: true,
hidden: true,
},
},
{
path: 'requirements',
name: 'component-issue-requirements',
component: markRaw(IssueRequirementsPage),
meta: {
title: 'Артефакты задачи',
icon: 'fa-solid fa-clipboard-list',
roles: [],
agreements: agreementsBase,
requiresAuth: true,
hidden: true,
},
},
{
path: 'commits',
name: 'component-issue-commits',
component: markRaw(IssueCommitsPage),
meta: {
title: 'Коммиты задачи',
icon: 'fa-solid fa-code-commit',
roles: [],
agreements: agreementsBase,
requiresAuth: true,
hidden: true,
},
},
{
path: 'history',
name: 'component-issue-history',
component: markRaw(IssueHistoryPage),
meta: {
title: 'История задачи',
icon: 'fa-solid fa-history',
roles: [],
agreements: agreementsBase,
requiresAuth: true,
hidden: true,
},
},
],
},
],
},
@@ -1,66 +1,37 @@
<template lang="pug">
//- Профиль участника Благороста: шапка-удостоверение, кошельки программ,
//- редактируемые параметры участия и взносы по ролям
.capital-profile(v-if='contributorStore.self')
IdentityPanel(:identity='identity')
template(#actions)
ContributorGamificationWidget
.profile-container
CapitalWalletsCardsWidget
// Заголовок профиля
.profile-header
.header-content
.avatar-section
BaseCard(title='Параметры участия')
.capital-profile__fields
EditAboutInput(@about-updated='handleFieldUpdated')
EditHoursPerDayInput(@hours-updated='handleFieldUpdated')
EditRatePerHourInput(@rate-updated='handleFieldUpdated')
AutoAvatar(:username="username")
.identity-section
.display-name {{ contributorStore?.self?.display_name }}
.gamification-section
ContributorGamificationWidget
// Кошельки программ
CapitalWalletsCardsWidget(v-if="contributorStore?.self")
.about-section
EditAboutInput(color='teal' :transparent="false" @about-updated="handleFieldUpdated")
.work-section
.row
.col-md-12.col-xs-12
EditHoursPerDayInput(color="teal" :transparent="false" @hours-updated="handleFieldUpdated")
.col-md-12.col-xs-12
EditRatePerHourInput(color="teal" :transparent="false" @rate-updated="handleFieldUpdated")
// Финансовая информация
.financial-section
// Загрузка данных
template(v-if="!contributorStore?.self")
.loading-container
q-spinner(color='grey-5' size='1.5em')
.loading-label Загрузка данных профиля...
// Данные загружены
template(v-else)
ColorCard(color='teal' :transparent="true")
// Вклады по ролям
.contributions-section
// Общая сумма как первый элемент
.total-row
.total-icon
q-icon(name="bar_chart", size="16px", color="grey-7")
.total-content
.total-name Общая сумма взносов
.total-amount {{ totalContributions }}
// Детализация по ролям
.role-row(
v-for="role in roleContributions"
:key="role.key"
).q-ml-md
.role-icon
q-icon(:name="role.icon", size="16px", color="grey-7")
.role-content
.role-name {{ role.name }}
.role-value {{ role.value }}
BaseCard(title='Взносы по ролям')
//- Итог — выделенной плашкой, детализация — строками с иконками ролей
.contrib-total
.contrib-total__label.t-sm.t-muted Общая сумма взносов
.contrib-total__value {{ totalContributions }}
.contrib-rows
.contrib-row(v-for='role in roleContributions', :key='role.key')
span.contrib-row__icon
q-icon(:name='role.icon', size='20px')
.contrib-row__name {{ role.name }}
.contrib-row__value.t-mono {{ role.value }}
//- Скелетон первичной загрузки (poll обновляет данные молча)
.capital-profile(v-else)
.capital-profile__skel-head
.skel.skel--circle.capital-profile__skel-avatar
.skel.skel--title.capital-profile__skel-name
.skel.capital-profile__skel-card(v-for='i in 3', :key='i')
</template>
<script lang="ts" setup>
@@ -71,18 +42,28 @@ import { useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import { useSessionStore } from 'src/entities/Session/model/store';
import { useSystemStore } from 'src/entities/System/model';
import { useHeaderActions } from 'src/shared/hooks/useHeaderActions';
import { EditAboutInput, EditHoursPerDayInput, EditRatePerHourInput } from 'app/extensions/capital/features/Contributor/EditContributor';
import { CreateProgramInvestButton } from 'app/extensions/capital/features/ProgramInvest/CreateProgramInvest/ui';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import {ColorCard} from 'src/shared/ui';
import { AutoAvatar } from 'src/shared/ui/domain/AutoAvatar';
import { BaseCard } from 'src/shared/ui/base';
import { IdentityPanel, type Identity } from 'src/shared/ui/domain/IdentityPanel';
import { CapitalWalletsCardsWidget } from 'app/extensions/capital/widgets/CapitalWalletsCardsWidget';
const contributorStore = useContributorStore();
const system = useSystemStore();
const { username } = useSessionStore();
const { registerAction } = useHeaderActions();
// Вычисляемые свойства
const governSymbol = computed(() => system.info?.symbols?.root_govern_symbol || 'GOV');
// Шапка-удостоверение: имя участника + аккаунт
const identity = computed<Identity>(() => ({
fullName: contributorStore.self?.display_name || '',
accountName: username,
}));
// Форматированные вклады по ролям
const formattedInvestor = computed(() => {
if (!contributorStore.self) return '0.00';
@@ -108,7 +89,6 @@ const formattedCoordinator = computed(() => {
return formatAsset2Digits(`${value} ${governSymbol.value}`);
});
const formattedContributor = computed(() => {
if (!contributorStore.self) return '0.00';
const value = contributorStore.self?.contributed_as_contributor || '0';
@@ -145,36 +125,31 @@ const roleContributions = computed(() => {
name: 'Соавтор',
value: formattedAuthor.value,
icon: 'lightbulb',
color: 'orange'
},
{
key: 'creator',
name: 'Исполнитель',
value: formattedCreator.value,
icon: 'build',
color: 'blue'
},
{
key: 'investor',
name: 'Инвестор',
value: formattedInvestor.value,
icon: 'account_balance_wallet',
color: 'green'
},
{
key: 'coordinator',
name: 'Координатор',
value: formattedCoordinator.value,
icon: 'campaign',
color: 'purple'
},
{
key: 'contributor',
name: 'Получено в Благорост',
value: formattedContributor.value,
icon: 'local_florist',
color: 'purple'
}
},
];
});
@@ -204,9 +179,16 @@ const { start: startProfilePoll, stop: stopProfilePoll } = useDataPoller(
// Проверяем при монтировании
onMounted(async () => {
// Главное действие страницы — в правом верхнем углу топбара
registerAction({
id: 'capital-profile-invest',
component: CreateProgramInvestButton,
order: 1,
});
// Загружаем данные текущего участника аналогично CapitalBase
await contributorStore.loadSelf({ username });
// Запускаем poll обновление данных
startProfilePoll();
});
@@ -218,211 +200,111 @@ onBeforeUnmount(() => {
</script>
<style lang="scss" scoped>
.profile-container {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
.capital-profile {
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
padding: var(--p-6, 24px);
}
@media (max-width: 768px) {
padding: 20px 16px;
@media (max-width: 768px) {
.capital-profile {
padding: var(--p-4, 16px);
}
}
// Заголовок профиля
.profile-header {
margin-bottom: 32px;
// Редактируемые поля внутри карточки «Параметры участия» —
// hairline-разделители между строками
.capital-profile__fields {
display: flex;
flex-direction: column;
.header-content {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 24px;
@media (max-width: 768px) {
flex-direction: column;
gap: 16px;
text-align: center;
margin-bottom: 16px;
}
> * {
padding: var(--p-3) 0;
border-bottom: 1px solid var(--p-line);
}
.avatar-section {
.avatar-circle {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--q-grey-1, #f5f5f5);
border: 1px solid var(--q-grey-3, #e0e0e0);
display: flex;
align-items: center;
justify-content: center;
.q-dark & {
background: var(--q-grey-9, #1a1a1a);
border-color: var(--q-grey-7, #424242);
}
}
> *:first-child {
padding-top: 0;
}
.identity-section {
flex: 1;
.display-name {
font-size: 24px;
font-weight: 400;
letter-spacing: -0.01em;
margin-bottom: 4px;
@media (max-width: 768px) {
font-size: 20px;
}
}
.identity-label {
font-size: 14px;
font-weight: 400;
}
}
.gamification-section {
display: flex;
align-items: center;
}
.about-section {
margin-top: 16px;
> *:last-child {
padding-bottom: 0;
border-bottom: none;
}
}
// Финансовая информация
.financial-section {
margin-bottom: 48px;
// Взносы по ролям: плашка итога + строки с иконками
.contrib-total {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--p-3);
padding: var(--p-3) var(--p-4);
margin-bottom: var(--p-2);
background: var(--p-surface-2);
border-radius: var(--p-r-md);
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 0;
.contrib-total__value {
font-family: var(--p-mono);
font-size: var(--p-fs-h2);
font-weight: 600;
}
.loading-label {
font-size: 14px;
font-weight: 400;
}
}
.contrib-row {
display: flex;
align-items: center;
gap: var(--p-3);
padding: var(--p-3) 0;
border-bottom: 1px solid var(--p-line);
.contributions-section {
.total-row {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
border-bottom: 1px solid var(--q-grey-3, #e0e0e0);
margin-bottom: 8px;
.q-dark & {
border-color: var(--q-grey-7, #424242);
}
.total-icon {
flex-shrink: 0;
}
.total-content {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
.total-name {
font-size: 14px;
font-weight: 400;
}
.total-amount {
font-size: 18px;
font-weight: 400;
}
}
}
.role-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--q-grey-2, #e0e0e0);
.q-dark & {
border-color: var(--q-grey-8, #424242);
}
&:last-child {
border-bottom: none;
}
.role-icon {
flex-shrink: 0;
}
.role-content {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
.role-name {
font-size: 14px;
font-weight: 400;
}
.role-value {
font-size: 16px;
font-weight: 400;
}
}
}
&:last-child {
padding-bottom: 0;
border-bottom: none;
}
}
// Разделители
.section-separator {
height: 1px;
background: var(--q-grey-2, #e0e0e0);
margin: 48px 0;
.q-dark & {
background: var(--q-grey-8, #424242);
}
.contrib-row__icon {
width: var(--p-8);
height: var(--p-8);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--p-r-sm);
background: var(--p-canvas-2);
color: var(--p-ink-2);
}
// Секции с контентом
.personal-section,
.work-section {
margin-bottom: 48px;
.contrib-row__name {
flex: 1;
min-width: 0;
}
.section-label {
font-size: 18px;
font-weight: 400;
margin-bottom: 24px;
.contrib-row__value {
font-weight: 600;
}
@media (max-width: 768px) {
font-size: 16px;
}
}
// Скелетон первичной загрузки
.capital-profile__skel-head {
display: flex;
align-items: center;
gap: var(--p-3);
}
.section-input,
.work-inputs {
.input-group {
margin-bottom: 16px;
.capital-profile__skel-avatar {
width: var(--p-9);
height: var(--p-9);
flex-shrink: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
.capital-profile__skel-name {
width: 40%;
}
.capital-profile__skel-card {
height: var(--p-10);
border-radius: var(--p-r-md);
}
</style>
@@ -1,5 +1,5 @@
<template lang="pug">
div.q-pa-md(ref="pageRootRef")
div.q-px-md(ref="pageRootRef")
// Индикатор авто-сохранения
AutoSaveIndicator(
:is-auto-saving="isAutoSaving"
@@ -1,98 +1,103 @@
<template lang="pug">
div.column.flex-1.min-h-0.min-w-0.no-wrap
// Мобильный layout - колонки одна под другой
div(v-if="isMobileLayout").column.col.flex-1.min-h-0.min-w-0
div.q-px-md.q-pt-md(v-if="project")
ComponentToProjectPathWidget.capital-entity-header-path(:project="project")
// Меню вкладок — сразу под шапкой, без внешних отступов
PageTabs(
v-if="project && !isIssueRoute"
:tabs="componentTabs"
:active-key="activeTabKey"
)
template(#actions)
PendingClearanceButton(
v-if="project.permissions?.pending_clearance && !project.permissions?.has_clearance"
)
MakeClearanceButton(
v-else-if="!project.permissions?.has_clearance"
ref="makeClearanceRef"
:project="project"
@clearance-submitted="handleClearanceSubmitted"
)
template(v-else)
CreateIssueButton(
v-if="activeTabKey === 'component-tasks' && project.permissions?.can_manage_issues"
ref="createIssueRef"
:project-hash="projectHash"
size="sm"
label="Задача"
@action-completed="handleIssueCreated"
)
CreateRequirementButton(
v-if="activeTabKey === 'component-requirements'"
ref="createRequirementRef"
:filter="{ project_hash: projectHash }"
:permissions="project.permissions"
@action-completed="handleRequirementCreated"
)
SetPlanButton(
v-if="activeTabKey === 'component-planning' && project.permissions?.can_set_plan"
ref="setPlanRef"
:project="project"
@action-completed="handlePlanSet"
)
CreateProjectInvestButton(
v-if="activeTabKey === 'component-planning'"
ref="investRef"
:project="project"
@action-completed="handleInvestCompleted"
)
AddAuthorButton(
v-if="activeTabKey === 'component-contributors' && project.permissions?.can_manage_authors"
ref="addAuthorRef"
:project="project"
@authors-added="handleAuthorsAdded"
)
// Скелетон первичной загрузки компонента
.component-page-skeleton(v-if="!project")
.component-page-skeleton__side(v-if="showSidebar")
.skel(v-for="i in 4", :key="i")
.component-page-skeleton__main
.skel.skel--title
.skel.skel--text(v-for="i in 3", :key="i")
// Мобильный layout — сайдбар только на «Описание»
div.column.col.flex-1.min-h-0.min-w-0(
v-else-if="isMobileLayout"
)
.q-px-md(v-if="showSidebar")
ProjectTitleEditor(
:project="project"
label="Компонент"
@field-change="handleFieldChange"
@update:title="handleTitleUpdate"
).full-width.q-mt-xs
).full-width
template(#prepend-icon)
q-icon(name='fa-regular fa-file-code', size='24px', color='primary')
div
q-icon(name='code', size='24px', color='primary')
template(#hint)
ComponentToProjectPathWidget(:project="project")
ComponentSidebarWidget(
:project="project"
compact-mobile
@project-deleted="handleProjectDeleted"
)
// Правая колонка: один скролл внутри (не дублировать с внешним overflow)
div.col.flex-1.min-h-0.min-w-0.column.overflow-hidden.relative-position
div.col.min-h-0.overflow-auto.q-pt-md.min-w-0
div.col.min-h-0.overflow-auto.min-w-0
router-view
Fab(v-if="project")
// Если доступно больше одного действия - показываем раскрывающийся список
template(#actions v-if="project?.permissions?.has_clearance && availableActions.length > 1")
// Показываем кнопку создания задачи и артефакта, если пользователь имеет допуск к проекту
CreateIssueFabAction(
ref="createIssueFabRef"
v-if="project?.permissions?.can_manage_issues"
:project-hash="projectHash"
@action-completed="handleIssueCreated"
)
CreateRequirementFabAction(
ref="createRequirementFabRef"
:filter="{ project_hash: projectHash }"
:permissions="project?.permissions"
@action-completed="handleRequirementCreated"
)
SetPlanFabAction(
ref="setPlanFabRef"
v-if="project?.permissions?.can_set_plan"
:project="project"
@action-completed="handlePlanSet"
)
AddAuthorFabAction(
ref="addAuthorFabRef"
v-if="project?.permissions?.can_manage_authors"
:project="project"
@action-completed="handleAuthorsAdded"
)
ComponentInvestFabAction(
ref="componentInvestFabRef"
:project="project"
@action-completed="handleInvestCompleted"
)
// Если доступно только одно действие - показываем его как основную кнопку
template(#default)
ComponentInvestFabAction(
ref="componentInvestFabRef"
v-if="project?.permissions?.has_clearance && availableActions.length === 1 && availableActions.includes('invest')"
:project="project"
fab
@action-completed="handleInvestCompleted"
)
// Показываем кнопку ожидания, если запрос на допуск в рассмотрении
PendingClearanceButton(
v-else-if="project?.permissions?.pending_clearance"
)
// Показываем кнопку участия, если пользователь не имеет допуска к проекту
MakeClearanceButton(
ref="makeClearanceFabRef"
v-else-if="!project?.permissions?.has_clearance"
:project="project"
fab
@clearance-submitted="handleClearanceSubmitted"
)
.column.flex-1.min-h-0.min-w-0.no-wrap(v-else)
.q-px-md.q-pt-md(v-if="project")
ComponentToProjectPathWidget.capital-entity-header-path(:project="project")
// Десктоп «Описание»: название + сайдбар управления + контент
div.column.col.flex-1.min-h-0.min-w-0(
v-else-if="showSidebar"
)
.q-px-md
ProjectTitleEditor(
:project="project"
label="Компонент"
@field-change="handleFieldChange"
@update:title="handleTitleUpdate"
).full-width.q-mt-xs
).full-width
template(#prepend-icon)
q-icon(name='fa-regular fa-file-code', size='24px', color='primary')
q-icon(name='code', size='24px', color='primary')
template(#hint)
ComponentToProjectPathWidget(:project="project")
q-splitter.col.flex-1.min-h-0(
v-model="sidebarWidth"
:limits="[200, 800]"
@@ -107,80 +112,29 @@ div.column.flex-1.min-h-0.min-w-0.no-wrap
:project="project"
@project-deleted="handleProjectDeleted"
)
template(#after)
div.column.full-height.min-h-0.relative-position
div.col.min-h-0.overflow-auto.q-pt-md.min-w-0
div.col.min-h-0.overflow-auto.min-w-0
router-view
Fab(v-if="project")
template(#actions v-if="project?.permissions?.has_clearance && availableActions.length > 1")
CreateIssueFabAction(
ref="createIssueFabRef"
v-if="project?.permissions?.can_manage_issues"
:project-hash="projectHash"
@action-completed="handleIssueCreated"
)
CreateRequirementFabAction(
ref="createRequirementFabRef"
:filter="{ project_hash: projectHash }"
:permissions="project?.permissions"
@action-completed="handleRequirementCreated"
)
SetPlanFabAction(
ref="setPlanFabRef"
v-if="project?.permissions?.can_set_plan"
:project="project"
@action-completed="handlePlanSet"
)
AddAuthorFabAction(
ref="addAuthorFabRef"
v-if="project?.permissions?.can_manage_authors"
:project="project"
@action-completed="handleAuthorsAdded"
)
ComponentInvestFabAction(
ref="componentInvestFabRef"
:project="project"
@action-completed="handleInvestCompleted"
)
template(#default)
ComponentInvestFabAction(
ref="componentInvestFabRef"
v-if="project?.permissions?.has_clearance && availableActions.length === 1 && availableActions.includes('invest')"
:project="project"
fab
@action-completed="handleInvestCompleted"
)
PendingClearanceButton(
v-else-if="project?.permissions?.pending_clearance"
)
MakeClearanceButton(
ref="makeClearanceFabRef"
v-else-if="!project?.permissions?.has_clearance"
:project="project"
fab
@clearance-submitted="handleClearanceSubmitted"
)
// Остальные вкладки — контент на всю ширину
div.column.col.flex-1.min-h-0.min-w-0.relative-position(v-else)
div.col.min-h-0.overflow-auto.min-w-0
router-view
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount, computed, markRaw, watch, ref } from 'vue';
import { onMounted, computed, watch, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useWindowSize } from 'src/shared/hooks/useWindowSize';
import { useProjectLoader } from 'app/extensions/capital/entities/Project/model';
import { useBackButton } from 'src/shared/lib/navigation';
import { useHeaderActions } from 'src/shared/hooks';
import { RouteMenuButton, Fab } from 'src/shared/ui';
import { CreateIssueFabAction } from 'app/extensions/capital/features/Issue/CreateIssue';
import { CreateRequirementFabAction } from 'app/extensions/capital/features/Story/CreateStory';
import { PageTabs } from 'src/shared/ui/layout';
import { CreateIssueButton } from 'app/extensions/capital/features/Issue/CreateIssue';
import { CreateRequirementButton } from 'app/extensions/capital/features/Story/CreateStory';
import { CreateProjectInvestButton } from 'app/extensions/capital/features/Invest/CreateProjectInvest';
import { AddAuthorButton } from 'app/extensions/capital/features/Project/AddAuthor';
import { SetPlanButton } from 'app/extensions/capital/features/Project/SetPlan';
import { MakeClearanceButton } from 'app/extensions/capital/features/Contributor/MakeClearance';
import { SetPlanFabAction } from 'app/extensions/capital/features/Project/SetPlan';
import { ComponentInvestFabAction } from 'app/extensions/capital/features/Invest/CreateProjectInvest';
import { AddAuthorFabAction } from 'app/extensions/capital/features/Project/AddAuthor';
import { PendingClearanceButton } from 'app/extensions/capital/shared/ui/PendingClearanceButton';
import { ComponentSidebarWidget } from 'app/extensions/capital/widgets';
import { ProjectTitleEditor } from 'app/extensions/capital/widgets/ProjectTitleEditor';
@@ -216,179 +170,88 @@ const saveSidebarWidth = (width: number) => {
// Определение layout в зависимости от размера экрана
const isMobileLayout = isMobile;
// Список доступных действий для FAB
const availableActions = computed(() => {
if (!project.value?.permissions?.has_clearance) return [];
type CapitalActionOpen = { openDialog: () => void } | null;
const actions: any = [];
const p = project.value.permissions;
if (p.can_manage_issues) actions.push('issue');
if (p.can_create_requirement) actions.push('requirement');
if (p.can_set_plan) actions.push('plan');
if (p.can_manage_authors) actions.push('author');
// Инвестирование доступно всем участникам с допуском.
// Мы оставляем его в списке даже если проект не запланирован,
// чтобы кнопка отображалась в disabled состоянии.
actions.push('invest');
return actions;
});
type CapitalFabOpen = { openDialog: () => void } | null;
const createIssueFabRef = ref<CapitalFabOpen>(null);
const createRequirementFabRef = ref<CapitalFabOpen>(null);
const setPlanFabRef = ref<CapitalFabOpen>(null);
const addAuthorFabRef = ref<CapitalFabOpen>(null);
const componentInvestFabRef = ref<CapitalFabOpen>(null);
const makeClearanceFabRef = ref<CapitalFabOpen>(null);
const createIssueRef = ref<CapitalActionOpen>(null);
const createRequirementRef = ref<CapitalActionOpen>(null);
const setPlanRef = ref<CapitalActionOpen>(null);
const addAuthorRef = ref<CapitalActionOpen>(null);
const investRef = ref<CapitalActionOpen>(null);
const makeClearanceRef = ref<CapitalActionOpen>(null);
// Используем composable для загрузки проекта
const { project, projectHash, loadProject } = useProjectLoader();
// Хоткеи действуют на той вкладке, где видна соответствующая кнопка
useCapitalFabHotkeys(() => {
const perms = project.value?.permissions;
if (!perms) {
return {};
}
const joinHandler =
!perms.has_clearance && !perms.pending_clearance
? () => makeClearanceFabRef.value?.openDialog()
: undefined;
if (!perms.has_clearance) {
return joinHandler ? { join: joinHandler } : {};
return !perms.pending_clearance
? { join: () => makeClearanceRef.value?.openDialog() }
: {};
}
return {
issue: perms.can_manage_issues
? () => createIssueFabRef.value?.openDialog()
? () => createIssueRef.value?.openDialog()
: undefined,
requirement: perms.can_create_requirement
? () => createRequirementFabRef.value?.openDialog()
? () => createRequirementRef.value?.openDialog()
: undefined,
plan: perms.can_set_plan
? () => setPlanFabRef.value?.openDialog()
? () => setPlanRef.value?.openDialog()
: undefined,
author: perms.can_manage_authors
? () => addAuthorFabRef.value?.openDialog()
? () => addAuthorRef.value?.openDialog()
: undefined,
invest: () => componentInvestFabRef.value?.openDialog(),
invest: () => investRef.value?.openDialog(),
};
});
const route = useRoute();
const router = useRouter();
// Массив кнопок меню для шапки
const menuButtons = computed(() => {
// На странице задачи субменю компонента не показываем
const isIssueRoute = computed(() => {
const name = String(route.name ?? '')
return name === 'component-issue' || name.startsWith('component-issue-')
})
// Активная вкладка: вложенные маршруты подсвечивают родительскую вкладку
const activeTabKey = computed(() => {
const name = String(route.name ?? '');
if (name === 'component-requirement-detail') return 'component-requirements';
return name;
});
// Управление (статус, мастер, video, git, удалить) — только на вкладке «Описание»
const showSidebar = computed(
() => !isIssueRoute.value && activeTabKey.value === 'component-description',
);
// Субменю компонента (вкладки)
const componentTabs = computed(() => {
const params = { project_hash: projectHash.value };
const currentBackRoute = route.query._backRoute as string;
const query = currentBackRoute ? { _backRoute: currentBackRoute } : {};
return [
{
id: 'component-description-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'component-description',
label: 'Описание',
icon: 'description',
routeParams: { project_hash: projectHash.value },
query,
},
order: 1,
},
{
id: 'component-tasks-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'component-tasks',
label: 'Задачи',
icon: 'task_alt',
routeParams: { project_hash: projectHash.value },
query,
},
order: 2,
},
{
id: 'component-requirements-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'component-requirements',
label: 'Артефакты',
icon: 'inventory_2',
routeParams: { project_hash: projectHash.value },
query,
},
order: 3,
},
{
id: 'component-planning-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'component-planning',
label: 'План',
icon: 'timeline',
routeParams: { project_hash: projectHash.value },
query,
},
order: 4,
},
{
id: 'component-voting-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'component-voting',
label: 'Голосование',
icon: 'how_to_vote',
routeParams: { project_hash: projectHash.value },
query,
},
order: 5,
},
{
id: 'component-results-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'component-results',
label: 'Результаты',
icon: 'assessment',
routeParams: { project_hash: projectHash.value },
query,
},
order: 6,
},
{
id: 'component-contributors-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'component-contributors',
label: 'Участники',
icon: 'groups',
routeParams: { project_hash: projectHash.value },
query,
},
order: 8,
},
{
id: 'component-history-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'component-history',
label: 'История',
icon: 'history',
routeParams: { project_hash: projectHash.value },
query,
},
order: 9,
},
{ key: 'component-description', label: 'Описание', route: { name: 'component-description', params, query } },
{ key: 'component-tasks', label: 'Задачи', route: { name: 'component-tasks', params, query } },
{ key: 'component-requirements', label: 'Артефакты', route: { name: 'component-requirements', params, query } },
{ key: 'component-planning', label: 'План', route: { name: 'component-planning', params, query } },
{ key: 'component-voting', label: 'Голосование', route: { name: 'component-voting', params, query } },
{ key: 'component-results', label: 'Результаты', route: { name: 'component-results', params, query } },
{ key: 'component-contributors', label: 'Участники', route: { name: 'component-contributors', params, query } },
{ key: 'component-history', label: 'История', route: { name: 'component-history', params, query } },
];
});
// Настраиваем кнопку "Назад"
useBackButton({
const { setBackButton } = useBackButton({
text: 'Назад',
componentId: 'component-base-' + projectHash.value,
onClick: () => {
@@ -419,54 +282,11 @@ useBackButton({
}
});
// Регистрируем кнопки меню в header
const { registerAction: registerHeaderAction, clearActions } = useHeaderActions();
// Регистрируем действия в header
onMounted(async () => {
// Загружаем сохраненную ширину sidebar
loadSidebarWidth();
await loadProject();
// Регистрируем кнопки меню только если мы НЕ на странице задачи
if (route.name !== 'component-issue') {
menuButtons.value.forEach(button => {
registerHeaderAction(button);
});
}
// Отслеживаем изменение backRoute для обновления кнопки "Назад"
watch(() => route.query._backRoute, () => {
setBackButton();
});
// Явно очищаем кнопки при уходе со страницы
onBeforeUnmount(() => {
clearActions();
});
// Обработчик изменения полей в sidebar
const handleFieldChange = () => {
// Просто триггер реактивности
};
// Обработчик обновления названия компонента в sidebar
const handleTitleUpdate = (value: string) => {
if (project.value) {
project.value.title = value;
}
};
const handleProjectDeleted = () => {
const coopname = route.params.coopname as string;
const parentHash = project.value?.parent_hash;
if (parentHash) {
router.push({
name: 'project-description',
params: { coopname, project_hash: parentHash },
});
return;
}
router.push({ name: 'projects-list', params: { coopname } });
};
// Обработчик создания задачи
const handleIssueCreated = () => {
// Можно добавить логику обновления списка задач
@@ -498,26 +318,78 @@ const handleClearanceSubmitted = async () => {
await loadProject();
};
// Отслеживаем переходы на дочерние маршруты (например, на страницу задачи)
watch(() => route.name, (newRouteName) => {
if (newRouteName === 'component-issue') {
// Если перешли на страницу задачи - очищаем кнопки меню компонента
clearActions();
} else if (newRouteName && newRouteName.toString().startsWith('component-') && newRouteName !== 'component-base') {
// Если вернулись на страницы компонента - регистрируем кнопки снова
menuButtons.value.forEach(button => {
registerHeaderAction(button);
});
// Обработчик изменения полей в sidebar
const handleFieldChange = () => {
// Просто триггер реактивности
};
// Обработчик обновления названия компонента
const handleTitleUpdate = (value: string) => {
if (project.value) {
project.value.title = value;
}
};
const handleProjectDeleted = () => {
const coopname = route.params.coopname as string;
const parentHash = project.value?.parent_hash;
if (parentHash) {
router.push({
name: 'project-description',
params: { coopname, project_hash: parentHash },
});
return;
}
router.push({ name: 'projects-list', params: { coopname } });
};
onMounted(async () => {
// Загружаем сохраненную ширину sidebar
loadSidebarWidth();
// Загружаем компонент при монтировании
await loadProject();
});
</script>
<style lang="scss" scoped>
.q-chip {
font-weight: 500;
}
// Каркас первичной загрузки: повторяет раскладку «сайдбар + контент»
.component-page-skeleton {
display: flex;
gap: var(--p-4);
padding: var(--p-4);
.text-h6 {
margin-bottom: 4px;
&__side {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--p-3);
.skel {
height: 40px;
}
}
&__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--p-3);
.skel--title {
width: 240px;
max-width: 60%;
}
}
@media (max-width: 640px) {
flex-direction: column;
&__side {
width: 100%;
}
}
}
</style>
@@ -5,10 +5,10 @@ div
ArtifactsAccessPlaceholder(
v-else-if='!canViewArtifacts'
scope='component'
:pending='componentPermissions?.pending_clearance === true'
:pending='componentPermissions?.pending_clearance === true && componentPermissions?.has_clearance !== true'
)
template(#action)
PendingClearanceButton(v-if='componentPermissions?.pending_clearance')
PendingClearanceButton(v-if='componentPermissions?.pending_clearance && !componentPermissions?.has_clearance')
MakeClearanceButton(
v-else-if='component'
:project='component'
@@ -49,7 +49,7 @@ const loadProject = async () => {
// Обработчик клика по задаче
const handleIssueClick = (issue: IIssue) => {
router.push({
name: 'component-issue',
name: 'component-issue-description',
params: {
project_hash: projectHash.value,
issue_hash: issue.issue_hash,
@@ -0,0 +1 @@
export { default as IssueCommitsPage } from './ui'
@@ -0,0 +1,32 @@
<template lang="pug">
div.q-px-md
IssueLinkedGitCommitsWidget(
v-if="linkedGitCommits.length"
:commits="linkedGitCommits"
)
CapitalSectionEmpty.capital-section-empty--centered(
v-else
icon="code"
title="Коммитов пока нет"
body="Привязанные коммиты появятся здесь после связи задачи с репозиторием."
)
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue'
import { Zeus } from '@coopenomics/sdk'
import { IssueLinkedGitCommitsWidget } from 'app/extensions/capital/widgets'
import { CapitalSectionEmpty } from 'app/extensions/capital/shared/ui/CapitalSectionEmpty'
import { ISSUE_PAGE_KEY } from '../../IssuePage/model/context'
type LinkedGitCommit = Zeus.ModelTypes['CapitalIssueLinkedGitCommit']
const ctx = inject(ISSUE_PAGE_KEY)
if (!ctx) {
throw new Error('IssueCommitsPage: отсутствует контекст IssuePage')
}
const linkedGitCommits = computed(
() => (ctx.linkedGitCommits.value ?? []) as LinkedGitCommit[],
)
</script>
@@ -0,0 +1 @@
export { default } from './IssueCommitsPage.vue'
@@ -0,0 +1 @@
export { default as IssueDescriptionPage } from './ui'
@@ -0,0 +1,47 @@
<template lang="pug">
div.q-px-md
AutoSaveIndicator(
:is-auto-saving="isAutoSaving"
:auto-save-error="autoSaveError"
)
Editor(
v-if="issue"
v-model="issue.description"
label="Описание задачи"
placeholder="Опишите задачу подробно..."
:readonly="!issue.permissions?.can_edit_issue"
:padded="false"
@change="handleDescriptionChange"
)
.issue-worklog.q-mt-md(v-if="issue")
.issue-worklog__title История рабочего времени
TimeEntriesWidget(:issue-hash="issue.issue_hash")
</template>
<script lang="ts" setup>
import { inject } from 'vue'
import { Editor, AutoSaveIndicator } from 'src/shared/ui'
import { TimeEntriesWidget } from 'app/extensions/capital/widgets'
import { ISSUE_PAGE_KEY } from '../../IssuePage/model/context'
const ctx = inject(ISSUE_PAGE_KEY)
if (!ctx) {
throw new Error('IssueDescriptionPage: отсутствует контекст IssuePage')
}
const issue = ctx.issue
const isAutoSaving = ctx.isAutoSaving
const autoSaveError = ctx.autoSaveError
const handleDescriptionChange = () => ctx.handleDescriptionChange()
</script>
<style lang="scss" scoped>
.issue-worklog__title {
font-size: var(--p-fs-body-sm);
font-weight: 600;
color: var(--p-ink-2);
margin-bottom: var(--p-2);
}
</style>
@@ -0,0 +1 @@
export { default } from './IssueDescriptionPage.vue'
@@ -0,0 +1 @@
export { default as IssueHistoryPage } from './ui'
@@ -0,0 +1,21 @@
<template lang="pug">
div.q-px-md(v-if="issueHash")
IssueLogsTableWidget(
:issue-hash="issueHash"
:refresh-trigger="logsRefreshTrigger"
)
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue'
import { IssueLogsTableWidget } from 'app/extensions/capital/widgets/IssueLogsTableWidget'
import { ISSUE_PAGE_KEY } from '../../IssuePage/model/context'
const ctx = inject(ISSUE_PAGE_KEY)
if (!ctx) {
throw new Error('IssueHistoryPage: отсутствует контекст IssuePage')
}
const issueHash = computed(() => ctx.issue.value?.issue_hash ?? '')
const logsRefreshTrigger = computed(() => ctx.logsRefreshTrigger.value)
</script>
@@ -0,0 +1 @@
export { default } from './IssueHistoryPage.vue'
@@ -0,0 +1,21 @@
import type { ComputedRef, InjectionKey, Ref } from 'vue'
import type { IIssue } from 'app/extensions/capital/entities/Issue/model'
import type { IProject } from 'app/extensions/capital/entities/Project/model'
import type { IGetStoriesInput } from 'app/extensions/capital/entities/Story/model'
export interface IssuePageContext {
issue: Ref<IIssue | null>
parentProject: Ref<IProject | null>
projectHash: ComputedRef<string>
logsRefreshTrigger: Ref<number>
isAutoSaving: Ref<boolean>
autoSaveError: Ref<string | null>
requirementsFilter: ComputedRef<Partial<IGetStoriesInput['filter']>>
createRequirementFilter: ComputedRef<{ project_hash: string; issue_hash: string }>
canCreateRequirement: ComputedRef<boolean>
linkedGitCommits: ComputedRef<unknown[]>
handleDescriptionChange: () => Promise<void>
openCreateRequirementDialog: () => void
}
export const ISSUE_PAGE_KEY: InjectionKey<IssuePageContext> = Symbol('issuePage')
@@ -1,27 +1,48 @@
<template lang="pug">
div.column.flex-1.min-h-0.min-w-0.no-wrap
// Мобильный layout - колонки одна под другой
div(v-if="isMobileLayout").column.col.flex-1.min-h-0.min-w-0
div.q-px-md.q-pt-md(v-if="issue")
ProjectPathWidget.capital-entity-header-path(
v-if="parentProject"
:project="parentProject"
PageTabs(
v-if="issue"
:tabs="issueTabs"
:active-key="activeTabKey"
)
template(#actions)
q-btn(
v-if="activeTabKey === 'component-issue-requirements' && canCreateRequirement"
flat
dense
no-caps
color="primary"
icon="add"
label="Артефакт"
size="sm"
@click="openCreateRequirementDialog"
)
.issue-page-skeleton(v-if="!issue")
.issue-page-skeleton__side(v-if="showSidebar")
.skel(v-for="i in 4", :key="i")
.issue-page-skeleton__main
.skel.skel--title
.skel.skel--text(v-for="i in 3", :key="i")
div.column.col.flex-1.min-h-0.min-w-0(
v-else-if="isMobileLayout"
)
.q-px-md(v-if="showSidebar")
IssueTitleEditor(
:issue="issue"
@field-change="handleFieldChange"
@update:title="handleTitleUpdate"
).full-width.q-mt-xs
).full-width
template(#prepend-icon)
q-icon(name='task', size='24px', color='primary')
div
q-icon(name="task", size="24px", color="primary")
template(#hint)
ProjectPathWidget(v-if="parentProject", :project="parentProject")
IssueSidebarWidget(
v-if="issue"
:issue="issue"
:permissions="issue.permissions"
:project-hash="projectHash"
:parent-project-hash="parentProjectHash"
:logs-refresh-trigger="logsRefreshTrigger"
compact-mobile
@update:status="handleStatusUpdate"
@update:priority="handlePriorityUpdate"
@@ -32,80 +53,23 @@ div.column.flex-1.min-h-0.min-w-0.no-wrap
@issue-deleted="handleIssueDeleted"
@issue-moved="handleIssueMoved"
)
div.col.flex-1.min-h-0.min-w-0.column.overflow-hidden.relative-position
div.col.min-h-0.overflow-auto.min-w-0
router-view
div.col.flex-1.min-h-0.min-w-0.column.overflow-hidden
div.col.min-h-0.overflow-auto.q-pt-md.min-w-0
.row.items-center.q-gutter-md.q-mb-sm.q-px-md.q-pb-sm
.col.min-w-0
AutoSaveIndicator(
:is-auto-saving="isAutoSaving"
:auto-save-error="autoSaveError"
)
Editor(
v-if="issue"
v-model='issue.description',
label='Описание задачи',
placeholder='Опишите задачу подробно...',
:readonly="!issue.permissions?.can_edit_issue",
:padded="false"
@change='handleDescriptionChange'
)
IssueLinkedGitCommitsWidget.q-px-md.q-mt-md(
v-if='linkedGitCommits.length'
:commits='linkedGitCommits'
)
q-expansion-item.q-px-md.q-mt-md(
v-if='issue'
icon='assignment'
label='Требования к задаче'
:caption='requirementsCaption'
header-class='text-grey-7'
dense-toggle
default-opened
)
RequirementsListWidget(
:filter='requirementsFilter'
:max-items='50'
:permissions='issue.permissions'
)
.row.justify-end.q-mt-sm(v-if='canCreateRequirement')
q-btn(
flat
dense
no-caps
color='primary'
icon='add'
label='Добавить требование'
@click='openCreateRequirementDialog'
)
q-expansion-item.q-px-md.q-mt-md(
v-if='issue'
icon='schedule'
label='История рабочего времени'
:caption='worklogCaption'
header-class='text-grey-7'
dense-toggle
)
TimeEntriesWidget(
:issue-hash='issue.issue_hash'
)
.column.flex-1.min-h-0.min-w-0.no-wrap(v-else)
.q-px-md.q-pt-md(v-if="issue")
ProjectPathWidget.capital-entity-header-path(
v-if="parentProject"
:project="parentProject"
)
div.column.col.flex-1.min-h-0.min-w-0(
v-else-if="showSidebar"
)
.q-px-md
IssueTitleEditor(
:issue="issue"
@field-change="handleFieldChange"
@update:title="handleTitleUpdate"
).full-width.q-mt-xs
).full-width
template(#prepend-icon)
q-icon(name='task', size='24px', color='primary')
q-icon(name="task", size="24px", color="primary")
template(#hint)
ProjectPathWidget(v-if="parentProject", :project="parentProject")
q-splitter.col.flex-1.min-h-0(
v-model="sidebarWidth"
:limits="[200, 800]"
@@ -117,12 +81,10 @@ div.column.flex-1.min-h-0.min-w-0.no-wrap
)
template(#before)
IssueSidebarWidget(
v-if="issue"
:issue="issue"
:permissions="issue.permissions"
:project-hash="projectHash"
:parent-project-hash="parentProjectHash"
:logs-refresh-trigger="logsRefreshTrigger"
@update:status="handleStatusUpdate"
@update:priority="handlePriorityUpdate"
@update:estimate="handleEstimateUpdate"
@@ -132,446 +94,368 @@ div.column.flex-1.min-h-0.min-w-0.no-wrap
@issue-deleted="handleIssueDeleted"
@issue-moved="handleIssueMoved"
)
template(#after)
div.column.full-height.min-h-0
div.col.min-h-0.overflow-auto.q-pt-md.min-w-0
.row.items-center.q-gutter-md.q-mb-sm.q-px-md.q-pb-sm
.col.min-w-0
AutoSaveIndicator(
:is-auto-saving="isAutoSaving"
:auto-save-error="autoSaveError"
)
div.column.full-height.min-h-0.relative-position
div.col.min-h-0.overflow-auto.min-w-0
router-view
Editor(
v-if="issue"
v-model='issue.description',
label='Описание задачи',
placeholder='Опишите задачу подробно...',
:readonly="!issue.permissions?.can_edit_issue",
:padded="false"
@change='handleDescriptionChange'
)
IssueLinkedGitCommitsWidget.q-px-md.q-mt-md(
v-if='linkedGitCommits.length'
:commits='linkedGitCommits'
)
q-expansion-item.q-px-md.q-mt-md(
v-if='issue'
icon='assignment'
label='Требования к задаче'
:caption='requirementsCaption'
header-class='text-grey-7'
dense-toggle
default-opened
)
RequirementsListWidget(
:filter='requirementsFilter'
:max-items='50'
:permissions='issue.permissions'
)
.row.justify-end.q-mt-sm(v-if='canCreateRequirement')
q-btn(
flat
dense
no-caps
color='primary'
icon='add'
label='Добавить требование'
@click='openCreateRequirementDialog'
)
q-expansion-item.q-px-md.q-mt-md(
v-if='issue'
icon='schedule'
label='История рабочего времени'
:caption='worklogCaption'
header-class='text-grey-7'
dense-toggle
)
TimeEntriesWidget(
:issue-hash='issue.issue_hash'
)
div.column.col.flex-1.min-h-0.min-w-0.relative-position(v-else)
div.col.min-h-0.overflow-auto.min-w-0
router-view
CreateRequirementWithEditorDialog(
ref='createRequirementDialog'
:filter='createRequirementFilter'
@success='handleRequirementCreated'
ref="createRequirementDialog"
:filter="createRequirementFilter"
@success="handleRequirementCreated"
)
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useWindowSize } from 'src/shared/hooks/useWindowSize';
import { FailAlert } from 'src/shared/api';
import { api as IssueApi } from 'app/extensions/capital/entities/Issue/api';
import { api as ProjectApi } from 'app/extensions/capital/entities/Project/api';
import type { IIssue } from 'app/extensions/capital/entities/Issue/model';
import type { IProject } from 'app/extensions/capital/entities/Project/model';
import { EMPTY_HASH } from 'src/shared/lib/consts';
import { useBackButton } from 'src/shared/lib/navigation';
import { Editor, AutoSaveIndicator } from 'src/shared/ui';
import { toMarkdown } from 'src/shared/lib/utils';
import { useUpdateIssue } from 'app/extensions/capital/features/Issue/UpdateIssue';
import { IssueSidebarWidget, IssueLinkedGitCommitsWidget, TimeEntriesWidget } from 'app/extensions/capital/widgets';
import { IssueTitleEditor } from 'app/extensions/capital/widgets/IssueTitleEditor';
import { ProjectPathWidget } from 'app/extensions/capital/widgets/ProjectPathWidget';
import { RequirementsListWidget } from 'app/extensions/capital/widgets/RequirementsListWidget';
import { CreateRequirementWithEditorDialog } from 'app/extensions/capital/features/Story/CreateStory';
import { useStoryStore, type IGetStoriesInput } from 'app/extensions/capital/entities/Story/model';
import { ref, computed, onMounted, watch, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWindowSize } from 'src/shared/hooks/useWindowSize'
import { FailAlert } from 'src/shared/api'
import { api as IssueApi } from 'app/extensions/capital/entities/Issue/api'
import { api as ProjectApi } from 'app/extensions/capital/entities/Project/api'
import type { IIssue } from 'app/extensions/capital/entities/Issue/model'
import type { IProject } from 'app/extensions/capital/entities/Project/model'
import { EMPTY_HASH } from 'src/shared/lib/consts'
import { useBackButton } from 'src/shared/lib/navigation'
import { PageTabs } from 'src/shared/ui/layout'
import { toMarkdown } from 'src/shared/lib/utils'
import { useUpdateIssue } from 'app/extensions/capital/features/Issue/UpdateIssue'
import { IssueSidebarWidget } from 'app/extensions/capital/widgets'
import { IssueTitleEditor } from 'app/extensions/capital/widgets/IssueTitleEditor'
import { ProjectPathWidget } from 'app/extensions/capital/widgets/ProjectPathWidget'
import { CreateRequirementWithEditorDialog } from 'app/extensions/capital/features/Story/CreateStory'
import type { IGetStoriesInput } from 'app/extensions/capital/entities/Story/model'
import { ISSUE_PAGE_KEY } from '../model/context'
const route = useRoute();
const router = useRouter();
const route = useRoute()
const router = useRouter()
const { isMobile } = useWindowSize()
// Используем Quasar и window size для определения размера экрана
const { isMobile } = useWindowSize();
const issue = ref<IIssue | null>(null)
const parentProject = ref<IProject | null>(null)
const logsRefreshTrigger = ref(0)
const issue = ref<IIssue | null>(null);
const loading = ref(false);
const SIDEBAR_WIDTH_KEY = 'sidebar-width'
const DEFAULT_SIDEBAR_WIDTH = 300
const sidebarWidth = ref(DEFAULT_SIDEBAR_WIDTH)
// Информация о родительском элементе (компонент-контейнер задачи)
const parentProject = ref<IProject | null>(null);
// Триггер для обновления логов задачи
const logsRefreshTrigger = ref(0);
// Управление шириной sidebar
const SIDEBAR_WIDTH_KEY = 'sidebar-width';
const DEFAULT_SIDEBAR_WIDTH = 300;
// Reactive переменная для ширины sidebar
const sidebarWidth = ref(DEFAULT_SIDEBAR_WIDTH);
// Загрузка ширины sidebar из localStorage
const loadSidebarWidth = () => {
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY)
if (saved) {
const parsed = parseInt(saved, 10);
const parsed = parseInt(saved, 10)
if (!isNaN(parsed) && parsed >= 200 && parsed <= 800) {
sidebarWidth.value = parsed;
sidebarWidth.value = parsed
}
}
};
}
// Сохранение ширины sidebar в localStorage
const saveSidebarWidth = (width: number) => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, width.toString());
};
localStorage.setItem(SIDEBAR_WIDTH_KEY, width.toString())
}
// Определение layout в зависимости от размера экрана
const isMobileLayout = isMobile;
const isMobileLayout = isMobile
const { debounceSave, isAutoSaving, autoSaveError } = useUpdateIssue()
// Используем composable для обновления задач
const { debounceSave, isAutoSaving, autoSaveError } = useUpdateIssue();
const issueHash = computed(() => route.params.issue_hash as string)
const projectHash = computed(() => route.params.project_hash as string)
// Получаем параметры из маршрута
const issueHash = computed(() => route.params.issue_hash as string);
const projectHash = computed(() => route.params.project_hash as string);
const parentHash = computed(() => projectHash.value);
const linkedGitCommits = computed(() => (issue.value as { linked_git_commits?: unknown[] } | null)?.linked_git_commits ?? [])
const linkedGitCommits = computed(() => issue.value?.linked_git_commits ?? []);
// Фильтр для виджета требований — story только этой задачи
const requirementsFilter = computed<Partial<IGetStoriesInput['filter']>>(() => ({
issue_hash: issue.value?.issue_hash ?? '',
}));
}))
// Фильтр для диалога создания — issue_hash + project_hash родителя
const createRequirementFilter = computed(() => ({
project_hash: projectHash.value,
issue_hash: issue.value?.issue_hash ?? '',
}));
}))
// Право на создание требования к задаче
const canCreateRequirement = computed((): boolean => {
return Boolean(issue.value?.permissions?.can_create_requirement);
});
return Boolean(issue.value?.permissions?.can_create_requirement)
})
// Подпись рядом с заголовком «Требования к задаче»
const storyStore = useStoryStore();
const requirementsCaption = computed(() => {
const stored = storyStore.stories;
const cur = issue.value?.issue_hash;
if (!cur || !stored) return '';
const count = stored.items.filter((s) => s.issue_hash === cur).length;
if (count === 0) return 'пока пусто';
return `${count} шт.`;
});
const createRequirementDialog = ref<InstanceType<typeof CreateRequirementWithEditorDialog> | null>(null);
const createRequirementDialog = ref<InstanceType<typeof CreateRequirementWithEditorDialog> | null>(null)
const openCreateRequirementDialog = () => {
createRequirementDialog.value?.openDialog();
};
const handleRequirementCreated = () => {
// RequirementsListWidget сам подцепит новый story через store
};
createRequirementDialog.value?.openDialog()
}
const handleRequirementCreated = () => {}
// Подпись рядом с заголовком «История рабочего времени» — отражает факт/план одной строкой.
const worklogCaption = computed(() => {
const fact = issue.value?.fact ?? 0;
const estimate = issue.value?.estimate ?? 0;
const format = (h: number) => {
if (h <= 0) return '0ч';
if (h < 8) {
const rounded = h % 1 === 0 ? h : parseFloat(h.toFixed(2));
return `${rounded}ч`;
}
return `${Math.round((h / 8) * 10) / 10}д`;
};
if (fact <= 0 && estimate <= 0) return 'записей пока нет';
if (estimate <= 0) return `отработано ${format(fact)}`;
return `${format(fact)} из ${format(estimate)}`;
});
const activeTabKey = computed(() => {
const name = String(route.name ?? '')
if (name === 'component-issue' || name === 'component-issue-redirect') {
return 'component-issue-description'
}
return name
})
const showSidebar = computed(() => activeTabKey.value === 'component-issue-description')
const issueTabs = computed(() => {
const params = {
project_hash: projectHash.value,
issue_hash: issueHash.value,
}
const currentBackRoute = route.query._backRoute as string
const query = currentBackRoute ? { _backRoute: currentBackRoute } : {}
return [
{ key: 'component-issue-description', label: 'Описание', route: { name: 'component-issue-description', params, query } },
{ key: 'component-issue-requirements', label: 'Артефакты', route: { name: 'component-issue-requirements', params, query } },
{ key: 'component-issue-commits', label: 'Коммиты', route: { name: 'component-issue-commits', params, query } },
{ key: 'component-issue-history', label: 'История', route: { name: 'component-issue-history', params, query } },
]
})
/** parent_hash родительского проекта — перенос задачи только между компонентами этого проекта */
const parentProjectHash = computed(() => {
const p = parentProject.value?.parent_hash?.trim();
if (!p || p === EMPTY_HASH) return null;
return p;
});
const p = parentProject.value?.parent_hash?.trim()
if (!p || p === EMPTY_HASH) return null
return p
})
const routeIssueKey = computed(
() => `${String(route.params.issue_hash)}:${String(route.params.project_hash)}`,
);
)
// Проверяем и конвертируем описание в Markdown формат если необходимо
const ensureMarkdownFormat = (description: any) => {
if (!description) return '';
const ensureMarkdownFormat = (description: unknown) => {
if (!description) return ''
return toMarkdown(description)
}
// Используем универсальную утилиту конвертации
return toMarkdown(description);
};
// Загрузка информации о родительском элементе
const loadParentInfo = async () => {
try {
const projectData = await ProjectApi.loadProject({
hash: parentHash.value,
});
if (projectData) {
parentProject.value = projectData;
} else {
parentProject.value = null;
}
const projectData = await ProjectApi.loadProject({ hash: projectHash.value })
parentProject.value = projectData || null
} catch (error) {
console.error('Ошибка при загрузке информации о родителе:', error);
parentProject.value = null;
console.error('Ошибка при загрузке информации о родителе:', error)
parentProject.value = null
}
};
}
// Обработчик изменения полей
const handleFieldChange = () => {
// Просто триггер реактивности для computed hasChanges в виджетах
};
const handleFieldChange = () => {}
// Обработчик обновления названия задачи
const handleTitleUpdate = async (value: string) => {
if (issue.value) {
issue.value.title = value;
// Отправляем мутацию на обновление названия
const updateData = {
issue_hash: issue.value.issue_hash,
title: value,
};
try {
await debounceSave(updateData, projectHash.value);
// Обновляем логи после успешного сохранения
logsRefreshTrigger.value++;
} catch (error) {
console.error('Failed to update title:', error);
}
}
};
// Обработчик изменения описания задачи
const handleDescriptionChange = async () => {
if (!issue.value) return;
const updateData = {
issue_hash: issue.value.issue_hash,
description: issue.value.description,
};
if (!issue.value) return
issue.value.title = value
try {
// Запускаем авто-сохранение с задержкой
await debounceSave(updateData, projectHash.value);
// Обновляем логи после успешного сохранения
logsRefreshTrigger.value++;
await debounceSave({ issue_hash: issue.value.issue_hash, title: value }, projectHash.value)
logsRefreshTrigger.value++
} catch (error) {
console.error('Failed to update description:', error);
console.error('Failed to update title:', error)
}
};
}
// Убрали правый drawer - StoriesWidget теперь в сайдбаре
const handleDescriptionChange = async () => {
if (!issue.value) return
try {
await debounceSave(
{ issue_hash: issue.value.issue_hash, description: issue.value.description },
projectHash.value,
)
logsRefreshTrigger.value++
} catch (error) {
console.error('Failed to update description:', error)
}
}
// Настраиваем кнопку "Назад"
useBackButton({
text: 'Назад',
componentId: 'issue-page-' + issueHash.value,
onClick: () => {
const backRoute = route.query._backRoute as string;
const backRoute = route.query._backRoute as string
if (backRoute) {
// Проверяем, является ли backRoute ключом sessionStorage
const storedRoute = sessionStorage.getItem(backRoute);
const storedRoute = sessionStorage.getItem(backRoute)
if (storedRoute) {
try {
const routeData = JSON.parse(storedRoute);
const routeData = JSON.parse(storedRoute)
router.push({
name: routeData.name,
params: routeData.params,
query: routeData.query
});
// Очищаем сохраненные данные
sessionStorage.removeItem(backRoute);
return;
query: routeData.query,
})
sessionStorage.removeItem(backRoute)
return
} catch (error) {
console.warn('Failed to parse stored route:', error);
console.warn('Failed to parse stored route:', error)
}
}
// Если это обычное название маршрута, переходим стандартно
router.push({ name: backRoute });
router.push({ name: backRoute })
} else {
router.back();
router.back()
}
}
});
},
})
// Загрузка задачи
const loadIssue = async () => {
loading.value = true;
try {
console.log('Загрузка задачи:', issueHash.value);
// Получаем задачу по HASH
const issueData = await IssueApi.loadIssue({
issue_hash: issueHash.value,
});
issue.value = issueData || null;
// Конвертируем описание в Markdown формат если необходимо
const issueData = await IssueApi.loadIssue({ issue_hash: issueHash.value })
issue.value = issueData || null
if (issue.value?.description) {
issue.value.description = ensureMarkdownFormat(issue.value.description);
issue.value.description = ensureMarkdownFormat(issue.value.description)
}
} catch (error) {
console.error('Ошибка при загрузке задачи:', error);
FailAlert('Не удалось загрузить задачу');
} finally {
loading.value = false;
console.error('Ошибка при загрузке задачи:', error)
FailAlert('Не удалось загрузить задачу')
}
};
}
// Обработчики обновления полей задачи
const handleStatusUpdate = (value: any) => {
const handleStatusUpdate = (value: unknown) => {
if (issue.value) {
issue.value.status = value;
// Обновляем логи после изменения статуса
logsRefreshTrigger.value++;
issue.value.status = value as IIssue['status']
logsRefreshTrigger.value++
}
};
}
const handlePriorityUpdate = (value: any) => {
const handlePriorityUpdate = (value: unknown) => {
if (issue.value) {
issue.value.priority = value;
// Обновляем логи после изменения приоритета
logsRefreshTrigger.value++;
issue.value.priority = value as IIssue['priority']
logsRefreshTrigger.value++
}
};
}
const handleEstimateUpdate = (value: number) => {
if (issue.value) {
issue.value.estimate = value;
// Обновляем логи после изменения оценки
logsRefreshTrigger.value++;
issue.value.estimate = value
logsRefreshTrigger.value++
}
};
}
const handleLabelsUpdate = (value: string[]) => {
if (!issue.value) return;
const prev = issue.value.metadata;
if (!issue.value) return
const prev = issue.value.metadata
const base: Record<string, unknown> =
prev && typeof prev === 'object' && prev !== null && !Array.isArray(prev)
? { ...(prev as Record<string, unknown>) }
: {};
base.labels = value;
issue.value.metadata = base as IIssue['metadata'];
logsRefreshTrigger.value++;
};
: {}
base.labels = value
issue.value.metadata = base as IIssue['metadata']
logsRefreshTrigger.value++
}
const handleCreatorsSet = (creators: any[]) => {
const handleCreatorsSet = (creators: unknown[]) => {
if (issue.value) {
// Обновляем список создателей в локальном состоянии
issue.value.creators = creators.map(c => c.username);
// Обновляем логи после изменения ответственных
logsRefreshTrigger.value++;
issue.value.creators = (creators as { username: string }[]).map((c) => c.username)
logsRefreshTrigger.value++
}
};
}
const handleIssueUpdated = (updatedIssue: any) => {
const handleIssueUpdated = (updatedIssue: unknown) => {
if (updatedIssue && issue.value) {
// Обновляем локальную задачу обновленными данными
issue.value = { ...issue.value, ...updatedIssue };
issue.value = { ...issue.value, ...(updatedIssue as IIssue) }
}
};
}
const handleIssueDeleted = () => {
const coopname = route.params.coopname as string;
const coopname = route.params.coopname as string
router.push({
name: 'component-tasks',
params: {
coopname,
project_hash: projectHash.value,
},
});
};
params: { coopname, project_hash: projectHash.value },
})
}
const handleIssueMoved = ({
updatedIssue,
toProjectHash,
}: {
updatedIssue: IIssue;
fromProjectHash: string;
toProjectHash: string;
updatedIssue: IIssue
fromProjectHash: string
toProjectHash: string
}) => {
const coopname = route.params.coopname as string;
const coopname = route.params.coopname as string
void router.replace({
name: 'component-issue',
name: 'component-issue-description',
params: {
coopname,
project_hash: toProjectHash,
issue_hash: updatedIssue.issue_hash,
},
query: { ...route.query },
});
};
})
}
provide(ISSUE_PAGE_KEY, {
issue,
parentProject,
projectHash,
logsRefreshTrigger,
isAutoSaving,
autoSaveError,
requirementsFilter,
createRequirementFilter,
canCreateRequirement,
linkedGitCommits,
handleDescriptionChange,
openCreateRequirementDialog,
})
watch(routeIssueKey, async (_key, prev) => {
if (prev === undefined) return
await loadParentInfo()
await loadIssue()
})
// Дефолт: пустой/родительский маршрут → вкладка «Описание»
watch(
routeIssueKey,
async (_key, prev) => {
if (prev === undefined) return;
await loadParentInfo();
await loadIssue();
() => route.name,
(name) => {
if (name === 'component-issue' || name === 'component-issue-redirect') {
void router.replace({
name: 'component-issue-description',
params: route.params,
query: route.query,
})
}
},
);
{ immediate: true },
)
// Инициализация
onMounted(async () => {
// Загружаем сохраненную ширину sidebar
loadSidebarWidth();
// Загружаем информацию о родителе
await loadParentInfo();
// Загружаем задачу
await loadIssue();
});
loadSidebarWidth()
await loadParentInfo()
await loadIssue()
})
</script>
<style lang="scss" scoped>
.issue-page-skeleton {
display: flex;
gap: var(--p-4);
padding: var(--p-4);
&__side {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--p-3);
.skel {
height: 40px;
}
}
&__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--p-3);
.skel--title {
width: 240px;
max-width: 60%;
}
}
@media (max-width: 640px) {
flex-direction: column;
&__side {
width: 100%;
}
}
}
</style>
@@ -0,0 +1 @@
export { default as IssueRequirementsPage } from './ui'
@@ -0,0 +1,24 @@
<template lang="pug">
div.q-px-md(v-if="issue")
RequirementsListWidget(
:filter="requirementsFilter"
:max-items="50"
:permissions="issue.permissions"
empty-title="Артефактов пока нет"
empty-body="Добавьте первый артефакт, чтобы зафиксировать требования к задаче."
)
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue'
import { RequirementsListWidget } from 'app/extensions/capital/widgets/RequirementsListWidget'
import { ISSUE_PAGE_KEY } from '../../IssuePage/model/context'
const ctx = inject(ISSUE_PAGE_KEY)
if (!ctx) {
throw new Error('IssueRequirementsPage: отсутствует контекст IssuePage')
}
const issue = computed(() => ctx.issue.value)
const requirementsFilter = computed(() => ctx.requirementsFilter.value)
</script>
@@ -0,0 +1 @@
export { default } from './IssueRequirementsPage.vue'
@@ -3,6 +3,7 @@ div
// Список компонентов проекта
ComponentsListWidget(
:components='project?.components || []',
:project='project || undefined',
:expanded='expandedComponents',
@open-component='handleComponentClick',
@toggle-component='handleComponentToggle'
@@ -55,7 +56,7 @@ const handleComponentToggle = (componentHash: string) => {
// Обработчик клика по задаче
const handleIssueClick = (issue: IIssue) => {
router.push({
name: 'component-issue',
name: 'component-issue-description',
params: {
project_hash: issue.project_hash,
issue_hash: issue.issue_hash,
@@ -1,5 +1,5 @@
<template lang="pug">
div.q-pa-md(ref="pageRootRef")
div.q-px-md(ref="pageRootRef")
// Индикатор авто-сохранения
AutoSaveIndicator(
:is-auto-saving="isAutoSaving"
@@ -1,8 +1,62 @@
<template lang="pug">
div.column.flex-1.min-h-0.min-w-0.no-wrap
// Мобильный layout - колонки одна под другой
div(v-if="isMobileLayout").column.col.flex-1.min-h-0.min-w-0
div.q-px-md.q-pt-md(v-if="project")
// Меню вкладок — сразу под шапкой, без внешних отступов
PageTabs(
v-if="project && !isIssueRoute"
:tabs="projectTabs"
:active-key="activeTabKey"
)
template(#actions)
PendingClearanceButton(
v-if="project.permissions?.pending_clearance && !project.permissions?.has_clearance"
)
MakeClearanceButton(
v-else-if="!project.permissions?.has_clearance"
ref="makeClearanceRef"
:project="project"
@clearance-submitted="handleClearanceSubmitted"
)
template(v-else)
CreateComponentButton(
v-if="activeTabKey === 'project-components' && project.permissions?.can_edit_project"
ref="createComponentRef"
:project="project"
size="sm"
@on-click="handleComponentCreated"
)
CreateRequirementButton(
v-if="activeTabKey === 'project-requirements'"
ref="createRequirementRef"
:filter="{ project_hash: projectHash }"
:permissions="project.permissions"
@action-completed="handleRequirementCreated"
)
AddAuthorButton(
v-if="activeTabKey === 'project-contributors' && project.permissions?.can_manage_authors"
ref="addAuthorRef"
:project="project"
@authors-added="handleAuthorsAdded"
)
CreateProjectInvestButton(
v-if="activeTabKey === 'project-planning'"
ref="investRef"
:project="project"
@action-completed="handleInvestCompleted"
)
// Скелетон первичной загрузки проекта
.project-page-skeleton(v-if="!project")
.project-page-skeleton__side(v-if="showSidebar")
.skel(v-for="i in 4", :key="i")
.project-page-skeleton__main
.skel.skel--title
.skel.skel--text(v-for="i in 3", :key="i")
// Мобильный layout — сайдбар только на «Описание»
div.column.col.flex-1.min-h-0.min-w-0(
v-else-if="isMobileLayout"
)
.q-px-md(v-if="showSidebar")
ProjectTitleEditor(
:project="project"
@field-change="handleFieldChange"
@@ -10,71 +64,20 @@ div.column.flex-1.min-h-0.min-w-0.no-wrap
).full-width
template(#prepend-icon)
q-icon(name='work', size='24px', color='primary')
div
ProjectSidebarWidget(
:project="project"
compact-mobile
@project-deleted="handleProjectDeleted"
)
div.col.flex-1.min-h-0.min-w-0.column.overflow-hidden.relative-position
div.col.min-h-0.overflow-auto.q-pt-md.min-w-0
div.col.min-h-0.overflow-auto.min-w-0
router-view
Fab(v-if="project")
// Если доступно больше одного действия - показываем раскрывающийся список
template(#actions v-if="project?.permissions?.has_clearance && availableActions.length > 1")
CreateComponentFabAction(
ref="createComponentFabRef"
v-if="project?.permissions?.can_edit_project"
:project="project"
@action-completed="handleComponentCreated"
)
CreateRequirementFabAction(
ref="createRequirementFabRef"
:filter="{ project_hash: projectHash }"
:permissions="project?.permissions"
@action-completed="handleRequirementCreated"
)
AddAuthorFabAction(
ref="addAuthorFabRef"
v-if="project?.permissions?.can_manage_authors"
:project="project"
@action-completed="handleAuthorsAdded"
)
ProjectInvestFabAction(
ref="projectInvestFabRef"
:project="project"
@action-completed="handleInvestCompleted"
)
// Если доступно только одно действие - показываем его как основную кнопку
template(#default)
ProjectInvestFabAction(
ref="projectInvestFabRef"
v-if="project?.permissions?.has_clearance && availableActions.length === 1 && availableActions.includes('invest')"
:project="project"
fab
@action-completed="handleInvestCompleted"
)
// Показываем кнопку ожидания, если запрос на допуск в рассмотрении
PendingClearanceButton(
v-else-if="project?.permissions?.pending_clearance"
)
// Показываем кнопку участия, если пользователь не имеет допуска к проекту
MakeClearanceButton(
ref="makeClearanceFabRef"
v-else-if="!project?.permissions?.has_clearance"
:project="project"
fab
@clearance-submitted="handleClearanceSubmitted"
)
// Десктоп: панель after без overflow-hidden — у q-splitter снова overflow:auto, иначе скролл «глушится»
.column.flex-1.min-h-0.min-w-0.no-wrap(v-else)
.q-px-md.q-pt-md(v-if="project")
// Десктоп «Описание»: название + сайдбар управления + контент
div.column.col.flex-1.min-h-0.min-w-0(
v-else-if="showSidebar"
)
.q-px-md
ProjectTitleEditor(
:project="project"
@field-change="handleFieldChange"
@@ -96,72 +99,27 @@ div.column.flex-1.min-h-0.min-w-0.no-wrap
:project="project"
@project-deleted="handleProjectDeleted"
)
template(#after)
div.column.full-height.min-h-0.relative-position
div.col.min-h-0.overflow-auto.q-pt-md.min-w-0
div.col.min-h-0.overflow-auto.min-w-0
router-view
Fab(v-if="project")
template(#actions v-if="project?.permissions?.has_clearance && availableActions.length > 1")
CreateComponentFabAction(
ref="createComponentFabRef"
v-if="project?.permissions?.can_edit_project"
:project="project"
@action-completed="handleComponentCreated"
)
CreateRequirementFabAction(
ref="createRequirementFabRef"
:filter="{ project_hash: projectHash }"
:permissions="project?.permissions"
@action-completed="handleRequirementCreated"
)
AddAuthorFabAction(
ref="addAuthorFabRef"
v-if="project?.permissions?.can_manage_authors"
:project="project"
@action-completed="handleAuthorsAdded"
)
ProjectInvestFabAction(
ref="projectInvestFabRef"
:project="project"
@action-completed="handleInvestCompleted"
)
template(#default)
ProjectInvestFabAction(
ref="projectInvestFabRef"
v-if="project?.permissions?.has_clearance && availableActions.length === 1 && availableActions.includes('invest')"
:project="project"
fab
@action-completed="handleInvestCompleted"
)
PendingClearanceButton(
v-else-if="project?.permissions?.pending_clearance"
)
MakeClearanceButton(
ref="makeClearanceFabRef"
v-else-if="!project?.permissions?.has_clearance"
:project="project"
fab
@clearance-submitted="handleClearanceSubmitted"
)
// Остальные вкладки — контент на всю ширину
div.column.col.flex-1.min-h-0.min-w-0.relative-position(v-else)
div.col.min-h-0.overflow-auto.min-w-0
router-view
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount, computed, markRaw, watch, ref } from 'vue';
import { onMounted, computed, watch, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useWindowSize } from 'src/shared/hooks/useWindowSize';
import { useProjectLoader } from 'app/extensions/capital/entities/Project/model';
import { useBackButton } from 'src/shared/lib/navigation';
import { useHeaderActions } from 'src/shared/hooks';
import { RouteMenuButton, Fab } from 'src/shared/ui';
import { CreateComponentFabAction } from 'app/extensions/capital/features/Project/CreateComponent';
import { CreateRequirementFabAction } from 'app/extensions/capital/features/Story/CreateStory';
// import { SetPlanFabAction } from 'app/extensions/capital/features/Project/SetPlan';
import { ProjectInvestFabAction } from 'app/extensions/capital/features/Invest/CreateProjectInvest';
import { AddAuthorFabAction } from 'app/extensions/capital/features/Project/AddAuthor';
import { PageTabs } from 'src/shared/ui/layout';
import { CreateComponentButton } from 'app/extensions/capital/features/Project/CreateComponent';
import { CreateRequirementButton } from 'app/extensions/capital/features/Story/CreateStory';
import { CreateProjectInvestButton } from 'app/extensions/capital/features/Invest/CreateProjectInvest';
import { AddAuthorButton } from 'app/extensions/capital/features/Project/AddAuthor';
import { MakeClearanceButton } from 'app/extensions/capital/features/Contributor/MakeClearance';
import { PendingClearanceButton } from 'app/extensions/capital/shared/ui/PendingClearanceButton';
import { ProjectSidebarWidget } from 'app/extensions/capital/widgets';
@@ -197,142 +155,74 @@ const saveSidebarWidth = (width: number) => {
// Определение layout в зависимости от размера экрана
const isMobileLayout = isMobile;
// Список доступных действий для FAB
const availableActions = computed(() => {
if (!project.value?.permissions?.has_clearance) return [];
type CapitalActionOpen = { openDialog: () => void } | null;
const actions: any = [];
const p = project.value.permissions;
if (p.can_edit_project) actions.push('component');
if (p.can_create_requirement) actions.push('requirement');
if (p.can_manage_authors) actions.push('author');
// Инвестирование доступно всем участникам с допуском
actions.push('invest');
return actions;
});
type CapitalFabOpen = { openDialog: () => void } | null;
const createComponentFabRef = ref<CapitalFabOpen>(null);
const createRequirementFabRef = ref<CapitalFabOpen>(null);
const addAuthorFabRef = ref<CapitalFabOpen>(null);
const projectInvestFabRef = ref<CapitalFabOpen>(null);
const makeClearanceFabRef = ref<CapitalFabOpen>(null);
const createComponentRef = ref<CapitalActionOpen>(null);
const createRequirementRef = ref<CapitalActionOpen>(null);
const addAuthorRef = ref<CapitalActionOpen>(null);
const investRef = ref<CapitalActionOpen>(null);
const makeClearanceRef = ref<CapitalActionOpen>(null);
// Используем composable для загрузки проекта
const { project, projectHash, loadProject } = useProjectLoader();
// Хоткеи действуют на той вкладке, где видна соответствующая кнопка
useCapitalFabHotkeys(() => {
const perms = project.value?.permissions;
if (!perms) {
return {};
}
const joinHandler =
!perms.has_clearance && !perms.pending_clearance
? () => makeClearanceFabRef.value?.openDialog()
: undefined;
if (!perms.has_clearance) {
return joinHandler ? { join: joinHandler } : {};
return !perms.pending_clearance
? { join: () => makeClearanceRef.value?.openDialog() }
: {};
}
return {
component: perms.can_edit_project
? () => createComponentFabRef.value?.openDialog()
? () => createComponentRef.value?.openDialog()
: undefined,
requirement: perms.can_create_requirement
? () => createRequirementFabRef.value?.openDialog()
? () => createRequirementRef.value?.openDialog()
: undefined,
author: perms.can_manage_authors
? () => addAuthorFabRef.value?.openDialog()
? () => addAuthorRef.value?.openDialog()
: undefined,
invest: () => projectInvestFabRef.value?.openDialog(),
invest: () => investRef.value?.openDialog(),
};
});
const route = useRoute();
const router = useRouter();
// Массив кнопок меню для шапки
const menuButtons = computed(() => {
// На странице задачи субменю проекта не показываем
const isIssueRoute = computed(() => route.name === 'project-issue');
// Активная вкладка: вложенные маршруты подсвечивают родительскую вкладку
const activeTabKey = computed(() => {
const name = String(route.name ?? '');
if (name === 'project-requirement-detail') return 'project-requirements';
return name;
});
// Управление (статус, мастер, video, git, удалить) — только на вкладке «Описание»
const showSidebar = computed(
() => !isIssueRoute.value && activeTabKey.value === 'project-description',
);
// Субменю проекта (вкладки)
const projectTabs = computed(() => {
const params = { project_hash: projectHash.value };
const currentBackRoute = route.query._backRoute as string;
const query = currentBackRoute ? { _backRoute: currentBackRoute } : {};
return [
{
id: 'project-description-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'project-description',
label: 'Описание',
icon: 'description',
routeParams: { project_hash: projectHash.value },
query,
},
order: 1,
},
{
id: 'project-requirements-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'project-requirements',
label: 'Артефакты',
icon: 'inventory_2',
routeParams: { project_hash: projectHash.value },
query,
},
order: 2,
},
{
id: 'project-components-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'project-components',
label: 'Компоненты',
icon: 'widgets',
routeParams: { project_hash: projectHash.value },
query,
},
order: 3,
},
{
id: 'project-planning-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'project-planning',
label: 'План',
icon: 'timeline',
routeParams: { project_hash: projectHash.value },
query,
},
order: 4,
},
{
id: 'project-contributors-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'project-contributors',
label: 'Участники',
icon: 'groups',
routeParams: { project_hash: projectHash.value },
query,
},
order: 6,
},
{
id: 'project-history-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'project-history',
label: 'История',
icon: 'history',
routeParams: { project_hash: projectHash.value },
query,
},
order: 7,
},
{ key: 'project-description', label: 'Описание', route: { name: 'project-description', params, query } },
{ key: 'project-requirements', label: 'Артефакты', route: { name: 'project-requirements', params, query } },
{ key: 'project-components', label: 'Компоненты', route: { name: 'project-components', params, query } },
{ key: 'project-planning', label: 'План', route: { name: 'project-planning', params, query } },
{ key: 'project-contributors', label: 'Участники', route: { name: 'project-contributors', params, query } },
{ key: 'project-history', label: 'История', route: { name: 'project-history', params, query } },
];
});
@@ -368,34 +258,11 @@ const { setBackButton } = useBackButton({
}
});
// Регистрируем кнопки меню в header
const { registerAction: registerHeaderAction, clearActions } = useHeaderActions();
// Явно очищаем кнопки при уходе со страницы
onBeforeUnmount(() => {
clearActions();
});
// Отслеживаем изменение backRoute для обновления кнопки "Назад"
watch(() => route.query._backRoute, () => {
setBackButton();
});
// Отслеживаем переходы на дочерние маршруты (например, на страницу задачи)
watch(() => route.name, (newRouteName) => {
if (newRouteName === 'project-issue') {
// Если перешли на страницу задачи - очищаем кнопки меню проекта
clearActions();
} else if (newRouteName && newRouteName.toString().startsWith('project-') && newRouteName !== 'project-base') {
// Если вернулись на страницы проекта - регистрируем кнопки снова
menuButtons.value.forEach(button => {
registerHeaderAction(button);
});
}
});
// Обработчик создания компонента
const handleComponentCreated = () => {
// Можно добавить логику обновления списка компонентов
@@ -406,11 +273,6 @@ const handleRequirementCreated = () => {
// Можно добавить логику обновления списка артефактов
};
// Обработчик установки плана
// const handlePlanSet = () => {
// Можно добавить логику обновления данных проекта
// };
// Обработчик добавления соавторов
const handleAuthorsAdded = () => {
// Можно добавить логику обновления данных проекта
@@ -444,54 +306,53 @@ const handleProjectDeleted = () => {
router.push({ name: 'projects-list', params: { coopname } });
};
// Регистрируем действия в header
onMounted(async () => {
// Загружаем сохраненную ширину sidebar
loadSidebarWidth();
// Загружаем проект при монтировании (composable сделает это автоматически)
await loadProject();
// Регистрируем кнопки меню только если мы НЕ на странице задачи
if (route.name !== 'project-issue') {
menuButtons.value.forEach(button => {
registerHeaderAction(button);
});
}
});
// Явно очищаем кнопки при уходе со страницы
onBeforeUnmount(() => {
clearActions();
});
// Отслеживаем изменение backRoute для обновления кнопки "Назад"
watch(() => route.query._backRoute, () => {
setBackButton();
});
// Отслеживаем переходы на дочерние маршруты (например, на страницу задачи)
watch(() => route.name, (newRouteName) => {
if (newRouteName === 'project-issue') {
// Если перешли на страницу задачи - очищаем кнопки меню проекта
clearActions();
} else if (newRouteName && newRouteName.toString().startsWith('project-') && newRouteName !== 'project-base') {
// Если вернулись на страницы проекта - регистрируем кнопки снова
menuButtons.value.forEach(button => {
registerHeaderAction(button);
});
}
});
</script>
<style lang="scss" scoped>
.q-chip {
font-weight: 500;
}
// Каркас первичной загрузки: повторяет раскладку «сайдбар + контент»
.project-page-skeleton {
display: flex;
gap: var(--p-4);
padding: var(--p-4);
.text-h6 {
margin-bottom: 4px;
&__side {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--p-3);
.skel {
height: 40px;
}
}
&__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--p-3);
.skel--title {
width: 240px;
max-width: 60%;
}
}
@media (max-width: 640px) {
flex-direction: column;
&__side {
width: 100%;
}
}
}
</style>
@@ -5,10 +5,10 @@ div
ArtifactsAccessPlaceholder(
v-else-if='!canViewArtifacts'
scope='project'
:pending='projectPermissions?.pending_clearance === true'
:pending='projectPermissions?.pending_clearance === true && projectPermissions?.has_clearance !== true'
)
template(#action)
PendingClearanceButton(v-if='projectPermissions?.pending_clearance')
PendingClearanceButton(v-if='projectPermissions?.pending_clearance && !projectPermissions?.has_clearance')
MakeClearanceButton(
v-else-if='project'
:project='project'
@@ -1,54 +1,49 @@
<template lang="pug">
div
q-card(flat)
div
.projects-list-page
//- Панель фильтров (ProjectsFilterPanel) намеренно скрыта со страницы —
//- фильтрами пока не пользуемся; виджет остаётся в проекте на будущее
// Виджет списка проектов
ProjectsListWidget(
:key='projectsListKey',
:expanded='expanded',
:has-issues-with-statuses='hasIssuesWithStatuses',
:has-issues-with-priorities='hasIssuesWithPriorities',
:has-issues-with-creators='hasIssuesWithCreators',
:master='master',
@toggle-expand='handleProjectToggleExpand',
@data-loaded='handleProjectsDataLoaded',
@open-project='handleOpenProject'
@pagination-changed='handlePaginationChanged'
// Виджет списка проектов
ProjectsListWidget(
:key='projectsListKey',
:expanded='expanded',
:has-issues-with-statuses='hasIssuesWithStatuses',
:has-issues-with-priorities='hasIssuesWithPriorities',
:has-issues-with-creators='hasIssuesWithCreators',
:master='master',
@toggle-expand='handleProjectToggleExpand',
@data-loaded='handleProjectsDataLoaded',
@open-project='handleOpenProject'
@pagination-changed='handlePaginationChanged'
)
template(#project-content='{ project }')
ComponentsListWidget(
:components='project.components',
:project='project',
:expanded='expandedComponents',
@open-component='(componentHash) => router.push({ name: "component-description", params: { project_hash: componentHash }, query: { _backRoute: "projects-list" } })',
@toggle-component='handleComponentToggle'
)
template(#project-content='{ project }')
ComponentsListWidget(
:components='project.components',
:expanded='expandedComponents',
@open-component='(componentHash) => router.push({ name: "component-description", params: { project_hash: componentHash }, query: { _backRoute: "projects-list" } })',
@toggle-component='handleComponentToggle'
template(#component-content='{ component }')
IssuesListWidget(
:project-hash='component.project_hash',
:statuses='componentStatuses',
:priorities='componentPriorities',
:creators='componentCreators',
:master='componentMaster',
:compact='true',
@issue-click='(issue) => router.push({ name: "component-issue", params: { project_hash: issue.project_hash, issue_hash: issue.issue_hash }, query: { _backRoute: "projects-list" } })'
)
template(#component-content='{ component }')
IssuesListWidget(
:project-hash='component.project_hash',
:statuses='componentStatuses',
:priorities='componentPriorities',
:creators='componentCreators',
:master='componentMaster',
:compact='true',
@issue-click='(issue) => router.push({ name: "component-issue", params: { project_hash: issue.project_hash, issue_hash: issue.issue_hash }, query: { _backRoute: "projects-list" } })'
)
// Floating Action Button для создания проекта
Fab(v-if='session.isChairman || session.isMember')
template(#actions)
CreateProjectFabAction(ref='createProjectFabRef')
</template>
<script lang="ts" setup>
import { onMounted, onBeforeMount, onBeforeUnmount, ref, computed, markRaw, watch } from 'vue';
import { onMounted, onBeforeMount, onBeforeUnmount, ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useExpandableState } from 'src/shared/lib/composables';
import { Fab } from 'src/shared/ui';
import { FilterDialogWithButton } from 'app/extensions/capital/shared/ui';
import { useHeaderActions } from 'src/shared/hooks';
import { CreateProjectFabAction } from 'app/extensions/capital/features/Project/CreateProject';
import { CreateProjectHeaderButton } from 'app/extensions/capital/features/Project/CreateProject';
import { ProjectsListWidget, ComponentsListWidget, IssuesListWidget } from 'app/extensions/capital/widgets';
import { useProjectStore } from 'app/extensions/capital/entities/Project/model';
import { useSessionStore } from 'src/entities/Session';
@@ -57,12 +52,13 @@ import { useCapitalFabHotkeys } from 'app/extensions/capital/shared/lib';
const router = useRouter();
const session = useSessionStore();
const createProjectFabRef = ref<{ openDialog: () => void } | null>(null);
// openDialog кнопки-в-шапке прилетает колбэком (см. CreateProjectHeaderButton)
const openProjectDialog = ref<(() => void) | null>(null);
const capitalFabHotkeysEnabled = computed(() => session.isChairman || session.isMember);
useCapitalFabHotkeys(
() => ({
project: () => createProjectFabRef.value?.openDialog(),
project: () => openProjectDialog.value?.(),
}),
{ enabled: capitalFabHotkeysEnabled },
);
@@ -84,19 +80,9 @@ const componentMaster = computed(() => projectStore.projectFilters.master);
const projectsListKey = ref(0);
// Регистрируем кнопку фильтров в header
// Регистрируем главное действие страницы в header
const { registerAction: registerHeaderAction, clearActions } = useHeaderActions();
// Кнопка фильтров для header
const filterButton = computed(() => ({
id: 'projects-filter-menu',
component: markRaw(FilterDialogWithButton),
props: {},
stretch: true,
style: { height: 'var(--header-action-height)' },
order: 1,
}));
// Ключи для сохранения состояния в LocalStorage
const PROJECTS_EXPANDED_KEY = 'capital_projects_expanded';
const COMPONENTS_EXPANDED_KEY = 'capital_project_components_expanded';
@@ -178,8 +164,19 @@ onMounted(async () => {
loadProjectsExpandedState();
loadComponentsExpandedState();
// Регистрируем кнопку фильтров в header
registerHeaderAction(filterButton.value);
// Главное действие страницы — создание проекта в правом верхнем углу топбара
if (session.isChairman || session.isMember) {
registerHeaderAction({
id: 'capital-projects-create',
component: CreateProjectHeaderButton,
props: {
exposeOpen: (fn: () => void) => {
openProjectDialog.value = fn;
},
},
order: 1,
});
}
});
// Следим за изменениями фильтров и обновляем список
@@ -196,7 +193,21 @@ onBeforeUnmount(() => {
</script>
<style lang="scss" scoped>
.q-chip {
font-weight: 500;
// Flex-колонка: панель фильтров сверху, список занимает остаток вьюпорта,
// скролл живёт внутри списка (virtual-scroll), низ не уезжает за экран
.projects-list-page {
display: flex;
flex-direction: column;
height: calc(100vh - 55px);
:deep(.projects-list-widget) {
flex: 1;
min-height: 0;
}
:deep(.projects-scroll-area) {
max-height: none;
}
}
</style>
@@ -4,6 +4,10 @@ export * from './CapitalBase';
export * from './ProjectsListPage';
export * from './ComponentPage';
export * from './IssuePage';
export * from './IssueDescriptionPage';
export * from './IssueRequirementsPage';
export * from './IssueCommitsPage';
export * from './IssueHistoryPage';
export * from './TrackerPage';
export * from './ProjectsVotingPage';
export * from './ProjectsResultsPage';
@@ -0,0 +1,43 @@
/**
* Компактное форматирование метрик проекта/компонента для строк списка
* Мастерской: часы «факт / план» и привлечённые инвестиции.
*/
const parseAssetAmount = (value?: string | number | null): number => {
if (value === undefined || value === null) return 0;
const numeric = parseFloat(String(value).split(' ')[0] || '0');
return isNaN(numeric) ? 0 : numeric;
};
const compactFormatter = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
});
/** «12,5 тыс» из "12500.0000 RUB" / числа; «0» для пустого */
export const formatCompactAmount = (value?: string | number | null): string =>
compactFormatter.format(parseAssetAmount(value));
const hoursFormatter = new Intl.NumberFormat('ru-RU', {
maximumFractionDigits: 1,
});
/** Часы «фактч / планч» по пулам проекта — формат синхронен с
* инлайн-чипом времени задачи (IssueTimeChip) */
export const formatHoursFactPlan = (
fact?: string | number | null,
plan?: string | number | null,
): string =>
`${hoursFormatter.format(parseAssetAmount(fact))}ч / ${hoursFormatter.format(parseAssetAmount(plan))}ч`;
/** Инвестиции «факт / план» компактно */
export const formatInvestFactPlan = (
fact?: string | number | null,
plan?: string | number | null,
): string => `${formatCompactAmount(fact)} / ${formatCompactAmount(plan)}`;
/** Есть ли смысл показывать инвестиционную метрику (что-то ненулевое) */
export const hasInvestMeta = (
fact?: string | number | null,
plan?: string | number | null,
): boolean => parseAssetAmount(fact) > 0 || parseAssetAmount(plan) > 0;
@@ -12,6 +12,7 @@ export * from './constants';
export * from './capital-fab-hotkeys';
export * from './composables/useCapitalFabHotkeys';
export * from './project-utils';
export * from './formatProjectMeta';
export * from './registration';
export * from './composables/useBatchImport';
export * from './composables/useCsvParser';
@@ -22,15 +22,15 @@ export const getIssuePriorityIcon = (priority: string) => {
export const getIssuePriorityColor = (priority: string) => {
switch (priority) {
case Zeus.IssuePriority.URGENT:
return 'red';
return 'negative';
case Zeus.IssuePriority.HIGH:
return 'deep-orange';
return 'warning';
case Zeus.IssuePriority.MEDIUM:
return 'blue';
return 'info';
case Zeus.IssuePriority.LOW:
return 'green';
return 'positive';
default:
return 'grey';
return 'grey-6';
}
};
@@ -6,19 +6,19 @@ import { Zeus } from '@coopenomics/sdk';
export const getIssueStatusColor = (status: string) => {
switch (status) {
case Zeus.IssueStatus.TODO:
return 'red';
return 'negative';
case Zeus.IssueStatus.IN_PROGRESS:
return 'blue';
return 'info';
case Zeus.IssueStatus.BACKLOG:
return 'orange';
return 'warning';
case Zeus.IssueStatus.ON_REVIEW:
return 'purple';
return 'primary';
case Zeus.IssueStatus.DONE:
return 'green';
return 'positive';
case Zeus.IssueStatus.CANCELED:
return 'grey';
return 'grey-6';
default:
return 'grey';
return 'grey-6';
}
};
@@ -6,21 +6,21 @@ import { Zeus } from '@coopenomics/sdk';
export const getProjectStatusColor = (status: string) => {
switch (status) {
case Zeus.ProjectStatus.UNDEFINED:
return 'grey';
return 'grey-6';
case Zeus.ProjectStatus.PENDING:
return 'orange';
return 'warning';
case Zeus.ProjectStatus.ACTIVE:
return 'green';
return 'positive';
case Zeus.ProjectStatus.CANCELLED:
return 'red';
return 'negative';
case Zeus.ProjectStatus.RESULT:
return 'blue';
return 'info';
case Zeus.ProjectStatus.FINALIZED:
return 'green';
return 'positive';
case Zeus.ProjectStatus.VOTING:
return 'purple';
return 'primary';
default:
return 'grey';
return 'grey-6';
}
};
@@ -55,23 +55,23 @@ export const getProjectStatusIcon = (status: string) => {
switch (status) {
case Zeus.ProjectStatus.PENDING:
case 'pending':
return 'fa-solid fa-pause';
return 'pause';
case Zeus.ProjectStatus.ACTIVE:
case 'active':
return 'fa-solid fa-play';
return 'play_arrow';
case Zeus.ProjectStatus.VOTING:
return 'fa-solid fa-check-to-slot';
return 'how_to_vote';
case Zeus.ProjectStatus.RESULT:
case 'result':
return 'fa-solid fa-check';
return 'check';
case Zeus.ProjectStatus.FINALIZED:
return 'fa-solid fa-check-circle';
return 'check_circle';
case Zeus.ProjectStatus.CANCELLED:
return 'fa-solid fa-lock';
return 'lock';
case Zeus.ProjectStatus.UNDEFINED:
case 'cancelled':
default:
return 'fa-regular fa-circle';
return 'radio_button_unchecked';
}
};
@@ -81,19 +81,19 @@ export const getProjectStatusIcon = (status: string) => {
export const getProjectStatusDotColor = (status: string) => {
switch (status) {
case Zeus.ProjectStatus.PENDING:
return 'grey';
return 'grey-6';
case Zeus.ProjectStatus.ACTIVE:
return 'green';
return 'positive';
case Zeus.ProjectStatus.VOTING:
return 'green';
return 'positive';
case Zeus.ProjectStatus.RESULT:
return 'green';
return 'positive';
case Zeus.ProjectStatus.FINALIZED:
return 'green';
return 'positive';
case Zeus.ProjectStatus.CANCELLED:
return 'grey';
return 'grey-6';
case Zeus.ProjectStatus.UNDEFINED:
default:
return 'grey';
return 'grey-6';
}
};
@@ -0,0 +1 @@
export { default as CapitalSectionEmpty } from './ui/CapitalSectionEmpty.vue'
@@ -0,0 +1,35 @@
<template lang="pug">
.text-center.q-pa-xl.capital-section-empty
q-icon(:name="icon", size="4rem", color="grey-5")
.q-mt-md
.text-h6.text-grey-7 {{ title }}
.text-body2.text-grey-6.q-mt-sm(v-if="body") {{ body }}
.row.justify-center.q-mt-md(v-if="$slots.action")
slot(name="action")
</template>
<script lang="ts" setup>
withDefaults(
defineProps<{
icon?: string
title: string
body?: string
}>(),
{
icon: 'inbox',
body: undefined,
},
)
</script>
<style lang="scss" scoped>
.capital-section-empty--centered {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: min(60vh, 28rem);
width: 100%;
box-sizing: border-box;
}
</style>
@@ -1,13 +1,11 @@
<template>
<q-btn
color="black"
label="жидание"
icon="schedule"
disable
fab
/>
<BaseChip variant="warn" size="sm">
<q-icon name="schedule" size="14px" class="q-mr-xs" />
Запрос на рассмотрении
</BaseChip>
</template>
<script setup lang="ts">
// Компонент кнопки ожидания допуска к проекту
// Индикатор ожидания допуска к проекту/компоненту
import { BaseChip } from 'src/shared/ui/base';
</script>
@@ -41,11 +41,17 @@ const toggleRole = (roleValue: string) => {
const getRoleIcon = (roleValue: string) => {
const icons: Record<string, string> = {
master: 'supervisor_account',
author: 'edit_note',
creator: 'engineering',
investor: 'account_balance_wallet',
coordinator: 'campaign',
contributor: 'groups',
// legacy-алиасы
noble: 'lightbulb',
benefactor: 'build',
philanthropist: 'account_balance_wallet',
herald: 'campaign',
early_contributor: 'stars'
early_contributor: 'stars',
};
return icons[roleValue] || 'help';
};
@@ -4,3 +4,4 @@ export * from './RoleBadges';
export * from './ProjectComponentInfo';
export * from './PendingClearanceButton';
export * from './FilterDialogWithButton';
export * from './CapitalSectionEmpty';
@@ -1,133 +1,57 @@
<template lang="pug">
.row
div.col-md-6.col-xs-12.q-pr-sm
// Кошелек Генерации (generator)
ColorCard(color='teal' :transparent="false")
.wallet-section
.wallet-header
.wallet-icon
q-icon(name="bolt", size="20px", color="blue")
.wallet-title Кошелек Генерации
.wallet-body
.wallet-available
.wallet-label Всего
.wallet-value {{ formatAmount(generatorWallet.total) }}
div.col-md-6.col-xs-12.q-pl-sm
// Кошелек Благороста (blagorost)
ColorCard(color='teal' :transparent="false")
.wallet-section
.wallet-header
.wallet-icon
q-icon(name="local_florist", size="20px", color="green")
.wallet-title Кошелек Благороста
.wallet-body
.wallet-available
.wallet-label Всего
.wallet-value {{ formatAmount(blagorostWallet.total) }}
CreateProgramInvestButton
//- Кошельки программ Благороста канон-карточки WalletCard в две колонки
.row.q-col-gutter-md
.col-12.col-md-6
WalletCard(
program='generator',
:balance='generatorWallet.amount',
:symbol='generatorWallet.symbol',
balance-label='Всего'
)
.col-12.col-md-6
WalletCard(
program='blagorost',
:balance='blagorostWallet.amount',
:symbol='blagorostWallet.symbol',
balance-label='Всего'
)
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useWalletStore } from 'src/entities/Wallet/model';
import { ColorCard } from 'src/shared/ui';
import { useSystemStore } from 'src/entities/System/model';
import { WalletCard } from 'src/shared/ui/domain/WalletCard';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { Zeus } from '@coopenomics/sdk';
import { CreateProgramInvestButton } from 'app/extensions/capital/features/ProgramInvest/CreateProgramInvest/ui';
const walletStore = useWalletStore();
const { info } = useSystemStore();
// Находим кошелек генерации
const generatorWallet = computed(() => {
const wallet = walletStore.program_wallets.find(
(wallet) => wallet.program_details.program_type.toLowerCase() === Zeus.ProgramType.GENERATOR.toLowerCase()
// WalletCard принимает сумму и тикер раздельно
function splitAsset(asset?: string | null): { amount: string; symbol: string } {
const fallbackSymbol = info?.symbols?.root_govern_symbol || 'RUB';
if (!asset) return { amount: '0,00', symbol: fallbackSymbol };
const formatted = formatAsset2Digits(asset);
const parts = formatted.split(' ');
return { amount: parts[0] || '0,00', symbol: parts[1] || fallbackSymbol };
}
function findProgramWallet(type: Zeus.ProgramType) {
return walletStore.program_wallets.find(
(wallet) =>
wallet.program_details.program_type.toLowerCase() === type.toLowerCase(),
);
}
return {
available: wallet?.available || '0.00',
total: wallet?.available || '0.00',
};
});
// Кошелёк генерации
const generatorWallet = computed(() =>
splitAsset(findProgramWallet(Zeus.ProgramType.GENERATOR)?.available),
);
// Находим кошелек благороста
const blagorostWallet = computed(() => {
const wallet = walletStore.program_wallets.find(
(wallet) => (wallet.program_details.program_type).toLowerCase() === Zeus.ProgramType.BLAGOROST.toLowerCase()
);
return {
available: wallet?.available || '0.00',
total: wallet?.available || '0.00',
};
});
// Форматирование суммы
const formatAmount = (amount: string | number | undefined) => {
if (!amount) return '0.00';
return formatAsset2Digits(`${amount}`);
};
// Кошелёк Благороста
const blagorostWallet = computed(() =>
splitAsset(findProgramWallet(Zeus.ProgramType.BLAGOROST)?.available),
);
</script>
<style lang="scss" scoped>
.wallet-section {
.wallet-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
.wallet-icon {
display: flex;
align-items: center;
justify-content: center;
}
.wallet-title {
font-size: 16px;
font-weight: 500;
}
}
.wallet-body {
.wallet-available {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--q-grey-3, #e0e0e0);
.q-dark & {
border-color: var(--q-grey-7, #424242);
}
.wallet-label {
font-size: 14px;
font-weight: 400;
opacity: 0.7;
}
.wallet-value {
font-size: 18px;
font-weight: 600;
}
}
.wallet-detail {
display: flex;
align-items: center;
gap: 8px;
padding-top: 10px;
.wallet-address {
font-size: 12px;
font-family: monospace;
opacity: 0.6;
word-break: break-all;
}
}
}
}
</style>
@@ -82,18 +82,13 @@ const emit = defineEmits<{
.capital-sidebar-delete-footer {
flex-shrink: 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
border-top: 1px solid var(--p-line);
:deep(.q-btn) {
margin-top: 0;
}
}
.body--dark .capital-sidebar-delete-footer,
.q-dark .capital-sidebar-delete-footer {
border-top-color: rgba(255, 255, 255, 0.12);
}
.capital-sidebar-mobile-compact {
padding-top: 0;
}
@@ -98,35 +98,36 @@ onMounted(async () => {
<style lang="scss" scoped>
.component-to-project-path {
background: rgba(var(--q-primary-rgb), 0.04);
border: 1px solid rgba(var(--q-primary-rgb), 0.1);
border-radius: 6px;
font-size: 13px;
line-height: 1.4;
font-size: var(--p-fs-meta);
line-height: var(--p-lh-meta);
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
}
// Шапка страницы сущности: над заголовком, без верхнего «воздуха» (без q-mt-md)
// Шапка страницы сущности: над заголовком, без верхнего «воздуха»
.component-to-project-path.capital-entity-header-path {
margin-top: 0;
margin-bottom: 0;
margin-bottom: var(--p-1);
font-size: var(--p-fs-body-sm);
line-height: var(--p-lh-body-sm);
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 4px;
gap: var(--p-1);
cursor: pointer;
transition: all 0.2s ease;
color: #666;
color: var(--p-ink-2);
font-weight: 500;
transition: color var(--p-dur-fast) var(--p-ease-standard);
&:hover {
color: var(--q-primary);
text-decoration: underline;
color: var(--p-primary);
.breadcrumb-link {
opacity: 1;
transform: scale(1.1);
}
}
@@ -145,8 +146,8 @@ onMounted(async () => {
.breadcrumb-info {
display: flex;
align-items: center;
gap: 4px;
color: #999;
gap: var(--p-1);
color: var(--p-ink-3);
font-weight: normal;
.q-icon {
@@ -156,39 +157,7 @@ onMounted(async () => {
.breadcrumb-link {
opacity: 0.4;
transition: all 0.2s ease;
transition: opacity var(--p-dur-fast) var(--p-ease-standard);
flex-shrink: 0;
}
@media (max-width: 600px) {
.component-to-project-path {
padding: 6px 8px;
font-size: 12px;
gap: 4px;
}
.component-to-project-path.capital-entity-header-path {
padding: 4px 6px;
min-height: 28px;
}
.breadcrumb-item {
gap: 2px;
.q-icon {
font-size: 12px;
}
.breadcrumb-link {
font-size: 8px;
}
}
.breadcrumb-loading,
.breadcrumb-info {
.q-icon {
font-size: 12px;
}
}
}
</style>
@@ -10,54 +10,61 @@ div
square,
hide-header,
hide-pagination,
no-data-label='Нет компонентов'
)
template(#body='props')
q-tr(
:props='props'
)
q-td
.row.items-center(style='padding-left:25px; min-height: 48px')
// Кнопка раскрытия (35px)
.col-auto(style='width: 35px; flex-shrink: 0')
.row.items-center.component-row
// Кнопка раскрытия
.col-auto.component-row__toggle
ExpandToggleButton(
:expanded='expanded[props.row.project_hash]',
@click='handleToggleComponent(props.row.project_hash)'
)
// ID с иконкой типа внутри бейджа (100px + отступ 0px)
.col-auto(style='width: 100px; flex-shrink: 0')
// ID плоский muted-текст фиксированной колонки (Linear-style)
.col-auto.component-row__id
EntityIdBadge(
v-if='props.row.id'
:raw-id='props.row.id'
copy-on-click
address-clipboard
flat
)
template(#prefix)
q-icon(name='fa-regular fa-file-code', size='xs')
// Title со статусом (400px)
.col(style='width: 400px; padding-left: 10px')
// Статус фиксированный слот, заголовки уровня стартуют с одной координаты
.col-auto.row-status
q-icon(
:name='getProjectStatusIcon(props.row.status)',
:color='getProjectStatusDotColor(props.row.status)',
size='xs'
)
// Title
.col.component-row__title-col
.list-item-title(
@click.stop='handleOpenComponent(props.row.project_hash)'
style='display: inline-block; vertical-align: top; word-wrap: break-word; white-space: normal'
)
q-icon(
:name='getProjectStatusIcon(props.row.status)',
:color='getProjectStatusDotColor(props.row.status)',
size='xs'
).q-mr-sm
span {{ props.row.title }}
// Actions - только CreateIssueButton (140px, выравнивание по правому краю)
.col-auto.ml-auto(style='width: 140px')
.row.items-center.justify-end.q-gutter-xs
CreateIssueButton(
@click.stop,
:mini='true',
:project-hash='props.row.project_hash',
flat
)
// Правая сетка строки: время | инвестиции | действие
// фиксированные колонки, одинаковые для всех уровней дерева
.col-auto.row-cells
.cell-time
.row-meta
q-icon(name='schedule', size='14px')
span.t-mono-sm {{ formatHoursFactPlan(props.row.fact?.creators_hours, props.row.plan?.creators_hours) }}
q-tooltip Часы исполнителей: факт / план
.cell-side
.row-meta(v-if='hasInvestMeta(props.row.fact?.total_received_investments, props.row.plan?.invest_pool)')
q-icon(name='payments', size='14px')
span.t-mono-sm {{ formatInvestFactPlan(props.row.fact?.total_received_investments, props.row.plan?.invest_pool) }}
q-tooltip Инвестиции: привлечено / план
.cell-actions
// Мастер ответственный за компонент (зеркально исполнителям задач)
SetMasterAvatar(:project='props.row')
//- ProjectMenuWidget(:project='props.row', @click.stop)
// Слот для дополнительного контента компонента
@@ -66,24 +73,36 @@ div
v-if='expanded[props.row.project_hash]',
:key='`e_${props.row.project_hash}`'
)
q-td(colspan='100%')
// Скелетон загрузки
div(v-if='loadingComponents[props.row.project_hash]', style='padding: 16px')
q-skeleton(height='24px', style='margin-bottom: 8px')
q-skeleton(height='24px', style='margin-bottom: 8px')
q-skeleton(height='24px', style='margin-bottom: 8px')
q-skeleton(height='24px')
q-td.component-row__nested(colspan='100%')
// Скелетон загрузки задач компонента
.component-row__skeleton(v-if='loadingComponents[props.row.project_hash]')
.skel.skel--num(v-for='i in 4', :key='i')
// Реальный контент
slot(v-else, name='component-content', :component='props.row')
// Канон-пустое состояние вместо дефолтного q-table no-data
template(#no-data)
.list-empty
q-icon(name='inbox', size='20px')
span Нет компонентов
// Полоска-добавлялка после всех компонентов проекта
CreateComponentButton(
v-if='project && project.permissions?.can_edit_project',
:project='project',
row
)
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import type { IProjectComponent } from 'app/extensions/capital/entities/Project/model';
import type { IProject, IProjectComponent } from 'app/extensions/capital/entities/Project/model';
import {
getProjectStatusIcon,
getProjectStatusDotColor,
} from 'app/extensions/capital/shared/lib/projectStatus';
import { CreateIssueButton } from 'app/extensions/capital/features/Issue/CreateIssue';
import { formatHoursFactPlan, formatInvestFactPlan, hasInvestMeta } from 'app/extensions/capital/shared/lib';
import { SetMasterAvatar } from 'app/extensions/capital/features/Project/SetMaster';
import { CreateComponentButton } from 'app/extensions/capital/features/Project/CreateComponent';
import { EntityIdBadge } from 'src/shared/ui';
import { ExpandToggleButton } from 'src/shared/ui/ExpandToggleButton';
// import { ProjectMenuWidget } from 'app/extensions/capital/widgets/ProjectMenuWidget';
@@ -92,6 +111,8 @@ const props = defineProps<{
components: IProjectComponent[] | undefined;
expanded: Record<string, boolean>;
expandAll?: boolean;
/** Родительский проект — для полоски «Добавить компонент» в конце списка */
project?: IProject;
}>();
const emit = defineEmits<{
@@ -200,28 +221,126 @@ const columns = [
</script>
<style lang="scss" scoped>
// Структурные ширины колонок строки компонента. Собственного отступа
// уровня нет каскадный отступ задаёт родительский виджет, чтобы при
// одиночном использовании список начинался от края
.component-row {
padding-right: var(--p-3);
min-height: 48px;
}
.component-row__toggle {
width: 28px;
flex-shrink: 0;
}
.component-row__id {
width: 96px;
flex-shrink: 0;
}
.row-status {
width: 22px;
flex-shrink: 0;
display: flex;
align-items: center;
}
// Вложенный уровень (задачи компонента) отступ каскада
.component-row__nested {
padding: 0 0 0 var(--p-7) !important;
@media (max-width: 640px) {
padding-left: var(--p-3) !important;
}
}
// Правая сетка строки фиксированные колонки, общие для всех уровней
// дерева (проект/компонент/задача): время | статус-инвестиции | действия
.row-cells {
display: flex;
align-items: center;
}
.cell-time {
width: 110px;
display: flex;
justify-content: flex-end;
}
.cell-side {
width: 132px;
display: flex;
justify-content: flex-end;
}
.cell-actions {
width: 160px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--p-2);
}
@media (max-width: 640px) {
.cell-time,
.cell-side {
display: none;
}
.cell-actions {
width: auto;
}
}
.row-meta {
display: flex;
align-items: center;
gap: var(--p-1);
color: var(--p-ink-3);
white-space: nowrap;
}
// Канон-пустое состояние списка
.list-empty {
display: flex;
align-items: center;
gap: var(--p-2);
width: 100%;
padding: var(--p-3) var(--p-4);
color: var(--p-ink-3);
font-size: var(--p-fs-body-sm);
}
.component-row__skeleton {
display: flex;
flex-direction: column;
gap: var(--p-2);
padding: var(--p-4);
}
.q-table {
tr {
min-height: 48px;
}
.q-td {
padding: 0; // Убираем padding таблицы, так как теперь используем внутренний padding
padding: 0; // строка использует внутренние отступы .component-row
}
}
.q-chip {
font-weight: 500;
}
// Импорт глобального стиля для подсветки
:deep(.list-item-title) {
display: inline-block;
vertical-align: top;
word-wrap: break-word;
white-space: normal;
font-size: var(--p-fs-body);
font-weight: 500;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: var(--q-accent);
color: var(--p-primary);
}
}
</style>
@@ -1,35 +1,13 @@
<template lang="pug">
// Объединенный бейдж уровня и энергии
q-badge(
:color="currentEnergy >= 90 ? 'teal' : currentEnergy >= 25 ? 'orange' : 'red'"
flat
)
q-icon(name='local_fire_department', size='14px', class='q-mr-xs')
| Уровень {{ Number(contributorStore.self?.level) || 1 }}
.q-mx-sm
// Компактный индикатор энергии
.energy-compact-inline.row.items-center
.energy-progress-container
q-linear-progress(
:value="currentEnergy / 100",
:color="currentEnergy >= 90 ? 'teal' : currentEnergy >= 25 ? 'orange' : 'red'",
track-color="grey-3",
size="24px",
style="width: 65px;"
rounded
)
.energy-overlay.row.items-center.justify-center.full-width.q-px-xs
q-icon(
name="arrow_left",
color="red",
size="24px",
class="energy-arrow"
)
.text-caption.text-white {{ currentEnergy.toFixed(0) }}%
// Tooltip с информацией о следующем уровне
q-tooltip
| До следующего уровня: {{ nextLevelRequirement }} {{ info.symbols.root_govern_symbol }}
//- Объединённый бейдж уровня и энергии: вариант чипа отражает запас энергии.
//- Мигающий красный «тик» влево энергия затухает со временем, и требуемый
//- для следующего уровня взнос растёт.
BaseChip(:variant='energyVariant')
q-icon.q-mr-xs(name='local_fire_department', size='14px')
| Уровень {{ Number(contributorStore.self?.level) || 1 }} ·
q-icon.energy-decay-tick(name='arrow_left', color='negative', size='16px')
| {{ currentEnergy.toFixed(0) }}%
q-tooltip До следующего уровня: {{ nextLevelRequirement }} {{ info.symbols.root_govern_symbol }}
</template>
<script lang="ts" setup>
@@ -37,6 +15,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useContributorStore } from 'app/extensions/capital/entities/Contributor/model';
import { useConfigStore } from 'app/extensions/capital/entities/Config/model';
import { useSystemStore } from 'src/entities/System/model';
import { BaseChip } from 'src/shared/ui/base';
import type { BaseChipVariant } from 'src/shared/ui/base/BaseChip/BaseChip.types';
const contributorStore = useContributorStore();
const configStore = useConfigStore();
@@ -48,6 +28,13 @@ let energyUpdateTimer: ReturnType<typeof window.setInterval> | null = null;
// Текущая энергия с учетом decay
const currentEnergy = ref(0);
// Вариант чипа по запасу энергии: высокая / средняя / на исходе
const energyVariant = computed<BaseChipVariant>(() => {
if (currentEnergy.value >= 90) return 'pos';
if (currentEnergy.value >= 25) return 'warn';
return 'neg';
});
// Порог вклада для уровня (из GAMIFICATION.md)
const calculateLevelRequirement = (level: number): number => {
if (!configStore.state?.config) return 0;
@@ -139,39 +126,25 @@ onUnmounted(() => {
</script>
<style scoped>
.energy-compact-inline {
opacity: 0.8;
transition: opacity 0.2s ease;
.energy-decay-tick {
animation: energy-decay-blink 1s ease-in-out infinite;
}
.energy-compact-inline:hover {
opacity: 1;
}
.energy-progress-container {
position: relative;
}
.energy-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
}
.energy-arrow {
animation: blink 1s ease-in-out infinite;
}
@keyframes blink {
0%, 50% {
@keyframes energy-decay-blink {
0%,
50% {
opacity: 1;
}
51%, 100% {
51%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.energy-decay-tick {
animation: none;
}
}
</style>
@@ -28,26 +28,21 @@ div
@update:modelValue='handlePriorityUpdate'
).full-width.q-mb-sm
UpdateEstimate(
v-if='issue'
:model-value='issue.estimate'
:issue-hash='issue.issue_hash'
label='Оценка (ч)'
:readonly='!permissions?.can_set_estimate'
@update:modelValue='handleEstimateUpdate'
).full-width.q-mb-sm
.row.items-center.justify-between.full-width.q-mb-sm(
v-if='issue && (issue.fact > 0 || issue.estimate > 0)'
)
.col-auto
.text-caption.text-grey-7 Факт
.col-auto
Estimation(
:estimation='issue.estimate'
:fact='issue.fact'
size='sm'
)
.issue-controls-plan-fact.q-mb-sm(v-if='issue')
UpdateEstimate(
:model-value='issue.estimate'
:issue-hash='issue.issue_hash'
label='План (ч)'
:readonly='!permissions?.can_set_estimate'
@update:modelValue='handleEstimateUpdate'
)
UpdateEstimate(
:key='`fact-${legacyFactHours}`'
:model-value='legacyFactHours'
:issue-hash='issue.issue_hash'
label='Факт (ч)'
readonly
)
UpdateLabels(
v-if='issue'
@@ -66,7 +61,6 @@ import type { IIssuePermissions } from 'app/extensions/capital/entities/Issue/mo
import { UpdateStatus, UpdatePriority, UpdateEstimate, UpdateLabels } from '../../features/Issue/UpdateIssue'
import { getIssueLabels } from 'app/extensions/capital/shared/lib'
import { SetCreatorButton } from '../../features/Issue/SetCreator'
import { Estimation } from 'src/shared/ui'
interface Props {
issue: any // IIssue тип
@@ -89,6 +83,13 @@ const emit = defineEmits<Emits>()
const issueLabels = computed(() => (props.issue ? getIssueLabels(props.issue) : []))
/** Легаси: пока бэкенд не отдаёт факт из учёта — при отсутствии факта показываем план. */
const legacyFactHours = computed(() => {
const estimate = Number(props.issue?.estimate) || 0
const fact = Number(props.issue?.fact) || 0
return fact > 0 ? fact : estimate
})
const handleStatusUpdate = (value: any) => {
emit('update:status', value)
}
@@ -113,3 +114,16 @@ const handleIssueUpdated = (issue: any) => {
emit('issue-updated', issue)
}
</script>
<style lang="scss" scoped>
.issue-controls-plan-fact {
display: flex;
width: 100%;
gap: var(--p-2);
> * {
flex: 1 1 0;
min-width: 0;
}
}
</style>
@@ -45,13 +45,6 @@ div(
@deleted='emit("issue-deleted")'
)
.capital-sidebar-logs.q-mt-md(v-if='issue?.issue_hash')
IssueLogsTableWidget(
:issue-hash='issue.issue_hash'
:refresh-trigger='logsRefreshTrigger'
compact
)
template(v-else)
IssueControls(
:issue='issue'
@@ -84,22 +77,12 @@ div(
:can-delete='true'
@deleted='emit("issue-deleted")'
)
.capital-sidebar-logs(
v-if='issue.issue_hash'
)
IssueLogsTableWidget(
:issue-hash='issue.issue_hash'
:refresh-trigger='logsRefreshTrigger'
compact
)
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import type { IIssue, IIssuePermissions } from 'app/extensions/capital/entities/Issue/model'
import { IssueControls } from 'app/extensions/capital/widgets/IssueControls'
import { IssueLogsTableWidget } from '../IssueLogsTableWidget'
import { DeleteIssueButton } from 'app/extensions/capital/features/Issue/DeleteIssue'
import { MoveIssueButton } from 'app/extensions/capital/features/Issue/MoveIssue'
@@ -112,13 +95,10 @@ interface Props {
projectHash?: string
/** parent_hash родительского проекта текущего компонента (для списка других компонентов того же проекта) */
parentProjectHash?: string | null
/** Счётчик для перезагрузки логов задачи (с IssuePage) */
logsRefreshTrigger?: number
}
const props = withDefaults(defineProps<Props>(), {
compactMobile: false,
logsRefreshTrigger: 0,
})
const detailsOpen = ref(false)
@@ -194,22 +174,6 @@ const handleIssueUpdated = (issue: unknown) => {
}
}
.capital-sidebar-logs {
padding-top: 4px;
}
.capital-sidebar-logs__heading {
font-size: 0.8125rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 6px;
}
.body--dark .capital-sidebar-logs__heading,
.q-dark .capital-sidebar-logs__heading {
color: rgba(255, 255, 255, 0.72);
}
.capital-sidebar-mobile-compact {
padding-top: 0;
}
@@ -6,7 +6,7 @@ q-input(
outline
:readonly="!permissions?.can_edit_issue"
@input="handleTitleChange"
hide-bottom-space
:hide-bottom-space="!$slots.hint"
:rules="[val => !!val || 'Название задачи обязательно']"
type="textarea"
autogrow
@@ -46,6 +46,9 @@ q-input(
copy-on-click
address-clipboard
)
template(v-if="$slots.hint", #hint)
slot(name="hint")
</template>
<script lang="ts" setup>
@@ -1,57 +1,60 @@
<template lang="pug">
.issue-row(role='row')
// 1. Метаблок (горизонтально): приоритет ID инлайн-чип времени.
// Чип фиксированной ширины title не «прыгает» между задачами с разным estimate/fact.
.meta-block
// 1. Левые колонки та же сетка, что у проектов/компонентов:
// приоритет (на месте chevron) плоский ID фиксированной ширины.
.row-lead
q-icon.priority-icon(
:name='priorityIcon'
:color='priorityColor'
size='18px'
)
q-tooltip(anchor='bottom middle', self='top middle') Приоритет: {{ priorityLabel }}
.row-id
EntityIdBadge(
:raw-id='issue.id'
copy-on-click
address-clipboard
)
template(#prefix)
q-icon(name='task', size='xs')
IssueTimeChip(
:issue-hash='issue.issue_hash'
:estimate='issue.estimate'
:fact='issue.fact'
:readonly='!canChangeEstimate'
flat
)
// 2. Тайтл: занимает всё свободное место, переносится по словам, ellipsis по необходимости.
.title-block(@click.stop="onTitleClick")
span.title-text {{ issue.title }}
q-chip.label-chip(
BaseChip.label-chip(
v-for='tag in tags'
:key='tag'
dense
variant='neutral'
size='sm'
color='grey-4'
text-color='dark'
) {{ tag }}
// 3. Действия: компактный chip статуса + аватарки исполнителей.
// 3. Правая сетка строки те же фиксированные колонки, что у проектов
// и компонентов: время | статус | люди
.actions-block(@click.stop)
IssueStatusChip(
:model-value='issue.status'
:issue-hash='issue.issue_hash'
:readonly='!issue.permissions.can_change_status'
:allowed-transitions='issue.permissions.allowed_status_transitions'
)
SetCreatorAvatars(
:issue='issue'
:permissions='issue.permissions'
)
.cell-time
IssueTimeChip(
:issue-hash='issue.issue_hash'
:estimate='issue.estimate'
:fact='issue.fact'
:readonly='!canChangeEstimate'
)
.cell-side
IssueStatusChip(
:model-value='issue.status'
:issue-hash='issue.issue_hash'
:readonly='!issue.permissions.can_change_status'
:allowed-transitions='issue.permissions.allowed_status_transitions'
)
.cell-actions
SetCreatorAvatars(
:issue='issue'
:permissions='issue.permissions'
)
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { EntityIdBadge } from 'src/shared/ui';
import { BaseChip } from 'src/shared/ui/base';
import { IssueStatusChip } from '../../../features/Issue/UpdateIssue/ui/UpdateStatus';
import { IssueTimeChip } from '../../../features/Issue/UpdateIssue/ui/UpdateEstimate';
import { SetCreatorAvatars } from '../../../features/Issue/SetCreator';
@@ -87,20 +90,27 @@ const canChangeEstimate = computed(
// мигрирует на вторую строку, не клипается за правый край.
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
row-gap: var(--p-2);
min-height: 48px;
padding: 6px 12px;
padding: var(--p-2) var(--p-3) var(--p-2) 0;
width: 100%;
box-sizing: border-box;
}
// 1. Meta горизонтальная цепочка приоритет ID время.
.meta-block {
display: flex;
flex-direction: row;
align-items: center;
// 1. Левые колонки сетка, синхронная со строками проектов/компонентов.
.row-lead {
width: 28px;
flex-shrink: 0;
gap: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.row-id {
width: 96px;
flex-shrink: 0;
display: flex;
align-items: center;
}
.priority-icon {
@@ -117,14 +127,14 @@ const canChangeEstimate = computed(
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
gap: var(--p-1);
cursor: pointer;
font-weight: 500;
font-size: 14px;
font-size: var(--p-fs-body);
transition: color 0.15s ease;
&:hover {
color: var(--q-accent);
color: var(--p-primary);
}
}
@@ -137,8 +147,6 @@ const canChangeEstimate = computed(
}
.label-chip {
font-size: 0.7rem;
font-weight: 500;
max-width: 140px;
:deep(.q-chip__content) {
@@ -148,25 +156,41 @@ const canChangeEstimate = computed(
}
}
// 3. Actions фиксированный набор справа, не сжимается.
// 3. Actions фиксированная колоночная сетка справа, синхронная со
// строками проектов/компонентов (см. .row-cells в их виджетах).
.actions-block {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.cell-time {
width: 110px;
display: flex;
justify-content: flex-end;
}
.cell-side {
width: 132px;
display: flex;
justify-content: flex-end;
}
.cell-actions {
width: 160px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--p-2);
}
// Mobile: meta + title в первой строке (title справа от меты), actions
// переносится во вторую строку и прижимается вправо.
@media (max-width: 640px) {
.issue-row {
flex-wrap: wrap;
row-gap: 6px;
}
.meta-block {
gap: 6px;
row-gap: var(--p-2);
}
.title-block {
@@ -178,6 +202,13 @@ const canChangeEstimate = computed(
width: 100%;
margin-left: 0;
justify-content: flex-end;
gap: var(--p-2);
}
.cell-time,
.cell-side,
.cell-actions {
width: auto;
}
}
</style>
@@ -1,61 +1,72 @@
<template lang="pug">
div
q-card(flat)
q-card-section(style='padding: 0px')
//- Полноэкранный режим с виртуальным скроллом (для отдельных страниц)
.issues-scroll-area(
v-if='!compact',
style='height: calc(100vh - 55px); overflow-y: auto'
)
q-table(
ref='tableRef',
:rows='issues?.items || []',
:columns='columns',
row-key='_id',
:pagination='pagination',
:loading='onLoading',
flat,
square,
hide-header,
hide-pagination,
virtual-scroll,
@virtual-scroll='onScroll',
:virtual-scroll-target='".issues-scroll-area"',
:virtual-scroll-item-size='48',
:virtual-scroll-sticky-size-start='48',
:rows-per-page-options='[0]',
no-data-label="Нет задач"
)
template(#body='props')
q-tr(:props='props')
q-td
IssueListRow(
:issue='props.row'
@click='handleIssueClick'
)
//- Полноэкранный режим с виртуальным скроллом (для отдельных страниц)
.issues-scroll-area(v-if='!compact')
q-table(
ref='tableRef',
:rows='issues?.items || []',
:columns='columns',
row-key='_id',
:pagination='pagination',
:loading='onLoading',
flat,
square,
hide-header,
hide-pagination,
virtual-scroll,
@virtual-scroll='onScroll',
:virtual-scroll-target='".issues-scroll-area"',
:virtual-scroll-item-size='48',
:virtual-scroll-sticky-size-start='48',
:rows-per-page-options='[0]'
)
template(#body='props')
q-tr(:props='props')
q-td
IssueListRow(
:issue='props.row'
@click='handleIssueClick'
)
//- Компактный режим без фиксированной высоты (для вложенного использования)
div(v-else)
q-table(
:rows='issues?.items || []',
:columns='columns',
row-key='_id',
:pagination='compactPagination',
:loading='loading',
flat,
square,
hide-header,
hide-pagination,
:rows-per-page-options='[0]',
no-data-label="Нет задач"
)
template(#body='props')
q-tr(:props='props')
q-td
IssueListRow(
:issue='props.row'
@click='handleIssueClick'
)
// Канон-пустое состояние вместо дефолтного q-table no-data
template(#no-data)
.list-empty
q-icon(name='inbox', size='20px')
span Нет задач
// Полоска-добавлялка после всех задач
CreateIssueButton(:project-hash='projectHash', row)
//- Компактный режим без фиксированной высоты (для вложенного использования)
div(v-else)
q-table(
:rows='issues?.items || []',
:columns='columns',
row-key='_id',
:pagination='compactPagination',
:loading='loading',
flat,
square,
hide-header,
hide-pagination,
:rows-per-page-options='[0]'
)
template(#body='props')
q-tr(:props='props')
q-td
IssueListRow(
:issue='props.row'
@click='handleIssueClick'
)
// Канон-пустое состояние вместо дефолтного q-table no-data
template(#no-data)
.list-empty
q-icon(name='inbox', size='20px')
span Нет задач
// Полоска-добавлялка после всех задач
CreateIssueButton(:project-hash='projectHash', row)
</template>
<script lang="ts" setup>
@@ -66,6 +77,7 @@ import {
} from 'app/extensions/capital/entities/Issue/model';
import { useSystemStore } from 'src/entities/System/model';
import { FailAlert } from 'src/shared/api';
import { CreateIssueButton } from 'app/extensions/capital/features/Issue/CreateIssue';
import IssueListRow from './IssueListRow.vue';
const props = defineProps<{
@@ -275,6 +287,11 @@ onMounted(async () => {
// table-layout: fixed + width: 100% иначе html-table ужимает колонки под
// контент: длинный title распирает строку шире контейнера, actions-блок
// уезжает за правый край (наблюдалось на ComponentTasksPage с боковой панелью).
.issues-scroll-area {
height: calc(100vh - 55px);
overflow-y: auto;
}
.q-table {
table-layout: fixed;
width: 100%;
@@ -288,4 +305,15 @@ onMounted(async () => {
overflow: hidden;
}
}
// Канон-пустое состояние списка
.list-empty {
display: flex;
align-items: center;
gap: var(--p-2);
width: 100%;
padding: var(--p-3) var(--p-4);
color: var(--p-ink-3);
font-size: var(--p-fs-body-sm);
}
</style>
@@ -1,14 +1,13 @@
<template lang="pug">
.breadcrumb-path(:style="{ color: textColor }")
.breadcrumb-path(:class="{ 'breadcrumb-path--custom': useCustomColor }" :style="pathStyle")
// Родительский проект (если есть)
.breadcrumb-item(
:class="{ 'custom-color': useCustomColor }"
v-if="project?.parent_hash && project?.parent_title"
@click="goToParentProject(project.parent_hash)"
)
q-icon(name="folder", size="14px" :color="useCustomColor ? 'white' : 'grey-7'")
q-icon(name="folder", size="14px" :color="iconColorMuted")
span {{ truncateText(project.parent_title, 30) }}
q-icon.breadcrumb-link(name="open_in_new", size="10px" :color="useCustomColor ? 'white' : 'grey-7'")
q-icon.breadcrumb-link(name="open_in_new", size="10px" :color="iconColorMuted")
// Разделитель
.breadcrumb-separator(
@@ -16,15 +15,13 @@
) /
// Текущий проект/компонент
.breadcrumb-item.current(
:class="{ 'custom-color': useCustomColor }"
v-if="project?.title"
@click="goToCurrentItem(project?.project_hash)"
)
q-icon(name="task", size="14px" :color="useCustomColor ? 'white' : 'primary'")
q-icon(name="task", size="14px" :color="iconColorCurrent")
span {{ truncateText(project?.title || 'Загрузка...', 35) }}
q-icon.breadcrumb-link(name="open_in_new", size="10px" :color="useCustomColor ? 'white' : 'grey-7'")
q-icon.breadcrumb-link(name="open_in_new", size="10px" :color="iconColorMuted")
</template>
@@ -38,15 +35,19 @@ const route = useRoute();
const props = defineProps<{
project?: IProject | null;
/** Опциональный цвет текста (например, для тёмного фона). Без него — канонные ink-токены. */
textColor?: string;
}>();
// Вычисляемый цвет текста (по умолчанию или переданный)
const textColor = computed(() => props.textColor || '#666');
// Флаг использования кастомного цвета
const useCustomColor = computed(() => !!props.textColor);
const pathStyle = computed(() =>
props.textColor ? { color: props.textColor } : undefined,
);
const iconColorMuted = computed(() => (useCustomColor.value ? props.textColor : 'grey-7'));
const iconColorCurrent = computed(() => (useCustomColor.value ? props.textColor : 'primary'));
// Функция для сокращения текста
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text;
@@ -88,7 +89,7 @@ const goToCurrentItem = (projectHash?: string) => {
// Для текущего элемента: если есть parent_hash это компонент, иначе проект.
// Со страницы задачи компонента чаще нужен список задач, а не описание.
const routeName = props.project?.parent_hash
? route.name === 'component-issue'
? String(route.name ?? '').startsWith('component-issue')
? 'component-tasks'
: 'component-description'
: 'project-description';
@@ -105,50 +106,51 @@ const goToCurrentItem = (projectHash?: string) => {
<style lang="scss" scoped>
.breadcrumb-path {
display: flex;
display: inline-flex;
align-items: center;
gap: 8px;
gap: var(--p-2);
flex-wrap: wrap;
background: rgba(var(--q-primary-rgb), 0.04);
border: 1px solid rgba(var(--q-primary-rgb), 0.1);
border-radius: 6px;
font-size: 13px;
line-height: 1.4;
max-width: 100%;
font-size: var(--p-fs-meta);
line-height: var(--p-lh-meta);
color: var(--p-ink-2);
}
.breadcrumb-path.capital-entity-header-path {
margin-top: 0;
margin-bottom: 0;
gap: 6px;
font-size: var(--p-fs-body-sm);
line-height: var(--p-lh-body-sm);
}
.breadcrumb-path--custom .breadcrumb-item:hover,
.breadcrumb-path--custom .breadcrumb-item.current {
color: inherit;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 4px;
gap: var(--p-1);
cursor: pointer;
transition: all 0.2s ease;
color: inherit;
font-weight: 500;
transition: color var(--p-dur-fast) var(--p-ease-standard);
&:hover {
text-decoration: underline;
color: var(--p-primary);
.breadcrumb-link {
opacity: 1;
transform: scale(1.1);
}
&:not(.custom-color) {
color: var(--q-primary);
}
}
&.current {
font-weight: 600;
color: var(--p-ink);
&:not(.custom-color) {
color: var(--q-primary);
&:hover {
color: var(--p-primary);
}
}
@@ -164,45 +166,14 @@ const goToCurrentItem = (projectHash?: string) => {
}
.breadcrumb-separator {
color: inherit;
opacity: 0.7;
color: var(--p-ink-3);
font-weight: normal;
user-select: none;
}
.breadcrumb-link {
opacity: 0.4;
transition: all 0.2s ease;
transition: opacity var(--p-dur-fast) var(--p-ease-standard);
flex-shrink: 0;
}
@media (max-width: 600px) {
.breadcrumb-path {
padding: 6px 8px;
font-size: 12px;
gap: 4px;
}
.breadcrumb-path.capital-entity-header-path {
padding: 4px 6px;
min-height: 28px;
gap: 4px;
}
.breadcrumb-item {
gap: 2px;
.q-icon {
font-size: 12px;
}
.breadcrumb-link {
font-size: 8px;
}
}
.breadcrumb-separator {
font-size: 12px;
}
}
</style>
@@ -82,18 +82,13 @@ const emit = defineEmits<{
.capital-sidebar-delete-footer {
flex-shrink: 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
border-top: 1px solid var(--p-line);
:deep(.q-btn) {
margin-top: 0;
}
}
.body--dark .capital-sidebar-delete-footer,
.q-dark .capital-sidebar-delete-footer {
border-top-color: rgba(255, 255, 255, 0.12);
}
.capital-sidebar-mobile-compact {
padding-top: 0;
}
@@ -9,7 +9,7 @@ div
outline
type="textarea"
autogrow
hide-bottom-space
:hide-bottom-space="!$slots.hint"
:rules="[val => !!val || 'Название проекта обязательно']"
).full-width.capital-title-editor-input
template(#prepend)
@@ -47,6 +47,9 @@ div
copy-on-click
address-clipboard
)
template(v-if="$slots.hint", #hint)
slot(name="hint")
</template>
<script lang="ts" setup>
@@ -0,0 +1,208 @@
<template lang="pug">
.projects-filter-panel
//- Компактные инпуты фильтров применяются сразу, без кнопки «Применить»
q-select.fp-select(
v-model='selectedStatuses',
:options='statusOptions',
option-value='value',
option-label='label',
emit-value,
map-options,
multiple,
outlined,
dense,
color='primary',
label='Статусы задач',
:display-value='statusesDisplay'
)
q-select.fp-select(
v-model='selectedPriorities',
:options='priorityOptions',
option-value='value',
option-label='label',
emit-value,
map-options,
multiple,
outlined,
dense,
color='primary',
label='Приоритеты',
:display-value='prioritiesDisplay'
)
.fp-contributor
ContributorSelector(
v-model='selectedCreator',
:multi-select='false',
dense,
outlined,
label='Исполнитель',
placeholder=''
)
.fp-contributor
ContributorSelector(
v-model='selectedMaster',
:multi-select='false',
dense,
outlined,
label='Мастер',
placeholder=''
)
BaseButton.fp-reset(
v-if='hasActiveFilters',
variant='ghost',
size='sm',
@click='resetFilters'
)
template(#icon-left)
q-icon(name='close', size='16px')
| Сбросить
</template>
<script lang="ts" setup>
import { ref, computed, watch, onMounted } from 'vue';
import { Zeus } from '@coopenomics/sdk';
import { BaseButton } from 'src/shared/ui/base';
import { ContributorSelector } from 'app/extensions/capital/entities/Contributor';
import { useContributorStore } from 'app/extensions/capital/entities/Contributor/model';
import { useProjectStore } from 'app/extensions/capital/entities/Project/model';
import { getIssueStatusLabel } from 'app/extensions/capital/shared/lib/issueStatus';
import type { IContributor } from 'app/extensions/capital/entities/Contributor/model';
const projectStore = useProjectStore();
const contributorStore = useContributorStore();
const selectedStatuses = ref<string[]>([...projectStore.projectFilters.statuses]);
const selectedPriorities = ref<string[]>([...projectStore.projectFilters.priorities]);
const selectedCreator = ref<IContributor | null>(null);
const selectedMaster = ref<IContributor | null>(null);
// Восстановление исполнителя/мастера из store идёт асинхронно на это время
// глушим watcher, чтобы не перезаписать фильтры промежуточным состоянием
const isRestoring = ref(false);
const statusOptions = computed(() => [
{ label: getIssueStatusLabel(Zeus.IssueStatus.BACKLOG), value: Zeus.IssueStatus.BACKLOG },
{ label: getIssueStatusLabel(Zeus.IssueStatus.TODO), value: Zeus.IssueStatus.TODO },
{ label: getIssueStatusLabel(Zeus.IssueStatus.IN_PROGRESS), value: Zeus.IssueStatus.IN_PROGRESS },
{ label: getIssueStatusLabel(Zeus.IssueStatus.ON_REVIEW), value: Zeus.IssueStatus.ON_REVIEW },
{ label: getIssueStatusLabel(Zeus.IssueStatus.DONE), value: Zeus.IssueStatus.DONE },
{ label: getIssueStatusLabel(Zeus.IssueStatus.CANCELED), value: Zeus.IssueStatus.CANCELED },
]);
const priorityOptions = [
{ label: 'Срочный', value: Zeus.IssuePriority.URGENT },
{ label: 'Высокий', value: Zeus.IssuePriority.HIGH },
{ label: 'Средний', value: Zeus.IssuePriority.MEDIUM },
{ label: 'Низкий', value: Zeus.IssuePriority.LOW },
];
const statusesDisplay = computed(() => {
const n = selectedStatuses.value.length;
if (n === 0) return '';
if (n === 1) {
return statusOptions.value.find((o) => o.value === selectedStatuses.value[0])?.label ?? '';
}
return `Выбрано: ${n}`;
});
const prioritiesDisplay = computed(() => {
const n = selectedPriorities.value.length;
if (n === 0) return '';
if (n === 1) {
return priorityOptions.find((o) => o.value === selectedPriorities.value[0])?.label ?? '';
}
return `Выбрано: ${n}`;
});
const hasActiveFilters = computed(
() =>
selectedStatuses.value.length > 0 ||
selectedPriorities.value.length > 0 ||
!!selectedCreator.value ||
!!selectedMaster.value,
);
const applyFilters = () => {
projectStore.setProjectFilters({
statuses: [...selectedStatuses.value],
priorities: [...selectedPriorities.value],
creators: selectedCreator.value?.username ? [selectedCreator.value.username] : [],
master: selectedMaster.value?.username || undefined,
});
};
watch(
[selectedStatuses, selectedPriorities, selectedCreator, selectedMaster],
() => {
if (isRestoring.value) return;
applyFilters();
},
{ deep: true },
);
const resetFilters = () => {
isRestoring.value = true;
selectedStatuses.value = [];
selectedPriorities.value = [];
selectedCreator.value = null;
selectedMaster.value = null;
isRestoring.value = false;
applyFilters();
};
// Восстанавливаем исполнителя/мастера из сохранённых фильтров
onMounted(async () => {
const { creators, master } = projectStore.projectFilters;
if (!creators.length && !master) return;
isRestoring.value = true;
try {
if (creators.length) {
selectedCreator.value =
(await contributorStore.loadContributor({ username: creators[0] })) ?? null;
}
if (master) {
selectedMaster.value =
(await contributorStore.loadContributor({ username: master })) ?? null;
}
} catch (error) {
console.error('ProjectsFilterPanel: restore contributors failed', error);
} finally {
isRestoring.value = false;
}
});
</script>
<style lang="scss" scoped>
.projects-filter-panel {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--p-2);
padding: var(--p-2) var(--p-3);
border-bottom: 1px solid var(--p-line);
background: var(--p-surface);
}
.fp-select {
width: 180px;
}
.fp-contributor {
width: 200px;
}
.fp-reset {
margin-left: auto;
}
@media (max-width: 640px) {
.fp-select,
.fp-contributor {
width: calc(50% - var(--p-2));
}
.fp-reset {
margin-left: 0;
}
}
</style>
@@ -0,0 +1 @@
export { default as ProjectsFilterPanel } from './ProjectsFilterPanel.vue';

Some files were not shown because too many files have changed in this diff Show More