notifications on novu improve (sanity), make a coop settings for chairman and select default workspace / page
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
globs: notifications/src/workflows/**/*.ts
|
||||||
|
---
|
||||||
|
# Правила валидации воркфлоу
|
||||||
|
|
||||||
|
## 🚫 ID воркфлоу
|
||||||
|
- **Длина ID не должна превышать 32 символа**
|
||||||
|
- Используйте описательные, но короткие идентификаторы малыми латинскими буквами и тире.
|
||||||
|
|
||||||
|
## 🚫 Условия в шаблонах
|
||||||
|
- **Запрещено использовать JavaScript выражения в шаблонах Novu**
|
||||||
|
- Нельзя использовать:
|
||||||
|
- Тернарные операторы: `{{condition ? "text1" : "text2"}}`
|
||||||
|
- Логические операторы: `{{field && "text"}}`
|
||||||
|
- Любые другие JS конструкции
|
||||||
|
|
||||||
|
## ✅ Рекомендации
|
||||||
|
- Добавляйте текстовые поля в payload для условной логики
|
||||||
|
- Вычисляйте значения на стороне сервера перед отправкой уведомления
|
||||||
|
- Используйте только простые переменные: `{{payload.fieldName}}`
|
||||||
Vendored
+16
-11
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+27
-31
@@ -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,
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.length === 0) {
|
|
||||||
this.logger.warn('Нет подходящих получателей для уведомления');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправляем уведомления пакетом
|
|
||||||
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = {
|
|
||||||
name: 'noviy-vopros-na-povestke',
|
|
||||||
events,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData);
|
try {
|
||||||
this.logger.log(
|
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||||
`Уведомления отправлены ${events.length} членам совета о новом вопросе на повестке (решение ${action.decision_id})`
|
sentCount++;
|
||||||
);
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Ошибка отправки уведомления члену совета ${member.username}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentCount === 0) {
|
||||||
|
this.logger.warn('Не удалось отправить уведомления ни одному члену совета');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.log(`Уведомления отправлены ${sentCount} членам совета о новом вопросе на повестке (решение ${agendaId})`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Ошибка при обработке создания вопроса на повестке: ${error.message}`, error.stack);
|
this.logger.error(`Ошибка при обработке создания вопроса на повестке: ${error.message}`, error.stack);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-6
@@ -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: точную ссылку потом
|
||||||
};
|
};
|
||||||
|
|
||||||
// Отправляем уведомление
|
// Отправляем уведомление
|
||||||
|
|||||||
+61
@@ -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 транзакции для отмены
|
||||||
|
|||||||
-35
@@ -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;
|
||||||
|
}
|
||||||
+25
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -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: 'Базовый вклад создателя',
|
||||||
|
|||||||
+21
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+85
-52
@@ -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) => ({
|
// Отправляем уведомления каждому пользователю в цикле
|
||||||
|
let sentCount = 0;
|
||||||
|
for (const user of users) {
|
||||||
|
const triggerData: WorkflowTriggerDomainInterface = {
|
||||||
|
name: Workflows.MeetInitial.id,
|
||||||
to: {
|
to: {
|
||||||
subscriberId: user.username,
|
subscriberId: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
payload,
|
payload,
|
||||||
}));
|
|
||||||
|
|
||||||
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = {
|
|
||||||
name: Workflows.MeetInitial.id,
|
|
||||||
events,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData);
|
try {
|
||||||
|
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||||
|
sentCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Ошибка отправки начального уведомления пользователю ${user.username}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) => ({
|
// Отправляем уведомления каждому пользователю в цикле
|
||||||
|
let sentCount = 0;
|
||||||
|
for (const user of users) {
|
||||||
|
const triggerData: WorkflowTriggerDomainInterface = {
|
||||||
|
name: Workflows.MeetReminderStart.id,
|
||||||
to: {
|
to: {
|
||||||
subscriberId: user.username,
|
subscriberId: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
payload,
|
payload,
|
||||||
}));
|
|
||||||
|
|
||||||
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = {
|
|
||||||
name: Workflows.MeetReminderStart.id,
|
|
||||||
events,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData);
|
try {
|
||||||
|
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||||
|
sentCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Ошибка отправки уведомления о начале собрания пользователю ${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} пользователей`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,23 +220,28 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
|||||||
meetUrl,
|
meetUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({
|
// Отправляем уведомления каждому пользователю в цикле
|
||||||
|
let sentCount = 0;
|
||||||
|
for (const user of users) {
|
||||||
|
const triggerData: WorkflowTriggerDomainInterface = {
|
||||||
|
name: Workflows.MeetStarted.id,
|
||||||
to: {
|
to: {
|
||||||
subscriberId: user.username,
|
subscriberId: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
payload,
|
payload,
|
||||||
}));
|
|
||||||
|
|
||||||
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = {
|
|
||||||
name: Workflows.MeetStarted.id,
|
|
||||||
events,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData);
|
try {
|
||||||
|
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||||
|
sentCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Ошибка отправки уведомления о старте собрания пользователю ${user.username}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) => ({
|
// Отправляем уведомления каждому пользователю в цикле
|
||||||
|
let sentCount = 0;
|
||||||
|
for (const user of users) {
|
||||||
|
const triggerData: WorkflowTriggerDomainInterface = {
|
||||||
|
name: Workflows.MeetReminderEnd.id,
|
||||||
to: {
|
to: {
|
||||||
subscriberId: user.username,
|
subscriberId: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
payload,
|
payload,
|
||||||
}));
|
|
||||||
|
|
||||||
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = {
|
|
||||||
name: Workflows.MeetReminderEnd.id,
|
|
||||||
events,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData);
|
try {
|
||||||
|
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||||
|
sentCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`Ошибка отправки уведомления об окончании собрания пользователю ${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) => ({
|
// Отправляем уведомления каждому пользователю в цикле
|
||||||
|
let sentCount = 0;
|
||||||
|
for (const user of users) {
|
||||||
|
const triggerData: WorkflowTriggerDomainInterface = {
|
||||||
|
name: Workflows.MeetRestart.id,
|
||||||
to: {
|
to: {
|
||||||
subscriberId: user.username,
|
subscriberId: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
payload,
|
payload,
|
||||||
}));
|
|
||||||
|
|
||||||
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = {
|
|
||||||
name: Workflows.MeetRestart.id,
|
|
||||||
events,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData);
|
try {
|
||||||
|
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||||
|
sentCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`Ошибка отправки уведомления о повторном собрании пользователю ${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) => ({
|
// Отправляем уведомления каждому пользователю в цикле
|
||||||
|
let sentCount = 0;
|
||||||
|
for (const user of users) {
|
||||||
|
const triggerData: WorkflowTriggerDomainInterface = {
|
||||||
|
name: Workflows.MeetEnded.id,
|
||||||
to: {
|
to: {
|
||||||
subscriberId: user.username,
|
subscriberId: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
payload,
|
payload,
|
||||||
}));
|
|
||||||
|
|
||||||
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = {
|
|
||||||
name: Workflows.MeetEnded.id,
|
|
||||||
events,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData);
|
try {
|
||||||
|
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||||
|
sentCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`Ошибка отправки уведомления о завершении собрания пользователю ${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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -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, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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": {
|
||||||
"~/*": ["*"]
|
"~/*": ["*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipDefaultLibCheck": true,
|
"skipDefaultLibCheck": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
}
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
+91
@@ -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>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { default as CreateProjectInvestDialog } from './CreateProjectInvestDialog.vue';
|
||||||
+43
@@ -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>
|
||||||
+39
@@ -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>
|
||||||
+2
@@ -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';
|
||||||
|
|||||||
+2
-14
@@ -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) },
|
||||||
|
|||||||
+2
-3
@@ -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='Добавить соавторов')
|
||||||
|
|||||||
+130
@@ -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';
|
||||||
+30
@@ -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';
|
||||||
|
|||||||
+128
@@ -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';
|
||||||
+30
@@ -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';
|
||||||
+90
-15
@@ -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';
|
||||||
|
|||||||
+169
@@ -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';
|
||||||
+34
@@ -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';
|
||||||
|
|||||||
-6
@@ -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';
|
|
||||||
-70
@@ -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';
|
|
||||||
-3
@@ -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();
|
||||||
|
|
||||||
// Регистрируем кнопки меню
|
// Регистрируем кнопки меню только если мы НЕ на странице задачи
|
||||||
|
if (route.name !== 'component-issue') {
|
||||||
menuButtons.value.forEach(button => {
|
menuButtons.value.forEach(button => {
|
||||||
registerHeaderAction(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 () => {
|
||||||
// Обновляем данные проекта, чтобы отразить изменения в разрешениях
|
// Обновляем данные проекта, чтобы отразить изменения в разрешениях
|
||||||
|
|||||||
+3
-10
@@ -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();
|
||||||
|
|||||||
+8
-13
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+3
@@ -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,8 +18,10 @@ 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
|
|
||||||
|
.text-h6(v-if="!issue") Загрузка...
|
||||||
|
|
||||||
.row.items-center.q-gutter-md.q-mb-sm
|
.row.items-center.q-gutter-md.q-mb-sm
|
||||||
.col
|
.col
|
||||||
// Индикатор авто-сохранения
|
// Индикатор авто-сохранения
|
||||||
@@ -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';
|
|
||||||
-25
@@ -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>
|
|
||||||
-3
@@ -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();
|
||||||
|
|
||||||
// Регистрируем кнопки меню
|
// Регистрируем кнопки меню только если мы НЕ на странице задачи
|
||||||
|
if (route.name !== 'project-issue') {
|
||||||
menuButtons.value.forEach(button => {
|
menuButtons.value.forEach(button => {
|
||||||
registerHeaderAction(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>
|
||||||
|
|
||||||
|
|||||||
+3
-10
@@ -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();
|
||||||
|
|||||||
+8
-13
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
+25
-5
@@ -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'
|
||||||
+24
-6
@@ -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>
|
||||||
|
|||||||
-111
@@ -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>
|
|
||||||
+116
-16
@@ -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>
|
||||||
|
|||||||
+2
-17
@@ -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) => {
|
||||||
|
|||||||
-243
@@ -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>
|
|
||||||
+98
@@ -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
Reference in New Issue
Block a user