notifications on novu improve (sanity), make a coop settings for chairman and select default workspace / page

This commit is contained in:
Alex Ant
2025-10-25 20:19:10 +05:00
parent 95ac0d36f8
commit bc1c54c43e
157 changed files with 6154 additions and 1114 deletions
+20
View File
@@ -0,0 +1,20 @@
---
globs: notifications/src/workflows/**/*.ts
---
# Правила валидации воркфлоу
## 🚫 ID воркфлоу
- **Длина ID не должна превышать 32 символа**
- Используйте описательные, но короткие идентификаторы малыми латинскими буквами и тире.
## 🚫 Условия в шаблонах
- **Запрещено использовать JavaScript выражения в шаблонах Novu**
- Нельзя использовать:
- Тернарные операторы: `{{condition ? "text1" : "text2"}}`
- Логические операторы: `{{field && "text"}}`
- Любые другие JS конструкции
## ✅ Рекомендации
- Добавляйте текстовые поля в payload для условной логики
- Вычисляйте значения на стороне сервера перед отправкой уведомления
- Используйте только простые переменные: `{{payload.fieldName}}`
+16 -11
View File
@@ -13,9 +13,7 @@
// Оптимизация для монорепозитория // Оптимизация для монорепозитория
"typescript.preferences.useAliasesForRenames": false, "typescript.preferences.useAliasesForRenames": false,
"typescript.preferences.include": false, "typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.disableAutomaticTypeAcquisition": true,
"typescript.preferences.includePackageJsonAutoImports": "off",
"typescript.suggest.autoImports": true, "typescript.suggest.autoImports": true,
"typescript.suggest.paths": true, "typescript.suggest.paths": true,
"typescript.updateImportsOnFileMove.enabled": "always", "typescript.updateImportsOnFileMove.enabled": "always",
@@ -47,13 +45,15 @@
"**/node_modules/.cache": true "**/node_modules/.cache": true
}, },
// TypeScript server настройки // TypeScript server настройки для монорепозитория
"typescript.tsserver.maxTsServerMemory": 4000, "typescript.tsserver.maxTsServerMemory": 8192,
"typescript.tsserver.watchOptions": { "typescript.tsserver.watchOptions": {
"excludeFiles": [ "excludeDirectories": [
"**/node_modules/**/*", "**/node_modules",
"**/dist/**/*", "**/dist",
"**/.cache/**/*" "**/.cache",
"**/.quasar",
"**/build"
] ]
}, },
@@ -61,6 +61,11 @@
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true, "typescript.enablePromptUseWorkspaceTsdk": true,
// Отключить автоматическую инициализацию для всех tsconfig // КРИТИЧЕСКИ ВАЖНО: включить project references для монорепозитория
"typescript.tsserver.experimental.enableProjectDiagnostics": false "typescript.tsserver.useSyntaxServer": "auto",
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
// Оптимизация для больших монорепозиториев
"typescript.disableAutomaticTypeAcquisition": true,
"typescript.surveys.enabled": false
} }
+49 -9
View File
@@ -31,7 +31,7 @@ type Account {
private_account: PrivateAccount private_account: PrivateAccount
""" """
объект аккаунта в системе учёта провайдера, т.е. MONO. Здесь хранится приватная информация о пайщике кооператива, которая содержит его приватные данные. Эти данные не публикуются в блокчейне и не выходят за пределы базы данных провайдера. Они используются для заполнения шаблонов документов при нажатии соответствующих кнопок на платформе. объект аккаунта в системе учёта провайдера, т.е. MONO. Здесь хранится приватная информация о пайщике кооператива, которая содержит его приватные данные. Эти данные не публикуются в блокчейне и не выходят за пределы базы данных провайдера. Они используются для заполнения шаблонов документов при нажатии соответствующих кнопок на платформе.
""" """
provider_account: MonoAccount provider_account: MonoAccount
@@ -2473,7 +2473,7 @@ type CapitalProjectFactPool {
"""Общая сумма""" """Общая сумма"""
total: String! total: String!
"""Общий объем вклада""" """Общий объем взноса старших участников"""
total_contribution: String! total_contribution: String!
"""Общий генерационный пул""" """Общий генерационный пул"""
@@ -2914,6 +2914,9 @@ type CapitalSegment {
"""Имя пользователя""" """Имя пользователя"""
username: String! username: String!
"""Вклад участника словами участника"""
value: String
"""Бонус голосования""" """Бонус голосования"""
voting_bonus: String! voting_bonus: String!
} }
@@ -4318,18 +4321,12 @@ input DeltaFiltersInput {
} }
type Desktop { type Desktop {
"""Имя шаблона рабочих столов"""
authorizedHome: String!
"""Имя аккаунта кооператива""" """Имя аккаунта кооператива"""
coopname: String! coopname: String!
"""Имя шаблона рабочих столов""" """Имя шаблона рабочих столов"""
layout: String! layout: String!
"""Имя шаблона рабочих столов"""
nonAuthorizedHome: String!
"""Состав приложений рабочего стола""" """Состав приложений рабочего стола"""
workspaces: [DesktopWorkspace!]! workspaces: [DesktopWorkspace!]!
} }
@@ -6111,6 +6108,9 @@ type Mutation {
"""Обновить заявку""" """Обновить заявку"""
updateRequest(data: UpdateRequestInput!): Transaction! updateRequest(data: UpdateRequestInput!): Transaction!
"""Обновить настройки системы (рабочие столы и маршруты по умолчанию)"""
updateSettings(data: UpdateSettingsInput!): Settings!
"""Обновить параметры системы""" """Обновить параметры системы"""
updateSystem(data: Update!): SystemInfo! updateSystem(data: Update!): SystemInfo!
@@ -8171,6 +8171,29 @@ input SetWifInput {
wif: String! wif: String!
} }
type Settings {
"""Маршрут по умолчанию для авторизованных пользователей"""
authorized_default_route: String!
"""Рабочий стол по умолчанию для авторизованных пользователей"""
authorized_default_workspace: String!
"""Название кооператива"""
coopname: String!
"""Дата создания"""
created_at: DateTime!
"""Маршрут по умолчанию для неавторизованных пользователей"""
non_authorized_default_route: String!
"""Рабочий стол по умолчанию для неавторизованных пользователей"""
non_authorized_default_workspace: String!
"""Дата последнего обновления"""
updated_at: DateTime!
}
input SignActAsChairmanInput { input SignActAsChairmanInput {
"""Акт о вкладе результатов""" """Акт о вкладе результатов"""
act: SignedDigitalDocumentInput! act: SignedDigitalDocumentInput!
@@ -8427,6 +8450,9 @@ type SystemInfo {
"""Имя аккаунта кооператива""" """Имя аккаунта кооператива"""
coopname: String! coopname: String!
"""Настройки системы"""
settings: Settings!
"""Символы и их точности блокчейна""" """Символы и их точности блокчейна"""
symbols: Symbols! symbols: Symbols!
@@ -8714,6 +8740,20 @@ input UpdateRequestInput {
username: String! username: String!
} }
input UpdateSettingsInput {
"""Маршрут по умолчанию для авторизованных пользователей"""
authorized_default_route: String
"""Рабочий стол по умолчанию для авторизованных пользователей"""
authorized_default_workspace: String
"""Маршрут по умолчанию для неавторизованных пользователей"""
non_authorized_default_route: String
"""Рабочий стол по умолчанию для неавторизованных пользователей"""
non_authorized_default_workspace: String
}
input UpdateStoryInput { input UpdateStoryInput {
"""Описание истории""" """Описание истории"""
description: String description: String
@@ -8941,4 +8981,4 @@ input WebPushSubscriptionKeysInput {
"""P256DH ключ для шифрования""" """P256DH ключ для шифрования"""
p256dh: String! p256dh: String!
} }
@@ -7,10 +7,8 @@ import { ACCOUNT_EXTENSION_PORT, AccountExtensionPort } from '~/domain/extension
import config from '~/config/config'; import config from '~/config/config';
import { SovietContract } from 'cooptypes'; import { SovietContract } from 'cooptypes';
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface'; import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
import type { import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
WorkflowBulkTriggerDomainInterface, import { Workflows } from '@coopenomics/notifications';
WorkflowBulkEventDomainInterface,
} from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
/** /**
* Сервис для отправки уведомлений о новых вопросах на повестке совета * Сервис для отправки уведомлений о новых вопросах на повестке совета
@@ -41,14 +39,15 @@ export class AgendaNotificationService implements OnModuleInit {
@OnEvent(`action::${SovietContract.contractName.production}::${SovietContract.Actions.Decisions.CreateAgenda.actionName}`) @OnEvent(`action::${SovietContract.contractName.production}::${SovietContract.Actions.Decisions.CreateAgenda.actionName}`)
async handleCreateAgenda(actionData: ActionDomainInterface): Promise<void> { async handleCreateAgenda(actionData: ActionDomainInterface): Promise<void> {
try { try {
const action = actionData.data as any; const action = actionData.data as SovietContract.Interfaces.ICreateagenda;
const agendaId = action.hash.substring(0, 4);
// Проверяем что это наш кооператив // Проверяем что это наш кооператив
if (action.coopname !== config.coopname) { if (action.coopname !== config.coopname) {
return; return;
} }
this.logger.debug(`Обработка создания нового вопроса на повестке для решения ${action.decision_id}`); this.logger.debug(`Обработка создания нового вопроса на повестке для решения ${agendaId}`);
// Получаем членов совета (chairman и member) через порт // Получаем членов совета (chairman и member) через порт
// Получаем председателей // Получаем председателей
@@ -71,19 +70,18 @@ export class AgendaNotificationService implements OnModuleInit {
const authorName = await this.accountPort.getDisplayName(action.username); const authorName = await this.accountPort.getDisplayName(action.username);
// Формируем данные для workflow // Формируем данные для workflow
const payload = { const payload: Workflows.NewAgenda.IPayload = {
coopname: action.coopname, coopname: action.coopname,
coopShortName, coopShortName,
itemTitle: `Вопрос ${action.decision_id}`, itemTitle: `${agendaId}`, //TODO: по типу запроса
itemDescription: `Новый вопрос добавлен на повестку заседания совета`, itemDescription: ``, //TODO: по типу запроса
authorName, authorName,
decision_id: action.decision_id, decision_id: agendaId,
agendaUrl: `${config.base_url}/${action.coopname}/council/agenda`, agendaUrl: `${config.base_url}/${action.coopname}/soviet/agenda`,
}; };
// Подготавливаем события для пакетной отправки // Отправляем уведомления каждому члену совета в цикле
const events: WorkflowBulkEventDomainInterface[] = []; let sentCount = 0;
for (const member of allCouncilMembers) { for (const member of allCouncilMembers) {
const memberEmail = member.provider_account?.email; const memberEmail = member.provider_account?.email;
if (!memberEmail) { if (!memberEmail) {
@@ -91,30 +89,28 @@ export class AgendaNotificationService implements OnModuleInit {
continue; continue;
} }
events.push({ const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.NewAgenda.id,
to: { to: {
subscriberId: member.username, subscriberId: member.username,
email: memberEmail, email: memberEmail,
}, },
payload, payload,
}); };
try {
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
sentCount++;
} catch (error: any) {
this.logger.error(`Ошибка отправки уведомления члену совета ${member.username}: ${error.message}`);
}
} }
if (events.length === 0) { if (sentCount === 0) {
this.logger.warn('Нет подходящих получателей для уведомления'); this.logger.warn('Не удалось отправить уведомления ни одному члену совета');
return; return;
} }
this.logger.log(`Уведомления отправлены ${sentCount} членам совета о новом вопросе на повестке (решение ${agendaId})`);
// Отправляем уведомления пакетом
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = {
name: 'noviy-vopros-na-povestke',
events,
};
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData);
this.logger.log(
`Уведомления отправлены ${events.length} членам совета о новом вопросе на повестке (решение ${action.decision_id})`
);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Ошибка при обработке создания вопроса на повестке: ${error.message}`, error.stack); this.logger.error(`Ошибка при обработке создания вопроса на повестке: ${error.message}`, error.stack);
} }
@@ -39,19 +39,19 @@ export class DecisionNotificationService implements OnModuleInit {
@OnEvent(`action::${SovietContract.contractName.production}::newdecision`) @OnEvent(`action::${SovietContract.contractName.production}::newdecision`)
async handleNewDecision(actionData: ActionDomainInterface): Promise<void> { async handleNewDecision(actionData: ActionDomainInterface): Promise<void> {
try { try {
const action = actionData.data as any; const action = actionData.data as SovietContract.Interfaces.INewdecision;
const decisionId = action.package.substring(0, 4);
// Проверяем что это наш кооператив // Проверяем что это наш кооператив
if (action.coopname !== config.coopname) { if (action.coopname !== config.coopname) {
return; return;
} }
this.logger.debug(`Обработка нового решения совета: ${action.decision_id}`); this.logger.debug(`Обработка нового решения совета: ${action.package}`);
// Получаем username из действия // Получаем username из действия
const username = action.username; const username = action.username;
if (!username) { if (!username) {
this.logger.warn(`Username не найден в действии ${action.decision_id}`); this.logger.warn(`Username не найден в действии ${action.package}`);
return; return;
} }
@@ -69,10 +69,10 @@ export class DecisionNotificationService implements OnModuleInit {
// Формируем данные для workflow (без приватных данных) // Формируем данные для workflow (без приватных данных)
const payload: Workflows.DecisionApproved.IPayload = { const payload: Workflows.DecisionApproved.IPayload = {
userName, userName,
decisionTitle: `Решение №${action.decision_id}`, decisionTitle: `Решение №${decisionId}`,
coopname: action.coopname, coopname: action.coopname,
decision_id: action.decision_id, decision_id: decisionId,
decisionUrl: `${config.base_url}/${action.coopname}/user/decisions/${action.decision_id}`, decisionUrl: `${config.base_url}`,
}; };
// Отправляем уведомление // Отправляем уведомление
@@ -86,7 +86,7 @@ export class DecisionNotificationService implements OnModuleInit {
}; };
await this.novuWorkflowAdapter.triggerWorkflow(triggerData); await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
this.logger.log(`Уведомление отправлено пользователю ${username} о принятии решения ${action.decision_id}`); this.logger.log(`Уведомление отправлено пользователю ${username} о принятии решения ${decisionId}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Ошибка при обработке нового решения совета: ${error.message}`, error.stack); this.logger.error(`Ошибка при обработке нового решения совета: ${error.message}`, error.stack);
} }
@@ -25,4 +25,12 @@ export class DesktopWorkspaceDTO implements DesktopWorkspaceDomainInterface {
@IsOptional() @IsOptional()
@IsString() @IsString()
public readonly defaultRoute?: string; public readonly defaultRoute?: string;
constructor(data: DesktopWorkspaceDomainInterface) {
this.name = data.name;
this.title = data.title;
this.extension_name = data.extension_name;
this.icon = data.icon;
this.defaultRoute = data.defaultRoute;
}
} }
@@ -14,16 +14,14 @@ export class DesktopDTO implements DesktopDomainInterface {
@IsString() @IsString()
public readonly layout!: string; public readonly layout!: string;
@Field(() => String, { description: 'Имя шаблона рабочих столов' })
@IsString()
public readonly authorizedHome!: string;
@Field(() => String, { description: 'Имя шаблона рабочих столов' })
@IsString()
public readonly nonAuthorizedHome!: string;
@Field(() => [DesktopWorkspaceDTO], { description: 'Состав приложений рабочего стола' }) @Field(() => [DesktopWorkspaceDTO], { description: 'Состав приложений рабочего стола' })
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => DesktopWorkspaceDTO) @Type(() => DesktopWorkspaceDTO)
public readonly workspaces!: DesktopWorkspaceDTO[]; public readonly workspaces!: DesktopWorkspaceDTO[];
constructor(data: DesktopDomainInterface) {
this.coopname = data.coopname;
this.layout = data.layout;
this.workspaces = data.workspaces.map((workspace) => new DesktopWorkspaceDTO(workspace));
}
} }
@@ -9,6 +9,6 @@ export class DesktopService {
public async getDesktop(): Promise<DesktopDTO> { public async getDesktop(): Promise<DesktopDTO> {
const desktop = await this.desktopDomainInteractor.getDesktop(); const desktop = await this.desktopDomainInteractor.getDesktop();
return desktop; return new DesktopDTO(desktop);
} }
} }
@@ -33,8 +33,8 @@ export class PaymentNotificationService implements OnModuleInit {
*/ */
async notifyPaymentStatus(payment: PaymentDomainEntity): Promise<void> { async notifyPaymentStatus(payment: PaymentDomainEntity): Promise<void> {
try { try {
// Отправляем уведомления только для завершенных и отмененных платежей // Отправляем уведомления только для оплаченных и отмененных платежей
if (payment.status !== PaymentStatusEnum.COMPLETED && payment.status !== PaymentStatusEnum.CANCELLED) { if (payment.status !== PaymentStatusEnum.PAID && payment.status !== PaymentStatusEnum.CANCELLED) {
return; return;
} }
@@ -53,16 +53,15 @@ export class PaymentNotificationService implements OnModuleInit {
// Выбираем workflow в зависимости от статуса платежа // Выбираем workflow в зависимости от статуса платежа
const workflowId = const workflowId =
payment.status === PaymentStatusEnum.COMPLETED ? Workflows.PaymentCompleted.id : Workflows.PaymentCancelled.id; payment.status === PaymentStatusEnum.PAID ? Workflows.PaymentPaid.id : Workflows.PaymentCancelled.id;
// Формируем данные для workflow (без приватных данных) // Формируем данные для workflow (без приватных данных)
const payload: Workflows.PaymentCompleted.IPayload | Workflows.PaymentCancelled.IPayload = { const payload: Workflows.PaymentPaid.IPayload | Workflows.PaymentCancelled.IPayload = {
userName, userName,
paymentAmount: payment.quantity.toFixed(2), paymentAmount: payment.quantity.toFixed(2),
paymentCurrency: payment.symbol, paymentCurrency: payment.symbol,
paymentId: payment.id || '',
paymentDate: payment.created_at.toLocaleString('ru-RU'), paymentDate: payment.created_at.toLocaleString('ru-RU'),
paymentUrl: `${config.base_url}/${payment.coopname}/user/payments/${payment.id}`, paymentUrl: `${config.base_url}`, //TODO: точную ссылку потом
}; };
// Отправляем уведомление // Отправляем уведомление
@@ -5,6 +5,7 @@ import { NOVU_WORKFLOW_PORT } from '~/domain/notification/interfaces/novu-workfl
import { ACCOUNT_EXTENSION_PORT, AccountExtensionPort } from '~/domain/extension/ports/account-extension-port'; import { ACCOUNT_EXTENSION_PORT, AccountExtensionPort } from '~/domain/extension/ports/account-extension-port';
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface'; import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
import { Workflows } from '@coopenomics/notifications'; import { Workflows } from '@coopenomics/notifications';
import config from '~/config/config';
/** /**
* Сервис для отправки уведомлений участникам * Сервис для отправки уведомлений участникам
@@ -64,4 +65,64 @@ export class ParticipantNotificationService implements OnModuleInit {
this.logger.error(`Ошибка при отправке приветственного уведомления: ${error.message}`, error.stack); this.logger.error(`Ошибка при отправке приветственного уведомления: ${error.message}`, error.stack);
} }
} }
/**
* Отправить уведомление председателю о новом заявке на вступительный взнос
*/
async sendNewInitialPaymentNotification(
participantUsername: string,
paymentAmount: string,
paymentCurrency: string,
coopname: string
): Promise<void> {
try {
this.logger.debug(`Отправка уведомления председателю о новой заявке на вступительный взнос от ${participantUsername}`);
// Получаем председателя через порт
const chairmen = await this.accountPort.getAccounts({ role: 'chairman' }, { page: 1, limit: 1, sortOrder: 'ASC' });
if (!chairmen.items || chairmen.items.length === 0) {
this.logger.warn('Председатель не найден, пропускаем отправку уведомления');
return;
}
const chairman = chairmen.items[0];
const chairmanEmail = chairman.provider_account?.email;
if (!chairmanEmail) {
this.logger.warn(`Email председателя ${chairman.username} не найден`);
return;
}
// Получаем отображаемые имена
const chairmanName = await this.accountPort.getDisplayName(chairman.username);
const participantName = await this.accountPort.getDisplayName(participantUsername);
// Формируем данные для workflow
const payload: Workflows.NewInitialPaymentRequest.IPayload = {
chairmanName,
participantName,
paymentAmount,
paymentCurrency,
paymentType: 'Вступительный и минимальный паевой взнос',
coopname,
paymentUrl: `${config.base_url}`,
};
// Отправляем уведомление
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.NewInitialPaymentRequest.id,
to: {
subscriberId: chairman.username,
email: chairmanEmail,
},
payload,
};
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
this.logger.log(
`Уведомление отправлено председателю ${chairman.username} о новой заявке на вступительный взнос от ${participantUsername}`
);
} catch (error: any) {
this.logger.error(`Ошибка при отправке уведомления о новом вступительном взносе: ${error.message}`, error.stack);
}
}
} }
@@ -62,6 +62,15 @@ export class ParticipantService {
public async createInitialPayment(data: CreateInitialPaymentInputDTO): Promise<GatewayPaymentDTO> { public async createInitialPayment(data: CreateInitialPaymentInputDTO): Promise<GatewayPaymentDTO> {
const result = await this.participantDomainInteractor.createInitialPayment(data); const result = await this.participantDomainInteractor.createInitialPayment(data);
const usernameCertificate = await this.userCertificateInteractor.getCertificateByUsername(result.username); const usernameCertificate = await this.userCertificateInteractor.getCertificateByUsername(result.username);
// Отправляем уведомление председателю о новой заявке на вступительный взнос
await this.participantNotificationService.sendNewInitialPaymentNotification(
result.username,
result.quantity.toFixed(2),
result.symbol,
result.coopname
);
return result.toDTO(usernameCertificate); return result.toDTO(usernameCertificate);
} }
} }
@@ -0,0 +1,74 @@
import { ObjectType, Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator';
import type { SettingsDomainInterface } from '~/domain/settings/interfaces/settings-domain.interface';
import type { UpdateSettingsInputDomainInterface } from '~/domain/settings/interfaces/update-settings-input-domain.interface';
/**
* DTO для настроек системы
* Используется для отображения настроек в GraphQL API
*/
@ObjectType('Settings')
export class SettingsDTO implements SettingsDomainInterface {
@Field(() => String, { description: 'Название кооператива' })
@IsString()
coopname!: string;
@Field(() => String, { description: 'Рабочий стол по умолчанию для авторизованных пользователей' })
@IsString()
authorized_default_workspace!: string;
@Field(() => String, { description: 'Маршрут по умолчанию для авторизованных пользователей' })
@IsString()
authorized_default_route!: string;
@Field(() => String, { description: 'Рабочий стол по умолчанию для неавторизованных пользователей' })
@IsString()
non_authorized_default_workspace!: string;
@Field(() => String, { description: 'Маршрут по умолчанию для неавторизованных пользователей' })
@IsString()
non_authorized_default_route!: string;
@Field(() => Date, { description: 'Дата создания' })
created_at!: Date;
@Field(() => Date, { description: 'Дата последнего обновления' })
updated_at!: Date;
constructor(data: SettingsDomainInterface) {
this.coopname = data.coopname;
this.authorized_default_workspace = data.authorized_default_workspace;
this.authorized_default_route = data.authorized_default_route;
this.non_authorized_default_workspace = data.non_authorized_default_workspace;
this.non_authorized_default_route = data.non_authorized_default_route;
this.created_at = data.created_at;
this.updated_at = data.updated_at;
}
}
/**
* DTO для обновления настроек системы
* Все поля опциональны для частичного обновления
*/
@InputType('UpdateSettingsInput')
export class UpdateSettingsInputDTO implements UpdateSettingsInputDomainInterface {
@Field(() => String, { nullable: true, description: 'Рабочий стол по умолчанию для авторизованных пользователей' })
@IsString()
@IsOptional()
authorized_default_workspace?: string;
@Field(() => String, { nullable: true, description: 'Маршрут по умолчанию для авторизованных пользователей' })
@IsString()
@IsOptional()
authorized_default_route?: string;
@Field(() => String, { nullable: true, description: 'Рабочий стол по умолчанию для неавторизованных пользователей' })
@IsString()
@IsOptional()
non_authorized_default_workspace?: string;
@Field(() => String, { nullable: true, description: 'Маршрут по умолчанию для неавторизованных пользователей' })
@IsString()
@IsOptional()
non_authorized_default_route?: string;
}
@@ -9,6 +9,7 @@ import { Type } from 'class-transformer';
import { ContactsDTO } from './contacts.dto'; import { ContactsDTO } from './contacts.dto';
import { VarsDTO } from './vars.dto'; import { VarsDTO } from './vars.dto';
import { SymbolsDTO } from './symbols.dto'; import { SymbolsDTO } from './symbols.dto';
import { SettingsDTO } from './settings.dto';
@ObjectType('SystemInfo') @ObjectType('SystemInfo')
export class SystemInfoDTO { export class SystemInfoDTO {
@@ -46,6 +47,10 @@ export class SystemInfoDTO {
@ValidateNested() @ValidateNested()
public readonly symbols: SymbolsDTO; public readonly symbols: SymbolsDTO;
@Field(() => SettingsDTO, { description: 'Настройки системы' })
@ValidateNested()
public readonly settings: SettingsDTO;
constructor(entity: SystemInfoDomainEntity) { constructor(entity: SystemInfoDomainEntity) {
this.coopname = entity.coopname; this.coopname = entity.coopname;
this.contacts = entity.contacts; this.contacts = entity.contacts;
@@ -53,6 +58,7 @@ export class SystemInfoDTO {
this.blockchain_info = new BlockchainInfoDTO(entity.blockchain_info); this.blockchain_info = new BlockchainInfoDTO(entity.blockchain_info);
this.system_status = entity.system_status as SystemStatus; this.system_status = entity.system_status as SystemStatus;
this.symbols = entity.symbols; this.symbols = entity.symbols;
this.settings = new SettingsDTO(entity.settings);
this.blockchain_account = entity.blockchain_account; this.blockchain_account = entity.blockchain_account;
this.cooperator_account = new CooperativeOperatorAccountDTO({ this.cooperator_account = new CooperativeOperatorAccountDTO({
...entity.cooperator_account, ...entity.cooperator_account,
@@ -5,6 +5,7 @@ import { InstallDTO } from '../dto/install.dto';
import { InitDTO } from '../dto/init.dto'; import { InitDTO } from '../dto/init.dto';
import { SetWifInputDTO } from '../dto/set-wif-input.dto'; import { SetWifInputDTO } from '../dto/set-wif-input.dto';
import { UpdateDTO } from '../dto/update.dto'; import { UpdateDTO } from '../dto/update.dto';
import { SettingsDTO, UpdateSettingsInputDTO } from '../dto/settings.dto';
@Resolver(() => SystemInfoDTO) @Resolver(() => SystemInfoDTO)
export class SystemResolver { export class SystemResolver {
@@ -62,4 +63,15 @@ export class SystemResolver {
await this.systemService.setWif(data); await this.systemService.setWif(data);
return true; return true;
} }
@Mutation(() => SettingsDTO, {
name: 'updateSettings',
description: 'Обновить настройки системы (рабочие столы и маршруты по умолчанию)',
})
async updateSettings(
@Args('data', { type: () => UpdateSettingsInputDTO })
data: UpdateSettingsInputDTO
): Promise<SettingsDTO> {
return this.systemService.updateSettings(data);
}
} }
@@ -7,6 +7,7 @@ import type { SetWifInputDTO } from '../dto/set-wif-input.dto';
import type { UpdateDTO } from '../dto/update.dto'; import type { UpdateDTO } from '../dto/update.dto';
import { OrganizationDomainEntity } from '~/domain/branch/entities/organization-domain.entity'; import { OrganizationDomainEntity } from '~/domain/branch/entities/organization-domain.entity';
import config from '~/config/config'; import config from '~/config/config';
import { SettingsDTO, UpdateSettingsInputDTO } from '../dto/settings.dto';
@Injectable() @Injectable()
export class SystemService { export class SystemService {
@@ -41,4 +42,12 @@ export class SystemService {
public async setWif(data: SetWifInputDTO): Promise<void> { public async setWif(data: SetWifInputDTO): Promise<void> {
await this.systemDomainInteractor.setWif(data); await this.systemDomainInteractor.setWif(data);
} }
/**
* Обновляет настройки системы
*/
public async updateSettings(data: UpdateSettingsInputDTO): Promise<SettingsDTO> {
const settings = await this.systemDomainInteractor.updateSettings(data);
return new SettingsDTO(settings);
}
} }
@@ -0,0 +1,88 @@
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { NovuWorkflowAdapter } from '~/infrastructure/novu/novu-workflow.adapter';
import { NOVU_WORKFLOW_PORT } from '~/domain/notification/interfaces/novu-workflow.port';
import { ACCOUNT_EXTENSION_PORT, AccountExtensionPort } from '~/domain/extension/ports/account-extension-port';
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
import { Workflows } from '@coopenomics/notifications';
import config from '~/config/config';
/**
* Сервис для отправки уведомлений по wallet модулю
*/
@Injectable()
export class WalletNotificationService implements OnModuleInit {
constructor(
@Inject(NOVU_WORKFLOW_PORT)
private readonly novuWorkflowAdapter: NovuWorkflowAdapter,
@Inject(ACCOUNT_EXTENSION_PORT)
private readonly accountPort: AccountExtensionPort,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(WalletNotificationService.name);
}
async onModuleInit() {
this.logger.log('WalletNotificationService инициализирован');
}
/**
* Отправить уведомление председателю о новом заявке на паевой взнос
*/
async sendNewDepositPaymentNotification(
participantUsername: string,
paymentAmount: string,
paymentCurrency: string,
coopname: string
): Promise<void> {
try {
this.logger.debug(`Отправка уведомления председателю о новой заявке на паевой взнос от ${participantUsername}`);
// Получаем председателя через порт
const chairmen = await this.accountPort.getAccounts({ role: 'chairman' }, { page: 1, limit: 1, sortOrder: 'ASC' });
if (!chairmen.items || chairmen.items.length === 0) {
this.logger.warn('Председатель не найден, пропускаем отправку уведомления');
return;
}
const chairman = chairmen.items[0];
const chairmanEmail = chairman.provider_account?.email;
if (!chairmanEmail) {
this.logger.warn(`Email председателя ${chairman.username} не найден`);
return;
}
// Получаем отображаемые имена
const chairmanName = await this.accountPort.getDisplayName(chairman.username);
const participantName = await this.accountPort.getDisplayName(participantUsername);
// Формируем данные для workflow
const payload: Workflows.NewDepositPaymentRequest.IPayload = {
chairmanName,
participantName,
paymentAmount,
paymentCurrency,
paymentType: 'Паевой взнос по соглашению о ЦПП "Цифровой Кошелёк"',
coopname,
paymentUrl: `${config.base_url}`,
};
// Отправляем уведомление
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.NewDepositPaymentRequest.id,
to: {
subscriberId: chairman.username,
email: chairmanEmail,
},
payload,
};
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
this.logger.log(
`Уведомление отправлено председателю ${chairman.username} о новой заявке на паевой взнос от ${participantUsername}`
);
} catch (error: any) {
this.logger.error(`Ошибка при отправке уведомления о новом паевом взносе: ${error.message}`, error.stack);
}
}
}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { WalletDomainInteractor } from '~/domain/wallet/interactors/wallet.interactor'; import { WalletDomainInteractor } from '~/domain/wallet/interactors/wallet.interactor';
import { UserCertificateInteractor } from '~/domain/user-certificate/interactors/user-certificate.interactor'; import { UserCertificateInteractor } from '~/domain/user-certificate/interactors/user-certificate.interactor';
import { WalletNotificationService } from './wallet-notification.service';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto'; import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto'; import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { ReturnByMoneyGenerateDocumentInputDTO } from '~/application/document/documents-dto/return-by-money-statement.dto'; import { ReturnByMoneyGenerateDocumentInputDTO } from '~/application/document/documents-dto/return-by-money-statement.dto';
@@ -18,7 +19,8 @@ import type { GatewayPaymentDTO } from '../../gateway/dto/gateway-payment.dto';
export class WalletService { export class WalletService {
constructor( constructor(
private readonly walletDomainInteractor: WalletDomainInteractor, private readonly walletDomainInteractor: WalletDomainInteractor,
private readonly userCertificateInteractor: UserCertificateInteractor private readonly userCertificateInteractor: UserCertificateInteractor,
private readonly walletNotificationService: WalletNotificationService
) {} ) {}
/** /**
@@ -27,6 +29,15 @@ export class WalletService {
public async createDepositPayment(data: CreateDepositPaymentInputDTO): Promise<GatewayPaymentDTO> { public async createDepositPayment(data: CreateDepositPaymentInputDTO): Promise<GatewayPaymentDTO> {
const result = await this.walletDomainInteractor.createDepositPayment(data); const result = await this.walletDomainInteractor.createDepositPayment(data);
const usernameCertificate = await this.userCertificateInteractor.getCertificateByUsername(result.username); const usernameCertificate = await this.userCertificateInteractor.getCertificateByUsername(result.username);
// Отправляем уведомление председателю о новой заявке на паевой взнос
await this.walletNotificationService.sendNewDepositPaymentNotification(
result.username,
result.quantity.toFixed(2),
result.symbol,
result.coopname
);
return result.toDTO(usernameCertificate); return result.toDTO(usernameCertificate);
} }
@@ -1,15 +1,17 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { WalletResolver } from './resolvers/wallet.resolver'; import { WalletResolver } from './resolvers/wallet.resolver';
import { WalletService } from './services/wallet.service'; import { WalletService } from './services/wallet.service';
import { WalletNotificationService } from './services/wallet-notification.service';
import { WalletDomainInteractor } from '~/domain/wallet/interactors/wallet.interactor'; import { WalletDomainInteractor } from '~/domain/wallet/interactors/wallet.interactor';
import { DomainModule } from '~/domain/domain.module'; import { DomainModule } from '~/domain/domain.module';
import { NovuModule } from '~/infrastructure/novu/novu.module';
/** /**
* Модуль wallet для управления выводом средств, депозитными платежами и генерацией документов * Модуль wallet для управления выводом средств, депозитными платежами и генерацией документов
*/ */
@Module({ @Module({
imports: [DomainModule], imports: [DomainModule, NovuModule],
providers: [WalletResolver, WalletService, WalletDomainInteractor], providers: [WalletResolver, WalletService, WalletNotificationService, WalletDomainInteractor],
exports: [WalletService, WalletDomainInteractor], exports: [WalletService, WalletDomainInteractor],
}) })
export class WalletModule {} export class WalletModule {}
@@ -1,19 +1,19 @@
import type { DesktopDomainInterface } from '../interfaces/desktop-domain.interface'; import type { DesktopDomainInterface } from '../interfaces/desktop-domain.interface';
import type { DesktopWorkspaceDomainEntity } from './workspace-domain.entity'; import type { DesktopWorkspaceDomainEntity } from './workspace-domain.entity';
/**
* Доменная сущность Desktop
* Содержит информацию о доступных рабочих столах (workspaces)
* Настройки по умолчанию (authorized/nonAuthorizedHome) перенесены в Settings
*/
export class DesktopDomainEntity implements DesktopDomainInterface { export class DesktopDomainEntity implements DesktopDomainInterface {
public readonly coopname!: string; public readonly coopname!: string;
public readonly layout!: string; public readonly layout!: string;
public readonly authorizedHome!: string;
public readonly nonAuthorizedHome!: string;
public readonly workspaces!: DesktopWorkspaceDomainEntity[]; public readonly workspaces!: DesktopWorkspaceDomainEntity[];
constructor(data: DesktopDomainEntity) { constructor(data: DesktopDomainEntity) {
this.coopname = data.coopname; this.coopname = data.coopname;
this.layout = data.layout; this.layout = data.layout;
this.authorizedHome = data.authorizedHome;
this.nonAuthorizedHome = data.nonAuthorizedHome;
this.workspaces = data.workspaces; this.workspaces = data.workspaces;
} }
} }
@@ -40,14 +40,11 @@ export class DesktopDomainInteractor {
} }
const layout = 'default'; const layout = 'default';
const authorizedHome = 'profile';
const nonAuthorizedHome = 'signup';
return new DesktopDomainEntity({ return new DesktopDomainEntity({
coopname: config.coopname, coopname: config.coopname,
layout, layout,
workspaces, workspaces,
authorizedHome,
nonAuthorizedHome,
}); });
} }
} }
@@ -1,9 +1,11 @@
import type { DesktopWorkspaceDomainInterface } from './desktop-workspace-domain.interface'; import type { DesktopWorkspaceDomainInterface } from './desktop-workspace-domain.interface';
/**
* Доменный интерфейс Desktop
* Настройки по умолчанию (authorized/nonAuthorizedHome) перенесены в Settings
*/
export interface DesktopDomainInterface { export interface DesktopDomainInterface {
coopname: string; coopname: string;
layout: string; layout: string;
authorizedHome: string;
nonAuthorizedHome: string;
workspaces: DesktopWorkspaceDomainInterface[]; workspaces: DesktopWorkspaceDomainInterface[];
} }
@@ -21,6 +21,7 @@ import { WalletDomainModule } from './wallet/wallet-domain.module';
import { NotificationDomainModule } from './notification/notification-domain.module'; import { NotificationDomainModule } from './notification/notification-domain.module';
import { LedgerDomainModule } from './ledger/ledger-domain.module'; import { LedgerDomainModule } from './ledger/ledger-domain.module';
import { ParserDomainModule } from './parser/parser-domain.module'; import { ParserDomainModule } from './parser/parser-domain.module';
import { SettingsDomainModule } from './settings/settings-domain.module';
@Module({ @Module({
imports: [ imports: [
@@ -45,6 +46,7 @@ import { ParserDomainModule } from './parser/parser-domain.module';
NotificationDomainModule, NotificationDomainModule,
LedgerDomainModule, LedgerDomainModule,
ParserDomainModule, ParserDomainModule,
SettingsDomainModule,
], ],
exports: [ exports: [
AuthDomainModule, AuthDomainModule,
@@ -68,6 +70,7 @@ import { ParserDomainModule } from './parser/parser-domain.module';
NotificationDomainModule, NotificationDomainModule,
LedgerDomainModule, LedgerDomainModule,
ParserDomainModule, ParserDomainModule,
SettingsDomainModule,
], ],
}) })
export class DomainModule {} export class DomainModule {}
@@ -1,7 +1,6 @@
import type { import type {
WorkflowTriggerDomainInterface, WorkflowTriggerDomainInterface,
WorkflowTriggerResultDomainInterface, WorkflowTriggerResultDomainInterface,
WorkflowBulkTriggerDomainInterface,
} from './workflow-trigger-domain.interface'; } from './workflow-trigger-domain.interface';
/** /**
@@ -16,13 +15,6 @@ export interface NovuWorkflowPort {
*/ */
triggerWorkflow(triggerData: WorkflowTriggerDomainInterface): Promise<WorkflowTriggerResultDomainInterface>; triggerWorkflow(triggerData: WorkflowTriggerDomainInterface): Promise<WorkflowTriggerResultDomainInterface>;
/**
* Запустить воркфлоу для множественной отправки уведомлений
* @param bulkTriggerData Данные для пакетного запуска воркфлоу
* @returns Promise<WorkflowTriggerResultDomainInterface[]>
*/
triggerBulkWorkflow(bulkTriggerData: WorkflowBulkTriggerDomainInterface): Promise<WorkflowTriggerResultDomainInterface[]>;
/** /**
* Отменить триггер воркфлоу * Отменить триггер воркфлоу
* @param transactionId ID транзакции для отмены * @param transactionId ID транзакции для отмены
@@ -118,38 +118,3 @@ export interface WorkflowTriggerResultDomainInterface {
*/ */
error?: string[]; error?: string[];
} }
/**
* Доменный интерфейс для пакетного триггера воркфлоу
*/
export interface WorkflowBulkTriggerDomainInterface {
/**
* Имя воркфлоу
*/
name: string;
/**
* Массив событий для пакетной отправки
*/
events: WorkflowBulkEventDomainInterface[];
}
/**
* Доменный интерфейс для события в пакетном триггере
*/
export interface WorkflowBulkEventDomainInterface {
/**
* Данные для воркфлоу (payload)
*/
to: WorkflowRecipientDomainInterface | WorkflowRecipientDomainInterface[];
/**
* Данные для шаблона уведомления
*/
payload?: Record<string, any>;
/**
* Дополнительные параметры
*/
overrides?: Record<string, any>;
}
@@ -0,0 +1,63 @@
import type { SettingsDomainInterface } from '../interfaces/settings-domain.interface';
/**
* Доменная сущность настроек системы
* Инкапсулирует бизнес-логику работы с настройками
*/
export class SettingsDomainEntity implements SettingsDomainInterface {
coopname: string;
authorized_default_workspace: string;
authorized_default_route: string;
non_authorized_default_workspace: string;
non_authorized_default_route: string;
extra_settings?: Record<string, any>;
created_at: Date;
updated_at: Date;
constructor(data: SettingsDomainInterface) {
this.coopname = data.coopname;
this.authorized_default_workspace = data.authorized_default_workspace;
this.authorized_default_route = data.authorized_default_route;
this.non_authorized_default_workspace = data.non_authorized_default_workspace;
this.non_authorized_default_route = data.non_authorized_default_route;
this.created_at = data.created_at;
this.updated_at = data.updated_at;
}
/**
* Обновляет настройки частично
* @param updates - объект с полями для обновления
*/
update(updates: Partial<Omit<SettingsDomainInterface, 'coopname' | 'created_at' | 'updated_at'>>): void {
if (updates.authorized_default_workspace !== undefined) {
this.authorized_default_workspace = updates.authorized_default_workspace;
}
if (updates.authorized_default_route !== undefined) {
this.authorized_default_route = updates.authorized_default_route;
}
if (updates.non_authorized_default_workspace !== undefined) {
this.non_authorized_default_workspace = updates.non_authorized_default_workspace;
}
if (updates.non_authorized_default_route !== undefined) {
this.non_authorized_default_route = updates.non_authorized_default_route;
}
this.updated_at = new Date();
}
/**
* Создает дефолтные настройки для кооператива
* @param coopname - название кооператива
*/
static createDefault(coopname: string): SettingsDomainEntity {
const now = new Date();
return new SettingsDomainEntity({
coopname,
authorized_default_workspace: 'participant',
authorized_default_route: 'wallet',
non_authorized_default_workspace: 'participant',
non_authorized_default_route: 'signup',
created_at: now,
updated_at: now,
});
}
}
@@ -0,0 +1,48 @@
import { Inject, Injectable } from '@nestjs/common';
import { SETTINGS_REPOSITORY, SettingsRepository } from '../repositories/settings.repository';
import { SettingsDomainEntity } from '../entities/settings-domain.entity';
import type { UpdateSettingsInputDomainInterface } from '../interfaces/update-settings-input-domain.interface';
import config from '~/config/config';
/**
* Интерактор для работы с настройками системы
* Реализует бизнес-логику управления настройками
*/
@Injectable()
export class SettingsDomainInteractor {
constructor(@Inject(SETTINGS_REPOSITORY) private readonly settingsRepository: SettingsRepository) {}
/**
* Получает настройки кооператива
* Если настройки не найдены, создает дефолтные
*/
async getSettings(): Promise<SettingsDomainEntity> {
const settings = await this.settingsRepository.get(config.coopname);
if (!settings) {
// Если настроек нет, создаем дефолтные
const defaultSettings = SettingsDomainEntity.createDefault(config.coopname);
const created = await this.settingsRepository.upsert(defaultSettings);
return new SettingsDomainEntity(created);
}
return new SettingsDomainEntity(settings);
}
/**
* Обновляет настройки кооператива
* @param updates - объект с полями для обновления
*/
async updateSettings(updates: UpdateSettingsInputDomainInterface): Promise<SettingsDomainEntity> {
// Получаем текущие настройки или создаем дефолтные
const currentSettings = await this.getSettings();
// Обновляем настройки
currentSettings.update(updates);
// Сохраняем обновленные настройки
const updated = await this.settingsRepository.upsert(currentSettings);
return new SettingsDomainEntity(updated);
}
}
@@ -0,0 +1,44 @@
/**
* Доменный интерфейс для настроек системы
* Содержит конфигурацию рабочих столов и маршрутов по умолчанию
*/
export interface SettingsDomainInterface {
/**
* Название кооператива, к которому относятся настройки
*/
coopname: string;
/**
* Рабочий стол по умолчанию для авторизованных пользователей
* Например: 'participant', 'soviet', 'chairman'
*/
authorized_default_workspace: string;
/**
* Маршрут по умолчанию для авторизованных пользователей
* Например: 'wallet', 'agenda', 'profile'
*/
authorized_default_route: string;
/**
* Рабочий стол по умолчанию для неавторизованных пользователей
* Обычно 'participant' или пустой
*/
non_authorized_default_workspace: string;
/**
* Маршрут по умолчанию для неавторизованных пользователей
* Обычно 'signup' или 'signin'
*/
non_authorized_default_route: string;
/**
* Дата создания настроек
*/
created_at: Date;
/**
* Дата последнего обновления настроек
*/
updated_at: Date;
}
@@ -0,0 +1,25 @@
/**
* Доменный интерфейс для обновления настроек системы
* Все поля опциональны для частичного обновления
*/
export interface UpdateSettingsInputDomainInterface {
/**
* Рабочий стол по умолчанию для авторизованных пользователей
*/
authorized_default_workspace?: string;
/**
* Маршрут по умолчанию для авторизованных пользователей
*/
authorized_default_route?: string;
/**
* Рабочий стол по умолчанию для неавторизованных пользователей
*/
non_authorized_default_workspace?: string;
/**
* Маршрут по умолчанию для неавторизованных пользователей
*/
non_authorized_default_route?: string;
}
@@ -0,0 +1,33 @@
import type { SettingsDomainInterface } from '../interfaces/settings-domain.interface';
import type { UpdateSettingsInputDomainInterface } from '../interfaces/update-settings-input-domain.interface';
/**
* Репозиторий для работы с настройками системы
* Определяет контракт для работы с хранилищем настроек
*/
export interface SettingsRepository {
/**
* Получает настройки кооператива
* @param coopname - название кооператива
* @returns настройки или null, если не найдены
*/
get(coopname: string): Promise<SettingsDomainInterface | null>;
/**
* Создает или обновляет настройки кооператива
* @param settings - объект настроек
*/
upsert(settings: SettingsDomainInterface): Promise<SettingsDomainInterface>;
/**
* Обновляет настройки кооператива частично
* @param coopname - название кооператива
* @param updates - объект с полями для обновления
*/
update(coopname: string, updates: UpdateSettingsInputDomainInterface): Promise<SettingsDomainInterface>;
}
/**
* Токен для инъекции зависимости репозитория настроек
*/
export const SETTINGS_REPOSITORY = Symbol('SettingsRepository');
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SettingsDomainInteractor } from './interactors/settings.interactor';
/**
* Модуль доменного слоя для работы с настройками
*/
@Module({
providers: [SettingsDomainInteractor],
exports: [SettingsDomainInteractor],
})
export class SettingsDomainModule {}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SettingsDomainModule } from './settings-domain.module';
/**
* Модуль настроек, объединяющий домен и инфраструктуру
* Импортирует SettingsDomainModule и предоставляет репозиторий через TypeOrmModule
*/
@Module({
imports: [SettingsDomainModule],
exports: [SettingsDomainModule],
})
export class SettingsModule {}
@@ -5,6 +5,7 @@ import type { GetInfoResult } from '~/types/shared/blockchain.types';
import { CooperativeContactsDomainInterface } from '../interfaces/cooperative-contacts-domain.interface'; import { CooperativeContactsDomainInterface } from '../interfaces/cooperative-contacts-domain.interface';
import type { VarsDomainInterface } from '../interfaces/vars-domain.interface'; import type { VarsDomainInterface } from '../interfaces/vars-domain.interface';
import { SymbolsDTO } from '~/application/system/dto/symbols.dto'; import { SymbolsDTO } from '~/application/system/dto/symbols.dto';
import type { SettingsDomainInterface } from '~/domain/settings/interfaces/settings-domain.interface';
export class SystemInfoDomainEntity { export class SystemInfoDomainEntity {
public readonly coopname!: string; public readonly coopname!: string;
@@ -16,6 +17,7 @@ export class SystemInfoDomainEntity {
public readonly contacts?: CooperativeContactsDomainInterface; public readonly contacts?: CooperativeContactsDomainInterface;
public readonly vars!: VarsDomainInterface | null; public readonly vars!: VarsDomainInterface | null;
public readonly symbols!: SymbolsDTO; public readonly symbols!: SymbolsDTO;
public readonly settings!: SettingsDomainInterface;
constructor(data: SystemInfoDomainEntity) { constructor(data: SystemInfoDomainEntity) {
Object.assign(this, data); Object.assign(this, data);
@@ -14,6 +14,9 @@ import { VARS_REPOSITORY, VarsRepository } from '~/domain/common/repositories/va
import type { UpdateInputDomainInterface } from '../interfaces/update-input-domain.interface'; import type { UpdateInputDomainInterface } from '../interfaces/update-input-domain.interface';
import { ORGANIZATION_REPOSITORY, type OrganizationRepository } from '~/domain/common/repositories/organization.repository'; import { ORGANIZATION_REPOSITORY, type OrganizationRepository } from '~/domain/common/repositories/organization.repository';
import { SymbolsDTO } from '~/application/system/dto/symbols.dto'; import { SymbolsDTO } from '~/application/system/dto/symbols.dto';
import { SettingsDomainInteractor } from '~/domain/settings/interactors/settings.interactor';
import type { UpdateSettingsInputDomainInterface } from '~/domain/settings/interfaces/update-settings-input-domain.interface';
import type { SettingsDomainEntity } from '~/domain/settings/entities/settings-domain.entity';
@Injectable() @Injectable()
export class SystemDomainInteractor { export class SystemDomainInteractor {
@@ -22,7 +25,8 @@ export class SystemDomainInteractor {
@Inject(SYSTEM_BLOCKCHAIN_PORT) private readonly systemBlockchainPort: SystemBlockchainPort, @Inject(SYSTEM_BLOCKCHAIN_PORT) private readonly systemBlockchainPort: SystemBlockchainPort,
private readonly systemDomainService: SystemDomainService, private readonly systemDomainService: SystemDomainService,
@Inject(VARS_REPOSITORY) private readonly varsRepository: VarsRepository, @Inject(VARS_REPOSITORY) private readonly varsRepository: VarsRepository,
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
private readonly settingsDomainInteractor: SettingsDomainInteractor
) {} ) {}
async setWif(data: SetWifInputDomainInterface): Promise<void> { async setWif(data: SetWifInputDomainInterface): Promise<void> {
@@ -79,6 +83,9 @@ export class SystemDomainInteractor {
config.blockchain.root_govern_precision config.blockchain.root_govern_precision
); );
// Получаем настройки системы
const settings = await this.getSettings();
return new SystemInfoDomainEntity({ return new SystemInfoDomainEntity({
blockchain_info, blockchain_info,
contacts, contacts,
@@ -89,6 +96,22 @@ export class SystemDomainInteractor {
blockchain_account, blockchain_account,
system_status, system_status,
symbols, symbols,
settings,
}); });
} }
/**
* Получает настройки системы
*/
async getSettings(): Promise<SettingsDomainEntity> {
return this.settingsDomainInteractor.getSettings();
}
/**
* Обновляет настройки системы
* @param updates - объект с полями для обновления
*/
async updateSettings(updates: UpdateSettingsInputDomainInterface): Promise<SettingsDomainEntity> {
return this.settingsDomainInteractor.updateSettings(updates);
}
} }
@@ -3,9 +3,10 @@ import { Module } from '@nestjs/common';
import { SystemDomainInteractor } from './interactors/system.interactor'; import { SystemDomainInteractor } from './interactors/system.interactor';
import { AccountDomainModule } from '../account/account-domain.module'; import { AccountDomainModule } from '../account/account-domain.module';
import { SystemDomainService } from './services/system-domain.service'; import { SystemDomainService } from './services/system-domain.service';
import { SettingsModule } from '../settings/settings.module';
@Module({ @Module({
imports: [AccountDomainModule], imports: [AccountDomainModule, SettingsModule],
providers: [SystemDomainService, SystemDomainInteractor], providers: [SystemDomainService, SystemDomainInteractor],
exports: [SystemDomainService, SystemDomainInteractor], exports: [SystemDomainService, SystemDomainInteractor],
}) })
@@ -252,7 +252,7 @@ export class CapitalProjectFactPoolOutputDTO {
total_generation_pool!: string; total_generation_pool!: string;
@Field(() => String, { @Field(() => String, {
description: 'Общий объем вклада', description: 'Общий объем взноса старших участников',
}) })
total_contribution!: string; total_contribution!: string;
@@ -88,6 +88,12 @@ export class SegmentOutputDTO extends BaseOutputDTO {
}) })
investor_base!: string; investor_base!: string;
@Field(() => String, {
nullable: true,
description: 'Вклад участника словами участника',
})
value?: string;
// Вклады создателя // Вклады создателя
@Field(() => String, { @Field(() => String, {
description: 'Базовый вклад создателя', description: 'Базовый вклад создателя',
@@ -10,6 +10,8 @@ import { ResultOutputDTO } from '../../application/dto/result_submission/result.
import { DocumentAggregationService } from '~/domain/document/services/document-aggregation.service'; import { DocumentAggregationService } from '~/domain/document/services/document-aggregation.service';
import { ContributorRepository } from '../../domain/repositories/contributor.repository'; import { ContributorRepository } from '../../domain/repositories/contributor.repository';
import { CONTRIBUTOR_REPOSITORY } from '../../domain/repositories/contributor.repository'; import { CONTRIBUTOR_REPOSITORY } from '../../domain/repositories/contributor.repository';
import { AppendixRepository } from '../../domain/repositories/appendix.repository';
import { APPENDIX_REPOSITORY } from '../../domain/repositories/appendix.repository';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto'; import { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto';
@@ -26,7 +28,9 @@ export class SegmentMapper {
constructor( constructor(
private readonly documentAggregationService: DocumentAggregationService, private readonly documentAggregationService: DocumentAggregationService,
@Inject(CONTRIBUTOR_REPOSITORY) @Inject(CONTRIBUTOR_REPOSITORY)
private readonly contributorRepository: ContributorRepository private readonly contributorRepository: ContributorRepository,
@Inject(APPENDIX_REPOSITORY)
private readonly appendixRepository: AppendixRepository
) {} ) {}
/** /**
* Преобразование TypeORM сущности в доменную сущность * Преобразование TypeORM сущности в доменную сущность
@@ -222,6 +226,21 @@ export class SegmentMapper {
displayName = ''; displayName = '';
} }
// Получаем contribution из appendix
let contribution: string | undefined = undefined;
try {
if (domain.username && domain.project_hash) {
const appendix = await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
domain.username,
domain.project_hash
);
contribution = appendix?.contribution;
}
} catch (error) {
// Если не удалось получить appendix, оставляем undefined
contribution = undefined;
}
// Обогащаем документы в result // Обогащаем документы в result
let enrichedResult: ResultOutputDTO | undefined = undefined; let enrichedResult: ResultOutputDTO | undefined = undefined;
if (domain.result) { if (domain.result) {
@@ -251,6 +270,7 @@ export class SegmentMapper {
return { return {
...domain, ...domain,
display_name: displayName, display_name: displayName,
value: contribution,
result: enrichedResult, result: enrichedResult,
} as SegmentOutputDTO; } as SegmentOutputDTO;
} }
@@ -10,6 +10,7 @@ import type { WorkflowTriggerDomainInterface } from '~/domain/notification/inter
import { Workflows } from '@coopenomics/notifications'; import { Workflows } from '@coopenomics/notifications';
import { SovietContract } from 'cooptypes'; import { SovietContract } from 'cooptypes';
import { ApprovalRepository, APPROVAL_REPOSITORY } from '../../domain/repositories/approval.repository'; import { ApprovalRepository, APPROVAL_REPOSITORY } from '../../domain/repositories/approval.repository';
import { ApprovalStatus } from '../../domain';
/** /**
* Сервис для отправки уведомлений об ответах на запросы одобрения * Сервис для отправки уведомлений об ответах на запросы одобрения
@@ -114,6 +115,7 @@ export class ApprovalResponseNotificationService implements OnModuleInit {
const payload: Workflows.ApprovalResponse.IPayload = { const payload: Workflows.ApprovalResponse.IPayload = {
userName: authorName, userName: authorName,
approvalStatus: status, approvalStatus: status,
approvalStatusText: status === ApprovalStatus.APPROVED ? 'одобрен' : 'отклонён',
approvalId: approvalHash, approvalId: approvalHash,
coopname: config.coopname, coopname: config.coopname,
coopShortName, coopShortName,
@@ -7,10 +7,7 @@ import { DateUtils } from '~/shared/utils/date-utils';
import { ExtendedMeetStatus } from '~/domain/meet/enums/extended-meet-status.enum'; import { ExtendedMeetStatus } from '~/domain/meet/enums/extended-meet-status.enum';
import type { TrackedMeet } from './types'; import type { TrackedMeet } from './types';
import { ACCOUNT_EXTENSION_PORT, AccountExtensionPort } from '~/domain/extension/ports/account-extension-port'; import { ACCOUNT_EXTENSION_PORT, AccountExtensionPort } from '~/domain/extension/ports/account-extension-port';
import type { import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
WorkflowBulkTriggerDomainInterface,
WorkflowBulkEventDomainInterface,
} from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
import { Workflows } from '@coopenomics/notifications'; import { Workflows } from '@coopenomics/notifications';
/** /**
@@ -127,23 +124,28 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
meetUrl, meetUrl,
}; };
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({ // Отправляем уведомления каждому пользователю в цикле
to: { let sentCount = 0;
subscriberId: user.username, for (const user of users) {
email: user.email, const triggerData: WorkflowTriggerDomainInterface = {
}, name: Workflows.MeetInitial.id,
payload, to: {
})); subscriberId: user.username,
email: user.email,
},
payload,
};
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = { try {
name: Workflows.MeetInitial.id, await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
events, sentCount++;
}; } catch (error: any) {
this.logger.error(`Ошибка отправки начального уведомления пользователю ${user.username}: ${error.message}`);
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData); }
}
this.logger.info( this.logger.info(
`Отправлено начальное уведомление о собрании ${meet.hash} (№${meet.id}) для ${users.length} пользователей` `Отправлено начальное уведомление о собрании ${meet.hash} (№${meet.id}) для ${sentCount}/${users.length} пользователей`
); );
} }
@@ -173,23 +175,28 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
meetUrl, meetUrl,
}; };
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({ // Отправляем уведомления каждому пользователю в цикле
to: { let sentCount = 0;
subscriberId: user.username, for (const user of users) {
email: user.email, const triggerData: WorkflowTriggerDomainInterface = {
}, name: Workflows.MeetReminderStart.id,
payload, to: {
})); subscriberId: user.username,
email: user.email,
},
payload,
};
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = { try {
name: Workflows.MeetReminderStart.id, await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
events, sentCount++;
}; } catch (error: any) {
this.logger.error(`Ошибка отправки уведомления о начале собрания пользователю ${user.username}: ${error.message}`);
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData); }
}
this.logger.info( this.logger.info(
`Отправлено уведомление за ${timeDescription} до начала собрания ${meet.hash} (№${meet.id}) для ${users.length} пользователей` `Отправлено уведомление за ${timeDescription} до начала собрания ${meet.hash} (№${meet.id}) для ${sentCount}/${users.length} пользователей`
); );
} }
@@ -213,23 +220,28 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
meetUrl, meetUrl,
}; };
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({ // Отправляем уведомления каждому пользователю в цикле
to: { let sentCount = 0;
subscriberId: user.username, for (const user of users) {
email: user.email, const triggerData: WorkflowTriggerDomainInterface = {
}, name: Workflows.MeetStarted.id,
payload, to: {
})); subscriberId: user.username,
email: user.email,
},
payload,
};
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = { try {
name: Workflows.MeetStarted.id, await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
events, sentCount++;
}; } catch (error: any) {
this.logger.error(`Ошибка отправки уведомления о старте собрания пользователю ${user.username}: ${error.message}`);
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData); }
}
this.logger.info( this.logger.info(
`Отправлено уведомление о начале собрания ${meet.hash} (№${meet.id}) для ${users.length} пользователей` `Отправлено уведомление о начале собрания ${meet.hash} (№${meet.id}) для ${sentCount}/${users.length} пользователей`
); );
} }
@@ -261,23 +273,30 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
meetUrl, meetUrl,
}; };
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({ // Отправляем уведомления каждому пользователю в цикле
to: { let sentCount = 0;
subscriberId: user.username, for (const user of users) {
email: user.email, const triggerData: WorkflowTriggerDomainInterface = {
}, name: Workflows.MeetReminderEnd.id,
payload, to: {
})); subscriberId: user.username,
email: user.email,
},
payload,
};
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = { try {
name: Workflows.MeetReminderEnd.id, await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
events, sentCount++;
}; } catch (error: any) {
this.logger.error(
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData); `Ошибка отправки уведомления об окончании собрания пользователю ${user.username}: ${error.message}`
);
}
}
this.logger.info( this.logger.info(
`Отправлено уведомление за ${timeDescription} до завершения собрания ${meet.hash} (№${meet.id}) для ${users.length} пользователей` `Отправлено уведомление за ${timeDescription} до завершения собрания ${meet.hash} (№${meet.id}) для ${sentCount}/${users.length} пользователей`
); );
} }
@@ -305,23 +324,30 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
meetUrl, meetUrl,
}; };
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({ // Отправляем уведомления каждому пользователю в цикле
to: { let sentCount = 0;
subscriberId: user.username, for (const user of users) {
email: user.email, const triggerData: WorkflowTriggerDomainInterface = {
}, name: Workflows.MeetRestart.id,
payload, to: {
})); subscriberId: user.username,
email: user.email,
},
payload,
};
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = { try {
name: Workflows.MeetRestart.id, await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
events, sentCount++;
}; } catch (error: any) {
this.logger.error(
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData); `Ошибка отправки уведомления о повторном собрании пользователю ${user.username}: ${error.message}`
);
}
}
this.logger.info( this.logger.info(
`Отправлено уведомление о новой дате повторного собрания ${meet.hash} (№${meet.id}) для ${users.length} пользователей` `Отправлено уведомление о новой дате повторного собрания ${meet.hash} (№${meet.id}) для ${sentCount}/${users.length} пользователей`
); );
} }
@@ -371,23 +397,30 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
endMessage, endMessage,
}; };
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({ // Отправляем уведомления каждому пользователю в цикле
to: { let sentCount = 0;
subscriberId: user.username, for (const user of users) {
email: user.email, const triggerData: WorkflowTriggerDomainInterface = {
}, name: Workflows.MeetEnded.id,
payload, to: {
})); subscriberId: user.username,
email: user.email,
},
payload,
};
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = { try {
name: Workflows.MeetEnded.id, await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
events, sentCount++;
}; } catch (error: any) {
this.logger.error(
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData); `Ошибка отправки уведомления о завершении собрания пользователю ${user.username}: ${error.message}`
);
}
}
this.logger.info( this.logger.info(
`Отправлено уведомление о завершении собрания ${meet.hash} (№${meet.id}) для ${users.length} пользователей` `Отправлено уведомление о завершении собрания ${meet.hash} (№${meet.id}) для ${sentCount}/${users.length} пользователей`
); );
} }
} }
@@ -103,6 +103,9 @@ export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy
* Выполняет минимальную предварительную фильтрацию, сохраняет в базу и публикует событие во внутреннюю шину * Выполняет минимальную предварительную фильтрацию, сохраняет в базу и публикует событие во внутреннюю шину
*/ */
private async processAction(action: IAction): Promise<void> { private async processAction(action: IAction): Promise<void> {
if (action.receiver != action.account) {
return;
}
this.logger.debug(`Обработка действия: ${action.name} от ${action.account}: ${JSON.stringify(action.data)}`); this.logger.debug(`Обработка действия: ${action.name} от ${action.account}: ${JSON.stringify(action.data)}`);
// Откладываем обработку на 3 секунды асинхронно // Откладываем обработку на 3 секунды асинхронно
@@ -0,0 +1,69 @@
import { BeforeInsert, BeforeUpdate, Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
import { SettingsDomainEntity } from '~/domain/settings/entities/settings-domain.entity';
import type { SettingsDomainInterface } from '~/domain/settings/interfaces/settings-domain.interface';
/**
* TypeORM сущность для настроек системы
* Хранит конфигурацию рабочих столов и маршрутов по умолчанию
*/
@Entity('settings')
export class SettingsEntity implements SettingsDomainInterface {
@PrimaryColumn({ unique: true, length: 12 })
coopname!: string;
@Column({ length: 50 })
authorized_default_workspace!: string;
@Column({ length: 50 })
authorized_default_route!: string;
@Column({ length: 50 })
non_authorized_default_workspace!: string;
@Column({ length: 50 })
non_authorized_default_route!: string;
@Column('jsonb', { default: {} })
extra_settings?: Record<string, any>;
@CreateDateColumn()
created_at!: Date;
@UpdateDateColumn()
updated_at!: Date;
@BeforeInsert()
@BeforeUpdate()
setDefaultExtraSettings() {
if (this.extra_settings === null || this.extra_settings === undefined) {
this.extra_settings = {};
}
}
constructor(data?: SettingsDomainEntity) {
if (data) {
this.coopname = data.coopname;
this.authorized_default_workspace = data.authorized_default_workspace;
this.authorized_default_route = data.authorized_default_route;
this.non_authorized_default_workspace = data.non_authorized_default_workspace;
this.non_authorized_default_route = data.non_authorized_default_route;
this.extra_settings = data.extra_settings;
this.created_at = data.created_at;
this.updated_at = data.updated_at;
}
}
/**
* Преобразует ORM-сущность в доменную сущность
*/
toDomainEntity(): SettingsDomainEntity {
return new SettingsDomainEntity(this);
}
/**
* Создает ORM-сущность из доменной сущности
*/
static fromDomainEntity(domainEntity: SettingsDomainEntity): SettingsEntity {
return new SettingsEntity(domainEntity);
}
}
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SettingsEntity } from '../entities/settings.entity';
import type { SettingsRepository } from '~/domain/settings/repositories/settings.repository';
import type { SettingsDomainInterface } from '~/domain/settings/interfaces/settings-domain.interface';
import type { UpdateSettingsInputDomainInterface } from '~/domain/settings/interfaces/update-settings-input-domain.interface';
/**
* Реализация репозитория настроек на базе TypeORM
* Адаптер между доменным слоем и инфраструктурой базы данных
*/
@Injectable()
export class SettingsTypeormRepository implements SettingsRepository {
constructor(
@InjectRepository(SettingsEntity)
private readonly repository: Repository<SettingsEntity>
) {}
/**
* Получает настройки кооператива
*/
async get(coopname: string): Promise<SettingsDomainInterface | null> {
const entity = await this.repository.findOne({ where: { coopname } });
return entity ? entity.toDomainEntity() : null;
}
/**
* Создает или обновляет настройки кооператива
*/
async upsert(settings: SettingsDomainInterface): Promise<SettingsDomainInterface> {
const entity = SettingsEntity.fromDomainEntity(settings as any);
const saved = await this.repository.save(entity);
return saved.toDomainEntity();
}
/**
* Обновляет настройки кооператива частично
*/
async update(coopname: string, updates: UpdateSettingsInputDomainInterface): Promise<SettingsDomainInterface> {
// Получаем текущие настройки
const existing = await this.get(coopname);
if (!existing) {
throw new Error(`Settings not found for coopname: ${coopname}`);
}
// Обновляем только переданные поля
const updated = {
...existing,
...updates,
updated_at: new Date(),
};
return this.upsert(updated);
}
}
@@ -50,6 +50,9 @@ import { TypeOrmActionRepository } from './repositories/typeorm-action.repositor
import { TypeOrmDeltaRepository } from './repositories/typeorm-delta.repository'; import { TypeOrmDeltaRepository } from './repositories/typeorm-delta.repository';
import { TypeOrmForkRepository } from './repositories/typeorm-fork.repository'; import { TypeOrmForkRepository } from './repositories/typeorm-fork.repository';
import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.repository'; import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.repository';
import { SettingsEntity } from './entities/settings.entity';
import { SETTINGS_REPOSITORY } from '~/domain/settings/repositories/settings.repository';
import { SettingsTypeormRepository } from './repositories/settings.typeorm-repository';
@Global() @Global()
@Module({ @Module({
@@ -61,7 +64,7 @@ import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.re
username: config.postgres.username, username: config.postgres.username,
password: config.postgres.password, password: config.postgres.password,
database: config.postgres.database, database: config.postgres.database,
entities: [EntityVersionTypeormEntity, path.join(__dirname, '**/entities/*.entity.{ts,js}')], entities: [EntityVersionTypeormEntity, path.join(__dirname, '**/entities/*entity.{ts,js}')],
synchronize: config.env === 'development', // Используем миграции для production synchronize: config.env === 'development', // Используем миграции для production
logging: false, logging: false,
}), }),
@@ -81,6 +84,7 @@ import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.re
ForkEntity, ForkEntity,
SyncStateEntity, SyncStateEntity,
EntityVersionTypeormEntity, EntityVersionTypeormEntity,
SettingsEntity,
]), ]),
], ],
providers: [ providers: [
@@ -144,6 +148,10 @@ import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.re
provide: SYNC_STATE_REPOSITORY_PORT, provide: SYNC_STATE_REPOSITORY_PORT,
useClass: TypeOrmSyncStateRepository, useClass: TypeOrmSyncStateRepository,
}, },
{
provide: SETTINGS_REPOSITORY,
useClass: SettingsTypeormRepository,
},
EntityVersionRepository, EntityVersionRepository,
EntityVersioningService, EntityVersioningService,
], ],
@@ -164,6 +172,7 @@ import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.re
DELTA_REPOSITORY_PORT, DELTA_REPOSITORY_PORT,
FORK_REPOSITORY_PORT, FORK_REPOSITORY_PORT,
SYNC_STATE_REPOSITORY_PORT, SYNC_STATE_REPOSITORY_PORT,
SETTINGS_REPOSITORY,
EntityVersionRepository, EntityVersionRepository,
EntityVersioningService, EntityVersioningService,
], ],
@@ -5,7 +5,6 @@ import type { NovuWorkflowPort } from '~/domain/notification/interfaces/novu-wor
import type { import type {
WorkflowTriggerDomainInterface, WorkflowTriggerDomainInterface,
WorkflowTriggerResultDomainInterface, WorkflowTriggerResultDomainInterface,
WorkflowBulkTriggerDomainInterface,
} from '~/domain/notification/interfaces/workflow-trigger-domain.interface'; } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
/** /**
@@ -40,7 +39,13 @@ export class NovuWorkflowAdapter implements NovuWorkflowPort {
this.logger.log(`Запуск воркфлоу: ${triggerData.name}`); this.logger.log(`Запуск воркфлоу: ${triggerData.name}`);
try { try {
const response: AxiosResponse = await axios.post(`${this.novuBaseUrl}/events/trigger`, triggerData, { // Санитизируем payload для предотвращения проблем с кавычками в NOVU
const sanitizedTriggerData = {
...triggerData,
payload: this.sanitizePayload(triggerData.payload),
};
const response: AxiosResponse = await axios.post(`${this.novuBaseUrl}/events/trigger`, sanitizedTriggerData, {
headers: this.getHeaders(), headers: this.getHeaders(),
}); });
@@ -48,32 +53,10 @@ export class NovuWorkflowAdapter implements NovuWorkflowPort {
return this.mapToTriggerResult(response.data); return this.mapToTriggerResult(response.data);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Ошибка запуска воркфлоу ${triggerData.name}: ${error.message}`, error.stack); this.logger.error(
throw error; `Ошибка запуска воркфлоу ${triggerData.name}: ${error.message} ${error.response?.data}`,
} error.stack
} );
/**
* Запустить воркфлоу для множественной отправки уведомлений
* @param bulkTriggerData Данные для пакетного запуска воркфлоу
* @returns Promise<WorkflowTriggerResultDomainInterface[]>
*/
async triggerBulkWorkflow(
bulkTriggerData: WorkflowBulkTriggerDomainInterface
): Promise<WorkflowTriggerResultDomainInterface[]> {
this.logger.log(`Запуск пакетного воркфлоу: ${bulkTriggerData.name}, событий: ${bulkTriggerData.events.length}`);
try {
const response: AxiosResponse = await axios.post(`${this.novuBaseUrl}/events/trigger/bulk`, bulkTriggerData, {
headers: this.getHeaders(),
});
this.logger.log(`Пакетный воркфлоу запущен: ${bulkTriggerData.name}`);
console.log('NOVU bulk API response:', JSON.stringify(response.data, null, 2));
return response.data.map((item: any) => this.mapToTriggerResult(item));
} catch (error: any) {
this.logger.error(`Ошибка запуска пакетного воркфлоу ${bulkTriggerData.name}: ${error.message}`, error.stack);
throw error; throw error;
} }
} }
@@ -119,6 +102,34 @@ export class NovuWorkflowAdapter implements NovuWorkflowPort {
} }
} }
/**
* Санитизирует payload для безопасного использования в NOVU
* Заменяет кавычки на HTML entities для предотвращения проблем с шаблонизацией
* @param payload Объект payload для санитизации
* @returns Санитизированный payload
*/
private sanitizePayload(payload: any): any {
if (typeof payload === 'string') {
return payload.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
if (Array.isArray(payload)) {
return payload.map((item) => this.sanitizePayload(item));
}
if (payload !== null && typeof payload === 'object') {
const sanitized: any = {};
for (const key in payload) {
if (Object.prototype.hasOwnProperty.call(payload, key)) {
sanitized[key] = this.sanitizePayload(payload[key]);
}
}
return sanitized;
}
return payload;
}
/** /**
* Возвращает заголовки для запросов к NOVU API * Возвращает заголовки для запросов к NOVU API
*/ */
+3
View File
@@ -12,6 +12,9 @@
"allowJs": false, "allowJs": false,
"noImplicitAny": false, "noImplicitAny": false,
"skipLibCheck": true, "skipLibCheck": true,
"composite": true,
"incremental": true,
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"paths": { "paths": {
"~/*": ["*"] "~/*": ["*"]
} }
+2 -1
View File
@@ -10,5 +10,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipDefaultLibCheck": true, "skipDefaultLibCheck": true,
"skipLibCheck": true "skipLibCheck": true
} },
"include": ["src/**/*"]
} }
@@ -0,0 +1,91 @@
<template lang="pug">
CreateDialog(
ref="dialogRef"
title="Инвестирование в проект"
submit-text="Инвестировать"
dialog-style="width: 600px; max-width: 100% !important;"
:is-submitting="isGenerating"
@submit="handleSubmit"
@dialog-closed="clear"
)
template(#form-fields)
q-input(
v-model='quantity'
standout='bg-teal text-white'
placeholder='Введите сумму инвестиций'
type='number'
:min='0'
:rules='[(val) => val > 0 || "Сумма инвестиций должна быть положительной"]'
)
template(#append)
span.text-overline {{ currency }}
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { CreateDialog } from 'src/shared/ui/CreateDialog';
import { useCreateProjectInvest } from '../../model';
import { useSetPlan } from 'app/extensions/capital/features/Project/SetPlan';
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
import type { IProject } from '../../../../../entities/Project/model';
const props = defineProps<{ project: IProject | null | undefined }>();
const emit = defineEmits<{
success: [];
error: [error: any];
}>();
const dialogRef = ref();
const {
createProjectInvestWithGeneratedStatement,
isGenerating
} = useCreateProjectInvest();
const { governSymbol } = useSetPlan();
const quantity = ref(1000);
const clear = (): void => {
quantity.value = 1000;
};
// Обработка инвестирования (генерация + подпись + создание)
const handleSubmit = async (): Promise<void> => {
if (!props.project?.project_hash) {
FailAlert('Не указан проект');
return;
}
try {
// Создаем инвестицию с сгенерированным и подписанным заявлением
await createProjectInvestWithGeneratedStatement(
quantity.value.toString(),
props.project.project_hash
);
// Показываем сообщение об успехе и закрываем диалог
SuccessAlert('Инвестиция успешно создана');
// Закрываем диалог после успешного создания
dialogRef.value?.clear();
emit('success');
} catch (e: any) {
console.log('e.message', e.message);
FailAlert(e);
emit('error', e);
}
};
const currency = computed(() => governSymbol.value);
// Инициализация данных для инвестирования при изменении проекта
watch(() => props.project, () => {
// Теперь инициализация происходит в createProjectInvestWithGeneratedStatement
}, { immediate: true });
// Экспортируем функции для внешнего использования
defineExpose({
openDialog: () => dialogRef.value?.openDialog(),
clear: () => dialogRef.value?.clear(),
});
</script>
@@ -0,0 +1 @@
export { default as CreateProjectInvestDialog } from './CreateProjectInvestDialog.vue';
@@ -0,0 +1,43 @@
<template lang="pug">
q-fab-action(
icon="attach_money"
@click="handleClick"
text-color="white"
:disable="!project?.is_planed"
).bg-fab-accent-radial Инвестиция
q-tooltip(
v-if="!project?.is_planed"
anchor="top middle"
self="bottom middle"
)
| Инвестирование доступно только для запланированных проектов
CreateProjectInvestDialog(
ref="dialogRef"
:project="project"
@success="handleSuccess"
)
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { CreateProjectInvestDialog } from '../Dialog';
import type { IProject } from '../../../../../entities/Project/model';
const props = defineProps<{ project: IProject | null | undefined }>();
const emit = defineEmits<{
actionCompleted: [];
}>();
const dialogRef = ref();
const handleClick = () => {
if (props.project?.is_planed) {
dialogRef.value?.openDialog();
}
};
const handleSuccess = () => {
emit('actionCompleted');
};
</script>
@@ -0,0 +1,39 @@
<template lang="pug">
q-fab-action(
icon="attach_money"
@click="showDialog = true"
text-color="white"
).bg-fab-accent-radial Инвестиция
q-dialog(v-model="showDialog", @hide="showDialog = false")
ModalBase(title="Инвестирование в проект" style="width: 400px; max-width: 100%;")
q-card-section.row.items-center
span Инвестиции в проект доступны только через их компоненты. Пожалуйста, выберите компонент из списка и инвестируйте во все или в некоторые из них по отдельности.
q-card-actions(align="right")
q-btn(flat label="Понятно" @click="showDialog = false")
q-btn(
color="primary"
label="Перейти к компонентам"
@click="goToComponents"
)
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { ModalBase } from 'src/shared/ui/ModalBase';
import type { IProject } from '../../../../../entities/Project/model';
const props = defineProps<{ project: IProject | null | undefined }>();
const router = useRouter();
const showDialog = ref(false);
const goToComponents = () => {
if (props.project?.project_hash) {
router.push({
name: 'project-components',
params: { project_hash: props.project.project_hash }
});
}
showDialog.value = false;
};
</script>
@@ -0,0 +1,2 @@
export { default as ProjectInvestFabAction } from './ProjectInvestFabAction.vue';
export { default as ComponentInvestFabAction } from './ComponentInvestFabAction.vue';
@@ -1 +1,3 @@
export { default as CreateProjectInvestButton } from './CreateProjectInvestButton.vue'; export { default as CreateProjectInvestButton } from './CreateProjectInvestButton.vue';
export { CreateProjectInvestDialog } from './Dialog';
export { ProjectInvestFabAction, ComponentInvestFabAction } from './Fab';
@@ -6,11 +6,8 @@ q-select(
option-label="label" option-label="label"
emit-value emit-value
map-options map-options
:color="getIssuePriorityColor(selectedPriority)" filled
text-color="white"
:bg-color="priorityBgColor"
dense dense
:standout="priorityStandout"
:label="label" :label="label"
:readonly="readonly" :readonly="readonly"
@update:model-value="handlePriorityChange" @update:model-value="handlePriorityChange"
@@ -21,7 +18,7 @@ q-select(
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { Zeus } from '@coopenomics/sdk' import { Zeus } from '@coopenomics/sdk'
import { getIssuePriorityColor, getIssuePriorityLabel } from 'app/extensions/capital/shared/lib' import { getIssuePriorityLabel } from 'app/extensions/capital/shared/lib'
import { useUpdateIssue } from '../../model' import { useUpdateIssue } from '../../model'
interface Props { interface Props {
@@ -51,15 +48,6 @@ const { debounceSave } = useUpdateIssue()
// Текущий выбранный приоритет // Текущий выбранный приоритет
const selectedPriority = ref<Zeus.IssuePriority>(props.modelValue) const selectedPriority = ref<Zeus.IssuePriority>(props.modelValue)
// Computed свойство для standout в зависимости от приоритета
const priorityStandout = computed(() => {
const color = getIssuePriorityColor(selectedPriority.value)
return `bg-${color} text-white`
})
// Computed свойство для bg-color в зависимости от приоритета
const priorityBgColor = computed(() => getIssuePriorityColor(selectedPriority.value))
// Опции для выбора приоритета // Опции для выбора приоритета
const priorityOptions = [ const priorityOptions = [
{ value: Zeus.IssuePriority.LOW, label: getIssuePriorityLabel(Zeus.IssuePriority.LOW) }, { value: Zeus.IssuePriority.LOW, label: getIssuePriorityLabel(Zeus.IssuePriority.LOW) },
@@ -1,12 +1,11 @@
<template lang="pug"> <template lang="pug">
div q-btn(
q-btn( size='sm',
size='sm', color='primary',
color='primary', @click='showDialog = true',
@click='showDialog = true', :loading='isSubmitting',
:loading='isSubmitting', label='Добавить соавтора'
label='Добавить соавтора' )
)
q-dialog(v-model='showDialog', @hide='close') q-dialog(v-model='showDialog', @hide='close')
ModalBase(title='Добавить соавторов') ModalBase(title='Добавить соавторов')
@@ -0,0 +1,130 @@
<template lang="pug">
CreateDialog(
ref="dialogRef"
title="Добавить соавторов"
submit-text="Добавить"
dialog-style="width: 400px; max-width: 100% !important;"
:is-submitting="isSubmitting"
@submit="handleSubmit"
@dialog-closed="clear"
)
template(#form-fields)
div(style='max-width: 400px')
.text-body2.q-mb-sm
| После добавления соавторов их удаление будет невозможно. Для добавления соавторов они должны предварительно получить допуск на участие в проекте.
ContributorSelector(
v-model='selectedAuthors'
:multi-select='true'
:dense='true'
:disable='isSubmitting'
:project-hash='props.project?.project_hash'
placeholder='Выберите соавторов...'
label='Соавторы'
class='authors-selector'
)
.text-caption.text-grey-6.q-mt-sm(v-if='selectedAuthors.length > 0')
| Выбрано соавторов: {{ selectedAuthors.length }}
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useAddAuthor } from '../../model';
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
import { ContributorSelector } from '../../../../../entities/Contributor';
import { CreateDialog } from 'src/shared/ui/CreateDialog';
import type { IProject } from '../../../../../entities/Project/model';
import type { IContributor } from '../../../../../entities/Contributor/model';
const props = defineProps<{ project: IProject | null | undefined }>();
const emit = defineEmits<{
success: [];
error: [error: any];
}>();
const dialogRef = ref();
const { addAuthors, addAuthorInput } = useAddAuthor();
const isSubmitting = ref(false);
const selectedAuthors = ref<IContributor[]>([]);
// Обновляем входные данные при изменении проекта
watch(
() => props.project,
(newProject) => {
if (newProject) {
addAuthorInput.value.coopname = newProject.coopname || '';
addAuthorInput.value.project_hash = newProject.project_hash;
}
},
{ immediate: true },
);
const clear = () => {
selectedAuthors.value = [];
};
const handleSubmit = async () => {
if (selectedAuthors.value.length === 0) {
FailAlert('Выберите хотя бы одного соавтора');
return;
}
// Проверяем, что у всех выбранных участников есть username
const invalidContributors = selectedAuthors.value.filter(c => !c?.username);
if (invalidContributors.length > 0) {
FailAlert('У некоторых выбранных участников отсутствует имя пользователя');
return;
}
isSubmitting.value = true;
try {
const authorUsernames: string[] = [];
for (const contributor of selectedAuthors.value) {
if (contributor && contributor.username) {
authorUsernames.push(contributor.username);
}
}
const baseInput = {
coopname: addAuthorInput.value.coopname,
project_hash: addAuthorInput.value.project_hash,
};
await addAuthors(authorUsernames, baseInput);
const count = authorUsernames.length;
const message = count === 1
? 'Соавтор добавлен'
: `Добавлено соавторов: ${count}`;
SuccessAlert(message);
// Закрываем диалог после успешного создания
dialogRef.value?.clear();
emit('success');
} catch (error) {
console.error('AddAuthorDialog: addAuthors error', error);
FailAlert(error);
emit('error', error);
} finally {
isSubmitting.value = false;
}
};
// Экспортируем функции для внешнего использования
defineExpose({
openDialog: () => dialogRef.value?.openDialog(),
clear: () => dialogRef.value?.clear(),
});
</script>
<style lang="scss" scoped>
.authors-selector {
:deep(.q-field) {
min-width: 300px;
}
}
</style>
@@ -0,0 +1 @@
export { default as AddAuthorDialog } from './AddAuthorDialog.vue';
@@ -0,0 +1,30 @@
<template lang="pug">
q-fab-action(
icon="person_add"
@click="dialogRef?.openDialog()"
text-color="white"
).bg-fab-accent-radial Соавторы
AddAuthorDialog(
ref="dialogRef"
:project="project"
@success="handleSuccess"
)
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { AddAuthorDialog } from '../Dialog';
import type { IProject } from '../../../../../entities/Project/model';
defineProps<{ project: IProject | null | undefined }>();
const emit = defineEmits<{
actionCompleted: [];
}>();
const dialogRef = ref();
const handleSuccess = () => {
emit('actionCompleted');
};
</script>
@@ -0,0 +1 @@
export { default as AddAuthorFabAction } from './AddAuthorFabAction.vue';
@@ -1 +1,3 @@
export { default as AddAuthorButton } from './AddAuthorButton.vue'; export { default as AddAuthorButton } from './AddAuthorButton.vue';
export { AddAuthorDialog } from './Dialog';
export { AddAuthorFabAction } from './Fab';
@@ -0,0 +1,128 @@
<template lang="pug">
CreateDialog(
ref="dialogRef"
title="Установить план проекта"
submit-text="Установить план"
dialog-style="width: 600px; max-width: 100% !important;"
:is-submitting="isSubmitting"
@submit="handleSubmit"
@dialog-closed="clear"
)
template(#form-fields)
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_hour_cost'
label='Стоимость часа работы'
:rules='[(val) => !!val || "Стоимость часа работы обязательна"]'
outlined
)
template(#append)
span.text-grey-7 {{ governSymbol }}
q-input(
v-model='formData.plan_expenses'
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 { CreateDialog } from 'src/shared/ui/CreateDialog';
import type { IProject } from '../../../../../entities/Project/model';
const props = defineProps<{ project: IProject | null | undefined }>();
const emit = defineEmits<{
success: [];
error: [error: any];
}>();
const dialogRef = ref();
const { setPlan, governSymbol, formatAmountForEOSIO } = useSetPlan();
const isSubmitting = ref(false);
const formData = ref({
plan_creators_hours: 0,
plan_expenses: '',
plan_hour_cost: ''
});
const clear = () => {
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 handleSubmit = async () => {
if (!props.project) 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(props.project.is_planed ? 'План проекта успешно изменен' : 'План проекта успешно установлен');
// Закрываем диалог после успешного создания
dialogRef.value?.clear();
emit('success');
} catch (error) {
FailAlert(error);
emit('error', error);
} finally {
isSubmitting.value = false;
}
};
// Экспортируем функции для внешнего использования
defineExpose({
openDialog: () => dialogRef.value?.openDialog(),
clear: () => dialogRef.value?.clear(),
});
</script>
@@ -0,0 +1 @@
export { default as SetPlanDialog } from './SetPlanDialog.vue';
@@ -0,0 +1,30 @@
<template lang="pug">
q-fab-action(
icon="edit"
@click="dialogRef?.openDialog()"
text-color="white"
).bg-fab-accent-radial Финплан
SetPlanDialog(
ref="dialogRef"
:project="project"
@success="handleSuccess"
)
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { SetPlanDialog } from '../Dialog';
import type { IProject } from '../../../../../entities/Project/model';
defineProps<{ project: IProject | null | undefined }>();
const emit = defineEmits<{
actionCompleted: [];
}>();
const dialogRef = ref();
const handleSuccess = () => {
emit('actionCompleted');
};
</script>
@@ -0,0 +1 @@
export { default as SetPlanFabAction } from './SetPlanFabAction.vue';
@@ -2,45 +2,120 @@
q-btn( q-btn(
size='sm', size='sm',
color='primary', color='primary',
@click='handleSetPlan', @click='showDialog = true',
:loading='loading',
label='Установить план проекта' label='Установить план проекта'
) )
q-dialog(v-model='showDialog', @hide='clear')
ModalBase(:title='"Установить план проекта"')
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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useSetPlan } from '../model'; import { useSetPlan } from '../model';
import { FailAlert } from 'src/shared/api/alerts'; import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
import { ModalBase } from 'src/shared/ui/ModalBase';
import { Form } from 'src/shared/ui/Form';
import type { IProject } from '../../../../entities/Project/model'; import type { IProject } from '../../../../entities/Project/model';
const props = defineProps<{ project: IProject | null }>(); const props = defineProps<{ project: IProject | null | undefined }>();
const { setPlan, setPlanInput } = useSetPlan(); const { setPlan, governSymbol, formatAmountForEOSIO } = useSetPlan();
const loading = ref(false);
const showDialog = ref(false);
const isSubmitting = ref(false);
const formData = ref({
plan_creators_hours: 0,
plan_expenses: '',
plan_hour_cost: ''
});
const clear = () => {
showDialog.value = false;
formData.value = {
plan_creators_hours: 0,
plan_expenses: '',
plan_hour_cost: ''
};
};
// Обновляем входные данные при изменении проекта // Обновляем входные данные при изменении проекта
watch( watch(
() => props.project, () => props.project,
(newProject) => { (newProject) => {
if (newProject) { if (newProject && newProject.is_planed) {
setPlanInput.value.coopname = newProject.coopname || ''; // Для запланированного проекта заполняем форму текущими значениями
setPlanInput.value.project_hash = newProject.project_hash; formData.value.plan_creators_hours = newProject.plan.creators_hours || 0;
setPlanInput.value.master = newProject.master || ''; 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 }, { immediate: true }
); );
const handleSetPlan = async () => { const handleSetPlan = async () => {
loading.value = true; if (!props.project) return;
isSubmitting.value = true;
try { try {
console.log('setPlanInput', setPlanInput.value); const planData = {
await setPlan(setPlanInput.value); 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) { } catch (error) {
FailAlert(error); FailAlert(error);
} finally { } finally {
loading.value = false; isSubmitting.value = false;
} }
}; };
</script> </script>
@@ -1 +1,3 @@
export { default as SetPlanButton } from './SetPlanButton.vue'; export { default as SetPlanButton } from './SetPlanButton.vue';
export { SetPlanDialog } from './Dialog';
export { SetPlanFabAction } from './Fab';
@@ -0,0 +1,169 @@
<template lang="pug">
CreateDialog(
ref="dialogRef"
title="Создать требование"
submit-text="Создать"
dialog-style="width: 500px; max-width: 100% !important;"
:is-submitting="isSubmitting"
@submit="handleSubmit"
@dialog-closed="clear"
)
template(#form-fields)
q-input(
ref='titleInput'
autofocus
type="textarea"
rows=3
v-model='formData.title',
standout='bg-teal text-white',
label='Кратко сформулируйте требование',
:rules='[(val) => notEmpty(val)]',
autocomplete='off'
@keydown.enter.prevent='handleSubmit'
)
//- q-input(
//- standout='bg-teal text-white',
//- v-model='textDescription',
//- label='Описание требования',
//- placeholder='Опишите требование...',
//- type="textarea"
//- rows=2
//- @input='convertToEditorFormat'
//- )
// Скрытый Editor для конвертации текста в EditorJS формат
div(style='display: none')
Editor(
ref='hiddenEditor',
v-model='formData.description',
@ready='onEditorReady'
)
q-checkbox(
v-model='createAnother',
label='Создать еще одно требование'
)
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue';
import { useSystemStore } from 'src/entities/System/model';
import { CreateDialog } from 'src/shared/ui/CreateDialog';
import { Editor } from 'src/shared/ui';
import { textToEditorJS } from 'src/shared/lib/utils/editorjs';
import { useCreateStory } from '../../model';
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
const props = defineProps<{
filter?: {
project_hash?: string;
issue_id?: string;
};
}>();
const emit = defineEmits<{
success: [];
error: [error: any];
}>();
const dialogRef = ref();
const titleInput = ref();
const system = useSystemStore();
const { createStory } = useCreateStory();
// Для работы с текстовым описанием и конвертацией в EditorJS
const textDescription = ref('');
const hiddenEditor = ref();
const createAnother = ref(false);
const isSubmitting = ref(false);
const formData = ref({
title: '',
description: '',
});
const notEmpty = (val: any) => {
return !!val || 'Это поле обязательно для заполнения';
};
// Конвертация текста в EditorJS формат
const convertToEditorFormat = async () => {
if (hiddenEditor.value) {
try {
// Создаем EditorJS данные из текста
const editorJSData = textToEditorJS(textDescription.value);
formData.value.description = editorJSData;
} catch (error) {
console.error('Error converting text to EditorJS:', error);
}
}
};
// Обработчик готовности скрытого редактора
const onEditorReady = () => {
// Инициализируем конвертацию при готовности редактора
convertToEditorFormat();
};
const clearForm = async () => {
textDescription.value = '';
formData.value = {
title: '',
description: '',
};
// Сбрасываем валидацию
titleInput.value?.resetValidation();
// Устанавливаем фокус на первое поле после очистки
await nextTick();
titleInput.value?.focus();
};
const clear = async () => {
await clearForm();
createAnother.value = false;
};
const handleSubmit = async () => {
isSubmitting.value = true;
try {
// Финальная конвертация текста в EditorJS формат перед отправкой
await convertToEditorFormat();
const inputData = {
coopname: system.info.coopname,
title: formData.value.title,
description: formData.value.description,
story_hash: '',
...props.filter, // Добавляем фильтр (project_hash или issue_id)
};
await createStory(inputData);
SuccessAlert('Требование успешно создано');
if (createAnother.value) {
// Очищаем форму для создания следующего требования
clearForm();
// Диалог остается открытым
} else {
// Закрываем диалог после успешного создания
dialogRef.value?.clear();
emit('success');
}
} catch (error) {
FailAlert(error);
emit('error', error);
} finally {
isSubmitting.value = false;
}
};
// Экспортируем функции для внешнего использования
defineExpose({
openDialog: () => dialogRef.value?.openDialog(),
clear: () => dialogRef.value?.clear(),
});
</script>
@@ -0,0 +1 @@
export { default as CreateRequirementDialog } from './CreateRequirementDialog.vue';
@@ -0,0 +1,34 @@
<template lang="pug">
q-fab-action(
icon="add"
@click="dialogRef?.openDialog()"
text-color="white"
).bg-fab-accent-radial Требование
CreateRequirementDialog(
ref="dialogRef"
:filter="filter"
@success="handleSuccess"
)
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { CreateRequirementDialog } from '../Dialog'
defineProps<{
filter?: {
project_hash?: string;
issue_id?: string;
};
}>();
const emit = defineEmits<{
actionCompleted: [];
}>();
const dialogRef = ref();
const handleSuccess = () => {
emit('actionCompleted');
};
</script>
@@ -0,0 +1 @@
export { default as CreateRequirementFabAction } from './CreateRequirementFabAction.vue';
@@ -1 +1,3 @@
export { default as CreateStoryButton } from './CreateStoryButton.vue'; export { default as CreateStoryButton } from './CreateStoryButton.vue';
export { CreateRequirementFabAction } from './Fab';
export { CreateRequirementDialog } from './Dialog';
@@ -9,7 +9,6 @@ q-btn(
:loading='isSubmitting' :loading='isSubmitting'
) )
q-icon(name='delete') q-icon(name='delete')
q-dialog(v-model='showDialog', @hide='close') q-dialog(v-model='showDialog', @hide='close')
ModalBase(title='Удаление истории') ModalBase(title='Удаление истории')
Form.q-pa-sm( Form.q-pa-sm(
@@ -21,7 +20,6 @@ q-btn(
) )
div(style='max-width: 300px') div(style='max-width: 300px')
p Вы уверены, что хотите удалить историю? p Вы уверены, что хотите удалить историю?
.text-body2.text-grey-6.q-mt-sm {{ storyTitle }}
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -42,10 +40,6 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
storyTitle: {
type: String,
required: true,
},
}); });
const close = () => { const close = () => {
@@ -17,13 +17,11 @@ import { ProjectDescriptionPage } from './pages/ProjectDescriptionPage';
// import { ProjectInviteEditorPage } from './pages/ProjectInviteEditorPage'; // import { ProjectInviteEditorPage } from './pages/ProjectInviteEditorPage';
// import { ComponentInvitePage } from './pages/ComponentInvitePage'; // import { ComponentInvitePage } from './pages/ComponentInvitePage';
import { ProjectPlanningPage } from './pages/ProjectPlanningPage'; import { ProjectPlanningPage } from './pages/ProjectPlanningPage';
import { ProjectAuthorsPage } from './pages/ProjectAuthorsPage';
import { ProjectContributorsPage } from './pages/ProjectContributorsPage'; import { ProjectContributorsPage } from './pages/ProjectContributorsPage';
import { ProjectComponentsPage } from './pages/ProjectComponentsPage'; import { ProjectComponentsPage } from './pages/ProjectComponentsPage';
import { ProjectRequirementsPage } from './pages/ProjectRequirementsPage'; import { ProjectRequirementsPage } from './pages/ProjectRequirementsPage';
import { ComponentDescriptionPage } from './pages/ComponentDescriptionPage'; import { ComponentDescriptionPage } from './pages/ComponentDescriptionPage';
import { ComponentPlanningPage } from './pages/ComponentPlanningPage'; import { ComponentPlanningPage } from './pages/ComponentPlanningPage';
import { ComponentAuthorsPage } from './pages/ComponentAuthorsPage';
import { ComponentContributorsPage } from './pages/ComponentContributorsPage'; import { ComponentContributorsPage } from './pages/ComponentContributorsPage';
import { ComponentTasksPage } from './pages/ComponentTasksPage'; import { ComponentTasksPage } from './pages/ComponentTasksPage';
import { ComponentRequirementsPage } from './pages/ComponentRequirementsPage'; import { ComponentRequirementsPage } from './pages/ComponentRequirementsPage';
@@ -230,19 +228,6 @@ export default async function (): Promise<IWorkspaceConfig[]> {
hidden: true, hidden: true,
}, },
}, },
{
path: 'authors',
name: 'project-authors',
component: markRaw(ProjectAuthorsPage),
meta: {
title: 'Соавторы проекта',
icon: 'fa-solid fa-users',
roles: [],
agreements: agreementsBase,
requiresAuth: true,
hidden: true,
},
},
{ {
path: 'contributors', path: 'contributors',
name: 'project-contributors', name: 'project-contributors',
@@ -369,19 +354,6 @@ export default async function (): Promise<IWorkspaceConfig[]> {
hidden: true, hidden: true,
}, },
}, },
{
path: 'authors',
name: 'component-authors',
component: markRaw(ComponentAuthorsPage),
meta: {
title: 'Соавторы компонента',
icon: 'fa-solid fa-users',
roles: [],
agreements: agreementsBase,
requiresAuth: true,
hidden: true,
},
},
{ {
path: 'contributors', path: 'contributors',
name: 'component-contributors', name: 'component-contributors',
@@ -1 +0,0 @@
export { default as ComponentAuthorsPage } from './ui';
@@ -1,70 +0,0 @@
<template lang="pug">
div
// Заголовок страницы
h4.q-mb-md Соавторы компонента
// Список соавторов
ProjectAuthorsList(:project='project')
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { IProject } from 'app/extensions/capital/entities/Project/model';
import { useProjectStore } from 'app/extensions/capital/entities/Project/model';
import ProjectAuthorsList from 'app/extensions/capital/widgets/ProjectInfoSelectorWidget/ProjectAuthorsList.vue';
import { FailAlert } from 'src/shared/api';
const route = useRoute();
const projectStore = useProjectStore();
// Состояние проекта
const project = ref<IProject | null | undefined>(null);
// Получаем hash проекта из параметров маршрута
const projectHash = computed(() => route.params.project_hash as string);
// Загрузка проекта из store (родитель уже должен загрузить)
const loadProject = async () => {
// Ищем проект в store
const foundProject = projectStore.projects.items.find(p => p.project_hash === projectHash.value);
if (foundProject) {
project.value = foundProject;
} else {
// Если проект не найден в store, пробуем загрузить
try {
await projectStore.loadProject({
hash: projectHash.value,
});
} catch (error) {
console.error('Ошибка при загрузке компонента:', error);
FailAlert('Не удалось загрузить компонент');
}
}
};
// Watcher для синхронизации локального состояния с store
watch(() => projectStore.projects.items, (newItems) => {
if (newItems && projectHash.value) {
const foundProject = newItems.find(p => p.project_hash === projectHash.value);
if (foundProject) {
project.value = foundProject;
}
}
});
// Watcher для изменения projectHash
watch(projectHash, async (newHash, oldHash) => {
if (newHash && newHash !== oldHash) {
await loadProject();
}
});
// Инициализация
onMounted(async () => {
await loadProject();
});
</script>
<style lang="scss" scoped>
</style>
@@ -1 +0,0 @@
export { default } from './ComponentAuthorsPage.vue';
@@ -1,8 +1,5 @@
<template lang="pug"> <template lang="pug">
div div
// Заголовок страницы
h4.q-mb-md Участники компонента
// Список участников // Список участников
ProjectContributorsList(:project='project') ProjectContributorsList(:project='project')
</template> </template>
@@ -6,11 +6,28 @@ div
// Floating Action Button // Floating Action Button
Fab(v-if="project") Fab(v-if="project")
template(#actions v-if="project?.permissions?.has_clearance") template(#actions v-if="project?.permissions?.has_clearance")
// Показываем кнопку создания задачи, если пользователь имеет допуск к проекту // Показываем кнопку создания задачи и требования, если пользователь имеет допуск к проекту
CreateIssueFabAction( CreateIssueFabAction(
:project-hash="projectHash" :project-hash="projectHash"
@action-completed="handleIssueCreated" @action-completed="handleIssueCreated"
) )
CreateRequirementFabAction(
:filter="{ project_hash: projectHash }"
@action-completed="handleRequirementCreated"
)
SetPlanFabAction(
v-if="project?.permissions?.can_set_plan"
:project="project"
@action-completed="handlePlanSet"
)
AddAuthorFabAction(
:project="project"
@action-completed="handleAuthorsAdded"
)
ComponentInvestFabAction(
:project="project"
@action-completed="handleInvestCompleted"
)
template template
template(#default v-if="project?.permissions?.pending_clearance") template(#default v-if="project?.permissions?.pending_clearance")
@@ -33,15 +50,21 @@ div
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onBeforeUnmount, computed, markRaw } from 'vue'; import { onMounted, onBeforeUnmount, computed, markRaw, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useProjectLoader } from 'app/extensions/capital/entities/Project/model'; import { useProjectLoader } from 'app/extensions/capital/entities/Project/model';
import { useBackButton } from 'src/shared/lib/navigation'; import { useBackButton } from 'src/shared/lib/navigation';
import { useHeaderActions } from 'src/shared/hooks'; import { useHeaderActions } from 'src/shared/hooks';
import { RouteMenuButton, Fab } from 'src/shared/ui'; import { RouteMenuButton, Fab } from 'src/shared/ui';
import { CreateIssueFabAction } from 'app/extensions/capital/features/Issue/CreateIssue'; import { CreateIssueFabAction } from 'app/extensions/capital/features/Issue/CreateIssue';
import { CreateRequirementFabAction } from 'app/extensions/capital/features/Story/CreateStory';
import { MakeClearanceButton } from 'app/extensions/capital/features/Contributor/MakeClearance'; 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';
// Используем composable для загрузки проекта // Используем composable для загрузки проекта
const { project, projectHash, loadProject } = useProjectLoader(); const { project, projectHash, loadProject } = useProjectLoader();
const route = useRoute();
// Массив кнопок меню для шапки // Массив кнопок меню для шапки
const menuButtons = computed(() => [ const menuButtons = computed(() => [
@@ -80,21 +103,11 @@ const menuButtons = computed(() => [
component: markRaw(RouteMenuButton), component: markRaw(RouteMenuButton),
props: { props: {
routeName: 'component-planning', routeName: 'component-planning',
label: 'Планирование', label: 'План',
routeParams: { project_hash: projectHash.value }, routeParams: { project_hash: projectHash.value },
}, },
order: 4, order: 4,
}, },
{
id: 'component-authors-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'component-authors',
label: 'Соавторы',
routeParams: { project_hash: projectHash.value },
},
order: 5,
},
{ {
id: 'component-contributors-menu', id: 'component-contributors-menu',
component: markRaw(RouteMenuButton), component: markRaw(RouteMenuButton),
@@ -108,9 +121,9 @@ const menuButtons = computed(() => [
]); ]);
// Настраиваем кнопку "Назад" // Настраиваем кнопку "Назад"
// По умолчанию используем router.back(), но можно переопределить через query параметр _backRoute const { setBackButton } = useBackButton({
useBackButton({
text: 'Назад', text: 'Назад',
routeName: route.query._backRoute as string || undefined,
componentId: 'component-base-' + projectHash.value, componentId: 'component-base-' + projectHash.value,
}); });
@@ -121,10 +134,12 @@ const { registerAction: registerHeaderAction, clearActions } = useHeaderActions(
onMounted(async () => { onMounted(async () => {
await loadProject(); await loadProject();
// Регистрируем кнопки меню // Регистрируем кнопки меню только если мы НЕ на странице задачи
menuButtons.value.forEach(button => { if (route.name !== 'component-issue') {
registerHeaderAction(button); menuButtons.value.forEach(button => {
}); registerHeaderAction(button);
});
}
}); });
// Явно очищаем кнопки при уходе со страницы // Явно очищаем кнопки при уходе со страницы
@@ -132,12 +147,50 @@ onBeforeUnmount(() => {
clearActions(); clearActions();
}); });
// Отслеживаем изменение backRoute для обновления кнопки "Назад"
watch(() => route.query._backRoute, () => {
setBackButton();
});
// Отслеживаем переходы на дочерние маршруты (например, на страницу задачи)
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);
});
}
});
// Обработчик создания задачи // Обработчик создания задачи
const handleIssueCreated = () => { const handleIssueCreated = () => {
// Можно добавить логику обновления списка задач // Можно добавить логику обновления списка задач
}; };
// Обработчик создания требования
const handleRequirementCreated = () => {
// Можно добавить логику обновления списка требований
};
// Обработчик установки плана
const handlePlanSet = () => {
// Можно добавить логику обновления данных проекта
};
// Обработчик добавления соавторов
const handleAuthorsAdded = () => {
// Можно добавить логику обновления данных проекта
};
// Обработчик создания инвестиции
const handleInvestCompleted = () => {
// Можно добавить логику обновления данных проекта
};
// Обработчик успешной отправки запроса на допуск // Обработчик успешной отправки запроса на допуск
const handleClearanceSubmitted = async () => { const handleClearanceSubmitted = async () => {
// Обновляем данные проекта, чтобы отразить изменения в разрешениях // Обновляем данные проекта, чтобы отразить изменения в разрешениях
@@ -1,14 +1,7 @@
<template lang="pug"> <template lang="pug">
div div
// Заголовок страницы // Компонент планирования (всегда показывается)
h4.q-mb-md Финансирование компонента ProjectPlanningWidget(:project='project' :permissions='permissions')
// Компонент планирования (показывается только если есть разрешение на редактирование)
div(v-if="permissions?.can_edit_project")
ProjectPlanning(:project='project')
div(v-else)
.text-grey-7.q-pa-md
p У вас нет прав для редактирования финансового плана компонента
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -16,7 +9,7 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import type { IProject, IProjectPermissions } from 'app/extensions/capital/entities/Project/model'; import type { IProject, IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
import { useProjectStore } from 'app/extensions/capital/entities/Project/model'; import { useProjectStore } from 'app/extensions/capital/entities/Project/model';
import ProjectPlanning from 'app/extensions/capital/widgets/ProjectInfoSelectorWidget/ProjectPlanning.vue'; import { ProjectPlanningWidget } from 'app/extensions/capital/widgets';
import { FailAlert } from 'src/shared/api'; import { FailAlert } from 'src/shared/api';
const route = useRoute(); const route = useRoute();
@@ -1,31 +1,26 @@
<template lang="pug"> <template lang="pug">
div div
// Заголовок страницы // Виджет списка требований в виде таблицы
h4.q-mb-md Требования компонента RequirementsListWidget(
:filter='requirementsFilter',
// Виджет историй (требований) :maxItems='50'
StoriesWidget(
:filter='storiesFilter',
:canCreate='true',
:maxItems='50',
:emptyMessage='"Требований компонента пока нет"'
) )
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { StoriesWidget } from 'app/extensions/capital/widgets/StoryWidget'; import { RequirementsListWidget } from 'app/extensions/capital/widgets/RequirementsListWidget';
const route = useRoute(); const route = useRoute();
// Получаем hash проекта из параметров маршрута // Получаем hash проекта из параметров маршрута
const projectHash = computed(() => route.params.project_hash as string); const projectHash = computed(() => route.params.project_hash as string);
// Фильтр для историй проекта // Фильтр для требований проекта
const storiesFilter = computed(() => ({ const requirementsFilter = computed(() => ({
project_hash: projectHash.value, project_hash: projectHash.value,
issue_id: undefined, // Только истории проекта, не задач issue_id: undefined, // Только требования проекта, не задач
})); }));
</script> </script>
@@ -54,6 +54,9 @@ const handleIssueClick = (issue: IIssue) => {
project_hash: projectHash.value, project_hash: projectHash.value,
issue_hash: issue.issue_hash, issue_hash: issue.issue_hash,
}, },
query: {
_backRoute: 'component-tasks'
}
}); });
}; };
@@ -1,22 +1,16 @@
<template lang="pug"> <template lang="pug">
div div(style="padding-bottom: 100px;")
// Заголовок с информацией о задаче // Заголовок с информацией о задаче
q-card.q-mb-md(flat) div(v-if="issue")
q-card-section .row.items-center.q-gutter-md.q-pa-md
.row.items-center.q-gutter-sm q-icon(name='task', size='32px', color='primary')
q-icon(name='task', size='sm', color='primary') .col
div IssueTitleEditor(
q-input( :issue='issue'
v-if="issue" label='Задача'
v-model='issue.title' @field-change="handleFieldChange"
label='Название задачи' @update:title="handleTitleUpdate"
outlined )
dense
class="q-mb-sm"
:readonly="!issue?.permissions?.can_edit_issue"
@input="handleTitleChange"
)
.text-h6(v-if="!issue") Загрузка...
IssueControls( IssueControls(
:issue='issue' :issue='issue'
@@ -24,24 +18,26 @@ div
@update:status='handleStatusUpdate' @update:status='handleStatusUpdate'
@update:priority='handlePriorityUpdate' @update:priority='handlePriorityUpdate'
@update:estimate='handleEstimateUpdate' @update:estimate='handleEstimateUpdate'
).full-width.q-pt-md )
q-card-section
.row.items-center.q-gutter-md.q-mb-sm
.col
// Индикатор авто-сохранения
AutoSaveIndicator(
:is-auto-saving="isAutoSaving"
:auto-save-error="autoSaveError"
)
Editor( .text-h6(v-if="!issue") Загрузка...
v-if="issue"
v-model='issue.description', .row.items-center.q-gutter-md.q-mb-sm
label='Описание задачи', .col
placeholder='Опишите задачу подробно...', // Индикатор авто-сохранения
:readonly="!issue?.permissions?.can_edit_issue" AutoSaveIndicator(
@change='handleDescriptionChange' :is-auto-saving="isAutoSaving"
) :auto-save-error="autoSaveError"
)
Editor(
v-if="issue"
v-model='issue.description',
label='Описание задачи',
placeholder='Опишите задачу подробно...',
:readonly="!issue?.permissions?.can_edit_issue"
@change='handleDescriptionChange'
)
</template> </template>
@@ -59,7 +55,7 @@ import { StoriesWidget } from 'app/extensions/capital/widgets/StoryWidget';
import { Editor, AutoSaveIndicator } from 'src/shared/ui'; import { Editor, AutoSaveIndicator } from 'src/shared/ui';
import { textToEditorJS } from 'src/shared/lib/utils/editorjs'; import { textToEditorJS } from 'src/shared/lib/utils/editorjs';
import { useUpdateIssue } from 'app/extensions/capital/features/Issue/UpdateIssue'; import { useUpdateIssue } from 'app/extensions/capital/features/Issue/UpdateIssue';
import { IssueControls } from 'app/extensions/capital/widgets'; import { IssueControls, IssueTitleEditor } from 'app/extensions/capital/widgets';
const route = useRoute(); const route = useRoute();
const issue = ref<IIssue | null>(null); const issue = ref<IIssue | null>(null);
@@ -98,17 +94,16 @@ const ensureEditorJSFormat = (description: any) => {
return textToEditorJS(String(description)); return textToEditorJS(String(description));
}; };
// Обработчик изменения названия задачи // Обработчик изменения полей
const handleTitleChange = () => { const handleFieldChange = () => {
if (!issue.value) return; // Просто триггер реактивности для computed hasChanges в виджетах
};
const updateData = { // Обработчик обновления названия задачи
issue_hash: issue.value.issue_hash, const handleTitleUpdate = (value: string) => {
title: issue.value.title, if (issue.value) {
}; issue.value.title = value;
}
// Запускаем авто-сохранение с задержкой
debounceSave(updateData, projectHash.value);
}; };
// Обработчик изменения описания задачи // Обработчик изменения описания задачи
@@ -129,8 +124,10 @@ const handleDescriptionChange = () => {
const { registerAction: registerRightDrawerAction } = useRightDrawer(); const { registerAction: registerRightDrawerAction } = useRightDrawer();
// Настраиваем кнопку "Назад" // Настраиваем кнопку "Назад"
// По умолчанию используем router.back(), но можно переопределить через query параметр _backRoute
useBackButton({ useBackButton({
text: 'Назад', text: 'Назад',
routeName: route.query._backRoute as string || undefined,
componentId: 'issue-page-' + issueHash.value, componentId: 'issue-page-' + issueHash.value,
}); });
@@ -1 +0,0 @@
export { default as ProjectAuthorsPage } from './ui/ProjectAuthorsPage.vue';
@@ -1,25 +0,0 @@
<template lang="pug">
div
// Заголовок страницы
h4.q-mb-md Соавторы проекта
// Список соавторов
ProjectAuthorsList(:project='project')
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import { useProjectLoader } from 'app/extensions/capital/entities/Project/model';
import ProjectAuthorsList from 'app/extensions/capital/widgets/ProjectInfoSelectorWidget/ProjectAuthorsList.vue';
// Используем composable для загрузки проекта
const { project, loadProject } = useProjectLoader();
// Инициализация
onMounted(async () => {
await loadProject();
});
</script>
<style lang="scss" scoped>
</style>
@@ -1,8 +1,5 @@
<template lang="pug"> <template lang="pug">
div div
// Заголовок страницы
h4.q-mb-md Участники проекта
// Список участников // Список участников
ProjectContributorsList(:project='project') ProjectContributorsList(:project='project')
</template> </template>
@@ -3,25 +3,48 @@ div
// Контент страницы проекта // Контент страницы проекта
router-view router-view
// Floating Action Button для создания компонента // Floating Action Button для создания компонента, требования и установки плана
Fab(v-if="project") Fab(v-if="project")
template(#actions) template(#actions)
CreateComponentFabAction( CreateComponentFabAction(
:project="project" :project="project"
@action-completed="handleComponentCreated" @action-completed="handleComponentCreated"
) )
CreateRequirementFabAction(
:filter="{ project_hash: projectHash }"
@action-completed="handleRequirementCreated"
)
SetPlanFabAction(
v-if="project?.permissions?.can_set_plan"
:project="project"
@action-completed="handlePlanSet"
)
AddAuthorFabAction(
:project="project"
@action-completed="handleAuthorsAdded"
)
ProjectInvestFabAction(
:project="project"
@action-completed="handleInvestCompleted"
)
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onBeforeUnmount, computed, markRaw } from 'vue'; import { onMounted, onBeforeUnmount, computed, markRaw, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useProjectLoader } from 'app/extensions/capital/entities/Project/model'; import { useProjectLoader } from 'app/extensions/capital/entities/Project/model';
import { useBackButton } from 'src/shared/lib/navigation'; import { useBackButton } from 'src/shared/lib/navigation';
import { useHeaderActions } from 'src/shared/hooks'; import { useHeaderActions } from 'src/shared/hooks';
import { RouteMenuButton, Fab } from 'src/shared/ui'; import { RouteMenuButton, Fab } from 'src/shared/ui';
import { CreateComponentFabAction } from 'app/extensions/capital/features/Project/CreateComponent'; 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';
// Используем composable для загрузки проекта // Используем composable для загрузки проекта
const { project, projectHash, loadProject } = useProjectLoader(); const { project, projectHash, loadProject } = useProjectLoader();
const route = useRoute();
// Массив кнопок меню для шапки // Массив кнопок меню для шапки
const menuButtons = computed(() => [ const menuButtons = computed(() => [
@@ -60,21 +83,11 @@ const menuButtons = computed(() => [
component: markRaw(RouteMenuButton), component: markRaw(RouteMenuButton),
props: { props: {
routeName: 'project-planning', routeName: 'project-planning',
label: 'Планирование', label: 'План',
routeParams: { project_hash: projectHash.value }, routeParams: { project_hash: projectHash.value },
}, },
order: 4, order: 4,
}, },
{
id: 'project-authors-menu',
component: markRaw(RouteMenuButton),
props: {
routeName: 'project-authors',
label: 'Соавторы',
routeParams: { project_hash: projectHash.value },
},
order: 5,
},
{ {
id: 'project-contributors-menu', id: 'project-contributors-menu',
component: markRaw(RouteMenuButton), component: markRaw(RouteMenuButton),
@@ -88,10 +101,10 @@ const menuButtons = computed(() => [
]); ]);
// Настраиваем кнопку "Назад" на список проектов // Настраиваем кнопку "Назад"
useBackButton({ const { setBackButton } = useBackButton({
text: 'Назад', text: 'Назад',
routeName: 'projects-list', routeName: route.query._backRoute as string || 'projects-list',
componentId: 'project-base-' + projectHash.value, componentId: 'project-base-' + projectHash.value,
}); });
@@ -103,10 +116,12 @@ onMounted(async () => {
// Загружаем проект при монтировании (composable сделает это автоматически) // Загружаем проект при монтировании (composable сделает это автоматически)
await loadProject(); await loadProject();
// Регистрируем кнопки меню // Регистрируем кнопки меню только если мы НЕ на странице задачи
menuButtons.value.forEach(button => { if (route.name !== 'project-issue') {
registerHeaderAction(button); menuButtons.value.forEach(button => {
}); registerHeaderAction(button);
});
}
}); });
// Явно очищаем кнопки при уходе со страницы // Явно очищаем кнопки при уходе со страницы
@@ -114,12 +129,50 @@ onBeforeUnmount(() => {
clearActions(); 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 = () => { const handleComponentCreated = () => {
// Можно добавить логику обновления списка компонентов // Можно добавить логику обновления списка компонентов
}; };
// Обработчик создания требования
const handleRequirementCreated = () => {
// Можно добавить логику обновления списка требований
};
// Обработчик установки плана
const handlePlanSet = () => {
// Можно добавить логику обновления данных проекта
};
// Обработчик добавления соавторов
const handleAuthorsAdded = () => {
// Можно добавить логику обновления данных проекта
};
// Обработчик создания инвестиции
const handleInvestCompleted = () => {
// Можно добавить логику обновления данных проекта
};
</script> </script>
@@ -1,21 +1,14 @@
<template lang="pug"> <template lang="pug">
div div
// Заголовок страницы // Компонент планирования (всегда показывается)
h4.q-mb-md Финансирование проекта ProjectPlanningWidget(:project='project' :permissions='permissions')
// Компонент планирования (показывается только если есть разрешение на редактирование)
div(v-if="permissions?.can_edit_project")
ProjectPlanning(:project='project')
div(v-else)
.text-grey-7.q-pa-md
p У вас нет прав для редактирования финансового плана проекта
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import type { IProjectPermissions } from 'app/extensions/capital/entities/Project/model'; import type { IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
import { useProjectLoader } from 'app/extensions/capital/entities/Project/model'; import { useProjectLoader } from 'app/extensions/capital/entities/Project/model';
import ProjectPlanning from 'app/extensions/capital/widgets/ProjectInfoSelectorWidget/ProjectPlanning.vue'; import { ProjectPlanningWidget } from 'app/extensions/capital/widgets/ProjectPlanningWidget';
// Используем composable для загрузки проекта // Используем composable для загрузки проекта
const { project, loadProject } = useProjectLoader(); const { project, loadProject } = useProjectLoader();
@@ -1,31 +1,26 @@
<template lang="pug"> <template lang="pug">
div div
// Заголовок страницы // Виджет списка требований в виде таблицы
h4.q-mb-md Требования проекта RequirementsListWidget(
:filter='requirementsFilter',
// Виджет историй (требований) :maxItems='50'
StoriesWidget(
:filter='storiesFilter',
:canCreate='true',
:maxItems='50',
:emptyMessage='"Требований проекта пока нет"'
) )
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { StoriesWidget } from 'app/extensions/capital/widgets/StoryWidget'; import { RequirementsListWidget } from 'app/extensions/capital/widgets/RequirementsListWidget';
const route = useRoute(); const route = useRoute();
// Получаем hash проекта из параметров маршрута // Получаем hash проекта из параметров маршрута
const projectHash = computed(() => route.params.project_hash as string); const projectHash = computed(() => route.params.project_hash as string);
// Фильтр для историй проекта // Фильтр для требований проекта
const storiesFilter = computed(() => ({ const requirementsFilter = computed(() => ({
project_hash: projectHash.value, project_hash: projectHash.value,
issue_id: undefined, // Только истории проекта, не задач issue_id: undefined, // Только требования проекта, не задач
})); }));
</script> </script>
@@ -16,13 +16,14 @@ div
:has-issues-with-creators='hasIssuesWithCreators', :has-issues-with-creators='hasIssuesWithCreators',
:master='master', :master='master',
@toggle-expand='handleProjectToggleExpand', @toggle-expand='handleProjectToggleExpand',
@data-loaded='handleProjectsDataLoaded' @data-loaded='handleProjectsDataLoaded',
@open-project='handleOpenProject'
) )
template(#project-content='{ project }') template(#project-content='{ project }')
ComponentsListWidget( ComponentsListWidget(
:components='project.components', :components='project.components',
:expanded='expandedComponents', :expanded='expandedComponents',
@open-component='(componentHash) => router.push({ name: "component-description", params: { project_hash: componentHash } })', @open-component='(componentHash) => router.push({ name: "component-description", params: { project_hash: componentHash }, query: { _backRoute: "projects-list" } })',
@toggle-component='handleComponentToggle' @toggle-component='handleComponentToggle'
) )
template(#component-content='{ component }') template(#component-content='{ component }')
@@ -32,7 +33,7 @@ div
:priorities='componentPriorities', :priorities='componentPriorities',
:creators='componentCreators', :creators='componentCreators',
:master='componentMaster', :master='componentMaster',
@issue-click='(issue) => router.push({ name: "component-issue", params: { project_hash: issue.project_hash, issue_hash: issue.issue_hash } })' @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 для создания проекта // Floating Action Button для создания проекта
@@ -101,6 +102,10 @@ const handleComponentToggle = (componentHash: string) => {
toggleComponentExpanded(componentHash); toggleComponentExpanded(componentHash);
}; };
const handleOpenProject = (projectHash: string) => {
router.push({ name: 'project-description', params: { project_hash: projectHash }, query: { _backRoute: 'projects-list' } });
};
const handleProjectsDataLoaded = (projectHashes: string[], totalComponents?: number) => { const handleProjectsDataLoaded = (projectHashes: string[], totalComponents?: number) => {
// Очищаем устаревшие записи expanded проектов после загрузки данных // Очищаем устаревшие записи expanded проектов после загрузки данных
cleanupProjectsExpanded(projectHashes); cleanupProjectsExpanded(projectHashes);
@@ -16,7 +16,6 @@ export * from './MasterCommitsPage';
export * from './ProjectDescriptionPage'; export * from './ProjectDescriptionPage';
export * from './ProjectInviteEditorPage'; export * from './ProjectInviteEditorPage';
export * from './ProjectPlanningPage'; export * from './ProjectPlanningPage';
export * from './ProjectAuthorsPage';
export * from './ProjectContributorsPage'; export * from './ProjectContributorsPage';
export * from './ProjectComponentsPage'; export * from './ProjectComponentsPage';
export * from './ProjectRequirementsPage'; export * from './ProjectRequirementsPage';
@@ -0,0 +1,24 @@
import { useRouter } from 'vue-router';
export function useListNavigation() {
const router = useRouter();
const navigateToProject = (projectHash: string) => {
router.push({
name: 'project-description',
params: { project_hash: projectHash }
});
};
const navigateToComponent = (componentHash: string) => {
router.push({
name: 'component-description',
params: { project_hash: componentHash }
});
};
return {
navigateToProject,
navigateToComponent
};
}
@@ -31,8 +31,11 @@ q-card(flat)
// ID с иконкой (100px + отступ 30px) // ID с иконкой (100px + отступ 30px)
.col-auto(style='width: 100px; padding-left: 30px; flex-shrink: 0') .col-auto(style='width: 100px; padding-left: 30px; flex-shrink: 0')
q-icon(name='extension', size='xs', color='primary').q-mr-xs q-icon(name='extension', size='xs').q-mr-xs
span(v-if='props.row.prefix').text-grey-7 {{ '#' + props.row.prefix }} span.list-item-title(
v-if='props.row.prefix'
@click.stop='handleOpenComponent(props.row.project_hash)'
) {{ '#' + props.row.prefix }}
// Title со статусом (400px + отступ 30px) // Title со статусом (400px + отступ 30px)
.col(style='width: 400px; padding-left: 30px') .col(style='width: 400px; padding-left: 30px')
@@ -47,14 +50,20 @@ q-card(flat)
).q-mr-sm ).q-mr-sm
span {{ props.row.title }} span {{ props.row.title }}
// Actions - CreateIssueButton (120px, выравнивание по правому краю) // Actions - CreateIssueButton и кнопка перехода (140px, выравнивание по правому краю)
.col-auto.ml-auto(style='width: 120px') .col-auto.ml-auto(style='width: 140px')
.row.items-center.justify-end .row.items-center.justify-end.q-gutter-xs
CreateIssueButton( CreateIssueButton(
@click.stop, @click.stop,
:mini='true', :mini='true',
:project-hash='props.row.project_hash' :project-hash='props.row.project_hash'
) )
q-btn(
size='xs',
flat,
icon='arrow_forward',
@click.stop='handleOpenComponent(props.row.project_hash)'
)
//- ProjectMenuWidget(:project='props.row', @click.stop) //- ProjectMenuWidget(:project='props.row', @click.stop)
// Слот для дополнительного контента компонента // Слот для дополнительного контента компонента
@@ -208,4 +217,15 @@ const columns = [
.q-chip { .q-chip {
font-weight: 500; font-weight: 500;
} }
// Импорт глобального стиля для подсветки
:deep(.list-item-title) {
font-weight: 500;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: var(--q-accent);
}
}
</style> </style>
@@ -0,0 +1,76 @@
<template lang="pug">
q-input(
v-if="issue"
v-model='title'
:label='label || "Задача"'
filled
:readonly="!permissions?.can_edit_issue"
@input="handleTitleChange"
:rules="[val => !!val || 'Название задачи обязательно']"
).full-width
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { IIssue, IIssuePermissions } from 'app/extensions/capital/entities/Issue/model'
import { useUpdateIssue } from 'app/extensions/capital/features/Issue/UpdateIssue'
const props = defineProps<{
issue: IIssue | null | undefined
label?: string
}>()
const emit = defineEmits<{
fieldChange: []
'update:title': [value: string]
}>()
const route = useRoute()
const projectHash = computed(() => route.params.project_hash as string)
// Используем composable для обновления задач
const { debounceSave } = useUpdateIssue()
// Локальное состояние для title
const localTitle = ref('')
// Computed свойства для двухсторонней привязки
const title = computed({
get: () => localTitle.value,
set: (value: string) => {
localTitle.value = value
emit('update:title', value)
}
})
// Computed для разрешений
const permissions = computed((): IIssuePermissions | null => {
return props.issue?.permissions || null
})
// Обработчик изменения title
const handleTitleChange = () => {
if (!props.issue) return
const updateData = {
issue_hash: props.issue.issue_hash,
title: props.issue.title,
}
// Запускаем авто-сохранение с задержкой
debounceSave(updateData, projectHash.value)
emit('fieldChange')
}
// Watcher для отслеживания изменений issue
watch(() => props.issue, (newIssue) => {
if (newIssue && localTitle.value !== newIssue.title) {
localTitle.value = newIssue.title || ''
}
}, { immediate: true, deep: true })
</script>
<style lang="scss" scoped>
</style>
@@ -0,0 +1 @@
export { default as IssueTitleEditor } from './IssueTitleEditor.vue'
@@ -25,8 +25,10 @@ div
// ID с иконкой (100px + отступ 40px) // ID с иконкой (100px + отступ 40px)
.col-auto(style='width: 100px; padding-left: 40px; flex-shrink: 0') .col-auto(style='width: 100px; padding-left: 40px; flex-shrink: 0')
q-icon(name='task', size='xs', color='primary').q-mr-xs q-icon(name='task', size='xs').q-mr-xs
span.text-grey-7 {{ '#' + props.row.id }} span.list-item-title(
@click.stop='handleIssueClick(props.row)'
) {{ '#' + props.row.id }}
// Title с приоритетом (400px + отступ 40px) // Title с приоритетом (400px + отступ 40px)
.col(style='width: 400px; padding-left: 40px') .col(style='width: 400px; padding-left: 40px')
@@ -41,9 +43,9 @@ div
).q-mr-sm ).q-mr-sm
span.text-body2.font-weight-medium {{ props.row.title }} span.text-body2.font-weight-medium {{ props.row.title }}
// Actions (статус + исполнитель) - выравнивание по правому краю // Actions (статус + исполнитель + кнопка перехода) - выравнивание по правому краю
.col-auto.ml-auto .col-auto.ml-auto
.row.items-center.justify-end.q-gutter-sm .row.items-center.justify-end.q-gutter-xs
UpdateStatus( UpdateStatus(
:model-value='props.row.status' :model-value='props.row.status'
:issue-hash='props.row.issue_hash' :issue-hash='props.row.issue_hash'
@@ -58,10 +60,16 @@ div
@click.stop @click.stop
style="max-width: 250px;" style="max-width: 250px;"
) )
q-btn(
size='xs',
flat,
icon='arrow_forward',
@click.stop='handleIssueClick(props.row)'
)
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { import {
type IIssue, type IIssue,
useIssueStore, useIssueStore,
@@ -86,7 +94,6 @@ const emit = defineEmits<{
issueClick: [issue: IIssue]; issueClick: [issue: IIssue];
}>(); }>();
useRouter();
const issueStore = useIssueStore(); const issueStore = useIssueStore();
const { info } = useSystemStore(); const { info } = useSystemStore();
@@ -190,4 +197,15 @@ onMounted(async () => {
.q-chip { .q-chip {
font-weight: 500; font-weight: 500;
} }
// Импорт глобального стиля для подсветки
:deep(.list-item-title) {
font-weight: 500;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: var(--q-accent);
}
}
</style> </style>
@@ -1,111 +0,0 @@
<template lang="pug">
q-card(flat)
// Лоадер загрузки сегментов
WindowLoader(v-if='loading', text='')
q-table(
:rows='segments?.items || []',
:columns='columns',
row-key='id',
:pagination='{ rowsPerPage: 0 }',
flat,
square,
:no-data-label='"Нет соавторов"'
)
template(#top)
AddAuthorButton(
v-if='project'
:project='project'
@authors-added='loadSegmentWithDelay'
)
template(#body='props')
q-tr(:props='props')
q-td
.row.items-center.q-gutter-sm
q-avatar(size='32px')
q-icon(name='person', size='sm')
span {{ props.row.display_name }}
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import type { QTableProps } from 'quasar';
import { useSystemStore } from 'src/entities/System/model';
import { FailAlert } from 'src/shared/api';
import { WindowLoader } from 'src/shared/ui/Loader';
import { useSegmentStore } from 'app/extensions/capital/entities/Segment/model';
import { AddAuthorButton } from '../../features/Project/AddAuthor/ui';
import type { ISegmentsPagination, IGetSegmentsInput } from 'app/extensions/capital/entities/Segment/model';
import type { IProject } from '../../entities/Project/model';
const props = defineProps<{
project?: IProject | null;
}>();
const { info } = useSystemStore();
const segmentStore = useSegmentStore();
const segments = ref<ISegmentsPagination | null>(null);
const loading = ref(false);
const loadSegmentWithDelay = async () => {
await new Promise(resolve => setTimeout(resolve, 3000));
await loadSegments();
};
// Загрузка сегментов-авторов
const loadSegments = async () => {
loading.value = true;
try {
const filter: NonNullable<IGetSegmentsInput['filter']> = {
coopname: info.coopname,
is_author: true,
};
// Если передан проект, фильтруем по нему
if (props.project?.project_hash) {
filter.project_hash = props.project.project_hash;
}
const segmentsInput: IGetSegmentsInput = {
filter,
options: {
page: 1,
limit: 100,
sortBy: '_created_at',
sortOrder: 'DESC',
},
};
await segmentStore.loadSegments(segmentsInput);
segments.value = segmentStore.segments;
} catch (error) {
console.error('Ошибка при загрузке сегментов:', error);
FailAlert('Не удалось загрузить список соавторов');
} finally {
loading.value = false;
}
};
// Загружаем данные при монтировании
onMounted(async () => {
await loadSegments();
});
// Определяем столбцы таблицы
const columns: QTableProps['columns'] = [
{
name: 'author',
label: 'Соавтор',
align: 'left',
field: 'username',
sortable: true,
},
];
</script>
<style lang="scss" scoped>
// Стили если необходимо
</style>
@@ -1,27 +1,95 @@
<template lang="pug"> <template lang="pug">
q-card(flat) q-card(flat)
.q-pa-md // Лоадер загрузки сегментов
// Лоадер загрузки сегментов WindowLoader(v-if='loading', text='загрузка участников...')
WindowLoader(v-if='loading', text='')
q-table( q-table(
v-else
:rows='segments?.items || []', :rows='segments?.items || []',
:columns='columns', :columns='columns',
row-key='id', row-key='id',
:pagination='{ rowsPerPage: 0 }', :pagination='{ rowsPerPage: 0 }',
flat, flat,
square, square,
hide-bottom, hide-header,
hide-pagination,
:no-data-label='"Нет участников"' :no-data-label='"Нет участников"'
) )
template(#body='props') template(#body='props')
q-tr(:props='props') q-tr(:props='props')
q-td q-td
.row.items-center.q-gutter-sm .row.items-center(style='padding: 12px; min-height: 48px')
q-avatar(size='32px') // Порядковый номер
q-icon(name='person', size='sm') .col-auto(style='width: 30px; flex-shrink: 0; text-align: center')
span {{ props.row.display_name }} span.text-caption.text-grey-6 {{ props.rowIndex + 1 }}
q-td.text-right {{ props.row.total_segment_cost }}
// Иконка голоса
.col-auto(style='flex-shrink: 0; width: 32px; text-align: center')
q-icon(
:name='props.row.has_vote ? "how_to_vote" : "person_off"',
size='sm',
)
// Информация об участнике
.col.q-ml-sm
.row.items-center.justify-between
// Левая часть: имя и роли
.col-auto
// Имя участника
.row.items-center.q-mb-xs
span.text-body2 {{ props.row.display_name }}
// Роли участника
.row.items-center.q-gutter-xs
q-badge(
v-if='props.row.is_author',
color='primary',
label='автор'
)
q-badge(
v-if='props.row.is_creator',
color='primary',
label='создатель'
)
q-badge(
v-if='props.row.is_coordinator',
color='primary',
label='координатор'
)
q-badge(
v-if='props.row.is_investor',
color='primary',
label='инвестор'
)
q-badge(
v-if='props.row.is_propertor',
color='primary',
label='собственник'
)
q-badge(
v-if='props.row.is_contributor',
color='primary',
label='старший участник'
)
// Средняя часть: вклад участника
.col(style='width: 400px; padding-left: 16px')
.value-text(v-if='props.row.value')
| {{ props.row.value }}
// Правая часть: стоимость взноса
.col-auto
.row.items-center.q-gutter-xs
// Доля участника
ColorCard(color='blue')
.card-label доля в результате
.card-value {{ calculateShare(props.row.total_segment_cost) }}%
// Сумма взноса
ColorCard(color='green')
.card-label сумма взноса
.card-value {{ formatAsset2Digits(props.row.total_segment_cost) }}
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
@@ -29,6 +97,8 @@ import type { QTableProps } from 'quasar';
import { useSystemStore } from 'src/entities/System/model'; import { useSystemStore } from 'src/entities/System/model';
import { FailAlert } from 'src/shared/api'; import { FailAlert } from 'src/shared/api';
import { WindowLoader } from 'src/shared/ui/Loader'; import { WindowLoader } from 'src/shared/ui/Loader';
import { ColorCard } from 'src/shared/ui/ColorCard';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { useSegmentStore } from 'app/extensions/capital/entities/Segment/model'; import { useSegmentStore } from 'app/extensions/capital/entities/Segment/model';
import type { ISegmentsPagination, IGetSegmentsInput } from 'app/extensions/capital/entities/Segment/model'; import type { ISegmentsPagination, IGetSegmentsInput } from 'app/extensions/capital/entities/Segment/model';
import type { IProject } from '../../entities/Project/model'; import type { IProject } from '../../entities/Project/model';
@@ -78,6 +148,19 @@ const loadSegments = async () => {
} }
}; };
// Функция расчета доли участника
const calculateShare = (totalSegmentCost: string): string => {
if (!props.project?.fact?.total || !totalSegmentCost) return '0.00';
const projectTotal = parseFloat(props.project.fact.total);
const segmentCost = parseFloat(totalSegmentCost);
if (projectTotal === 0 || segmentCost === 0) return '0.00';
const share = (segmentCost / projectTotal) * 100;
return share.toFixed(2);
};
// Загружаем данные при монтировании // Загружаем данные при монтировании
onMounted(async () => { onMounted(async () => {
await loadSegments(); await loadSegments();
@@ -92,16 +175,33 @@ const columns: QTableProps['columns'] = [
field: 'display_name', field: 'display_name',
sortable: true, sortable: true,
}, },
{
name: 'total_cost',
label: 'Вклад',
align: 'right',
field: 'total_segment_cost',
sortable: true,
},
]; ];
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
// Стили если необходимо .q-table {
tr {
min-height: 48px;
}
.q-td {
padding: 0; // Убираем padding таблицы, так как теперь используем внутренний padding
}
}
.q-badge {
font-weight: 500;
margin-right: 4px;
}
.value-text {
font-size: 0.875rem;
line-height: 1.4;
color: var(--q-primary);
display: inline-block;
vertical-align: top;
word-wrap: break-word;
white-space: normal;
max-width: 100%;
}
</style> </style>
@@ -25,13 +25,6 @@ div
label="Финансирование", label="Финансирование",
@click="activeTab = 'planning'" @click="activeTab = 'planning'"
) )
q-btn(
size="md"
:outline="activeTab !== 'authors'"
color="primary"
label="Соавторы"
@click="activeTab = 'authors'"
)
q-btn( q-btn(
size="md" size="md"
:outline="activeTab !== 'contributors'" :outline="activeTab !== 'contributors'"
@@ -60,15 +53,8 @@ div
:readonly="!permissions?.can_edit_project" :readonly="!permissions?.can_edit_project"
) )
// Планирование проекта
div(v-if="activeTab === 'planning' && permissions?.can_edit_project")
ProjectPlanning(:project='project')
// Соавторы проекта
div(v-if="activeTab === 'authors'" )
ProjectAuthorsList(:project='project' style="min-height: 300px;")
// Участники проекта // Участники проекта
div(v-if="activeTab === 'contributors'" ) div(v-if="activeTab === 'contributors'" )
ProjectContributorsList(:project='project' style="min-height: 300px;") ProjectContributorsList(:project='project' style="min-height: 300px;")
@@ -79,8 +65,7 @@ div
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import type { IProject, IProjectPermissions } from 'app/extensions/capital/entities/Project/model'; import type { IProject, IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
import { Editor } from 'src/shared/ui'; import { Editor } from 'src/shared/ui';
import ProjectPlanning from './ProjectPlanning.vue';
import ProjectAuthorsList from './ProjectAuthorsList.vue';
import ProjectContributorsList from './ProjectContributorsList.vue'; import ProjectContributorsList from './ProjectContributorsList.vue';
const props = defineProps<{ const props = defineProps<{
@@ -97,7 +82,7 @@ const emit = defineEmits<{
}>(); }>();
// Активная вкладка // Активная вкладка
const activeTab = ref<'description' | 'invite' | 'management' | 'planning' | 'authors' | 'contributors'>('description'); const activeTab = ref<'description' | 'invite' | 'management' | 'planning' | 'contributors'>('description');
// Следим за изменением permissions и переключаем на доступную вкладку // Следим за изменением permissions и переключаем на доступную вкладку
watch(() => props.permissions, (newPermissions) => { watch(() => props.permissions, (newPermissions) => {
@@ -1,243 +0,0 @@
<template lang="pug">
div
.q-pa-md
q-card(flat)
q-card-section
// Управление инвестициями (показывается только для запланированных проектов, когда не редактируется план)
template(v-if='project && project.is_planed && !showPlanForm')
q-card.q-pa-sm(flat)
q-card-section.q-pa-sm
.row.q-gutter-sm
CreateProjectInvestButton(:project='project')
// Форма планирования (показывается когда нет плана или редактируется существующий)
template(v-if='project && (!project.is_planed || showPlanForm)')
.q-gutter-md
q-card.q-pa-sm(flat)
q-card-section.q-pa-sm
.text-subtitle2.q-mb-xs {{ showPlanForm ? 'Изменить план проекта' : 'Установить план проекта' }}
.text-caption.text-grey-7.q-mb-md Настройка финансового плана и сроков реализации проекта
q-form(@submit='handleSubmitPlan' class='q-gutter-md')
q-input(
v-model.number='planForm.plan_creators_hours'
label='Плановое количество часов создателей'
type='number'
:rules='[val => val > 0 || "Количество часов должно быть больше 0"]'
outlined
)
template(#append)
span.text-grey-7 ч
q-input(
v-model='planForm.plan_expenses'
label='Плановые расходы'
:rules='[val => !!val || "Плановые расходы обязательны"]'
outlined
)
template(#append)
span.text-grey-7 {{ governSymbol }}
q-input(
v-model='planForm.plan_hour_cost'
label='Стоимость часа работы'
:rules='[val => !!val || "Стоимость часа работы обязательна"]'
outlined
)
template(#append)
span.text-grey-7 {{ governSymbol }}
.row.q-gutter-sm
q-btn(
type='submit'
color='accent'
:label='showPlanForm ? "Сохранить изменения" : "Установить план"'
:loading='submitting'
)
q-btn(
v-if='showPlanForm'
flat
color='grey'
@click='cancelEdit'
label='Отмена'
)
// Если проект запланирован и не редактируется - показываем сравнение плана и факта
template(v-if='project && project.is_planed && !showPlanForm')
.q-gutter-md
q-card.q-pa-sm(flat)
q-card-section.q-pa-sm
q-table(
:rows='comparisonFields',
:columns='tableColumns',
row-key='key',
flat,
square,
:pagination='{ rowsPerPage: 0 }',
:no-data-label='"Нет данных"'
hide-bottom
)
template(#body='props')
q-tr(:props='props')
q-td {{ props.row.label }}
q-td.text-right
.text-weight-bold {{ formatValue(project.plan[props.row.key]) }}
q-td.text-right
.text-weight-bold {{ formatValue(project.fact[props.row.key]) }}
// Кнопка для изменения плана
q-btn.q-mt-md(
size='sm'
color='primary'
@click='showPlanForm = true'
label='Изменить план'
v-if='!showPlanForm'
)
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useSetPlan } from '../../features/Project/SetPlan/model';
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
import { useSessionStore } from 'src/entities/Session';
import CreateProjectInvestButton from '../../features/Invest/CreateProjectInvest/ui/CreateProjectInvestButton.vue';
import type { IProject } from '../../entities/Project/model';
const props = defineProps<{ project: IProject | null | undefined }>();
const session = useSessionStore();
const { setPlan, governSymbol, formatAmount, formatAmountForEOSIO } = useSetPlan();
const submitting = ref(false);
const showPlanForm = ref(false);
// Форма планирования
const planForm = ref({
plan_creators_hours: 0,
plan_expenses: '',
plan_hour_cost: ''
});
// Колонки для таблицы сравнения плана и факта
const tableColumns = [
{
name: 'label',
label: 'Показатель',
align: 'left' as const,
field: 'label' as const,
sortable: false,
},
{
name: 'plan',
label: 'План',
align: 'right' as const,
field: 'plan' as const,
sortable: false,
},
{
name: 'fact',
label: 'Факт',
align: 'right' as const,
field: 'fact' as const,
sortable: false,
},
];
// Поля для сравнения плана и факта
const comparisonFields = [
{ key: 'hour_cost', label: 'Стоимость часа создателей' },
{ key: 'creators_hours', label: 'Требуемый ресурс времени создателей' },
{ key: 'creators_base_pool', label: 'Себестоимость вклада создателей' },
{ key: 'authors_base_pool', label: 'Себестоимость вклада авторов' },
{ key: 'coordinators_base_pool', label: 'Себестоимость вклада координаторов' },
{ key: 'creators_bonus_pool', label: 'Премии создателей' },
{ key: 'authors_bonus_pool', label: 'Премии авторов' },
{ key: 'contributors_bonus_pool', label: 'Премии участников' },
{ key: 'target_expense_pool', label: 'Дополнительные расходы' },
{ key: 'invest_pool', label: 'Привлекаемые инвестиции' },
// { key: 'total_generation_pool', label: 'Общий генерационный пул' },
// { key: 'total', label: 'Общая сумма' }
];
// Сброс формы
const resetForm = () => {
planForm.value = {
plan_creators_hours: 0,
plan_expenses: '',
plan_hour_cost: ''
};
};
// Форматирование значений для отображения
const formatValue = (value: any): string => {
if (value == null || value === '') {
return '—';
}
if (typeof value === 'string') {
// Для строковых значений пробуем отформатировать как сумму
return formatAmount(value);
}
if (typeof value === 'number') {
return value.toString();
}
return String(value);
};
// Инициализация формы при изменении проекта
watch(
() => props.project,
(newProject) => {
if (newProject && newProject.is_planed) {
// Для запланированного проекта заполняем форму текущими значениями
planForm.value.plan_creators_hours = newProject.plan.creators_hours || 0;
planForm.value.plan_expenses = newProject.plan.target_expense_pool || '';
planForm.value.plan_hour_cost = newProject.plan.hour_cost || '';
} else {
// Для нового проекта сбрасываем форму
resetForm();
}
},
{ immediate: true }
);
// Обработка отправки формы для установки/обновления плана
const handleSubmitPlan = async () => {
console.log('handleSubmitPlan called');
if (!props.project || !session.username) return;
submitting.value = true;
try {
const planData = {
coopname: props.project.coopname,
master: props.project.master,
project_hash: props.project.project_hash,
plan_creators_hours: planForm.value.plan_creators_hours,
plan_expenses: formatAmountForEOSIO(planForm.value.plan_expenses),
plan_hour_cost: formatAmountForEOSIO(planForm.value.plan_hour_cost)
};
await setPlan(planData);
SuccessAlert(showPlanForm.value ? 'План проекта успешно изменен' : 'План проекта успешно установлен');
showPlanForm.value = false; // Закрываем форму после успешного сохранения
} catch (error) {
FailAlert(error);
} finally {
submitting.value = false;
}
};
// Отмена редактирования
const cancelEdit = () => {
showPlanForm.value = false;
// Восстанавливаем значения из проекта
if (props.project && props.project.is_planed) {
planForm.value.plan_creators_hours = props.project.plan.creators_hours || 0;
planForm.value.plan_expenses = props.project.plan.target_expense_pool || '';
planForm.value.plan_hour_cost = props.project.plan.hour_cost || '';
}
};
</script>
@@ -0,0 +1,98 @@
<template lang="pug">
q-card(flat)
// Таблица сравнения плана и факта (всегда показывается)
q-table(
:rows='comparisonFields',
:columns='tableColumns',
row-key='key',
flat,
square,
:pagination='{ rowsPerPage: 0 }',
:no-data-label='"Нет данных"'
hide-pagination
)
template(#body='props')
q-tr(:props='props')
q-td {{ props.row.label }}
q-td.text-right
.text-weight-bold {{ project?.is_planed ? formatValue(project?.plan?.[props.row.key], props.row.key) : 'не установлено' }}
q-td.text-right
.text-weight-bold {{ formatValue(project?.fact?.[props.row.key], props.row.key) }}
</template>
<script setup lang="ts">
import type { IProject, IProjectPermissions } from '../../entities/Project/model';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { formatHours } from 'src/shared/lib/utils/pluralizeHours';
defineProps<{
project: IProject | null | undefined;
permissions?: IProjectPermissions | null;
}>();
// Колонки для таблицы сравнения плана и факта
const tableColumns = [
{
name: 'label',
label: 'Показатель',
align: 'left' as const,
field: 'label' as const,
sortable: false,
},
{
name: 'plan',
label: 'План',
align: 'right' as const,
field: 'plan' as const,
sortable: false,
},
{
name: 'fact',
label: 'Факт',
align: 'right' as const,
field: 'fact' as const,
sortable: false,
},
];
// Поля для сравнения плана и факта
const comparisonFields = [
{ key: 'hour_cost', label: 'Стоимость часа создателей' },
{ key: 'creators_hours', label: 'Требуемый ресурс времени создателей' },
{ key: 'creators_base_pool', label: 'Себестоимость вклада создателей' },
{ key: 'authors_base_pool', label: 'Себестоимость вклада авторов' },
{ key: 'coordinators_base_pool', label: 'Себестоимость вклада координаторов' },
{ key: 'creators_bonus_pool', label: 'Премии создателей' },
{ key: 'authors_bonus_pool', label: 'Премии авторов' },
{ key: 'contributors_bonus_pool', label: 'Премии участников' },
{ key: 'target_expense_pool', label: 'Дополнительные расходы' },
{ key: 'invest_pool', label: 'Привлекаемые инвестиции' },
// { key: 'total_generation_pool', label: 'Общий генерационный пул' },
// { key: 'total', label: 'Общая сумма' }
];
// Форматирование значений для отображения
const formatValue = (value: any, fieldKey?: string): string => {
if (value == null || value === '') {
return '—';
}
// Для поля "Требуемый ресурс времени создателей" показываем число с правильным склонением часов
if (fieldKey === 'creators_hours') {
if (typeof value === 'number') {
return formatHours(value);
}
return String(value);
}
// Для всех остальных полей применяем formatAsset2Digits
if (typeof value === 'string') {
return formatAsset2Digits(value);
}
if (typeof value === 'number') {
return formatAsset2Digits(value.toString());
}
return String(value);
};
</script>
@@ -0,0 +1 @@
export { default as ProjectPlanningWidget } from './ProjectPlanningWidget.vue';

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