Compare commits
37 Commits
v2026.7.19
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a5866ea1b | |||
| 4b16a18716 | |||
| 67121d51cd | |||
| 489492695b | |||
| 757c785ee6 | |||
| a819b27fbe | |||
| 2b63f54117 | |||
| 8d0e837bb9 | |||
| 70f2e8e38b | |||
| 5e58c173ee | |||
| c1ce7a095d | |||
| 995f65f524 | |||
| 5298e55a7b | |||
| db28ba1137 | |||
| 6a05e88ba1 | |||
| 09eaa6192c | |||
| 81df662cfa | |||
| 845c13605d | |||
| f521d65a51 | |||
| 76b4988da8 | |||
| 3db80a30e9 | |||
| 1b457b8f87 | |||
| 710763f7db | |||
| 9ca51c13c8 | |||
| 4c86c911c6 | |||
| 7e5ef8e928 | |||
| 572984e32c | |||
| 6257730ccb | |||
| 641eaaad6d | |||
| 3b81f821c7 | |||
| 112d13114c | |||
| e1405a3b3d | |||
| 6d4809e8b6 | |||
| edc339b673 | |||
| 9b26834033 | |||
| ecf8d2b5de | |||
| 14170e6d93 |
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.7.19",
|
||||
"version": "2026.7.23-2",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.7.19",
|
||||
"version": "2026.7.23-2",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
+5
-4
@@ -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;
|
||||
|
||||
// Проверяем допуск к родителю — каскадно вниз по иерархии проектов:
|
||||
// если есть допуск к корневому проекту, пользователь видит артефакты всех его компонентов.
|
||||
|
||||
+25
-6
@@ -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(
|
||||
|
||||
+4
-2
@@ -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()
|
||||
|
||||
+8
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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),
|
||||
|
||||
+8
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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}$/;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+27
-7
@@ -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}`;
|
||||
}
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
|
||||
+15
-4
@@ -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();
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
+7
-2
@@ -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>();
|
||||
|
||||
+73
-49
@@ -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>
|
||||
|
||||
+75
-50
@@ -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>
|
||||
|
||||
+85
-56
@@ -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>
|
||||
|
||||
+10
-9
@@ -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"
|
||||
|
||||
+52
-33
@@ -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>
|
||||
|
||||
+66
-9
@@ -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>
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+29
-11
@@ -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;
|
||||
}
|
||||
|
||||
+17
-16
@@ -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;
|
||||
}
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-24
@@ -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('Инвестиция принята');
|
||||
|
||||
+15
-5
@@ -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>
|
||||
|
||||
+64
-12
@@ -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>
|
||||
|
||||
+45
@@ -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';
|
||||
|
||||
+225
@@ -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';
|
||||
|
||||
+28
-120
@@ -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>
|
||||
|
||||
+57
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+132
-250
@@ -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
-1
@@ -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>
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
+1
-1
@@ -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'
|
||||
+47
@@ -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'
|
||||
+24
@@ -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'
|
||||
+2
-1
@@ -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
-1
@@ -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>
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
+67
-56
@@ -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'
|
||||
+35
@@ -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>
|
||||
+6
-8
@@ -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';
|
||||
|
||||
+41
-117
@@ -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>
|
||||
|
||||
+1
-6
@@ -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;
|
||||
}
|
||||
|
||||
+17
-48
@@ -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>
|
||||
|
||||
+160
-41
@@ -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>
|
||||
|
||||
+32
-59
@@ -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>
|
||||
|
||||
-36
@@ -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>
|
||||
|
||||
+77
-46
@@ -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>
|
||||
|
||||
+83
-55
@@ -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>
|
||||
|
||||
+35
-64
@@ -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>
|
||||
|
||||
+1
-6
@@ -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;
|
||||
}
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
+208
@@ -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
Reference in New Issue
Block a user