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.include": false,
|
||||
"typescript.disableAutomaticTypeAcquisition": true,
|
||||
"typescript.preferences.includePackageJsonAutoImports": "off",
|
||||
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.suggest.paths": true,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
@@ -47,13 +45,15 @@
|
||||
"**/node_modules/.cache": true
|
||||
},
|
||||
|
||||
// TypeScript server настройки
|
||||
"typescript.tsserver.maxTsServerMemory": 4000,
|
||||
// TypeScript server настройки для монорепозитория
|
||||
"typescript.tsserver.maxTsServerMemory": 8192,
|
||||
"typescript.tsserver.watchOptions": {
|
||||
"excludeFiles": [
|
||||
"**/node_modules/**/*",
|
||||
"**/dist/**/*",
|
||||
"**/.cache/**/*"
|
||||
"excludeDirectories": [
|
||||
"**/node_modules",
|
||||
"**/dist",
|
||||
"**/.cache",
|
||||
"**/.quasar",
|
||||
"**/build"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -61,6 +61,11 @@
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
|
||||
// Отключить автоматическую инициализацию для всех tsconfig
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": false
|
||||
// КРИТИЧЕСКИ ВАЖНО: включить project references для монорепозитория
|
||||
"typescript.tsserver.useSyntaxServer": "auto",
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||
|
||||
// Оптимизация для больших монорепозиториев
|
||||
"typescript.disableAutomaticTypeAcquisition": true,
|
||||
"typescript.surveys.enabled": false
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ type Account {
|
||||
private_account: PrivateAccount
|
||||
|
||||
"""
|
||||
объект аккаунта в системе учёта провайдера, т.е. MONO. Здесь хранится приватная информация о пайщике кооператива, которая содержит его приватные данные. Эти данные не публикуются в блокчейне и не выходят за пределы базы данных провайдера. Они используются для заполнения шаблонов документов при нажатии соответствующих кнопок на платформе.
|
||||
объект аккаунта в системе учёта провайдера, т.е. MONO. Здесь хранится приватная информация о пайщике кооператива, которая содержит его приватные данные. Эти данные не публикуются в блокчейне и не выходят за пределы базы данных провайдера. Они используются для заполнения шаблонов документов при нажатии соответствующих кнопок на платформе.
|
||||
"""
|
||||
provider_account: MonoAccount
|
||||
|
||||
@@ -2473,7 +2473,7 @@ type CapitalProjectFactPool {
|
||||
"""Общая сумма"""
|
||||
total: String!
|
||||
|
||||
"""Общий объем вклада"""
|
||||
"""Общий объем взноса старших участников"""
|
||||
total_contribution: String!
|
||||
|
||||
"""Общий генерационный пул"""
|
||||
@@ -2914,6 +2914,9 @@ type CapitalSegment {
|
||||
"""Имя пользователя"""
|
||||
username: String!
|
||||
|
||||
"""Вклад участника словами участника"""
|
||||
value: String
|
||||
|
||||
"""Бонус голосования"""
|
||||
voting_bonus: String!
|
||||
}
|
||||
@@ -4318,18 +4321,12 @@ input DeltaFiltersInput {
|
||||
}
|
||||
|
||||
type Desktop {
|
||||
"""Имя шаблона рабочих столов"""
|
||||
authorizedHome: String!
|
||||
|
||||
"""Имя аккаунта кооператива"""
|
||||
coopname: String!
|
||||
|
||||
"""Имя шаблона рабочих столов"""
|
||||
layout: String!
|
||||
|
||||
"""Имя шаблона рабочих столов"""
|
||||
nonAuthorizedHome: String!
|
||||
|
||||
"""Состав приложений рабочего стола"""
|
||||
workspaces: [DesktopWorkspace!]!
|
||||
}
|
||||
@@ -6111,6 +6108,9 @@ type Mutation {
|
||||
"""Обновить заявку"""
|
||||
updateRequest(data: UpdateRequestInput!): Transaction!
|
||||
|
||||
"""Обновить настройки системы (рабочие столы и маршруты по умолчанию)"""
|
||||
updateSettings(data: UpdateSettingsInput!): Settings!
|
||||
|
||||
"""Обновить параметры системы"""
|
||||
updateSystem(data: Update!): SystemInfo!
|
||||
|
||||
@@ -8171,6 +8171,29 @@ input SetWifInput {
|
||||
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 {
|
||||
"""Акт о вкладе результатов"""
|
||||
act: SignedDigitalDocumentInput!
|
||||
@@ -8427,6 +8450,9 @@ type SystemInfo {
|
||||
"""Имя аккаунта кооператива"""
|
||||
coopname: String!
|
||||
|
||||
"""Настройки системы"""
|
||||
settings: Settings!
|
||||
|
||||
"""Символы и их точности блокчейна"""
|
||||
symbols: Symbols!
|
||||
|
||||
@@ -8714,6 +8740,20 @@ input UpdateRequestInput {
|
||||
username: String!
|
||||
}
|
||||
|
||||
input UpdateSettingsInput {
|
||||
"""Маршрут по умолчанию для авторизованных пользователей"""
|
||||
authorized_default_route: String
|
||||
|
||||
"""Рабочий стол по умолчанию для авторизованных пользователей"""
|
||||
authorized_default_workspace: String
|
||||
|
||||
"""Маршрут по умолчанию для неавторизованных пользователей"""
|
||||
non_authorized_default_route: String
|
||||
|
||||
"""Рабочий стол по умолчанию для неавторизованных пользователей"""
|
||||
non_authorized_default_workspace: String
|
||||
}
|
||||
|
||||
input UpdateStoryInput {
|
||||
"""Описание истории"""
|
||||
description: String
|
||||
@@ -8941,4 +8981,4 @@ input WebPushSubscriptionKeysInput {
|
||||
|
||||
"""P256DH ключ для шифрования"""
|
||||
p256dh: String!
|
||||
}
|
||||
}
|
||||
+25
-29
@@ -7,10 +7,8 @@ import { ACCOUNT_EXTENSION_PORT, AccountExtensionPort } from '~/domain/extension
|
||||
import config from '~/config/config';
|
||||
import { SovietContract } from 'cooptypes';
|
||||
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
|
||||
import type {
|
||||
WorkflowBulkTriggerDomainInterface,
|
||||
WorkflowBulkEventDomainInterface,
|
||||
} from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
import { Workflows } from '@coopenomics/notifications';
|
||||
|
||||
/**
|
||||
* Сервис для отправки уведомлений о новых вопросах на повестке совета
|
||||
@@ -41,14 +39,15 @@ export class AgendaNotificationService implements OnModuleInit {
|
||||
@OnEvent(`action::${SovietContract.contractName.production}::${SovietContract.Actions.Decisions.CreateAgenda.actionName}`)
|
||||
async handleCreateAgenda(actionData: ActionDomainInterface): Promise<void> {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Обработка создания нового вопроса на повестке для решения ${action.decision_id}`);
|
||||
this.logger.debug(`Обработка создания нового вопроса на повестке для решения ${agendaId}`);
|
||||
|
||||
// Получаем членов совета (chairman и member) через порт
|
||||
// Получаем председателей
|
||||
@@ -71,19 +70,18 @@ export class AgendaNotificationService implements OnModuleInit {
|
||||
const authorName = await this.accountPort.getDisplayName(action.username);
|
||||
|
||||
// Формируем данные для workflow
|
||||
const payload = {
|
||||
const payload: Workflows.NewAgenda.IPayload = {
|
||||
coopname: action.coopname,
|
||||
coopShortName,
|
||||
itemTitle: `Вопрос №${action.decision_id}`,
|
||||
itemDescription: `Новый вопрос добавлен на повестку заседания совета`,
|
||||
itemTitle: `№${agendaId}`, //TODO: по типу запроса
|
||||
itemDescription: ``, //TODO: по типу запроса
|
||||
authorName,
|
||||
decision_id: action.decision_id,
|
||||
agendaUrl: `${config.base_url}/${action.coopname}/council/agenda`,
|
||||
decision_id: agendaId,
|
||||
agendaUrl: `${config.base_url}/${action.coopname}/soviet/agenda`,
|
||||
};
|
||||
|
||||
// Подготавливаем события для пакетной отправки
|
||||
const events: WorkflowBulkEventDomainInterface[] = [];
|
||||
|
||||
// Отправляем уведомления каждому члену совета в цикле
|
||||
let sentCount = 0;
|
||||
for (const member of allCouncilMembers) {
|
||||
const memberEmail = member.provider_account?.email;
|
||||
if (!memberEmail) {
|
||||
@@ -91,30 +89,28 @@ export class AgendaNotificationService implements OnModuleInit {
|
||||
continue;
|
||||
}
|
||||
|
||||
events.push({
|
||||
const triggerData: WorkflowTriggerDomainInterface = {
|
||||
name: Workflows.NewAgenda.id,
|
||||
to: {
|
||||
subscriberId: member.username,
|
||||
email: memberEmail,
|
||||
},
|
||||
payload,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||
sentCount++;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка отправки уведомления члену совета ${member.username}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
this.logger.warn('Нет подходящих получателей для уведомления');
|
||||
if (sentCount === 0) {
|
||||
this.logger.warn('Не удалось отправить уведомления ни одному члену совета');
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем уведомления пакетом
|
||||
const bulkTriggerData: WorkflowBulkTriggerDomainInterface = {
|
||||
name: 'noviy-vopros-na-povestke',
|
||||
events,
|
||||
};
|
||||
|
||||
await this.novuWorkflowAdapter.triggerBulkWorkflow(bulkTriggerData);
|
||||
this.logger.log(
|
||||
`Уведомления отправлены ${events.length} членам совета о новом вопросе на повестке (решение ${action.decision_id})`
|
||||
);
|
||||
this.logger.log(`Уведомления отправлены ${sentCount} членам совета о новом вопросе на повестке (решение ${agendaId})`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка при обработке создания вопроса на повестке: ${error.message}`, error.stack);
|
||||
}
|
||||
|
||||
+8
-8
@@ -39,19 +39,19 @@ export class DecisionNotificationService implements OnModuleInit {
|
||||
@OnEvent(`action::${SovietContract.contractName.production}::newdecision`)
|
||||
async handleNewDecision(actionData: ActionDomainInterface): Promise<void> {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Обработка нового решения совета: ${action.decision_id}`);
|
||||
this.logger.debug(`Обработка нового решения совета: ${action.package}`);
|
||||
|
||||
// Получаем username из действия
|
||||
const username = action.username;
|
||||
if (!username) {
|
||||
this.logger.warn(`Username не найден в действии ${action.decision_id}`);
|
||||
this.logger.warn(`Username не найден в действии ${action.package}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@ export class DecisionNotificationService implements OnModuleInit {
|
||||
// Формируем данные для workflow (без приватных данных)
|
||||
const payload: Workflows.DecisionApproved.IPayload = {
|
||||
userName,
|
||||
decisionTitle: `Решение №${action.decision_id}`,
|
||||
decisionTitle: `Решение №${decisionId}`,
|
||||
coopname: action.coopname,
|
||||
decision_id: action.decision_id,
|
||||
decisionUrl: `${config.base_url}/${action.coopname}/user/decisions/${action.decision_id}`,
|
||||
decision_id: decisionId,
|
||||
decisionUrl: `${config.base_url}`,
|
||||
};
|
||||
|
||||
// Отправляем уведомление
|
||||
@@ -86,7 +86,7 @@ export class DecisionNotificationService implements OnModuleInit {
|
||||
};
|
||||
|
||||
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||
this.logger.log(`Уведомление отправлено пользователю ${username} о принятии решения ${action.decision_id}`);
|
||||
this.logger.log(`Уведомление отправлено пользователю ${username} о принятии решения ${decisionId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка при обработке нового решения совета: ${error.message}`, error.stack);
|
||||
}
|
||||
|
||||
@@ -25,4 +25,12 @@ export class DesktopWorkspaceDTO implements DesktopWorkspaceDomainInterface {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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()
|
||||
public readonly layout!: string;
|
||||
|
||||
@Field(() => String, { description: 'Имя шаблона рабочих столов' })
|
||||
@IsString()
|
||||
public readonly authorizedHome!: string;
|
||||
|
||||
@Field(() => String, { description: 'Имя шаблона рабочих столов' })
|
||||
@IsString()
|
||||
public readonly nonAuthorizedHome!: string;
|
||||
|
||||
@Field(() => [DesktopWorkspaceDTO], { description: 'Состав приложений рабочего стола' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => 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> {
|
||||
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> {
|
||||
try {
|
||||
// Отправляем уведомления только для завершенных и отмененных платежей
|
||||
if (payment.status !== PaymentStatusEnum.COMPLETED && payment.status !== PaymentStatusEnum.CANCELLED) {
|
||||
// Отправляем уведомления только для оплаченных и отмененных платежей
|
||||
if (payment.status !== PaymentStatusEnum.PAID && payment.status !== PaymentStatusEnum.CANCELLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,16 +53,15 @@ export class PaymentNotificationService implements OnModuleInit {
|
||||
|
||||
// Выбираем workflow в зависимости от статуса платежа
|
||||
const workflowId =
|
||||
payment.status === PaymentStatusEnum.COMPLETED ? Workflows.PaymentCompleted.id : Workflows.PaymentCancelled.id;
|
||||
payment.status === PaymentStatusEnum.PAID ? Workflows.PaymentPaid.id : Workflows.PaymentCancelled.id;
|
||||
|
||||
// Формируем данные для workflow (без приватных данных)
|
||||
const payload: Workflows.PaymentCompleted.IPayload | Workflows.PaymentCancelled.IPayload = {
|
||||
const payload: Workflows.PaymentPaid.IPayload | Workflows.PaymentCancelled.IPayload = {
|
||||
userName,
|
||||
paymentAmount: payment.quantity.toFixed(2),
|
||||
paymentCurrency: payment.symbol,
|
||||
paymentId: payment.id || '',
|
||||
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 type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить уведомление председателю о новом заявке на вступительный взнос
|
||||
*/
|
||||
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> {
|
||||
const result = await this.participantDomainInteractor.createInitialPayment(data);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { VarsDTO } from './vars.dto';
|
||||
import { SymbolsDTO } from './symbols.dto';
|
||||
import { SettingsDTO } from './settings.dto';
|
||||
|
||||
@ObjectType('SystemInfo')
|
||||
export class SystemInfoDTO {
|
||||
@@ -46,6 +47,10 @@ export class SystemInfoDTO {
|
||||
@ValidateNested()
|
||||
public readonly symbols: SymbolsDTO;
|
||||
|
||||
@Field(() => SettingsDTO, { description: 'Настройки системы' })
|
||||
@ValidateNested()
|
||||
public readonly settings: SettingsDTO;
|
||||
|
||||
constructor(entity: SystemInfoDomainEntity) {
|
||||
this.coopname = entity.coopname;
|
||||
this.contacts = entity.contacts;
|
||||
@@ -53,6 +58,7 @@ export class SystemInfoDTO {
|
||||
this.blockchain_info = new BlockchainInfoDTO(entity.blockchain_info);
|
||||
this.system_status = entity.system_status as SystemStatus;
|
||||
this.symbols = entity.symbols;
|
||||
this.settings = new SettingsDTO(entity.settings);
|
||||
this.blockchain_account = entity.blockchain_account;
|
||||
this.cooperator_account = new CooperativeOperatorAccountDTO({
|
||||
...entity.cooperator_account,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { InstallDTO } from '../dto/install.dto';
|
||||
import { InitDTO } from '../dto/init.dto';
|
||||
import { SetWifInputDTO } from '../dto/set-wif-input.dto';
|
||||
import { UpdateDTO } from '../dto/update.dto';
|
||||
import { SettingsDTO, UpdateSettingsInputDTO } from '../dto/settings.dto';
|
||||
|
||||
@Resolver(() => SystemInfoDTO)
|
||||
export class SystemResolver {
|
||||
@@ -62,4 +63,15 @@ export class SystemResolver {
|
||||
await this.systemService.setWif(data);
|
||||
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 { OrganizationDomainEntity } from '~/domain/branch/entities/organization-domain.entity';
|
||||
import config from '~/config/config';
|
||||
import { SettingsDTO, UpdateSettingsInputDTO } from '../dto/settings.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SystemService {
|
||||
@@ -41,4 +42,12 @@ export class SystemService {
|
||||
public async setWif(data: SetWifInputDTO): Promise<void> {
|
||||
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 { WalletDomainInteractor } from '~/domain/wallet/interactors/wallet.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 { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.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 {
|
||||
constructor(
|
||||
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> {
|
||||
const result = await this.walletDomainInteractor.createDepositPayment(data);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WalletResolver } from './resolvers/wallet.resolver';
|
||||
import { WalletService } from './services/wallet.service';
|
||||
import { WalletNotificationService } from './services/wallet-notification.service';
|
||||
import { WalletDomainInteractor } from '~/domain/wallet/interactors/wallet.interactor';
|
||||
import { DomainModule } from '~/domain/domain.module';
|
||||
import { NovuModule } from '~/infrastructure/novu/novu.module';
|
||||
|
||||
/**
|
||||
* Модуль wallet для управления выводом средств, депозитными платежами и генерацией документов
|
||||
*/
|
||||
@Module({
|
||||
imports: [DomainModule],
|
||||
providers: [WalletResolver, WalletService, WalletDomainInteractor],
|
||||
imports: [DomainModule, NovuModule],
|
||||
providers: [WalletResolver, WalletService, WalletNotificationService, WalletDomainInteractor],
|
||||
exports: [WalletService, WalletDomainInteractor],
|
||||
})
|
||||
export class WalletModule {}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { DesktopDomainInterface } from '../interfaces/desktop-domain.interface';
|
||||
import type { DesktopWorkspaceDomainEntity } from './workspace-domain.entity';
|
||||
|
||||
/**
|
||||
* Доменная сущность Desktop
|
||||
* Содержит информацию о доступных рабочих столах (workspaces)
|
||||
* Настройки по умолчанию (authorized/nonAuthorizedHome) перенесены в Settings
|
||||
*/
|
||||
export class DesktopDomainEntity implements DesktopDomainInterface {
|
||||
public readonly coopname!: string;
|
||||
public readonly layout!: string;
|
||||
public readonly authorizedHome!: string;
|
||||
public readonly nonAuthorizedHome!: string;
|
||||
|
||||
public readonly workspaces!: DesktopWorkspaceDomainEntity[];
|
||||
|
||||
constructor(data: DesktopDomainEntity) {
|
||||
this.coopname = data.coopname;
|
||||
this.layout = data.layout;
|
||||
this.authorizedHome = data.authorizedHome;
|
||||
this.nonAuthorizedHome = data.nonAuthorizedHome;
|
||||
this.workspaces = data.workspaces;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,14 +40,11 @@ export class DesktopDomainInteractor {
|
||||
}
|
||||
|
||||
const layout = 'default';
|
||||
const authorizedHome = 'profile';
|
||||
const nonAuthorizedHome = 'signup';
|
||||
|
||||
return new DesktopDomainEntity({
|
||||
coopname: config.coopname,
|
||||
layout,
|
||||
workspaces,
|
||||
authorizedHome,
|
||||
nonAuthorizedHome,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { DesktopWorkspaceDomainInterface } from './desktop-workspace-domain.interface';
|
||||
|
||||
/**
|
||||
* Доменный интерфейс Desktop
|
||||
* Настройки по умолчанию (authorized/nonAuthorizedHome) перенесены в Settings
|
||||
*/
|
||||
export interface DesktopDomainInterface {
|
||||
coopname: string;
|
||||
layout: string;
|
||||
authorizedHome: string;
|
||||
nonAuthorizedHome: string;
|
||||
workspaces: DesktopWorkspaceDomainInterface[];
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { WalletDomainModule } from './wallet/wallet-domain.module';
|
||||
import { NotificationDomainModule } from './notification/notification-domain.module';
|
||||
import { LedgerDomainModule } from './ledger/ledger-domain.module';
|
||||
import { ParserDomainModule } from './parser/parser-domain.module';
|
||||
import { SettingsDomainModule } from './settings/settings-domain.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -45,6 +46,7 @@ import { ParserDomainModule } from './parser/parser-domain.module';
|
||||
NotificationDomainModule,
|
||||
LedgerDomainModule,
|
||||
ParserDomainModule,
|
||||
SettingsDomainModule,
|
||||
],
|
||||
exports: [
|
||||
AuthDomainModule,
|
||||
@@ -68,6 +70,7 @@ import { ParserDomainModule } from './parser/parser-domain.module';
|
||||
NotificationDomainModule,
|
||||
LedgerDomainModule,
|
||||
ParserDomainModule,
|
||||
SettingsDomainModule,
|
||||
],
|
||||
})
|
||||
export class DomainModule {}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
WorkflowTriggerDomainInterface,
|
||||
WorkflowTriggerResultDomainInterface,
|
||||
WorkflowBulkTriggerDomainInterface,
|
||||
} from './workflow-trigger-domain.interface';
|
||||
|
||||
/**
|
||||
@@ -16,13 +15,6 @@ export interface NovuWorkflowPort {
|
||||
*/
|
||||
triggerWorkflow(triggerData: WorkflowTriggerDomainInterface): Promise<WorkflowTriggerResultDomainInterface>;
|
||||
|
||||
/**
|
||||
* Запустить воркфлоу для множественной отправки уведомлений
|
||||
* @param bulkTriggerData Данные для пакетного запуска воркфлоу
|
||||
* @returns Promise<WorkflowTriggerResultDomainInterface[]>
|
||||
*/
|
||||
triggerBulkWorkflow(bulkTriggerData: WorkflowBulkTriggerDomainInterface): Promise<WorkflowTriggerResultDomainInterface[]>;
|
||||
|
||||
/**
|
||||
* Отменить триггер воркфлоу
|
||||
* @param transactionId ID транзакции для отмены
|
||||
|
||||
-35
@@ -118,38 +118,3 @@ export interface WorkflowTriggerResultDomainInterface {
|
||||
*/
|
||||
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 type { VarsDomainInterface } from '../interfaces/vars-domain.interface';
|
||||
import { SymbolsDTO } from '~/application/system/dto/symbols.dto';
|
||||
import type { SettingsDomainInterface } from '~/domain/settings/interfaces/settings-domain.interface';
|
||||
|
||||
export class SystemInfoDomainEntity {
|
||||
public readonly coopname!: string;
|
||||
@@ -16,6 +17,7 @@ export class SystemInfoDomainEntity {
|
||||
public readonly contacts?: CooperativeContactsDomainInterface;
|
||||
public readonly vars!: VarsDomainInterface | null;
|
||||
public readonly symbols!: SymbolsDTO;
|
||||
public readonly settings!: SettingsDomainInterface;
|
||||
|
||||
constructor(data: SystemInfoDomainEntity) {
|
||||
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 { ORGANIZATION_REPOSITORY, type OrganizationRepository } from '~/domain/common/repositories/organization.repository';
|
||||
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()
|
||||
export class SystemDomainInteractor {
|
||||
@@ -22,7 +25,8 @@ export class SystemDomainInteractor {
|
||||
@Inject(SYSTEM_BLOCKCHAIN_PORT) private readonly systemBlockchainPort: SystemBlockchainPort,
|
||||
private readonly systemDomainService: SystemDomainService,
|
||||
@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> {
|
||||
@@ -79,6 +83,9 @@ export class SystemDomainInteractor {
|
||||
config.blockchain.root_govern_precision
|
||||
);
|
||||
|
||||
// Получаем настройки системы
|
||||
const settings = await this.getSettings();
|
||||
|
||||
return new SystemInfoDomainEntity({
|
||||
blockchain_info,
|
||||
contacts,
|
||||
@@ -89,6 +96,22 @@ export class SystemDomainInteractor {
|
||||
blockchain_account,
|
||||
system_status,
|
||||
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 { AccountDomainModule } from '../account/account-domain.module';
|
||||
import { SystemDomainService } from './services/system-domain.service';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountDomainModule],
|
||||
imports: [AccountDomainModule, SettingsModule],
|
||||
providers: [SystemDomainService, SystemDomainInteractor],
|
||||
exports: [SystemDomainService, SystemDomainInteractor],
|
||||
})
|
||||
|
||||
+1
-1
@@ -252,7 +252,7 @@ export class CapitalProjectFactPoolOutputDTO {
|
||||
total_generation_pool!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Общий объем вклада',
|
||||
description: 'Общий объем взноса старших участников',
|
||||
})
|
||||
total_contribution!: string;
|
||||
|
||||
|
||||
@@ -88,6 +88,12 @@ export class SegmentOutputDTO extends BaseOutputDTO {
|
||||
})
|
||||
investor_base!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Вклад участника словами участника',
|
||||
})
|
||||
value?: string;
|
||||
|
||||
// Вклады создателя
|
||||
@Field(() => String, {
|
||||
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 { ContributorRepository } 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 { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto';
|
||||
|
||||
@@ -26,7 +28,9 @@ export class SegmentMapper {
|
||||
constructor(
|
||||
private readonly documentAggregationService: DocumentAggregationService,
|
||||
@Inject(CONTRIBUTOR_REPOSITORY)
|
||||
private readonly contributorRepository: ContributorRepository
|
||||
private readonly contributorRepository: ContributorRepository,
|
||||
@Inject(APPENDIX_REPOSITORY)
|
||||
private readonly appendixRepository: AppendixRepository
|
||||
) {}
|
||||
/**
|
||||
* Преобразование TypeORM сущности в доменную сущность
|
||||
@@ -222,6 +226,21 @@ export class SegmentMapper {
|
||||
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
|
||||
let enrichedResult: ResultOutputDTO | undefined = undefined;
|
||||
if (domain.result) {
|
||||
@@ -251,6 +270,7 @@ export class SegmentMapper {
|
||||
return {
|
||||
...domain,
|
||||
display_name: displayName,
|
||||
value: contribution,
|
||||
result: enrichedResult,
|
||||
} as SegmentOutputDTO;
|
||||
}
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import type { WorkflowTriggerDomainInterface } from '~/domain/notification/inter
|
||||
import { Workflows } from '@coopenomics/notifications';
|
||||
import { SovietContract } from 'cooptypes';
|
||||
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 = {
|
||||
userName: authorName,
|
||||
approvalStatus: status,
|
||||
approvalStatusText: status === ApprovalStatus.APPROVED ? 'одобрен' : 'отклонён',
|
||||
approvalId: approvalHash,
|
||||
coopname: config.coopname,
|
||||
coopShortName,
|
||||
|
||||
+121
-88
@@ -7,10 +7,7 @@ import { DateUtils } from '~/shared/utils/date-utils';
|
||||
import { ExtendedMeetStatus } from '~/domain/meet/enums/extended-meet-status.enum';
|
||||
import type { TrackedMeet } from './types';
|
||||
import { ACCOUNT_EXTENSION_PORT, AccountExtensionPort } from '~/domain/extension/ports/account-extension-port';
|
||||
import type {
|
||||
WorkflowBulkTriggerDomainInterface,
|
||||
WorkflowBulkEventDomainInterface,
|
||||
} from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
import { Workflows } from '@coopenomics/notifications';
|
||||
|
||||
/**
|
||||
@@ -127,23 +124,28 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
meetUrl,
|
||||
};
|
||||
|
||||
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
payload,
|
||||
}));
|
||||
// Отправляем уведомления каждому пользователю в цикле
|
||||
let sentCount = 0;
|
||||
for (const user of users) {
|
||||
const triggerData: WorkflowTriggerDomainInterface = {
|
||||
name: Workflows.MeetInitial.id,
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
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(
|
||||
`Отправлено начальное уведомление о собрании ${meet.hash} (№${meet.id}) для ${users.length} пользователей`
|
||||
`Отправлено начальное уведомление о собрании ${meet.hash} (№${meet.id}) для ${sentCount}/${users.length} пользователей`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,23 +175,28 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
meetUrl,
|
||||
};
|
||||
|
||||
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
payload,
|
||||
}));
|
||||
// Отправляем уведомления каждому пользователю в цикле
|
||||
let sentCount = 0;
|
||||
for (const user of users) {
|
||||
const triggerData: WorkflowTriggerDomainInterface = {
|
||||
name: Workflows.MeetReminderStart.id,
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
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(
|
||||
`Отправлено уведомление за ${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,
|
||||
};
|
||||
|
||||
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
payload,
|
||||
}));
|
||||
// Отправляем уведомления каждому пользователю в цикле
|
||||
let sentCount = 0;
|
||||
for (const user of users) {
|
||||
const triggerData: WorkflowTriggerDomainInterface = {
|
||||
name: Workflows.MeetStarted.id,
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
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(
|
||||
`Отправлено уведомление о начале собрания ${meet.hash} (№${meet.id}) для ${users.length} пользователей`
|
||||
`Отправлено уведомление о начале собрания ${meet.hash} (№${meet.id}) для ${sentCount}/${users.length} пользователей`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -261,23 +273,30 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
meetUrl,
|
||||
};
|
||||
|
||||
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
payload,
|
||||
}));
|
||||
// Отправляем уведомления каждому пользователю в цикле
|
||||
let sentCount = 0;
|
||||
for (const user of users) {
|
||||
const triggerData: WorkflowTriggerDomainInterface = {
|
||||
name: Workflows.MeetReminderEnd.id,
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
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(
|
||||
`Отправлено уведомление за ${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,
|
||||
};
|
||||
|
||||
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
payload,
|
||||
}));
|
||||
// Отправляем уведомления каждому пользователю в цикле
|
||||
let sentCount = 0;
|
||||
for (const user of users) {
|
||||
const triggerData: WorkflowTriggerDomainInterface = {
|
||||
name: Workflows.MeetRestart.id,
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
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(
|
||||
`Отправлено уведомление о новой дате повторного собрания ${meet.hash} (№${meet.id}) для ${users.length} пользователей`
|
||||
`Отправлено уведомление о новой дате повторного собрания ${meet.hash} (№${meet.id}) для ${sentCount}/${users.length} пользователей`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -371,23 +397,30 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
endMessage,
|
||||
};
|
||||
|
||||
const events: WorkflowBulkEventDomainInterface[] = users.map((user) => ({
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
payload,
|
||||
}));
|
||||
// Отправляем уведомления каждому пользователю в цикле
|
||||
let sentCount = 0;
|
||||
for (const user of users) {
|
||||
const triggerData: WorkflowTriggerDomainInterface = {
|
||||
name: Workflows.MeetEnded.id,
|
||||
to: {
|
||||
subscriberId: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
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(
|
||||
`Отправлено уведомление о завершении собрания ${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> {
|
||||
if (action.receiver != action.account) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Обработка действия: ${action.name} от ${action.account}: ${JSON.stringify(action.data)}`);
|
||||
|
||||
// Откладываем обработку на 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 { TypeOrmForkRepository } from './repositories/typeorm-fork.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()
|
||||
@Module({
|
||||
@@ -61,7 +64,7 @@ import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.re
|
||||
username: config.postgres.username,
|
||||
password: config.postgres.password,
|
||||
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
|
||||
logging: false,
|
||||
}),
|
||||
@@ -81,6 +84,7 @@ import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.re
|
||||
ForkEntity,
|
||||
SyncStateEntity,
|
||||
EntityVersionTypeormEntity,
|
||||
SettingsEntity,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
@@ -144,6 +148,10 @@ import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.re
|
||||
provide: SYNC_STATE_REPOSITORY_PORT,
|
||||
useClass: TypeOrmSyncStateRepository,
|
||||
},
|
||||
{
|
||||
provide: SETTINGS_REPOSITORY,
|
||||
useClass: SettingsTypeormRepository,
|
||||
},
|
||||
EntityVersionRepository,
|
||||
EntityVersioningService,
|
||||
],
|
||||
@@ -164,6 +172,7 @@ import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.re
|
||||
DELTA_REPOSITORY_PORT,
|
||||
FORK_REPOSITORY_PORT,
|
||||
SYNC_STATE_REPOSITORY_PORT,
|
||||
SETTINGS_REPOSITORY,
|
||||
EntityVersionRepository,
|
||||
EntityVersioningService,
|
||||
],
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { NovuWorkflowPort } from '~/domain/notification/interfaces/novu-wor
|
||||
import type {
|
||||
WorkflowTriggerDomainInterface,
|
||||
WorkflowTriggerResultDomainInterface,
|
||||
WorkflowBulkTriggerDomainInterface,
|
||||
} from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
|
||||
/**
|
||||
@@ -40,7 +39,13 @@ export class NovuWorkflowAdapter implements NovuWorkflowPort {
|
||||
this.logger.log(`Запуск воркфлоу: ${triggerData.name}`);
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -48,32 +53,10 @@ export class NovuWorkflowAdapter implements NovuWorkflowPort {
|
||||
|
||||
return this.mapToTriggerResult(response.data);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка запуска воркфлоу ${triggerData.name}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить воркфлоу для множественной отправки уведомлений
|
||||
* @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);
|
||||
this.logger.error(
|
||||
`Ошибка запуска воркфлоу ${triggerData.name}: ${error.message} ${error.response?.data}`,
|
||||
error.stack
|
||||
);
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"allowJs": false,
|
||||
"noImplicitAny": false,
|
||||
"skipLibCheck": true,
|
||||
"composite": true,
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
"esModuleInterop": true,
|
||||
"skipDefaultLibCheck": 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 { CreateProjectInvestDialog } from './Dialog';
|
||||
export { ProjectInvestFabAction, ComponentInvestFabAction } from './Fab';
|
||||
|
||||
+2
-14
@@ -6,11 +6,8 @@ q-select(
|
||||
option-label="label"
|
||||
emit-value
|
||||
map-options
|
||||
:color="getIssuePriorityColor(selectedPriority)"
|
||||
text-color="white"
|
||||
:bg-color="priorityBgColor"
|
||||
filled
|
||||
dense
|
||||
:standout="priorityStandout"
|
||||
:label="label"
|
||||
:readonly="readonly"
|
||||
@update:model-value="handlePriorityChange"
|
||||
@@ -21,7 +18,7 @@ q-select(
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
@@ -51,15 +48,6 @@ const { debounceSave } = useUpdateIssue()
|
||||
// Текущий выбранный приоритет
|
||||
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 = [
|
||||
{ value: Zeus.IssuePriority.LOW, label: getIssuePriorityLabel(Zeus.IssuePriority.LOW) },
|
||||
|
||||
+7
-8
@@ -1,12 +1,11 @@
|
||||
<template lang="pug">
|
||||
div
|
||||
q-btn(
|
||||
size='sm',
|
||||
color='primary',
|
||||
@click='showDialog = true',
|
||||
:loading='isSubmitting',
|
||||
label='Добавить соавтора'
|
||||
)
|
||||
q-btn(
|
||||
size='sm',
|
||||
color='primary',
|
||||
@click='showDialog = true',
|
||||
:loading='isSubmitting',
|
||||
label='Добавить соавтора'
|
||||
)
|
||||
|
||||
q-dialog(v-model='showDialog', @hide='close')
|
||||
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 { 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(
|
||||
size='sm',
|
||||
color='primary',
|
||||
@click='handleSetPlan',
|
||||
:loading='loading',
|
||||
@click='showDialog = true',
|
||||
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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
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';
|
||||
|
||||
const props = defineProps<{ project: IProject | null }>();
|
||||
const props = defineProps<{ project: IProject | null | undefined }>();
|
||||
|
||||
const { setPlan, setPlanInput } = useSetPlan();
|
||||
const loading = ref(false);
|
||||
const { setPlan, governSymbol, formatAmountForEOSIO } = useSetPlan();
|
||||
|
||||
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(
|
||||
() => props.project,
|
||||
(newProject) => {
|
||||
if (newProject) {
|
||||
setPlanInput.value.coopname = newProject.coopname || '';
|
||||
setPlanInput.value.project_hash = newProject.project_hash;
|
||||
setPlanInput.value.master = newProject.master || '';
|
||||
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 },
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSetPlan = async () => {
|
||||
loading.value = true;
|
||||
if (!props.project) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
console.log('setPlanInput', setPlanInput.value);
|
||||
await setPlan(setPlanInput.value);
|
||||
const planData = {
|
||||
coopname: props.project.coopname || '',
|
||||
master: props.project.master || '',
|
||||
project_hash: props.project.project_hash,
|
||||
plan_creators_hours: formData.value.plan_creators_hours,
|
||||
plan_expenses: formatAmountForEOSIO(formData.value.plan_expenses),
|
||||
plan_hour_cost: formatAmountForEOSIO(formData.value.plan_hour_cost)
|
||||
};
|
||||
|
||||
await setPlan(planData);
|
||||
SuccessAlert('План проекта успешно установлен');
|
||||
clear();
|
||||
} catch (error) {
|
||||
FailAlert(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
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 { CreateRequirementFabAction } from './Fab';
|
||||
export { CreateRequirementDialog } from './Dialog';
|
||||
|
||||
-6
@@ -9,7 +9,6 @@ q-btn(
|
||||
:loading='isSubmitting'
|
||||
)
|
||||
q-icon(name='delete')
|
||||
|
||||
q-dialog(v-model='showDialog', @hide='close')
|
||||
ModalBase(title='Удаление истории')
|
||||
Form.q-pa-sm(
|
||||
@@ -21,7 +20,6 @@ q-btn(
|
||||
)
|
||||
div(style='max-width: 300px')
|
||||
p Вы уверены, что хотите удалить историю?
|
||||
.text-body2.text-grey-6.q-mt-sm {{ storyTitle }}
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -42,10 +40,6 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
storyTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
|
||||
@@ -17,13 +17,11 @@ import { ProjectDescriptionPage } from './pages/ProjectDescriptionPage';
|
||||
// import { ProjectInviteEditorPage } from './pages/ProjectInviteEditorPage';
|
||||
// import { ComponentInvitePage } from './pages/ComponentInvitePage';
|
||||
import { ProjectPlanningPage } from './pages/ProjectPlanningPage';
|
||||
import { ProjectAuthorsPage } from './pages/ProjectAuthorsPage';
|
||||
import { ProjectContributorsPage } from './pages/ProjectContributorsPage';
|
||||
import { ProjectComponentsPage } from './pages/ProjectComponentsPage';
|
||||
import { ProjectRequirementsPage } from './pages/ProjectRequirementsPage';
|
||||
import { ComponentDescriptionPage } from './pages/ComponentDescriptionPage';
|
||||
import { ComponentPlanningPage } from './pages/ComponentPlanningPage';
|
||||
import { ComponentAuthorsPage } from './pages/ComponentAuthorsPage';
|
||||
import { ComponentContributorsPage } from './pages/ComponentContributorsPage';
|
||||
import { ComponentTasksPage } from './pages/ComponentTasksPage';
|
||||
import { ComponentRequirementsPage } from './pages/ComponentRequirementsPage';
|
||||
@@ -230,19 +228,6 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
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',
|
||||
name: 'project-contributors',
|
||||
@@ -369,19 +354,6 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
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',
|
||||
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">
|
||||
div
|
||||
// Заголовок страницы
|
||||
h4.q-mb-md Участники компонента
|
||||
|
||||
// Список участников
|
||||
ProjectContributorsList(:project='project')
|
||||
</template>
|
||||
|
||||
@@ -6,11 +6,28 @@ div
|
||||
// Floating Action Button
|
||||
Fab(v-if="project")
|
||||
template(#actions v-if="project?.permissions?.has_clearance")
|
||||
// Показываем кнопку создания задачи, если пользователь имеет допуск к проекту
|
||||
// Показываем кнопку создания задачи и требования, если пользователь имеет допуск к проекту
|
||||
CreateIssueFabAction(
|
||||
:project-hash="projectHash"
|
||||
@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(#default v-if="project?.permissions?.pending_clearance")
|
||||
@@ -33,15 +50,21 @@ div
|
||||
</template>
|
||||
|
||||
<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 { useBackButton } from 'src/shared/lib/navigation';
|
||||
import { useHeaderActions } from 'src/shared/hooks';
|
||||
import { RouteMenuButton, Fab } from 'src/shared/ui';
|
||||
import { CreateIssueFabAction } from 'app/extensions/capital/features/Issue/CreateIssue';
|
||||
import { CreateRequirementFabAction } from 'app/extensions/capital/features/Story/CreateStory';
|
||||
import { 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 для загрузки проекта
|
||||
const { project, projectHash, loadProject } = useProjectLoader();
|
||||
const route = useRoute();
|
||||
|
||||
// Массив кнопок меню для шапки
|
||||
const menuButtons = computed(() => [
|
||||
@@ -80,21 +103,11 @@ const menuButtons = computed(() => [
|
||||
component: markRaw(RouteMenuButton),
|
||||
props: {
|
||||
routeName: 'component-planning',
|
||||
label: 'Планирование',
|
||||
label: 'План',
|
||||
routeParams: { project_hash: projectHash.value },
|
||||
},
|
||||
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',
|
||||
component: markRaw(RouteMenuButton),
|
||||
@@ -108,9 +121,9 @@ const menuButtons = computed(() => [
|
||||
]);
|
||||
|
||||
// Настраиваем кнопку "Назад"
|
||||
// По умолчанию используем router.back(), но можно переопределить через query параметр _backRoute
|
||||
useBackButton({
|
||||
const { setBackButton } = useBackButton({
|
||||
text: 'Назад',
|
||||
routeName: route.query._backRoute as string || undefined,
|
||||
componentId: 'component-base-' + projectHash.value,
|
||||
});
|
||||
|
||||
@@ -121,10 +134,12 @@ const { registerAction: registerHeaderAction, clearActions } = useHeaderActions(
|
||||
onMounted(async () => {
|
||||
await loadProject();
|
||||
|
||||
// Регистрируем кнопки меню
|
||||
menuButtons.value.forEach(button => {
|
||||
registerHeaderAction(button);
|
||||
});
|
||||
// Регистрируем кнопки меню только если мы НЕ на странице задачи
|
||||
if (route.name !== 'component-issue') {
|
||||
menuButtons.value.forEach(button => {
|
||||
registerHeaderAction(button);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Явно очищаем кнопки при уходе со страницы
|
||||
@@ -132,12 +147,50 @@ onBeforeUnmount(() => {
|
||||
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 handleRequirementCreated = () => {
|
||||
// Можно добавить логику обновления списка требований
|
||||
};
|
||||
|
||||
// Обработчик установки плана
|
||||
const handlePlanSet = () => {
|
||||
// Можно добавить логику обновления данных проекта
|
||||
};
|
||||
|
||||
// Обработчик добавления соавторов
|
||||
const handleAuthorsAdded = () => {
|
||||
// Можно добавить логику обновления данных проекта
|
||||
};
|
||||
|
||||
// Обработчик создания инвестиции
|
||||
const handleInvestCompleted = () => {
|
||||
// Можно добавить логику обновления данных проекта
|
||||
};
|
||||
|
||||
// Обработчик успешной отправки запроса на допуск
|
||||
const handleClearanceSubmitted = async () => {
|
||||
// Обновляем данные проекта, чтобы отразить изменения в разрешениях
|
||||
|
||||
+3
-10
@@ -1,14 +1,7 @@
|
||||
<template lang="pug">
|
||||
div
|
||||
// Заголовок страницы
|
||||
h4.q-mb-md Финансирование компонента
|
||||
|
||||
// Компонент планирования (показывается только если есть разрешение на редактирование)
|
||||
div(v-if="permissions?.can_edit_project")
|
||||
ProjectPlanning(:project='project')
|
||||
div(v-else)
|
||||
.text-grey-7.q-pa-md
|
||||
p У вас нет прав для редактирования финансового плана компонента
|
||||
// Компонент планирования (всегда показывается)
|
||||
ProjectPlanningWidget(:project='project' :permissions='permissions')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -16,7 +9,7 @@ import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { IProject, IProjectPermissions } 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';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
+8
-13
@@ -1,31 +1,26 @@
|
||||
<template lang="pug">
|
||||
div
|
||||
// Заголовок страницы
|
||||
h4.q-mb-md Требования компонента
|
||||
|
||||
// Виджет историй (требований)
|
||||
StoriesWidget(
|
||||
:filter='storiesFilter',
|
||||
:canCreate='true',
|
||||
:maxItems='50',
|
||||
:emptyMessage='"Требований компонента пока нет"'
|
||||
// Виджет списка требований в виде таблицы
|
||||
RequirementsListWidget(
|
||||
:filter='requirementsFilter',
|
||||
:maxItems='50'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { StoriesWidget } from 'app/extensions/capital/widgets/StoryWidget';
|
||||
import { RequirementsListWidget } from 'app/extensions/capital/widgets/RequirementsListWidget';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// Получаем hash проекта из параметров маршрута
|
||||
const projectHash = computed(() => route.params.project_hash as string);
|
||||
|
||||
// Фильтр для историй проекта
|
||||
const storiesFilter = computed(() => ({
|
||||
// Фильтр для требований проекта
|
||||
const requirementsFilter = computed(() => ({
|
||||
project_hash: projectHash.value,
|
||||
issue_id: undefined, // Только истории проекта, не задач
|
||||
issue_id: undefined, // Только требования проекта, не задач
|
||||
}));
|
||||
</script>
|
||||
|
||||
|
||||
+3
@@ -54,6 +54,9 @@ const handleIssueClick = (issue: IIssue) => {
|
||||
project_hash: projectHash.value,
|
||||
issue_hash: issue.issue_hash,
|
||||
},
|
||||
query: {
|
||||
_backRoute: 'component-tasks'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
<template lang="pug">
|
||||
div
|
||||
div(style="padding-bottom: 100px;")
|
||||
// Заголовок с информацией о задаче
|
||||
q-card.q-mb-md(flat)
|
||||
q-card-section
|
||||
.row.items-center.q-gutter-sm
|
||||
q-icon(name='task', size='sm', color='primary')
|
||||
div
|
||||
q-input(
|
||||
v-if="issue"
|
||||
v-model='issue.title'
|
||||
label='Название задачи'
|
||||
outlined
|
||||
dense
|
||||
class="q-mb-sm"
|
||||
:readonly="!issue?.permissions?.can_edit_issue"
|
||||
@input="handleTitleChange"
|
||||
)
|
||||
.text-h6(v-if="!issue") Загрузка...
|
||||
div(v-if="issue")
|
||||
.row.items-center.q-gutter-md.q-pa-md
|
||||
q-icon(name='task', size='32px', color='primary')
|
||||
.col
|
||||
IssueTitleEditor(
|
||||
:issue='issue'
|
||||
label='Задача'
|
||||
@field-change="handleFieldChange"
|
||||
@update:title="handleTitleUpdate"
|
||||
)
|
||||
|
||||
IssueControls(
|
||||
:issue='issue'
|
||||
@@ -24,24 +18,26 @@ div
|
||||
@update:status='handleStatusUpdate'
|
||||
@update:priority='handlePriorityUpdate'
|
||||
@update:estimate='handleEstimateUpdate'
|
||||
).full-width.q-pt-md
|
||||
q-card-section
|
||||
.row.items-center.q-gutter-md.q-mb-sm
|
||||
.col
|
||||
// Индикатор авто-сохранения
|
||||
AutoSaveIndicator(
|
||||
:is-auto-saving="isAutoSaving"
|
||||
:auto-save-error="autoSaveError"
|
||||
)
|
||||
)
|
||||
|
||||
Editor(
|
||||
v-if="issue"
|
||||
v-model='issue.description',
|
||||
label='Описание задачи',
|
||||
placeholder='Опишите задачу подробно...',
|
||||
:readonly="!issue?.permissions?.can_edit_issue"
|
||||
@change='handleDescriptionChange'
|
||||
)
|
||||
.text-h6(v-if="!issue") Загрузка...
|
||||
|
||||
.row.items-center.q-gutter-md.q-mb-sm
|
||||
.col
|
||||
// Индикатор авто-сохранения
|
||||
AutoSaveIndicator(
|
||||
:is-auto-saving="isAutoSaving"
|
||||
:auto-save-error="autoSaveError"
|
||||
)
|
||||
|
||||
Editor(
|
||||
v-if="issue"
|
||||
v-model='issue.description',
|
||||
label='Описание задачи',
|
||||
placeholder='Опишите задачу подробно...',
|
||||
:readonly="!issue?.permissions?.can_edit_issue"
|
||||
@change='handleDescriptionChange'
|
||||
)
|
||||
|
||||
|
||||
</template>
|
||||
@@ -59,7 +55,7 @@ import { StoriesWidget } from 'app/extensions/capital/widgets/StoryWidget';
|
||||
import { Editor, AutoSaveIndicator } from 'src/shared/ui';
|
||||
import { textToEditorJS } from 'src/shared/lib/utils/editorjs';
|
||||
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 issue = ref<IIssue | null>(null);
|
||||
@@ -98,17 +94,16 @@ const ensureEditorJSFormat = (description: any) => {
|
||||
return textToEditorJS(String(description));
|
||||
};
|
||||
|
||||
// Обработчик изменения названия задачи
|
||||
const handleTitleChange = () => {
|
||||
if (!issue.value) return;
|
||||
// Обработчик изменения полей
|
||||
const handleFieldChange = () => {
|
||||
// Просто триггер реактивности для computed hasChanges в виджетах
|
||||
};
|
||||
|
||||
const updateData = {
|
||||
issue_hash: issue.value.issue_hash,
|
||||
title: issue.value.title,
|
||||
};
|
||||
|
||||
// Запускаем авто-сохранение с задержкой
|
||||
debounceSave(updateData, projectHash.value);
|
||||
// Обработчик обновления названия задачи
|
||||
const handleTitleUpdate = (value: string) => {
|
||||
if (issue.value) {
|
||||
issue.value.title = value;
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик изменения описания задачи
|
||||
@@ -129,8 +124,10 @@ const handleDescriptionChange = () => {
|
||||
const { registerAction: registerRightDrawerAction } = useRightDrawer();
|
||||
|
||||
// Настраиваем кнопку "Назад"
|
||||
// По умолчанию используем router.back(), но можно переопределить через query параметр _backRoute
|
||||
useBackButton({
|
||||
text: 'Назад',
|
||||
routeName: route.query._backRoute as string || undefined,
|
||||
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">
|
||||
div
|
||||
// Заголовок страницы
|
||||
h4.q-mb-md Участники проекта
|
||||
|
||||
// Список участников
|
||||
ProjectContributorsList(:project='project')
|
||||
</template>
|
||||
|
||||
@@ -3,25 +3,48 @@ div
|
||||
// Контент страницы проекта
|
||||
router-view
|
||||
|
||||
// Floating Action Button для создания компонента
|
||||
// Floating Action Button для создания компонента, требования и установки плана
|
||||
Fab(v-if="project")
|
||||
template(#actions)
|
||||
CreateComponentFabAction(
|
||||
:project="project"
|
||||
@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>
|
||||
|
||||
<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 { useBackButton } from 'src/shared/lib/navigation';
|
||||
import { useHeaderActions } from 'src/shared/hooks';
|
||||
import { RouteMenuButton, Fab } from 'src/shared/ui';
|
||||
import { CreateComponentFabAction } from 'app/extensions/capital/features/Project/CreateComponent';
|
||||
import { CreateRequirementFabAction } from 'app/extensions/capital/features/Story/CreateStory';
|
||||
import { SetPlanFabAction } from 'app/extensions/capital/features/Project/SetPlan';
|
||||
import { ProjectInvestFabAction } from 'app/extensions/capital/features/Invest/CreateProjectInvest';
|
||||
import { AddAuthorFabAction } from 'app/extensions/capital/features/Project/AddAuthor';
|
||||
|
||||
// Используем composable для загрузки проекта
|
||||
const { project, projectHash, loadProject } = useProjectLoader();
|
||||
const route = useRoute();
|
||||
|
||||
// Массив кнопок меню для шапки
|
||||
const menuButtons = computed(() => [
|
||||
@@ -60,21 +83,11 @@ const menuButtons = computed(() => [
|
||||
component: markRaw(RouteMenuButton),
|
||||
props: {
|
||||
routeName: 'project-planning',
|
||||
label: 'Планирование',
|
||||
label: 'План',
|
||||
routeParams: { project_hash: projectHash.value },
|
||||
},
|
||||
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',
|
||||
component: markRaw(RouteMenuButton),
|
||||
@@ -88,10 +101,10 @@ const menuButtons = computed(() => [
|
||||
|
||||
]);
|
||||
|
||||
// Настраиваем кнопку "Назад" на список проектов
|
||||
useBackButton({
|
||||
// Настраиваем кнопку "Назад"
|
||||
const { setBackButton } = useBackButton({
|
||||
text: 'Назад',
|
||||
routeName: 'projects-list',
|
||||
routeName: route.query._backRoute as string || 'projects-list',
|
||||
componentId: 'project-base-' + projectHash.value,
|
||||
});
|
||||
|
||||
@@ -103,10 +116,12 @@ onMounted(async () => {
|
||||
// Загружаем проект при монтировании (composable сделает это автоматически)
|
||||
await loadProject();
|
||||
|
||||
// Регистрируем кнопки меню
|
||||
menuButtons.value.forEach(button => {
|
||||
registerHeaderAction(button);
|
||||
});
|
||||
// Регистрируем кнопки меню только если мы НЕ на странице задачи
|
||||
if (route.name !== 'project-issue') {
|
||||
menuButtons.value.forEach(button => {
|
||||
registerHeaderAction(button);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Явно очищаем кнопки при уходе со страницы
|
||||
@@ -114,12 +129,50 @@ onBeforeUnmount(() => {
|
||||
clearActions();
|
||||
});
|
||||
|
||||
// Отслеживаем изменение backRoute для обновления кнопки "Назад"
|
||||
watch(() => route.query._backRoute, () => {
|
||||
setBackButton();
|
||||
});
|
||||
|
||||
// Отслеживаем переходы на дочерние маршруты (например, на страницу задачи)
|
||||
watch(() => route.name, (newRouteName) => {
|
||||
if (newRouteName === 'project-issue') {
|
||||
// Если перешли на страницу задачи - очищаем кнопки меню проекта
|
||||
clearActions();
|
||||
} else if (newRouteName && newRouteName.toString().startsWith('project-') && newRouteName !== 'project-base') {
|
||||
// Если вернулись на страницы проекта - регистрируем кнопки снова
|
||||
menuButtons.value.forEach(button => {
|
||||
registerHeaderAction(button);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Обработчик создания компонента
|
||||
const handleComponentCreated = () => {
|
||||
// Можно добавить логику обновления списка компонентов
|
||||
};
|
||||
|
||||
// Обработчик создания требования
|
||||
const handleRequirementCreated = () => {
|
||||
// Можно добавить логику обновления списка требований
|
||||
};
|
||||
|
||||
// Обработчик установки плана
|
||||
const handlePlanSet = () => {
|
||||
// Можно добавить логику обновления данных проекта
|
||||
};
|
||||
|
||||
// Обработчик добавления соавторов
|
||||
const handleAuthorsAdded = () => {
|
||||
// Можно добавить логику обновления данных проекта
|
||||
};
|
||||
|
||||
// Обработчик создания инвестиции
|
||||
const handleInvestCompleted = () => {
|
||||
// Можно добавить логику обновления данных проекта
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
+3
-10
@@ -1,21 +1,14 @@
|
||||
<template lang="pug">
|
||||
div
|
||||
// Заголовок страницы
|
||||
h4.q-mb-md Финансирование проекта
|
||||
|
||||
// Компонент планирования (показывается только если есть разрешение на редактирование)
|
||||
div(v-if="permissions?.can_edit_project")
|
||||
ProjectPlanning(:project='project')
|
||||
div(v-else)
|
||||
.text-grey-7.q-pa-md
|
||||
p У вас нет прав для редактирования финансового плана проекта
|
||||
// Компонент планирования (всегда показывается)
|
||||
ProjectPlanningWidget(:project='project' :permissions='permissions')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import type { IProjectPermissions } 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 для загрузки проекта
|
||||
const { project, loadProject } = useProjectLoader();
|
||||
|
||||
+8
-13
@@ -1,31 +1,26 @@
|
||||
<template lang="pug">
|
||||
div
|
||||
// Заголовок страницы
|
||||
h4.q-mb-md Требования проекта
|
||||
|
||||
// Виджет историй (требований)
|
||||
StoriesWidget(
|
||||
:filter='storiesFilter',
|
||||
:canCreate='true',
|
||||
:maxItems='50',
|
||||
:emptyMessage='"Требований проекта пока нет"'
|
||||
// Виджет списка требований в виде таблицы
|
||||
RequirementsListWidget(
|
||||
:filter='requirementsFilter',
|
||||
:maxItems='50'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { StoriesWidget } from 'app/extensions/capital/widgets/StoryWidget';
|
||||
import { RequirementsListWidget } from 'app/extensions/capital/widgets/RequirementsListWidget';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// Получаем hash проекта из параметров маршрута
|
||||
const projectHash = computed(() => route.params.project_hash as string);
|
||||
|
||||
// Фильтр для историй проекта
|
||||
const storiesFilter = computed(() => ({
|
||||
// Фильтр для требований проекта
|
||||
const requirementsFilter = computed(() => ({
|
||||
project_hash: projectHash.value,
|
||||
issue_id: undefined, // Только истории проекта, не задач
|
||||
issue_id: undefined, // Только требования проекта, не задач
|
||||
}));
|
||||
</script>
|
||||
|
||||
|
||||
+8
-3
@@ -16,13 +16,14 @@ div
|
||||
:has-issues-with-creators='hasIssuesWithCreators',
|
||||
:master='master',
|
||||
@toggle-expand='handleProjectToggleExpand',
|
||||
@data-loaded='handleProjectsDataLoaded'
|
||||
@data-loaded='handleProjectsDataLoaded',
|
||||
@open-project='handleOpenProject'
|
||||
)
|
||||
template(#project-content='{ project }')
|
||||
ComponentsListWidget(
|
||||
:components='project.components',
|
||||
: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'
|
||||
)
|
||||
template(#component-content='{ component }')
|
||||
@@ -32,7 +33,7 @@ div
|
||||
:priorities='componentPriorities',
|
||||
:creators='componentCreators',
|
||||
: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 для создания проекта
|
||||
@@ -101,6 +102,10 @@ const handleComponentToggle = (componentHash: string) => {
|
||||
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) => {
|
||||
// Очищаем устаревшие записи expanded проектов после загрузки данных
|
||||
cleanupProjectsExpanded(projectHashes);
|
||||
|
||||
@@ -16,7 +16,6 @@ export * from './MasterCommitsPage';
|
||||
export * from './ProjectDescriptionPage';
|
||||
export * from './ProjectInviteEditorPage';
|
||||
export * from './ProjectPlanningPage';
|
||||
export * from './ProjectAuthorsPage';
|
||||
export * from './ProjectContributorsPage';
|
||||
export * from './ProjectComponentsPage';
|
||||
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)
|
||||
.col-auto(style='width: 100px; padding-left: 30px; flex-shrink: 0')
|
||||
q-icon(name='extension', size='xs', color='primary').q-mr-xs
|
||||
span(v-if='props.row.prefix').text-grey-7 {{ '#' + props.row.prefix }}
|
||||
q-icon(name='extension', size='xs').q-mr-xs
|
||||
span.list-item-title(
|
||||
v-if='props.row.prefix'
|
||||
@click.stop='handleOpenComponent(props.row.project_hash)'
|
||||
) {{ '#' + props.row.prefix }}
|
||||
|
||||
// Title со статусом (400px + отступ 30px)
|
||||
.col(style='width: 400px; padding-left: 30px')
|
||||
@@ -47,14 +50,20 @@ q-card(flat)
|
||||
).q-mr-sm
|
||||
span {{ props.row.title }}
|
||||
|
||||
// Actions - CreateIssueButton (120px, выравнивание по правому краю)
|
||||
.col-auto.ml-auto(style='width: 120px')
|
||||
.row.items-center.justify-end
|
||||
// Actions - CreateIssueButton и кнопка перехода (140px, выравнивание по правому краю)
|
||||
.col-auto.ml-auto(style='width: 140px')
|
||||
.row.items-center.justify-end.q-gutter-xs
|
||||
CreateIssueButton(
|
||||
@click.stop,
|
||||
:mini='true',
|
||||
:project-hash='props.row.project_hash'
|
||||
)
|
||||
q-btn(
|
||||
size='xs',
|
||||
flat,
|
||||
icon='arrow_forward',
|
||||
@click.stop='handleOpenComponent(props.row.project_hash)'
|
||||
)
|
||||
//- ProjectMenuWidget(:project='props.row', @click.stop)
|
||||
|
||||
// Слот для дополнительного контента компонента
|
||||
@@ -208,4 +217,15 @@ const columns = [
|
||||
.q-chip {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Импорт глобального стиля для подсветки
|
||||
:deep(.list-item-title) {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--q-accent);
|
||||
}
|
||||
}
|
||||
</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)
|
||||
.col-auto(style='width: 100px; padding-left: 40px; flex-shrink: 0')
|
||||
q-icon(name='task', size='xs', color='primary').q-mr-xs
|
||||
span.text-grey-7 {{ '#' + props.row.id }}
|
||||
q-icon(name='task', size='xs').q-mr-xs
|
||||
span.list-item-title(
|
||||
@click.stop='handleIssueClick(props.row)'
|
||||
) {{ '#' + props.row.id }}
|
||||
|
||||
// Title с приоритетом (400px + отступ 40px)
|
||||
.col(style='width: 400px; padding-left: 40px')
|
||||
@@ -41,9 +43,9 @@ div
|
||||
).q-mr-sm
|
||||
span.text-body2.font-weight-medium {{ props.row.title }}
|
||||
|
||||
// Actions (статус + исполнитель) - выравнивание по правому краю
|
||||
// Actions (статус + исполнитель + кнопка перехода) - выравнивание по правому краю
|
||||
.col-auto.ml-auto
|
||||
.row.items-center.justify-end.q-gutter-sm
|
||||
.row.items-center.justify-end.q-gutter-xs
|
||||
UpdateStatus(
|
||||
:model-value='props.row.status'
|
||||
:issue-hash='props.row.issue_hash'
|
||||
@@ -58,10 +60,16 @@ div
|
||||
@click.stop
|
||||
style="max-width: 250px;"
|
||||
)
|
||||
|
||||
q-btn(
|
||||
size='xs',
|
||||
flat,
|
||||
icon='arrow_forward',
|
||||
@click.stop='handleIssueClick(props.row)'
|
||||
)
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
type IIssue,
|
||||
useIssueStore,
|
||||
@@ -86,7 +94,6 @@ const emit = defineEmits<{
|
||||
issueClick: [issue: IIssue];
|
||||
}>();
|
||||
|
||||
useRouter();
|
||||
const issueStore = useIssueStore();
|
||||
const { info } = useSystemStore();
|
||||
|
||||
@@ -190,4 +197,15 @@ onMounted(async () => {
|
||||
.q-chip {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Импорт глобального стиля для подсветки
|
||||
:deep(.list-item-title) {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--q-accent);
|
||||
}
|
||||
}
|
||||
</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>
|
||||
+117
-17
@@ -1,27 +1,95 @@
|
||||
<template lang="pug">
|
||||
q-card(flat)
|
||||
.q-pa-md
|
||||
// Лоадер загрузки сегментов
|
||||
WindowLoader(v-if='loading', text='')
|
||||
// Лоадер загрузки сегментов
|
||||
WindowLoader(v-if='loading', text='загрузка участников...')
|
||||
q-table(
|
||||
v-else
|
||||
:rows='segments?.items || []',
|
||||
:columns='columns',
|
||||
row-key='id',
|
||||
:pagination='{ rowsPerPage: 0 }',
|
||||
flat,
|
||||
square,
|
||||
hide-bottom,
|
||||
hide-header,
|
||||
hide-pagination,
|
||||
:no-data-label='"Нет участников"'
|
||||
)
|
||||
|
||||
|
||||
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 }}
|
||||
q-td.text-right {{ props.row.total_segment_cost }}
|
||||
.row.items-center(style='padding: 12px; min-height: 48px')
|
||||
// Порядковый номер
|
||||
.col-auto(style='width: 30px; flex-shrink: 0; text-align: center')
|
||||
span.text-caption.text-grey-6 {{ props.rowIndex + 1 }}
|
||||
|
||||
// Иконка голоса
|
||||
.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>
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
@@ -29,6 +97,8 @@ 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 { ColorCard } from 'src/shared/ui/ColorCard';
|
||||
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
|
||||
import { useSegmentStore } 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';
|
||||
@@ -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 () => {
|
||||
await loadSegments();
|
||||
@@ -92,16 +175,33 @@ const columns: QTableProps['columns'] = [
|
||||
field: 'display_name',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'total_cost',
|
||||
label: 'Вклад',
|
||||
align: 'right',
|
||||
field: 'total_segment_cost',
|
||||
sortable: true,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
+2
-17
@@ -25,13 +25,6 @@ div
|
||||
label="Финансирование",
|
||||
@click="activeTab = 'planning'"
|
||||
)
|
||||
q-btn(
|
||||
size="md"
|
||||
:outline="activeTab !== 'authors'"
|
||||
color="primary"
|
||||
label="Соавторы"
|
||||
@click="activeTab = 'authors'"
|
||||
)
|
||||
q-btn(
|
||||
size="md"
|
||||
:outline="activeTab !== 'contributors'"
|
||||
@@ -60,15 +53,8 @@ div
|
||||
: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'" )
|
||||
ProjectContributorsList(:project='project' style="min-height: 300px;")
|
||||
@@ -79,8 +65,7 @@ div
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import type { IProject, IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
|
||||
import { Editor } from 'src/shared/ui';
|
||||
import ProjectPlanning from './ProjectPlanning.vue';
|
||||
import ProjectAuthorsList from './ProjectAuthorsList.vue';
|
||||
|
||||
import ProjectContributorsList from './ProjectContributorsList.vue';
|
||||
|
||||
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 и переключаем на доступную вкладку
|
||||
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