v1.57.0: Redesign category modals + i18n updates + demo transactions fix
- Redesigned category create/edit modal with elegant wizard-style UI - Redesigned batch categorization modal with visual cards and better preview - Added missing i18n translations (common.continue, creating, remove) - Added budgets.general and wizard translations for ES, PT-BR, EN - Fixed 3 demo user transactions that were missing categories
This commit is contained in:
parent
6292b62315
commit
9c9d6443e7
24
.github/copilot-instructions.md
vendored
24
.github/copilot-instructions.md
vendored
@ -58,6 +58,30 @@ Os scripts de deploy:
|
||||
- Novos comandos artisan
|
||||
- Mudança na estrutura do projeto
|
||||
|
||||
## 🚫 Regras de UI/UX
|
||||
|
||||
**NUNCA use alert(), confirm() ou prompt() do navegador.**
|
||||
|
||||
Sempre usar componentes modais ou toast:
|
||||
- Para erros: `toast.error('mensagem')`
|
||||
- Para sucesso: `toast.success('mensagem')`
|
||||
- Para confirmação: Usar `<ConfirmModal />` component
|
||||
- Para formulários: Criar modal customizado
|
||||
|
||||
```jsx
|
||||
// ❌ PROIBIDO
|
||||
alert('Erro!');
|
||||
confirm('Tem certeza?');
|
||||
|
||||
// ✅ CORRETO
|
||||
import { useToast } from '../components/Toast';
|
||||
import { ConfirmModal } from '../components/Modal';
|
||||
|
||||
const toast = useToast();
|
||||
toast.error('Erro!');
|
||||
toast.success('Sucesso!');
|
||||
```
|
||||
|
||||
## Estrutura do Servidor
|
||||
|
||||
```
|
||||
|
||||
193
CHANGELOG.md
193
CHANGELOG.md
@ -5,41 +5,178 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
|
||||
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
|
||||
|
||||
|
||||
## [1.54.0] - 2025-12-17
|
||||
## [1.57.0] - 2025-12-18
|
||||
|
||||
### Improved
|
||||
- 🎨 **Novo Design do Modal de Categorias** - Interface completamente redesenhada seguindo o estilo do BudgetWizard:
|
||||
- Preview em tempo real da categoria (ícone, cor, nome)
|
||||
- Seleção visual de categoria pai com cards clicáveis
|
||||
- Seção de palavras-chave com badge de auto-categorização
|
||||
- Layout mais organizado e elegante
|
||||
- Feedback visual melhorado para estado ativo/inativo
|
||||
|
||||
- 🎨 **Modal de Categorização em Lote Redesenhado**:
|
||||
- Cards de resumo com ícones e cores distintivas
|
||||
- Tabela de preview mais limpa e legível
|
||||
- Estado vazio com ilustração e orientação
|
||||
- Loading spinner maior e mais visível
|
||||
|
||||
- 🌐 **Traduções i18n Atualizadas** para ES, PT-BR e EN:
|
||||
- `common.continue`, `common.creating`, `common.remove`
|
||||
- `budgets.general`
|
||||
- `budgets.wizard.createBudget`, `fillRequired`, `updated`, `created`, `selectAtLeast`
|
||||
|
||||
### Fixed
|
||||
- 🔧 **Transações Demo Categorizadas** - 3 transações pendentes (luz, internet, seguro carro) agora possuem categorias corretas
|
||||
|
||||
|
||||
## [1.56.0] - 2025-12-18
|
||||
|
||||
### Added
|
||||
- 🏠 **Landing Page Pública WebMoney** - Nova página inicial institucional
|
||||
- **Navbar** com links para seções e botões login/registro
|
||||
- **Hero Section** com animação de preview do dashboard
|
||||
- **Features Section** com 6 recursos principais:
|
||||
- Múltiplas Contas, Categorias Inteligentes, Importação Bancária
|
||||
- Relatórios Detalhados, Metas e Orçamentos, Multi-moeda
|
||||
- **Pricing Section** integrada com API de planos
|
||||
- Exibe planos Free, Pro Mensual, Pro Anual
|
||||
- Destaque para plano mais popular
|
||||
- Mostra período de trial e desconto anual
|
||||
- **FAQ Section** com accordion interativo
|
||||
- **CTA Section** com chamada para registro
|
||||
- **Footer** completo com links úteis
|
||||
- 🧙 **Novo Wizard de Contas Unificado** - Wizard inteligente para criar diferentes tipos de contas com roteamento automático:
|
||||
- **Cuenta Corriente** → Criada como conta normal (Account)
|
||||
- **Cuenta de Ahorro** → Automaticamente criada como **Activo** (poupança é um investimento)
|
||||
- **Tarjeta de Crédito** → Automaticamente criada como **Pasivo** (dívida de cartão)
|
||||
- **Efectivo** → Criada como conta normal de caixa (Account)
|
||||
|
||||
- 📝 **Registro com Seleção de Plano**
|
||||
- Cards de planos no formulário de registro
|
||||
- Suporte a parâmetro `?plan=slug` na URL
|
||||
- Redirecionamento para PayPal após registro (planos pagos)
|
||||
- Texto dinâmico do botão baseado no plano selecionado
|
||||
### Features do AccountWizard
|
||||
- Interface de 4 etapas com feedback visual claro
|
||||
- Etapa 1: Seleção visual do tipo de conta com badges indicando destino (Activo/Pasivo/Cuenta)
|
||||
- Etapa 2: Informações básicas (nome, moeda, cor)
|
||||
- Etapa 3: Informações financeiras (saldo, limite, taxas de juros)
|
||||
- Etapa 4: Dados bancários opcionais e resumo
|
||||
- Campos dinâmicos baseados no tipo de conta:
|
||||
- Cartão de crédito: Limite, día de cierre, día de vencimiento, tasa de interés
|
||||
- Poupança: Taxa de juros de rendimento
|
||||
- Suporte completo a modo mobile (fullscreen)
|
||||
- Integração com serviços existentes (accountService, assetAccountService, liabilityAccountService)
|
||||
|
||||
|
||||
## [1.55.0] - 2025-12-18
|
||||
|
||||
### Added
|
||||
- 🎛️ **Campos Avançados no Wizard de Passivos** - Baseado em requisitos profissionais de contratos
|
||||
- **Seção Indexadores de Taxa**:
|
||||
- Tipo de indexador (CDI, SELIC, IPCA, Euribor, LIBOR, SOFR, Prime Rate, TJLP, INPC, TR, IGP-M, Fixo)
|
||||
- Spread adicional (%)
|
||||
- CET - Custo Efetivo Total (%)
|
||||
|
||||
- **Seção Garantias**:
|
||||
- Tipo de garantia (Alienação Fiduciária, Hipoteca, Penhor, Avalista, Carta Fiança Bancária, Fiança, Seguro Garantia, Depósito Caução, Patrimônio Líquido, Sem Garantia)
|
||||
- Valor da garantia
|
||||
- Nome do fiador/avalista
|
||||
- Descrição da garantia
|
||||
|
||||
- **Seção Penalidades por Atraso**:
|
||||
- Multa por atraso (%)
|
||||
- Mora diária (%)
|
||||
- Dias de tolerância antes de aplicar multa
|
||||
|
||||
- **Seção Dados do Bem** (para Leasing/Veículo):
|
||||
- Valor do bem
|
||||
- Valor residual (opção de compra)
|
||||
- Descrição do bem
|
||||
|
||||
- **Seção Gestão Interna**:
|
||||
- Dias de alerta antes do vencimento
|
||||
- Número do documento (ref. interna)
|
||||
- Responsável interno
|
||||
- Notas internas (covenants, condições especiais)
|
||||
|
||||
- 📊 **Expansão do Banco de Dados**:
|
||||
- 20+ novos campos na tabela `liability_accounts`
|
||||
- Suporte a JSON para covenants complexos
|
||||
- Campos específicos por jurisdição (EU, USA, Brasil)
|
||||
|
||||
### Changed
|
||||
- 🔄 **Rota inicial alterada** - "/" agora mostra Landing Page em vez de redirecionar para login
|
||||
- 🌍 **Traduções** adicionadas para Landing em EN, ES e PT-BR
|
||||
- Namespace `landing.*` com todas as seções
|
||||
- 🎨 **UI do Wizard Melhorada**:
|
||||
- Nova seção "Opciones Avanzadas" em acordeão colapsável
|
||||
- Badge "Opcional" para indicar campos não obrigatórios
|
||||
- Campos específicos aparecem dinamicamente (ex: Dados do Bem só para leasing/veículo)
|
||||
- Step 5 (Confirmação) exibe campos avançados preenchidos
|
||||
|
||||
- 🔧 **API Atualizada**:
|
||||
- `contractTypes()` agora retorna `index_types` e `guarantee_types`
|
||||
- `storeWithWizard()` valida e aceita todos os campos avançados
|
||||
|
||||
|
||||
## [1.54.0] - 2025-12-18
|
||||
|
||||
### Added
|
||||
- 🎯 **Sistema de Criação de Passivos Completo** - Wizard profissional para registro de contratos
|
||||
- **Tipos de Contrato Suportados**:
|
||||
- Préstamo Personal (Sistema PRICE)
|
||||
- Financiación de Vehículo
|
||||
- Hipoteca/Financiación Inmobiliaria (SAC ou PRICE)
|
||||
- Tarjeta de Crédito
|
||||
- Consorcio
|
||||
- Leasing
|
||||
- Descubierto/Cheque Especial
|
||||
- Préstamo con Nómina
|
||||
- Outro
|
||||
|
||||
- **Sistemas de Amortização**:
|
||||
- PRICE (Cuota Fija) - Parcelas iguais, juros decrescentes
|
||||
- SAC (Amortización Constante) - Amortização fixa, parcelas decrescentes
|
||||
- Americano - Só juros durante o prazo, principal no final
|
||||
- Consorcio - Parcelas variáveis
|
||||
|
||||
- **Wizard de 5 Passos**:
|
||||
1. Seleção do tipo de contrato (com ícones e descrições)
|
||||
2. Dados básicos (nome, acreedor, contrato, moeda)
|
||||
3. Valores e taxas (principal, taxas, amortização, carência, seguros)
|
||||
4. Datas (início, primeiro vencimento) + Preview de parcelas
|
||||
5. Confirmação com resumo completo
|
||||
|
||||
- **Preview Automático de Parcelas**:
|
||||
- Calcula todas as parcelas baseado no sistema de amortização
|
||||
- Mostra primeiras 6 e últimas 2 parcelas
|
||||
- Exibe totais (valor total, juros total, parcela média)
|
||||
|
||||
- 📊 **Template Excel Profissional para Importação**
|
||||
- Download direto via botão no frontend
|
||||
- 3 abas: Parcelas, Instruções, Ejemplo
|
||||
- Cabeçalhos formatados e validações de dados
|
||||
- Exemplo preenchido com contrato PRICE real
|
||||
- Instruções detalhadas sobre sistemas de amortização
|
||||
- Suporta carência, seguros e taxas administrativas
|
||||
|
||||
### Changed
|
||||
- 🔄 **UI Melhorada para Passivos**:
|
||||
- Novo botão "Crear Pasivo" (verde) para abrir wizard
|
||||
- Dropdown de importação com opções:
|
||||
- Importar desde Excel
|
||||
- Descargar Plantilla
|
||||
- Estado vazio com 3 opções claras (Crear, Importar, Baixar Template)
|
||||
|
||||
- 📝 **Labels em Espanhol** - Status de contas e parcelas traduzidos:
|
||||
- Activo, Liquidado, En mora, Renegociado
|
||||
- Pendiente, Pagado, Parcial, Vencido, Cancelado
|
||||
|
||||
### Technical Details
|
||||
- Novo componente: `frontend/src/pages/Landing.jsx`
|
||||
- Novo CSS: `frontend/src/pages/Landing.css`
|
||||
- Atualizado: `frontend/src/App.jsx` (rota "/" e import Register)
|
||||
- Atualizado: `frontend/src/pages/Register.jsx` (seleção de plano)
|
||||
- Arquivos de tradução atualizados: `en.json`, `es.json`, `pt-BR.json`
|
||||
- PayPal configurado no servidor (sandbox mode)
|
||||
- **Backend**:
|
||||
- `LiabilityTemplateService.php` - Gera template Excel com PhpSpreadsheet
|
||||
- `LiabilityAccount::CONTRACT_TYPES` - Constantes com metadata de cada tipo
|
||||
- `LiabilityAccount::AMORTIZATION_SYSTEMS` - Sistemas de amortização suportados
|
||||
- Migration: `add_contract_type_to_liability_accounts_table`
|
||||
- Novos campos: `contract_type`, `amortization_system`, `has_grace_period`, `grace_period_months`
|
||||
- Endpoint `POST /api/liability-accounts/wizard` - Criação via wizard
|
||||
- Endpoint `GET /api/liability-accounts/template` - Download do template
|
||||
- Endpoint `GET /api/liability-accounts/contract-types` - Lista tipos de contrato
|
||||
- Geração automática de parcelas (PRICE, SAC, Americano)
|
||||
|
||||
- **Frontend**:
|
||||
- `LiabilityWizard.jsx` - Componente de wizard com 5 passos
|
||||
- Cálculo de parcelas em tempo real no frontend
|
||||
- Download de arquivo Excel via Blob
|
||||
- Integração com `liabilityAccountService`
|
||||
|
||||
### API Endpoints
|
||||
```
|
||||
GET /api/liability-accounts/template - Download template Excel
|
||||
GET /api/liability-accounts/contract-types - Lista tipos de contrato
|
||||
POST /api/liability-accounts/wizard - Criar conta via wizard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
Wanna.xlsx
Normal file
BIN
Wanna.xlsx
Normal file
Binary file not shown.
119
backend/app/Console/Commands/GenerateDemoInstallments.php
Normal file
119
backend/app/Console/Commands/GenerateDemoInstallments.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\LiabilityAccount;
|
||||
use App\Models\LiabilityInstallment;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GenerateDemoInstallments extends Command
|
||||
{
|
||||
protected $signature = 'demo:generate-installments';
|
||||
protected $description = 'Gerar parcelas de exemplo para passivos DEMO que não têm parcelas';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Verificando passivos sem parcelas...');
|
||||
|
||||
// Buscar passivos que têm total_installments > 0 mas não têm parcelas
|
||||
$accounts = LiabilityAccount::withCount('installments')
|
||||
->having('installments_count', '=', 0)
|
||||
->where('total_installments', '>', 0)
|
||||
->get();
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('Todos os passivos já têm parcelas geradas.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Encontrados {$accounts->count()} passivos sem parcelas.");
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($accounts as $account) {
|
||||
$this->generateInstallments($account);
|
||||
}
|
||||
DB::commit();
|
||||
$this->info('✓ Parcelas geradas com sucesso!');
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error('Erro: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function generateInstallments(LiabilityAccount $account): void
|
||||
{
|
||||
$this->info("Gerando parcelas para: {$account->name}");
|
||||
|
||||
$totalInstallments = $account->total_installments;
|
||||
$paidInstallments = $account->paid_installments ?? 0;
|
||||
$principal = $account->principal_amount;
|
||||
$annualRate = $account->annual_interest_rate ?? 0;
|
||||
$monthlyRate = $annualRate / 12 / 100;
|
||||
$startDate = $account->first_due_date ?? $account->start_date ?? now();
|
||||
|
||||
if (is_string($startDate)) {
|
||||
$startDate = Carbon::parse($startDate);
|
||||
}
|
||||
|
||||
// Calcular parcela mensal (sistema PRICE)
|
||||
if ($monthlyRate > 0) {
|
||||
$pmt = $principal * ($monthlyRate * pow(1 + $monthlyRate, $totalInstallments)) /
|
||||
(pow(1 + $monthlyRate, $totalInstallments) - 1);
|
||||
} else {
|
||||
$pmt = $principal / $totalInstallments;
|
||||
}
|
||||
|
||||
$balance = $principal;
|
||||
$installments = [];
|
||||
|
||||
for ($i = 1; $i <= $totalInstallments; $i++) {
|
||||
$dueDate = $startDate->copy()->addMonths($i - 1);
|
||||
|
||||
// Calcular juros e capital
|
||||
$interestAmount = $balance * $monthlyRate;
|
||||
$principalPaid = $pmt - $interestAmount;
|
||||
|
||||
// Última parcela ajusta para zerar o saldo
|
||||
if ($i === $totalInstallments) {
|
||||
$principalPaid = $balance;
|
||||
$pmt = $principalPaid + $interestAmount;
|
||||
}
|
||||
|
||||
$balance -= $principalPaid;
|
||||
|
||||
// Determinar status
|
||||
$isPaid = $i <= $paidInstallments;
|
||||
$status = $isPaid ? 'paid' : ($dueDate->isPast() ? 'overdue' : 'pending');
|
||||
|
||||
$installments[] = [
|
||||
'liability_account_id' => $account->id,
|
||||
'installment_number' => $i,
|
||||
'due_date' => $dueDate->format('Y-m-d'),
|
||||
'installment_amount' => round($pmt, 2),
|
||||
'principal_amount' => round($principalPaid, 2),
|
||||
'interest_amount' => round($interestAmount, 2),
|
||||
'fee_amount' => 0,
|
||||
'paid_amount' => $isPaid ? round($pmt, 2) : 0,
|
||||
'paid_date' => $isPaid ? $dueDate->format('Y-m-d') : null,
|
||||
'status' => $status,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
// Inserir em lotes
|
||||
foreach (array_chunk($installments, 50) as $chunk) {
|
||||
LiabilityInstallment::insert($chunk);
|
||||
}
|
||||
|
||||
$this->info(" ✓ {$totalInstallments} parcelas criadas");
|
||||
|
||||
// Recalcular totais
|
||||
$account->recalculateTotals();
|
||||
}
|
||||
}
|
||||
661
backend/app/Console/Commands/PopulateDemoData.php
Normal file
661
backend/app/Console/Commands/PopulateDemoData.php
Normal file
@ -0,0 +1,661 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PopulateDemoData extends Command
|
||||
{
|
||||
protected $signature = 'demo:populate {--fresh : Limpar dados existentes antes de popular}';
|
||||
protected $description = 'Popular dados de demonstração (contas, categorias, transações) para o usuário DEMO';
|
||||
|
||||
private User $user;
|
||||
private array $accounts = [];
|
||||
private array $categories = [];
|
||||
private array $subcategories = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->user = User::where('email', 'demo@webmoney.com')->first();
|
||||
|
||||
if (!$this->user) {
|
||||
$this->error('Usuário demo@webmoney.com não encontrado!');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Populando dados para usuário DEMO (ID: {$this->user->id})...");
|
||||
|
||||
if ($this->option('fresh')) {
|
||||
$this->clearExistingData();
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$this->createAccounts();
|
||||
$this->createCategories();
|
||||
$this->createTransactions();
|
||||
|
||||
// Recalcular saldos das contas
|
||||
$this->recalculateBalances();
|
||||
|
||||
DB::commit();
|
||||
$this->newLine();
|
||||
$this->info('✓ Dados DEMO populados com sucesso!');
|
||||
$this->showSummary();
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error('Erro: ' . $e->getMessage());
|
||||
$this->error($e->getTraceAsString());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function clearExistingData(): void
|
||||
{
|
||||
$this->info('Limpando dados existentes...');
|
||||
Transaction::where('user_id', $this->user->id)->delete();
|
||||
// Subcategorias são Category com parent_id
|
||||
Category::where('user_id', $this->user->id)->whereNotNull('parent_id')->delete();
|
||||
Category::where('user_id', $this->user->id)->whereNull('parent_id')->delete();
|
||||
Account::where('user_id', $this->user->id)->delete();
|
||||
}
|
||||
|
||||
private function createAccounts(): void
|
||||
{
|
||||
$this->info('Criando contas...');
|
||||
|
||||
$accountsData = [
|
||||
[
|
||||
'name' => 'Cuenta Corriente Principal',
|
||||
'type' => 'checking',
|
||||
'bank_name' => 'Santander',
|
||||
'account_number' => 'ES12 3456 7890 1234',
|
||||
'initial_balance' => 5000.00,
|
||||
'currency' => 'EUR',
|
||||
'color' => '#3B82F6',
|
||||
'icon' => 'bi-bank',
|
||||
'is_active' => true,
|
||||
'include_in_total' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Cuenta de Ahorro',
|
||||
'type' => 'savings',
|
||||
'bank_name' => 'BBVA',
|
||||
'account_number' => 'ES98 7654 3210 9876',
|
||||
'initial_balance' => 15000.00,
|
||||
'currency' => 'EUR',
|
||||
'color' => '#10B981',
|
||||
'icon' => 'bi-piggy-bank',
|
||||
'is_active' => true,
|
||||
'include_in_total' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Efectivo',
|
||||
'type' => 'cash',
|
||||
'bank_name' => null,
|
||||
'account_number' => null,
|
||||
'initial_balance' => 500.00,
|
||||
'currency' => 'EUR',
|
||||
'color' => '#F59E0B',
|
||||
'icon' => 'bi-cash-stack',
|
||||
'is_active' => true,
|
||||
'include_in_total' => true,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($accountsData as $data) {
|
||||
$account = Account::create([
|
||||
'user_id' => $this->user->id,
|
||||
'name' => $data['name'],
|
||||
'type' => $data['type'],
|
||||
'bank_name' => $data['bank_name'],
|
||||
'account_number' => $data['account_number'],
|
||||
'initial_balance' => $data['initial_balance'],
|
||||
'current_balance' => $data['initial_balance'],
|
||||
'currency' => $data['currency'],
|
||||
'color' => $data['color'],
|
||||
'icon' => $data['icon'],
|
||||
'is_active' => $data['is_active'],
|
||||
'include_in_total' => $data['include_in_total'],
|
||||
]);
|
||||
$this->accounts[$data['type']] = $account;
|
||||
$this->info(" ✓ {$data['name']}");
|
||||
}
|
||||
}
|
||||
|
||||
private function createCategories(): void
|
||||
{
|
||||
$this->info('Criando categorias e subcategorias...');
|
||||
|
||||
$categoriesData = [
|
||||
// DESPESAS (expense)
|
||||
[
|
||||
'name' => 'Vivienda',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-house',
|
||||
'color' => '#EF4444',
|
||||
'subcategories' => ['Alquiler', 'Hipoteca', 'Comunidad', 'Seguro Hogar', 'Reparaciones', 'Muebles'],
|
||||
],
|
||||
[
|
||||
'name' => 'Alimentación',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-cart',
|
||||
'color' => '#F97316',
|
||||
'subcategories' => ['Supermercado', 'Restaurantes', 'Cafeterías', 'Delivery', 'Panadería'],
|
||||
],
|
||||
[
|
||||
'name' => 'Transporte',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-car-front',
|
||||
'color' => '#8B5CF6',
|
||||
'subcategories' => ['Combustible', 'Transporte Público', 'Taxi/Uber', 'Mantenimiento Coche', 'Parking', 'Seguro Coche'],
|
||||
],
|
||||
[
|
||||
'name' => 'Servicios',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-lightning',
|
||||
'color' => '#EC4899',
|
||||
'subcategories' => ['Electricidad', 'Gas', 'Agua', 'Internet', 'Teléfono', 'Streaming'],
|
||||
],
|
||||
[
|
||||
'name' => 'Salud',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-heart-pulse',
|
||||
'color' => '#14B8A6',
|
||||
'subcategories' => ['Médico', 'Farmacia', 'Dentista', 'Óptica', 'Gimnasio', 'Seguro Médico'],
|
||||
],
|
||||
[
|
||||
'name' => 'Ocio',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-controller',
|
||||
'color' => '#6366F1',
|
||||
'subcategories' => ['Cine', 'Conciertos', 'Viajes', 'Hobbies', 'Libros', 'Videojuegos'],
|
||||
],
|
||||
[
|
||||
'name' => 'Ropa',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-bag',
|
||||
'color' => '#A855F7',
|
||||
'subcategories' => ['Ropa', 'Calzado', 'Accesorios', 'Ropa Deportiva'],
|
||||
],
|
||||
[
|
||||
'name' => 'Educación',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-book',
|
||||
'color' => '#0EA5E9',
|
||||
'subcategories' => ['Cursos', 'Material Escolar', 'Idiomas', 'Certificaciones'],
|
||||
],
|
||||
[
|
||||
'name' => 'Mascotas',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-piggy-bank',
|
||||
'color' => '#84CC16',
|
||||
'subcategories' => ['Comida Mascota', 'Veterinario', 'Accesorios Mascota'],
|
||||
],
|
||||
[
|
||||
'name' => 'Otros Gastos',
|
||||
'type' => 'debit',
|
||||
'icon' => 'bi-three-dots',
|
||||
'color' => '#64748B',
|
||||
'subcategories' => ['Regalos', 'Donaciones', 'Imprevistos', 'Varios'],
|
||||
],
|
||||
// INGRESOS (income)
|
||||
[
|
||||
'name' => 'Salario',
|
||||
'type' => 'credit',
|
||||
'icon' => 'bi-briefcase',
|
||||
'color' => '#22C55E',
|
||||
'subcategories' => ['Nómina', 'Horas Extra', 'Bonus', 'Comisiones'],
|
||||
],
|
||||
[
|
||||
'name' => 'Inversiones',
|
||||
'type' => 'credit',
|
||||
'icon' => 'bi-graph-up-arrow',
|
||||
'color' => '#10B981',
|
||||
'subcategories' => ['Dividendos', 'Intereses', 'Plusvalías', 'Alquileres'],
|
||||
],
|
||||
[
|
||||
'name' => 'Freelance',
|
||||
'type' => 'credit',
|
||||
'icon' => 'bi-laptop',
|
||||
'color' => '#06B6D4',
|
||||
'subcategories' => ['Proyectos', 'Consultoría', 'Clases Particulares'],
|
||||
],
|
||||
[
|
||||
'name' => 'Otros Ingresos',
|
||||
'type' => 'credit',
|
||||
'icon' => 'bi-plus-circle',
|
||||
'color' => '#84CC16',
|
||||
'subcategories' => ['Reembolsos', 'Ventas', 'Premios', 'Herencias'],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($categoriesData as $catData) {
|
||||
$category = Category::create([
|
||||
'user_id' => $this->user->id,
|
||||
'name' => $catData['name'],
|
||||
'type' => $catData['type'],
|
||||
'icon' => $catData['icon'],
|
||||
'color' => $catData['color'],
|
||||
'is_active' => true,
|
||||
'parent_id' => null,
|
||||
]);
|
||||
|
||||
$this->categories[$catData['name']] = $category;
|
||||
|
||||
// Subcategorias são Category com parent_id
|
||||
foreach ($catData['subcategories'] as $subName) {
|
||||
$sub = Category::create([
|
||||
'user_id' => $this->user->id,
|
||||
'parent_id' => $category->id,
|
||||
'name' => $subName,
|
||||
'type' => $catData['type'],
|
||||
'icon' => $catData['icon'],
|
||||
'color' => $catData['color'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
$this->subcategories[$subName] = $sub;
|
||||
}
|
||||
|
||||
$this->info(" ✓ {$catData['name']} ({$catData['type']}) - " . count($catData['subcategories']) . " subcategorias");
|
||||
}
|
||||
}
|
||||
|
||||
private function createTransactions(): void
|
||||
{
|
||||
$this->info('Criando transações de 2025-2026...');
|
||||
|
||||
$checkingAccount = $this->accounts['checking'];
|
||||
$savingsAccount = $this->accounts['savings'];
|
||||
$cashAccount = $this->accounts['cash'];
|
||||
|
||||
$transactionCount = 0;
|
||||
$today = Carbon::today();
|
||||
|
||||
// Helper para determinar status baseado na data
|
||||
$getStatus = function(Carbon $date) use ($today) {
|
||||
return $date->isAfter($today) ? 'pending' : 'effective';
|
||||
};
|
||||
|
||||
// Gerar transações de Janeiro 2025 a Março 2026
|
||||
for ($i = 1; $i <= 15; $i++) { // 12 meses de 2025 + 3 de 2026
|
||||
$year = $i <= 12 ? 2025 : 2026;
|
||||
$month = $i <= 12 ? $i : $i - 12;
|
||||
|
||||
$daysInMonth = Carbon::create($year, $month)->daysInMonth;
|
||||
|
||||
// RECEITAS FIXAS (mensais)
|
||||
// Salário - dia 28 ou último dia útil
|
||||
$salaryDay = min(28, $daysInMonth);
|
||||
$salaryDate = Carbon::create($year, $month, $salaryDay);
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Salario',
|
||||
'subcategory' => 'Nómina',
|
||||
'type' => 'credit',
|
||||
'amount' => 3200.00,
|
||||
'date' => $salaryDate,
|
||||
'status' => $getStatus($salaryDate),
|
||||
'description' => 'Salario mensual',
|
||||
]);
|
||||
$transactionCount++;
|
||||
|
||||
// DESPESAS FIXAS (mensais)
|
||||
// Aluguel - dia 1
|
||||
$rentDate = Carbon::create($year, $month, 1);
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Vivienda',
|
||||
'subcategory' => 'Alquiler',
|
||||
'type' => 'debit',
|
||||
'amount' => 850.00,
|
||||
'date' => $rentDate,
|
||||
'status' => $getStatus($rentDate),
|
||||
'description' => 'Alquiler apartamento',
|
||||
]);
|
||||
$transactionCount++;
|
||||
|
||||
// Serviços - vários dias do mês
|
||||
$services = [
|
||||
['subcategory' => 'Electricidad', 'amount' => rand(45, 85), 'day' => 5],
|
||||
['subcategory' => 'Gas', 'amount' => rand(25, 55), 'day' => 8],
|
||||
['subcategory' => 'Agua', 'amount' => rand(20, 35), 'day' => 10],
|
||||
['subcategory' => 'Internet', 'amount' => 49.99, 'day' => 15],
|
||||
['subcategory' => 'Teléfono', 'amount' => 25.00, 'day' => 15],
|
||||
['subcategory' => 'Streaming', 'amount' => 17.99, 'day' => 20],
|
||||
];
|
||||
|
||||
foreach ($services as $service) {
|
||||
if ($service['day'] <= $daysInMonth) {
|
||||
$serviceDate = Carbon::create($year, $month, $service['day']);
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Servicios',
|
||||
'subcategory' => $service['subcategory'],
|
||||
'type' => 'debit',
|
||||
'amount' => $service['amount'],
|
||||
'date' => $serviceDate,
|
||||
'status' => $getStatus($serviceDate),
|
||||
'description' => $service['subcategory'],
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Supermercado - várias vezes por mês
|
||||
$supermarketDays = [3, 10, 17, 24];
|
||||
foreach ($supermarketDays as $day) {
|
||||
if ($day <= $daysInMonth) {
|
||||
$marketDate = Carbon::create($year, $month, $day);
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Alimentación',
|
||||
'subcategory' => 'Supermercado',
|
||||
'type' => 'debit',
|
||||
'amount' => rand(60, 120),
|
||||
'date' => $marketDate,
|
||||
'status' => $getStatus($marketDate),
|
||||
'description' => 'Compra supermercado',
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Transporte - combustível e outros
|
||||
$fuelDate = Carbon::create($year, $month, rand(1, min(15, $daysInMonth)));
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Transporte',
|
||||
'subcategory' => 'Combustible',
|
||||
'type' => 'debit',
|
||||
'amount' => rand(50, 80),
|
||||
'date' => $fuelDate,
|
||||
'status' => $getStatus($fuelDate),
|
||||
'description' => 'Gasolina',
|
||||
]);
|
||||
$transactionCount++;
|
||||
|
||||
// Restaurantes - algumas vezes por mês
|
||||
$restaurantCount = rand(2, 4);
|
||||
for ($j = 0; $j < $restaurantCount; $j++) {
|
||||
$restaurantDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
||||
$this->createTransaction([
|
||||
'account_id' => $cashAccount->id,
|
||||
'category' => 'Alimentación',
|
||||
'subcategory' => 'Restaurantes',
|
||||
'type' => 'debit',
|
||||
'amount' => rand(25, 60),
|
||||
'date' => $restaurantDate,
|
||||
'status' => $getStatus($restaurantDate),
|
||||
'description' => 'Cena/Almuerzo fuera',
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
|
||||
// Café - várias vezes
|
||||
$coffeeCount = rand(5, 10);
|
||||
for ($j = 0; $j < $coffeeCount; $j++) {
|
||||
$coffeeDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
||||
$this->createTransaction([
|
||||
'account_id' => $cashAccount->id,
|
||||
'category' => 'Alimentación',
|
||||
'subcategory' => 'Cafeterías',
|
||||
'type' => 'debit',
|
||||
'amount' => rand(3, 8),
|
||||
'date' => $coffeeDate,
|
||||
'status' => $getStatus($coffeeDate),
|
||||
'description' => 'Café',
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
|
||||
// Retirada de cajero para efectivo (do banco para cash)
|
||||
$atmDate = Carbon::create($year, $month, rand(1, min(5, $daysInMonth)));
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Otros Gastos',
|
||||
'subcategory' => 'Varios',
|
||||
'type' => 'debit',
|
||||
'amount' => 200.00,
|
||||
'date' => $atmDate,
|
||||
'status' => $getStatus($atmDate),
|
||||
'description' => 'Retiro cajero automático',
|
||||
]);
|
||||
$transactionCount++;
|
||||
|
||||
$this->createTransaction([
|
||||
'account_id' => $cashAccount->id,
|
||||
'category' => 'Otros Ingresos',
|
||||
'subcategory' => 'Reembolsos',
|
||||
'type' => 'credit',
|
||||
'amount' => 200.00,
|
||||
'date' => $atmDate,
|
||||
'status' => $getStatus($atmDate),
|
||||
'description' => 'Retiro cajero automático',
|
||||
]);
|
||||
$transactionCount++;
|
||||
|
||||
// Saúde - eventual
|
||||
if (rand(1, 3) == 1) {
|
||||
$healthDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
||||
$healthSubs = ['Farmacia', 'Médico', 'Gimnasio'];
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Salud',
|
||||
'subcategory' => $healthSubs[array_rand($healthSubs)],
|
||||
'type' => 'debit',
|
||||
'amount' => rand(15, 80),
|
||||
'date' => $healthDate,
|
||||
'status' => $getStatus($healthDate),
|
||||
'description' => 'Gasto salud',
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
|
||||
// Ginásio - mensal
|
||||
$gymDate = Carbon::create($year, $month, 1);
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Salud',
|
||||
'subcategory' => 'Gimnasio',
|
||||
'type' => 'debit',
|
||||
'amount' => 35.00,
|
||||
'date' => $gymDate,
|
||||
'status' => $getStatus($gymDate),
|
||||
'description' => 'Cuota gimnasio',
|
||||
]);
|
||||
$transactionCount++;
|
||||
|
||||
// Lazer - algumas vezes
|
||||
if (rand(1, 2) == 1) {
|
||||
$leisureDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
||||
$leisureSubs = ['Cine', 'Conciertos', 'Hobbies', 'Libros', 'Videojuegos'];
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Ocio',
|
||||
'subcategory' => $leisureSubs[array_rand($leisureSubs)],
|
||||
'type' => 'debit',
|
||||
'amount' => rand(15, 60),
|
||||
'date' => $leisureDate,
|
||||
'status' => $getStatus($leisureDate),
|
||||
'description' => 'Entretenimiento',
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
|
||||
// Roupa - eventual
|
||||
if (rand(1, 4) == 1) {
|
||||
$clothesDate = Carbon::create($year, $month, rand(1, $daysInMonth));
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Ropa',
|
||||
'subcategory' => 'Ropa',
|
||||
'type' => 'debit',
|
||||
'amount' => rand(30, 120),
|
||||
'date' => $clothesDate,
|
||||
'status' => $getStatus($clothesDate),
|
||||
'description' => 'Compra ropa',
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
|
||||
// Freelance - eventual (2-3 vezes por trimestre)
|
||||
if ($month % 3 == 0 || rand(1, 5) == 1) {
|
||||
$freelanceDate = Carbon::create($year, $month, rand(10, min(25, $daysInMonth)));
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Freelance',
|
||||
'subcategory' => 'Proyectos',
|
||||
'type' => 'credit',
|
||||
'amount' => rand(200, 800),
|
||||
'date' => $freelanceDate,
|
||||
'status' => $getStatus($freelanceDate),
|
||||
'description' => 'Proyecto freelance',
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
|
||||
// Dividendos - trimestral
|
||||
if ($month % 3 == 0) {
|
||||
$dividendDate = Carbon::create($year, $month, 15);
|
||||
$this->createTransaction([
|
||||
'account_id' => $savingsAccount->id,
|
||||
'category' => 'Inversiones',
|
||||
'subcategory' => 'Dividendos',
|
||||
'type' => 'credit',
|
||||
'amount' => rand(50, 150),
|
||||
'date' => $dividendDate,
|
||||
'status' => $getStatus($dividendDate),
|
||||
'description' => 'Dividendos trimestre',
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
|
||||
// Juros poupança - mensal
|
||||
$interestDate = Carbon::create($year, $month, $daysInMonth);
|
||||
$this->createTransaction([
|
||||
'account_id' => $savingsAccount->id,
|
||||
'category' => 'Inversiones',
|
||||
'subcategory' => 'Intereses',
|
||||
'type' => 'credit',
|
||||
'amount' => round(rand(15, 35) + (rand(0, 99) / 100), 2),
|
||||
'date' => $interestDate,
|
||||
'status' => $getStatus($interestDate),
|
||||
'description' => 'Intereses cuenta ahorro',
|
||||
]);
|
||||
$transactionCount++;
|
||||
|
||||
// Transferência para poupança - mensal
|
||||
if (rand(1, 2) == 1) {
|
||||
$transferDate = Carbon::create($year, $month, rand(25, min(28, $daysInMonth)));
|
||||
$transferAmount = rand(200, 500);
|
||||
$this->createTransaction([
|
||||
'account_id' => $checkingAccount->id,
|
||||
'category' => 'Otros Gastos',
|
||||
'subcategory' => 'Varios',
|
||||
'type' => 'debit',
|
||||
'amount' => $transferAmount,
|
||||
'date' => $transferDate,
|
||||
'status' => $getStatus($transferDate),
|
||||
'description' => 'Transferencia a cuenta ahorro',
|
||||
]);
|
||||
$transactionCount++;
|
||||
|
||||
$this->createTransaction([
|
||||
'account_id' => $savingsAccount->id,
|
||||
'category' => 'Otros Ingresos',
|
||||
'subcategory' => 'Reembolsos',
|
||||
'type' => 'credit',
|
||||
'amount' => $transferAmount,
|
||||
'date' => $transferDate,
|
||||
'status' => $getStatus($transferDate),
|
||||
'description' => 'Transferencia desde cuenta corriente',
|
||||
]);
|
||||
$transactionCount++;
|
||||
}
|
||||
|
||||
$this->info(" ✓ Mes $month/$year procesado");
|
||||
}
|
||||
|
||||
$this->info(" Total: $transactionCount transacciones creadas");
|
||||
}
|
||||
|
||||
private function createTransaction(array $data): Transaction
|
||||
{
|
||||
$category = $this->categories[$data['category']] ?? null;
|
||||
// Subcategoria é usada diretamente como category_id (pois são Categories com parent_id)
|
||||
$subcategory = $this->subcategories[$data['subcategory']] ?? null;
|
||||
|
||||
// Se tiver subcategoria, usa ela; senão usa a categoria pai
|
||||
$categoryId = $subcategory?->id ?? $category?->id;
|
||||
|
||||
// Status vem do data ou default 'effective'
|
||||
$status = $data['status'] ?? 'effective';
|
||||
|
||||
// Para transações pendentes, não definir effective_date
|
||||
$effectiveDate = $status === 'pending' ? null : $data['date'];
|
||||
|
||||
return Transaction::create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $data['account_id'],
|
||||
'category_id' => $categoryId,
|
||||
'type' => $data['type'],
|
||||
'amount' => $status === 'pending' ? null : $data['amount'],
|
||||
'planned_amount' => $data['amount'],
|
||||
'planned_date' => $data['date'],
|
||||
'effective_date' => $effectiveDate,
|
||||
'description' => $data['description'],
|
||||
'notes' => null,
|
||||
'is_recurring' => false,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
private function recalculateBalances(): void
|
||||
{
|
||||
$this->info('Recalculando saldos das contas...');
|
||||
|
||||
foreach ($this->accounts as $account) {
|
||||
// Apenas transações efetivas afetam o saldo atual
|
||||
$income = Transaction::where('account_id', $account->id)
|
||||
->where('type', 'credit')
|
||||
->where('status', 'effective')
|
||||
->sum('amount');
|
||||
|
||||
$expense = Transaction::where('account_id', $account->id)
|
||||
->where('type', 'debit')
|
||||
->where('status', 'effective')
|
||||
->sum('amount');
|
||||
|
||||
$newBalance = $account->initial_balance + $income - $expense;
|
||||
|
||||
$account->update(['current_balance' => $newBalance]);
|
||||
|
||||
$this->info(" ✓ {$account->name}: €" . number_format($newBalance, 2));
|
||||
}
|
||||
}
|
||||
|
||||
private function showSummary(): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('=== RESUMO ===');
|
||||
$this->info('Contas: ' . count($this->accounts));
|
||||
$this->info('Categorias: ' . count($this->categories));
|
||||
$this->info('Subcategorias: ' . count($this->subcategories));
|
||||
$this->info('Transações: ' . Transaction::where('user_id', $this->user->id)->count());
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Saldos finais:');
|
||||
foreach ($this->accounts as $account) {
|
||||
$account->refresh();
|
||||
$this->info(" {$account->name}: €" . number_format($account->current_balance, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
420
backend/app/Http/Controllers/Api/AssetAccountController.php
Normal file
420
backend/app/Http/Controllers/Api/AssetAccountController.php
Normal file
@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AssetAccount;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AssetAccountController extends Controller
|
||||
{
|
||||
/**
|
||||
* Listar todos os ativos do usuário
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = AssetAccount::where('user_id', Auth::id());
|
||||
|
||||
// Filtros
|
||||
if ($request->has('asset_type') && $request->asset_type) {
|
||||
$query->where('asset_type', $request->asset_type);
|
||||
}
|
||||
|
||||
if ($request->has('status') && $request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->has('search') && $request->search) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%")
|
||||
->orWhere('document_number', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Ordenação
|
||||
$sortBy = $request->get('sort_by', 'created_at');
|
||||
$sortDir = $request->get('sort_dir', 'desc');
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
|
||||
// Paginação ou todos
|
||||
if ($request->has('per_page')) {
|
||||
$assets = $query->paginate($request->per_page);
|
||||
} else {
|
||||
$assets = $query->get();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $assets,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar novo ativo
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'asset_type' => ['required', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'currency' => 'required|string|size:3',
|
||||
'color' => 'nullable|string|max:7',
|
||||
'acquisition_value' => 'required|numeric|min:0',
|
||||
'current_value' => 'required|numeric|min:0',
|
||||
'acquisition_date' => 'nullable|date',
|
||||
// Campos opcionais conforme tipo
|
||||
'property_type' => 'nullable|string',
|
||||
'investment_type' => 'nullable|string',
|
||||
'depreciation_method' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$data = $request->all();
|
||||
$data['user_id'] = Auth::id();
|
||||
$data['business_id'] = $request->business_id ?? Auth::user()->businesses()->first()?->id;
|
||||
|
||||
$asset = AssetAccount::create($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Activo creado con éxito',
|
||||
'data' => $asset,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ver um ativo específico
|
||||
*/
|
||||
public function show(AssetAccount $assetAccount): JsonResponse
|
||||
{
|
||||
// Verificar se pertence ao usuário
|
||||
if ($assetAccount->user_id !== Auth::id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $assetAccount->load('linkedLiability'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar ativo
|
||||
*/
|
||||
public function update(Request $request, AssetAccount $assetAccount): JsonResponse
|
||||
{
|
||||
if ($assetAccount->user_id !== Auth::id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'asset_type' => ['nullable', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
|
||||
'name' => 'nullable|string|max:255',
|
||||
'current_value' => 'nullable|numeric|min:0',
|
||||
'status' => ['nullable', Rule::in(array_keys(AssetAccount::STATUSES))],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$assetAccount->update($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Activo actualizado con éxito',
|
||||
'data' => $assetAccount->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletar ativo
|
||||
*/
|
||||
public function destroy(AssetAccount $assetAccount): JsonResponse
|
||||
{
|
||||
if ($assetAccount->user_id !== Auth::id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$assetAccount->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Activo eliminado con éxito',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retornar tipos de ativos e opções para o wizard
|
||||
*/
|
||||
public function assetTypes(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => AssetAccount::ASSET_TYPES,
|
||||
'property_types' => AssetAccount::PROPERTY_TYPES,
|
||||
'investment_types' => AssetAccount::INVESTMENT_TYPES,
|
||||
'depreciation_methods' => AssetAccount::DEPRECIATION_METHODS,
|
||||
'index_types' => AssetAccount::INDEX_TYPES,
|
||||
'statuses' => AssetAccount::STATUSES,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar ativo via wizard
|
||||
*/
|
||||
public function storeWithWizard(Request $request): JsonResponse
|
||||
{
|
||||
$rules = [
|
||||
// Step 1 - Tipo
|
||||
'asset_type' => ['required', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
|
||||
|
||||
// Step 2 - Dados básicos
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'currency' => 'required|string|size:3',
|
||||
'color' => 'nullable|string|max:7',
|
||||
|
||||
// Step 3 - Valores
|
||||
'acquisition_value' => 'required|numeric|min:0',
|
||||
'current_value' => 'required|numeric|min:0',
|
||||
'acquisition_date' => 'nullable|date',
|
||||
|
||||
// Depreciação
|
||||
'is_depreciable' => 'nullable|boolean',
|
||||
'depreciation_method' => ['nullable', Rule::in(array_keys(AssetAccount::DEPRECIATION_METHODS))],
|
||||
'useful_life_years' => 'nullable|numeric|min:0.5|max:100',
|
||||
'residual_value' => 'nullable|numeric|min:0',
|
||||
|
||||
// Imóveis
|
||||
'property_type' => ['nullable', Rule::in(array_keys(AssetAccount::PROPERTY_TYPES))],
|
||||
'address' => 'nullable|string|max:500',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:100',
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|size:2',
|
||||
'property_area_m2' => 'nullable|numeric|min:0',
|
||||
'registry_number' => 'nullable|string|max:100',
|
||||
|
||||
// Veículos
|
||||
'vehicle_brand' => 'nullable|string|max:100',
|
||||
'vehicle_model' => 'nullable|string|max:100',
|
||||
'vehicle_year' => 'nullable|integer|min:1900|max:2100',
|
||||
'vehicle_plate' => 'nullable|string|max:20',
|
||||
'vehicle_vin' => 'nullable|string|max:50',
|
||||
'vehicle_mileage' => 'nullable|integer|min:0',
|
||||
|
||||
// Investimentos
|
||||
'investment_type' => ['nullable', Rule::in(array_keys(AssetAccount::INVESTMENT_TYPES))],
|
||||
'institution' => 'nullable|string|max:100',
|
||||
'account_number' => 'nullable|string|max:100',
|
||||
'quantity' => 'nullable|integer|min:0',
|
||||
'unit_price' => 'nullable|numeric|min:0',
|
||||
'ticker' => 'nullable|string|max:20',
|
||||
'maturity_date' => 'nullable|date',
|
||||
'interest_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'index_type' => ['nullable', Rule::in(array_keys(AssetAccount::INDEX_TYPES))],
|
||||
|
||||
// Equipamentos
|
||||
'equipment_brand' => 'nullable|string|max:100',
|
||||
'equipment_model' => 'nullable|string|max:100',
|
||||
'serial_number' => 'nullable|string|max:100',
|
||||
'warranty_expiry' => 'nullable|date',
|
||||
|
||||
// Recebíveis
|
||||
'debtor_name' => 'nullable|string|max:200',
|
||||
'debtor_document' => 'nullable|string|max:50',
|
||||
'receivable_due_date' => 'nullable|date',
|
||||
'receivable_amount' => 'nullable|numeric|min:0',
|
||||
|
||||
// Garantias
|
||||
'is_collateral' => 'nullable|boolean',
|
||||
'collateral_for' => 'nullable|string|max:200',
|
||||
'linked_liability_id' => 'nullable|integer|exists:liability_accounts,id',
|
||||
|
||||
// Seguros
|
||||
'has_insurance' => 'nullable|boolean',
|
||||
'insurance_company' => 'nullable|string|max:100',
|
||||
'insurance_policy' => 'nullable|string|max:100',
|
||||
'insurance_value' => 'nullable|numeric|min:0',
|
||||
'insurance_expiry' => 'nullable|date',
|
||||
|
||||
// Gestão
|
||||
'alert_days_before' => 'nullable|integer|min:0|max:365',
|
||||
'internal_responsible' => 'nullable|string|max:200',
|
||||
'internal_notes' => 'nullable|string|max:2000',
|
||||
'document_number' => 'nullable|string|max:100',
|
||||
];
|
||||
|
||||
$validator = Validator::make($request->all(), $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Criar o ativo
|
||||
$data = $validator->validated();
|
||||
$data['user_id'] = Auth::id();
|
||||
$data['business_id'] = $request->business_id ?? Auth::user()->businesses()->first()?->id;
|
||||
$data['status'] = 'active';
|
||||
|
||||
$asset = AssetAccount::create($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Activo creado con éxito',
|
||||
'data' => $asset,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumo dos ativos do usuário
|
||||
*/
|
||||
public function summary(): JsonResponse
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
$summary = [
|
||||
'total_assets' => AssetAccount::where('user_id', $userId)->active()->count(),
|
||||
'total_value' => AssetAccount::where('user_id', $userId)->active()->sum('current_value'),
|
||||
'total_acquisition' => AssetAccount::where('user_id', $userId)->active()->sum('acquisition_value'),
|
||||
'by_type' => [],
|
||||
];
|
||||
|
||||
// Agrupar por tipo
|
||||
$byType = AssetAccount::where('user_id', $userId)
|
||||
->active()
|
||||
->selectRaw('asset_type, COUNT(*) as count, SUM(current_value) as total_value')
|
||||
->groupBy('asset_type')
|
||||
->get();
|
||||
|
||||
foreach ($byType as $item) {
|
||||
$summary['by_type'][$item->asset_type] = [
|
||||
'name' => AssetAccount::ASSET_TYPES[$item->asset_type]['name'] ?? $item->asset_type,
|
||||
'count' => $item->count,
|
||||
'total_value' => $item->total_value,
|
||||
];
|
||||
}
|
||||
|
||||
// Ganho/perda total
|
||||
$summary['total_gain_loss'] = $summary['total_value'] - $summary['total_acquisition'];
|
||||
$summary['total_gain_loss_percent'] = $summary['total_acquisition'] > 0
|
||||
? (($summary['total_value'] - $summary['total_acquisition']) / $summary['total_acquisition']) * 100
|
||||
: 0;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $summary,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar valor de mercado de um ativo
|
||||
*/
|
||||
public function updateValue(Request $request, AssetAccount $assetAccount): JsonResponse
|
||||
{
|
||||
if ($assetAccount->user_id !== Auth::id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'current_value' => 'required|numeric|min:0',
|
||||
'note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$assetAccount->update([
|
||||
'current_value' => $request->current_value,
|
||||
]);
|
||||
|
||||
// Adicionar nota se fornecida
|
||||
if ($request->note) {
|
||||
$notes = $assetAccount->internal_notes ?? '';
|
||||
$notes .= "\n[" . now()->format('d/m/Y') . "] Valor actualizado: {$request->note}";
|
||||
$assetAccount->update(['internal_notes' => trim($notes)]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Valor actualizado con éxito',
|
||||
'data' => $assetAccount->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar venda/baixa de ativo
|
||||
*/
|
||||
public function dispose(Request $request, AssetAccount $assetAccount): JsonResponse
|
||||
{
|
||||
if ($assetAccount->user_id !== Auth::id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'disposal_date' => 'required|date',
|
||||
'disposal_value' => 'required|numeric|min:0',
|
||||
'disposal_reason' => 'nullable|string|max:200',
|
||||
'status' => ['required', Rule::in(['sold', 'written_off', 'depreciated'])],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$assetAccount->update([
|
||||
'status' => $request->status,
|
||||
'disposal_date' => $request->disposal_date,
|
||||
'disposal_value' => $request->disposal_value,
|
||||
'disposal_reason' => $request->disposal_reason,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Baja registrada con éxito',
|
||||
'data' => $assetAccount->fresh(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -133,31 +133,34 @@ public function login(Request $request): JsonResponse
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Check if user has an active subscription
|
||||
$hasActiveSubscription = $user->subscriptions()->active()->exists();
|
||||
if (!$hasActiveSubscription) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No tienes una suscripción activa. Por favor, completa el pago.',
|
||||
'error' => 'no_subscription',
|
||||
'data' => [
|
||||
'email_verified' => true,
|
||||
'has_subscription' => false,
|
||||
]
|
||||
], 403);
|
||||
// Check if user has an active subscription (skip for demo users)
|
||||
if (!$user->is_demo) {
|
||||
$hasActiveSubscription = $user->subscriptions()->active()->exists();
|
||||
if (!$hasActiveSubscription) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No tienes una suscripción activa. Por favor, completa el pago.',
|
||||
'error' => 'no_subscription',
|
||||
'data' => [
|
||||
'email_verified' => true,
|
||||
'has_subscription' => false,
|
||||
]
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Inicio de sesión exitoso',
|
||||
'message' => $user->is_demo ? 'Bienvenido al modo demostración' : 'Inicio de sesión exitoso',
|
||||
'data' => [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'email_verified' => true,
|
||||
'is_demo' => $user->is_demo ?? false,
|
||||
],
|
||||
'token' => $token,
|
||||
]
|
||||
|
||||
@ -133,7 +133,7 @@ public function summary(Request $request): JsonResponse
|
||||
];
|
||||
|
||||
$monthlyStatsByCurrency = Transaction::where('transactions.user_id', $userId)
|
||||
->where('transactions.status', 'completed')
|
||||
->whereIn('transactions.status', ['completed', 'effective'])
|
||||
->where('transactions.is_transfer', false)
|
||||
->whereBetween('transactions.effective_date', $currentMonth)
|
||||
->whereNull('transactions.deleted_at')
|
||||
@ -465,18 +465,20 @@ public function calendar(Request $request): JsonResponse
|
||||
$endDate = $startDate->copy()->endOfMonth();
|
||||
|
||||
// Buscar transações do período
|
||||
// Usar planned_date para todas as transações (funciona para efetivadas e pendentes)
|
||||
$transactions = Transaction::ofUser($userId)
|
||||
->whereBetween('effective_date', [$startDate, $endDate])
|
||||
->whereBetween('planned_date', [$startDate, $endDate])
|
||||
->with(['account:id,name,currency', 'category:id,name,color,icon'])
|
||||
->orderBy('effective_date')
|
||||
->orderBy('planned_date')
|
||||
->get()
|
||||
->map(function ($t) {
|
||||
$date = $t->effective_date ?? $t->planned_date;
|
||||
return [
|
||||
'id' => $t->id,
|
||||
'type' => 'transaction',
|
||||
'date' => $t->effective_date->format('Y-m-d'),
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'description' => $t->description,
|
||||
'amount' => (float) $t->amount,
|
||||
'amount' => (float) ($t->amount ?? $t->planned_amount),
|
||||
'transaction_type' => $t->type,
|
||||
'status' => $t->status,
|
||||
'is_transfer' => $t->is_transfer,
|
||||
@ -573,20 +575,21 @@ public function calendarDay(Request $request): JsonResponse
|
||||
$userId = $request->user()->id;
|
||||
$date = Carbon::parse($request->get('date', now()->format('Y-m-d')));
|
||||
|
||||
// Buscar transações do dia
|
||||
// Buscar transações do dia (usar planned_date para incluir pendentes)
|
||||
$transactions = Transaction::ofUser($userId)
|
||||
->whereDate('effective_date', $date)
|
||||
->whereDate('planned_date', $date)
|
||||
->with(['account:id,name,currency', 'category:id,name,color,icon'])
|
||||
->orderBy('effective_date')
|
||||
->orderBy('planned_date')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(function ($t) {
|
||||
$txDate = $t->effective_date ?? $t->planned_date;
|
||||
return [
|
||||
'id' => $t->id,
|
||||
'type' => 'transaction',
|
||||
'date' => $t->effective_date->format('Y-m-d'),
|
||||
'date' => $txDate->format('Y-m-d'),
|
||||
'description' => $t->description,
|
||||
'amount' => (float) $t->amount,
|
||||
'amount' => (float) ($t->amount ?? $t->planned_amount),
|
||||
'transaction_type' => $t->type,
|
||||
'status' => $t->status,
|
||||
'is_transfer' => $t->is_transfer,
|
||||
@ -670,26 +673,28 @@ public function upcomingTransactions(Request $request): JsonResponse
|
||||
$endDate = now()->addDays($days - 1)->endOfDay();
|
||||
|
||||
// Buscar transações pendentes do período
|
||||
// Para pendentes: usar planned_date (effective_date é NULL)
|
||||
$transactions = Transaction::ofUser($userId)
|
||||
->whereIn('status', ['pending', 'scheduled'])
|
||||
->whereBetween('effective_date', [$startDate, $endDate])
|
||||
->whereBetween('planned_date', [$startDate, $endDate])
|
||||
->with(['account:id,name,currency', 'category:id,name,color,icon'])
|
||||
->orderBy('effective_date')
|
||||
->orderBy('planned_date')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(function ($t) {
|
||||
$date = $t->effective_date ?? $t->planned_date;
|
||||
return [
|
||||
'id' => $t->id,
|
||||
'type' => 'transaction',
|
||||
'date' => $t->effective_date->format('Y-m-d'),
|
||||
'date_formatted' => $t->effective_date->translatedFormat('D, d M'),
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'date_formatted' => $date->translatedFormat('D, d M'),
|
||||
'description' => $t->description,
|
||||
'amount' => (float) $t->amount,
|
||||
'amount' => (float) ($t->amount ?? $t->planned_amount),
|
||||
'currency' => $t->account->currency ?? 'EUR',
|
||||
'transaction_type' => $t->type,
|
||||
'status' => $t->status,
|
||||
'is_transfer' => $t->is_transfer,
|
||||
'days_until' => (int) now()->startOfDay()->diffInDays($t->effective_date, false),
|
||||
'days_until' => (int) now()->startOfDay()->diffInDays($date, false),
|
||||
'account' => $t->account ? [
|
||||
'id' => $t->account->id,
|
||||
'name' => $t->account->name,
|
||||
@ -769,6 +774,8 @@ public function upcomingTransactions(Request $request): JsonResponse
|
||||
'recurring_count' => $recurringInstances->count(),
|
||||
'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'),
|
||||
'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'),
|
||||
'credit_count' => $nonTransferItems->where('transaction_type', 'credit')->count(),
|
||||
'debit_count' => $nonTransferItems->where('transaction_type', 'debit')->count(),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
@ -891,7 +898,7 @@ public function overdueTransactions(Request $request): JsonResponse
|
||||
'planned_date' => $li->due_date->format('Y-m-d'),
|
||||
'planned_date_formatted' => $li->due_date->translatedFormat('D, d M Y'),
|
||||
'description' => $li->liabilityAccount->name . ' - Parcela ' . $li->installment_number,
|
||||
'amount' => (float) $li->amount,
|
||||
'amount' => (float) $li->installment_amount,
|
||||
'currency' => $li->liabilityAccount->currency ?? 'EUR',
|
||||
'transaction_type' => 'debit',
|
||||
'status' => $li->status,
|
||||
|
||||
@ -683,4 +683,290 @@ public function pendingReconciliation(): JsonResponse
|
||||
'count' => $installments->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download template Excel para importação
|
||||
*/
|
||||
public function downloadTemplate()
|
||||
{
|
||||
$service = new \App\Services\LiabilityTemplateService();
|
||||
$spreadsheet = $service->generateTemplate();
|
||||
|
||||
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
|
||||
|
||||
$filename = 'plantilla_importacion_pasivo.xlsx';
|
||||
|
||||
// Criar response com stream
|
||||
return response()->streamDownload(function () use ($writer) {
|
||||
$writer->save('php://output');
|
||||
}, $filename, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
'Cache-Control' => 'max-age=0',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter tipos de contrato disponíveis
|
||||
*/
|
||||
public function contractTypes(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => LiabilityAccount::CONTRACT_TYPES,
|
||||
'amortization_systems' => LiabilityAccount::AMORTIZATION_SYSTEMS,
|
||||
'index_types' => LiabilityAccount::INDEX_TYPES,
|
||||
'guarantee_types' => LiabilityAccount::GUARANTEE_TYPES,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar conta passivo com wizard (formulário completo)
|
||||
*/
|
||||
public function storeWithWizard(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
// Dados básicos
|
||||
'name' => 'required|string|max:150',
|
||||
'contract_type' => ['required', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::CONTRACT_TYPES))],
|
||||
'creditor' => 'nullable|string|max:150',
|
||||
'contract_number' => 'nullable|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'currency' => 'nullable|string|size:3',
|
||||
'color' => 'nullable|string|max:7',
|
||||
|
||||
// Dados do contrato
|
||||
'principal_amount' => 'required|numeric|min:0.01',
|
||||
'annual_interest_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'monthly_interest_rate' => 'nullable|numeric|min:0|max:20',
|
||||
'amortization_system' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::AMORTIZATION_SYSTEMS))],
|
||||
'total_installments' => 'nullable|integer|min:1|max:600',
|
||||
'start_date' => 'required|date',
|
||||
'first_due_date' => 'required|date',
|
||||
'end_date' => 'nullable|date',
|
||||
|
||||
// Opções básicas
|
||||
'has_grace_period' => 'nullable|boolean',
|
||||
'grace_period_months' => 'nullable|integer|min:0|max:12',
|
||||
'include_insurance' => 'nullable|boolean',
|
||||
'insurance_amount' => 'nullable|numeric|min:0',
|
||||
'include_admin_fee' => 'nullable|boolean',
|
||||
'admin_fee_amount' => 'nullable|numeric|min:0',
|
||||
|
||||
// ============================================
|
||||
// CAMPOS AVANÇADOS (opcionais)
|
||||
// ============================================
|
||||
// Indexadores
|
||||
'index_type' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::INDEX_TYPES))],
|
||||
'index_spread' => 'nullable|numeric|min:-50|max:50',
|
||||
'total_effective_cost' => 'nullable|numeric|min:0|max:500',
|
||||
|
||||
// Garantias
|
||||
'guarantee_type' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::GUARANTEE_TYPES))],
|
||||
'guarantee_value' => 'nullable|numeric|min:0',
|
||||
'guarantee_description' => 'nullable|string|max:500',
|
||||
'guarantor_name' => 'nullable|string|max:150',
|
||||
|
||||
// Penalidades
|
||||
'late_fee_percent' => 'nullable|numeric|min:0|max:100',
|
||||
'daily_penalty_percent' => 'nullable|numeric|min:0|max:10',
|
||||
'grace_days_for_penalty' => 'nullable|integer|min:0|max:30',
|
||||
|
||||
// Específicos por tipo
|
||||
'asset_value' => 'nullable|numeric|min:0',
|
||||
'asset_description' => 'nullable|string|max:300',
|
||||
'residual_value' => 'nullable|numeric|min:0',
|
||||
'admin_fee_percent' => 'nullable|numeric|min:0|max:50',
|
||||
'reserve_fund_percent' => 'nullable|numeric|min:0|max:20',
|
||||
|
||||
// Covenants e gestão
|
||||
'covenants' => 'nullable|array',
|
||||
'covenants.*.name' => 'required_with:covenants|string|max:100',
|
||||
'covenants.*.condition' => 'required_with:covenants|string|max:50',
|
||||
'covenants.*.value' => 'required_with:covenants|string|max:100',
|
||||
'alert_days_before' => 'nullable|integer|min:0|max:60',
|
||||
'internal_responsible' => 'nullable|string|max:150',
|
||||
'internal_notes' => 'nullable|string',
|
||||
'document_number' => 'nullable|string|max:100',
|
||||
'registry_office' => 'nullable|string|max:200',
|
||||
|
||||
// Parcelas (opcional - se não enviado, será calculado)
|
||||
'installments' => 'nullable|array',
|
||||
'installments.*.installment_number' => 'required|integer|min:1',
|
||||
'installments.*.due_date' => 'required|date',
|
||||
'installments.*.installment_amount' => 'required|numeric|min:0',
|
||||
'installments.*.principal_amount' => 'nullable|numeric|min:0',
|
||||
'installments.*.interest_amount' => 'nullable|numeric|min:0',
|
||||
'installments.*.fee_amount' => 'nullable|numeric|min:0',
|
||||
'installments.*.status' => 'nullable|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Calcular taxa mensal se não informada
|
||||
$monthlyRate = $validated['monthly_interest_rate'] ?? null;
|
||||
if (!$monthlyRate && isset($validated['annual_interest_rate'])) {
|
||||
$monthlyRate = $validated['annual_interest_rate'] / 12;
|
||||
}
|
||||
|
||||
// Criar conta passivo com todos os campos
|
||||
$account = LiabilityAccount::create([
|
||||
'user_id' => Auth::id(),
|
||||
'name' => $validated['name'],
|
||||
'contract_type' => $validated['contract_type'],
|
||||
'creditor' => $validated['creditor'] ?? null,
|
||||
'contract_number' => $validated['contract_number'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'currency' => $validated['currency'] ?? 'EUR',
|
||||
'color' => $validated['color'] ?? null,
|
||||
'principal_amount' => $validated['principal_amount'],
|
||||
'annual_interest_rate' => $validated['annual_interest_rate'] ?? null,
|
||||
'monthly_interest_rate' => $monthlyRate,
|
||||
'amortization_system' => $validated['amortization_system'] ?? 'price',
|
||||
'total_installments' => $validated['total_installments'] ?? null,
|
||||
'start_date' => $validated['start_date'],
|
||||
'first_due_date' => $validated['first_due_date'],
|
||||
'end_date' => $validated['end_date'] ?? null,
|
||||
'has_grace_period' => $validated['has_grace_period'] ?? false,
|
||||
'grace_period_months' => $validated['grace_period_months'] ?? 0,
|
||||
'status' => LiabilityAccount::STATUS_ACTIVE,
|
||||
// Campos avançados - Indexadores
|
||||
'index_type' => $validated['index_type'] ?? 'fixed',
|
||||
'index_spread' => $validated['index_spread'] ?? null,
|
||||
'total_effective_cost' => $validated['total_effective_cost'] ?? null,
|
||||
// Campos avançados - Garantias
|
||||
'guarantee_type' => $validated['guarantee_type'] ?? 'none',
|
||||
'guarantee_value' => $validated['guarantee_value'] ?? null,
|
||||
'guarantee_description' => $validated['guarantee_description'] ?? null,
|
||||
'guarantor_name' => $validated['guarantor_name'] ?? null,
|
||||
// Campos avançados - Penalidades
|
||||
'late_fee_percent' => $validated['late_fee_percent'] ?? null,
|
||||
'daily_penalty_percent' => $validated['daily_penalty_percent'] ?? null,
|
||||
'grace_days_for_penalty' => $validated['grace_days_for_penalty'] ?? 0,
|
||||
// Campos avançados - Específicos
|
||||
'asset_value' => $validated['asset_value'] ?? null,
|
||||
'asset_description' => $validated['asset_description'] ?? null,
|
||||
'residual_value' => $validated['residual_value'] ?? null,
|
||||
'admin_fee_percent' => $validated['admin_fee_percent'] ?? null,
|
||||
'reserve_fund_percent' => $validated['reserve_fund_percent'] ?? null,
|
||||
// Campos avançados - Covenants e gestão
|
||||
'covenants' => $validated['covenants'] ?? null,
|
||||
'alert_days_before' => $validated['alert_days_before'] ?? 5,
|
||||
'internal_responsible' => $validated['internal_responsible'] ?? null,
|
||||
'internal_notes' => $validated['internal_notes'] ?? null,
|
||||
'document_number' => $validated['document_number'] ?? null,
|
||||
'registry_office' => $validated['registry_office'] ?? null,
|
||||
]);
|
||||
|
||||
// Se parcelas foram enviadas, criar diretamente
|
||||
if (!empty($validated['installments'])) {
|
||||
foreach ($validated['installments'] as $inst) {
|
||||
LiabilityInstallment::create([
|
||||
'liability_account_id' => $account->id,
|
||||
'installment_number' => $inst['installment_number'],
|
||||
'due_date' => $inst['due_date'],
|
||||
'installment_amount' => $inst['installment_amount'],
|
||||
'principal_amount' => $inst['principal_amount'] ?? 0,
|
||||
'interest_amount' => $inst['interest_amount'] ?? 0,
|
||||
'fee_amount' => $inst['fee_amount'] ?? 0,
|
||||
'status' => $inst['status'] ?? 'pending',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Gerar parcelas automaticamente
|
||||
$this->generateInstallments($account, $validated);
|
||||
}
|
||||
|
||||
// Recalcular totais
|
||||
$account->recalculateTotals();
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Cuenta pasivo creada con éxito',
|
||||
'data' => $account->load('installments'),
|
||||
], 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Error al crear cuenta: ' . $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar parcelas automaticamente baseado no sistema de amortização
|
||||
*/
|
||||
private function generateInstallments(LiabilityAccount $account, array $data): void
|
||||
{
|
||||
$principal = $account->principal_amount;
|
||||
$monthlyRate = ($account->monthly_interest_rate ?? 0) / 100;
|
||||
$totalInstallments = $data['total_installments'] ?? 12;
|
||||
$amortizationSystem = $account->amortization_system ?? 'price';
|
||||
$hasGracePeriod = $data['has_grace_period'] ?? false;
|
||||
$gracePeriodMonths = $data['grace_period_months'] ?? 0;
|
||||
$insuranceAmount = $data['insurance_amount'] ?? 0;
|
||||
$adminFeeAmount = $data['admin_fee_amount'] ?? 0;
|
||||
|
||||
$firstDueDate = new \DateTime($data['first_due_date']);
|
||||
$remainingPrincipal = $principal;
|
||||
|
||||
// Para sistema PRICE, calcular parcela fixa
|
||||
$fixedInstallment = 0;
|
||||
if ($amortizationSystem === 'price' && $monthlyRate > 0) {
|
||||
$fixedInstallment = $principal * ($monthlyRate * pow(1 + $monthlyRate, $totalInstallments)) /
|
||||
(pow(1 + $monthlyRate, $totalInstallments) - 1);
|
||||
} elseif ($amortizationSystem === 'price') {
|
||||
$fixedInstallment = $principal / $totalInstallments;
|
||||
}
|
||||
|
||||
// Para sistema SAC, calcular amortização fixa
|
||||
$fixedAmortization = $principal / $totalInstallments;
|
||||
|
||||
for ($i = 1; $i <= $totalInstallments; $i++) {
|
||||
$dueDate = clone $firstDueDate;
|
||||
$dueDate->modify('+' . ($i - 1) . ' months');
|
||||
|
||||
// Carência
|
||||
if ($hasGracePeriod && $i <= $gracePeriodMonths) {
|
||||
$interestAmount = $remainingPrincipal * $monthlyRate;
|
||||
$principalAmount = 0;
|
||||
$installmentAmount = $interestAmount + $insuranceAmount + $adminFeeAmount;
|
||||
} else {
|
||||
if ($amortizationSystem === 'price') {
|
||||
// Sistema PRICE - parcela fixa
|
||||
$interestAmount = $remainingPrincipal * $monthlyRate;
|
||||
$principalAmount = $fixedInstallment - $interestAmount;
|
||||
$installmentAmount = $fixedInstallment + $insuranceAmount + $adminFeeAmount;
|
||||
} elseif ($amortizationSystem === 'sac') {
|
||||
// Sistema SAC - amortização constante
|
||||
$principalAmount = $fixedAmortization;
|
||||
$interestAmount = $remainingPrincipal * $monthlyRate;
|
||||
$installmentAmount = $principalAmount + $interestAmount + $insuranceAmount + $adminFeeAmount;
|
||||
} else {
|
||||
// Americano - só juros, principal no final
|
||||
$interestAmount = $remainingPrincipal * $monthlyRate;
|
||||
$principalAmount = ($i === $totalInstallments) ? $remainingPrincipal : 0;
|
||||
$installmentAmount = $interestAmount + $principalAmount + $insuranceAmount + $adminFeeAmount;
|
||||
}
|
||||
|
||||
$remainingPrincipal -= $principalAmount;
|
||||
}
|
||||
|
||||
LiabilityInstallment::create([
|
||||
'liability_account_id' => $account->id,
|
||||
'installment_number' => $i,
|
||||
'due_date' => $dueDate->format('Y-m-d'),
|
||||
'installment_amount' => round($installmentAmount, 2),
|
||||
'principal_amount' => round(max(0, $principalAmount), 2),
|
||||
'interest_amount' => round($interestAmount, 2),
|
||||
'fee_amount' => round($insuranceAmount + $adminFeeAmount, 2),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +44,9 @@ public function index(Request $request): JsonResponse
|
||||
if ($request->has('start_date') && $request->has('end_date')) {
|
||||
$dateField = $request->get('date_field', 'planned_date');
|
||||
$query->inPeriod($request->start_date, $request->end_date, $dateField);
|
||||
} else {
|
||||
// Sem filtro de data: não mostrar transações futuras
|
||||
$query->where('planned_date', '<=', now()->toDateString());
|
||||
}
|
||||
|
||||
// Busca por descrição e valores
|
||||
@ -424,6 +427,9 @@ public function byWeek(Request $request): JsonResponse
|
||||
// Filtro por período
|
||||
if ($request->has('start_date') && $request->has('end_date')) {
|
||||
$query->inPeriod($request->start_date, $request->end_date, $dateField);
|
||||
} else {
|
||||
// Sem filtro de data: não mostrar transações futuras
|
||||
$query->where('planned_date', '<=', now()->toDateString());
|
||||
}
|
||||
|
||||
// Busca por descrição e valores
|
||||
|
||||
78
backend/app/Http/Middleware/DemoProtection.php
Normal file
78
backend/app/Http/Middleware/DemoProtection.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DemoProtection
|
||||
{
|
||||
/**
|
||||
* Rotas e métodos permitidos para usuário demo (whitelist)
|
||||
* Estas rotas são operações de leitura/cálculo que não modificam dados reais
|
||||
*/
|
||||
protected array $allowedRoutes = [
|
||||
'GET' => ['*'], // Permite todos os GETs
|
||||
'POST' => [
|
||||
'api/login',
|
||||
'api/logout',
|
||||
'api/refresh',
|
||||
'api/accounts/recalculate-all', // Recálculo de saldos (não modifica dados)
|
||||
'api/accounts/*/recalculate', // Recálculo de conta específica
|
||||
'api/categories/match', // Matching de categorias (leitura)
|
||||
'api/cost-centers/match', // Matching de centros de custo (leitura)
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
* Bloqueia operações de escrita (POST/PUT/PATCH/DELETE) para usuário demo
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Se não está logado ou não é demo, permite tudo
|
||||
if (!$user || !$user->is_demo) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$method = $request->method();
|
||||
$path = $request->path();
|
||||
|
||||
// GET sempre permitido para demo
|
||||
if ($method === 'GET') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Verifica se a rota está na whitelist
|
||||
if (isset($this->allowedRoutes[$method])) {
|
||||
foreach ($this->allowedRoutes[$method] as $allowedPath) {
|
||||
if ($allowedPath === '*' || $this->pathMatches($path, $allowedPath)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bloqueia a operação para usuário demo
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Usuario DEMO: Esta acción no está permitida en modo demostración. Los datos no pueden ser modificados.',
|
||||
'demo_mode' => true,
|
||||
], 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se o path corresponde ao padrão
|
||||
*/
|
||||
protected function pathMatches(string $path, string $pattern): bool
|
||||
{
|
||||
if ($pattern === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$pattern = str_replace('*', '.*', $pattern);
|
||||
return (bool) preg_match('#^' . $pattern . '$#', $path);
|
||||
}
|
||||
}
|
||||
351
backend/app/Models/AssetAccount.php
Normal file
351
backend/app/Models/AssetAccount.php
Normal file
@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AssetAccount extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Status do ativo
|
||||
*/
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SOLD = 'sold';
|
||||
public const STATUS_DEPRECIATED = 'depreciated';
|
||||
public const STATUS_WRITTEN_OFF = 'written_off';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_ACTIVE => 'Activo',
|
||||
self::STATUS_SOLD => 'Vendido',
|
||||
self::STATUS_DEPRECIATED => 'Depreciado',
|
||||
self::STATUS_WRITTEN_OFF => 'Dado de baja',
|
||||
];
|
||||
|
||||
/**
|
||||
* Tipos de ativo
|
||||
*/
|
||||
public const ASSET_TYPE_REAL_ESTATE = 'real_estate';
|
||||
public const ASSET_TYPE_VEHICLE = 'vehicle';
|
||||
public const ASSET_TYPE_INVESTMENT = 'investment';
|
||||
public const ASSET_TYPE_EQUIPMENT = 'equipment';
|
||||
public const ASSET_TYPE_INVENTORY = 'inventory';
|
||||
public const ASSET_TYPE_RECEIVABLE = 'receivable';
|
||||
public const ASSET_TYPE_CASH = 'cash';
|
||||
public const ASSET_TYPE_OTHER = 'other';
|
||||
|
||||
public const ASSET_TYPES = [
|
||||
self::ASSET_TYPE_REAL_ESTATE => [
|
||||
'name' => 'Inmueble',
|
||||
'description' => 'Casa, apartamento, terreno, local comercial',
|
||||
'icon' => 'home',
|
||||
'is_depreciable' => true,
|
||||
'fields' => ['property_type', 'address', 'property_area_m2', 'registry_number'],
|
||||
],
|
||||
self::ASSET_TYPE_VEHICLE => [
|
||||
'name' => 'Vehículo',
|
||||
'description' => 'Coche, moto, camión, maquinaria móvil',
|
||||
'icon' => 'truck',
|
||||
'is_depreciable' => true,
|
||||
'fields' => ['vehicle_brand', 'vehicle_model', 'vehicle_year', 'vehicle_plate', 'vehicle_mileage'],
|
||||
],
|
||||
self::ASSET_TYPE_INVESTMENT => [
|
||||
'name' => 'Inversión',
|
||||
'description' => 'Acciones, fondos, bonos, criptomonedas',
|
||||
'icon' => 'chart-bar',
|
||||
'is_depreciable' => false,
|
||||
'fields' => ['investment_type', 'institution', 'ticker', 'quantity', 'unit_price'],
|
||||
],
|
||||
self::ASSET_TYPE_EQUIPMENT => [
|
||||
'name' => 'Equipamiento',
|
||||
'description' => 'Maquinaria, herramientas, ordenadores',
|
||||
'icon' => 'cog',
|
||||
'is_depreciable' => true,
|
||||
'fields' => ['equipment_brand', 'equipment_model', 'serial_number', 'warranty_expiry'],
|
||||
],
|
||||
self::ASSET_TYPE_INVENTORY => [
|
||||
'name' => 'Inventario',
|
||||
'description' => 'Stock, mercancías, materias primas',
|
||||
'icon' => 'archive-box',
|
||||
'is_depreciable' => false,
|
||||
'fields' => ['quantity'],
|
||||
],
|
||||
self::ASSET_TYPE_RECEIVABLE => [
|
||||
'name' => 'Cuenta por Cobrar',
|
||||
'description' => 'Créditos, préstamos otorgados',
|
||||
'icon' => 'banknotes',
|
||||
'is_depreciable' => false,
|
||||
'fields' => ['debtor_name', 'receivable_due_date', 'receivable_amount'],
|
||||
],
|
||||
self::ASSET_TYPE_CASH => [
|
||||
'name' => 'Efectivo/Caja',
|
||||
'description' => 'Dinero en efectivo, caja chica',
|
||||
'icon' => 'currency-euro',
|
||||
'is_depreciable' => false,
|
||||
'fields' => [],
|
||||
],
|
||||
self::ASSET_TYPE_OTHER => [
|
||||
'name' => 'Otro',
|
||||
'description' => 'Otros tipos de activos',
|
||||
'icon' => 'document-text',
|
||||
'is_depreciable' => false,
|
||||
'fields' => [],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Tipos de imóvel
|
||||
*/
|
||||
public const PROPERTY_TYPES = [
|
||||
'house' => 'Casa',
|
||||
'apartment' => 'Apartamento/Piso',
|
||||
'land' => 'Terreno',
|
||||
'commercial' => 'Local Comercial',
|
||||
'industrial' => 'Nave Industrial',
|
||||
'office' => 'Oficina',
|
||||
'parking' => 'Plaza de Garaje',
|
||||
'warehouse' => 'Almacén',
|
||||
'rural' => 'Finca Rústica',
|
||||
];
|
||||
|
||||
/**
|
||||
* Tipos de investimento
|
||||
*/
|
||||
public const INVESTMENT_TYPES = [
|
||||
'stocks' => ['name' => 'Acciones', 'description' => 'Participaciones en empresas cotizadas'],
|
||||
'bonds' => ['name' => 'Bonos', 'description' => 'Títulos de deuda pública o privada'],
|
||||
'funds' => ['name' => 'Fondos de Inversión', 'description' => 'Fondos mutuos, ETFs'],
|
||||
'fixed_income' => ['name' => 'Renta Fija', 'description' => 'Depósitos, letras del tesoro'],
|
||||
'crypto' => ['name' => 'Criptomonedas', 'description' => 'Bitcoin, Ethereum, etc.'],
|
||||
'real_estate_fund' => ['name' => 'Fondo Inmobiliario', 'description' => 'REITs, SOCIMIs'],
|
||||
'pension' => ['name' => 'Plan de Pensiones', 'description' => 'Ahorro para jubilación'],
|
||||
'savings' => ['name' => 'Cuenta de Ahorro', 'description' => 'Cuentas remuneradas'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Métodos de depreciação
|
||||
*/
|
||||
public const DEPRECIATION_METHODS = [
|
||||
'linear' => ['name' => 'Lineal', 'description' => 'Depreciación constante cada año'],
|
||||
'declining_balance' => ['name' => 'Saldo Decreciente', 'description' => 'Mayor depreciación al inicio'],
|
||||
'units_production' => ['name' => 'Unidades Producidas', 'description' => 'Según uso/producción'],
|
||||
'sum_years' => ['name' => 'Suma de Años', 'description' => 'Depreciación acelerada'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Tipos de indexadores (para investimentos)
|
||||
*/
|
||||
public const INDEX_TYPES = [
|
||||
'fixed' => ['name' => 'Tasa Fija', 'description' => 'Rentabilidad fija'],
|
||||
'cdi' => ['name' => 'CDI', 'description' => '% del CDI (Brasil)'],
|
||||
'selic' => ['name' => 'SELIC', 'description' => 'Tasa SELIC (Brasil)'],
|
||||
'ipca' => ['name' => 'IPCA+', 'description' => 'Inflación + spread (Brasil)'],
|
||||
'euribor' => ['name' => 'Euribor+', 'description' => 'Euribor + spread (UE)'],
|
||||
'ibex' => ['name' => 'IBEX 35', 'description' => 'Índice bursátil español'],
|
||||
'sp500' => ['name' => 'S&P 500', 'description' => 'Índice bursátil americano'],
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'business_id',
|
||||
'asset_type',
|
||||
'name',
|
||||
'description',
|
||||
'currency',
|
||||
'color',
|
||||
|
||||
// Valores
|
||||
'acquisition_value',
|
||||
'current_value',
|
||||
'acquisition_date',
|
||||
|
||||
// Depreciação
|
||||
'is_depreciable',
|
||||
'depreciation_method',
|
||||
'useful_life_years',
|
||||
'residual_value',
|
||||
'accumulated_depreciation',
|
||||
|
||||
// Imóveis
|
||||
'property_type',
|
||||
'address',
|
||||
'city',
|
||||
'state',
|
||||
'postal_code',
|
||||
'country',
|
||||
'property_area_m2',
|
||||
'registry_number',
|
||||
|
||||
// Veículos
|
||||
'vehicle_brand',
|
||||
'vehicle_model',
|
||||
'vehicle_year',
|
||||
'vehicle_plate',
|
||||
'vehicle_vin',
|
||||
'vehicle_mileage',
|
||||
|
||||
// Investimentos
|
||||
'investment_type',
|
||||
'institution',
|
||||
'account_number',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'ticker',
|
||||
'maturity_date',
|
||||
'interest_rate',
|
||||
'index_type',
|
||||
|
||||
// Equipamentos
|
||||
'equipment_brand',
|
||||
'equipment_model',
|
||||
'serial_number',
|
||||
'warranty_expiry',
|
||||
|
||||
// Recebíveis
|
||||
'debtor_name',
|
||||
'debtor_document',
|
||||
'receivable_due_date',
|
||||
'receivable_amount',
|
||||
|
||||
// Garantias
|
||||
'is_collateral',
|
||||
'collateral_for',
|
||||
'linked_liability_id',
|
||||
|
||||
// Seguros
|
||||
'has_insurance',
|
||||
'insurance_company',
|
||||
'insurance_policy',
|
||||
'insurance_value',
|
||||
'insurance_expiry',
|
||||
|
||||
// Gestão
|
||||
'alert_days_before',
|
||||
'internal_responsible',
|
||||
'internal_notes',
|
||||
'document_number',
|
||||
|
||||
// Status
|
||||
'status',
|
||||
'disposal_date',
|
||||
'disposal_value',
|
||||
'disposal_reason',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'acquisition_value' => 'decimal:2',
|
||||
'current_value' => 'decimal:2',
|
||||
'residual_value' => 'decimal:2',
|
||||
'accumulated_depreciation' => 'decimal:2',
|
||||
'property_area_m2' => 'decimal:2',
|
||||
'useful_life_years' => 'decimal:2',
|
||||
'unit_price' => 'decimal:6',
|
||||
'insurance_value' => 'decimal:2',
|
||||
'receivable_amount' => 'decimal:2',
|
||||
'disposal_value' => 'decimal:2',
|
||||
'interest_rate' => 'decimal:4',
|
||||
'is_depreciable' => 'boolean',
|
||||
'is_collateral' => 'boolean',
|
||||
'has_insurance' => 'boolean',
|
||||
'acquisition_date' => 'date',
|
||||
'maturity_date' => 'date',
|
||||
'warranty_expiry' => 'date',
|
||||
'receivable_due_date' => 'date',
|
||||
'insurance_expiry' => 'date',
|
||||
'disposal_date' => 'date',
|
||||
];
|
||||
|
||||
/**
|
||||
* Relações
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function linkedLiability(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LiabilityAccount::class, 'linked_liability_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('asset_type', $type);
|
||||
}
|
||||
|
||||
public function scopeDepreciable($query)
|
||||
{
|
||||
return $query->where('is_depreciable', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessors
|
||||
*/
|
||||
public function getAssetTypeNameAttribute(): string
|
||||
{
|
||||
return self::ASSET_TYPES[$this->asset_type]['name'] ?? $this->asset_type;
|
||||
}
|
||||
|
||||
public function getStatusNameAttribute(): string
|
||||
{
|
||||
return self::STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function getNetValueAttribute(): float
|
||||
{
|
||||
return $this->current_value - $this->accumulated_depreciation;
|
||||
}
|
||||
|
||||
public function getGainLossAttribute(): float
|
||||
{
|
||||
return $this->current_value - $this->acquisition_value;
|
||||
}
|
||||
|
||||
public function getGainLossPercentAttribute(): float
|
||||
{
|
||||
if ($this->acquisition_value == 0) return 0;
|
||||
return (($this->current_value - $this->acquisition_value) / $this->acquisition_value) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular depreciação anual (método linear)
|
||||
*/
|
||||
public function calculateAnnualDepreciation(): float
|
||||
{
|
||||
if (!$this->is_depreciable || !$this->useful_life_years || $this->useful_life_years == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$depreciableValue = $this->acquisition_value - ($this->residual_value ?? 0);
|
||||
return $depreciableValue / $this->useful_life_years;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar se seguro está próximo do vencimento
|
||||
*/
|
||||
public function isInsuranceExpiringSoon(int $days = 30): bool
|
||||
{
|
||||
if (!$this->has_insurance || !$this->insurance_expiry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->insurance_expiry->diffInDays(now()) <= $days;
|
||||
}
|
||||
}
|
||||
@ -21,16 +21,158 @@ class LiabilityAccount extends Model
|
||||
public const STATUS_RENEGOTIATED = 'renegotiated';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_ACTIVE => 'Ativo',
|
||||
self::STATUS_PAID_OFF => 'Quitado',
|
||||
self::STATUS_DEFAULTED => 'Inadimplente',
|
||||
self::STATUS_ACTIVE => 'Activo',
|
||||
self::STATUS_PAID_OFF => 'Liquidado',
|
||||
self::STATUS_DEFAULTED => 'En mora',
|
||||
self::STATUS_RENEGOTIATED => 'Renegociado',
|
||||
];
|
||||
|
||||
/**
|
||||
* Tipos de contrato
|
||||
*/
|
||||
public const CONTRACT_TYPE_PERSONAL_LOAN = 'personal_loan';
|
||||
public const CONTRACT_TYPE_VEHICLE = 'vehicle_financing';
|
||||
public const CONTRACT_TYPE_MORTGAGE = 'mortgage';
|
||||
public const CONTRACT_TYPE_CREDIT_CARD = 'credit_card';
|
||||
public const CONTRACT_TYPE_CONSORTIUM = 'consortium';
|
||||
public const CONTRACT_TYPE_LEASING = 'leasing';
|
||||
public const CONTRACT_TYPE_OVERDRAFT = 'overdraft';
|
||||
public const CONTRACT_TYPE_PAYROLL_LOAN = 'payroll_loan';
|
||||
public const CONTRACT_TYPE_OTHER = 'other';
|
||||
|
||||
public const CONTRACT_TYPES = [
|
||||
self::CONTRACT_TYPE_PERSONAL_LOAN => [
|
||||
'name' => 'Préstamo Personal',
|
||||
'description' => 'Préstamo con cuotas fijas (Sistema PRICE)',
|
||||
'icon' => 'banknotes',
|
||||
'default_amortization' => 'price',
|
||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date'],
|
||||
],
|
||||
self::CONTRACT_TYPE_VEHICLE => [
|
||||
'name' => 'Financiación de Vehículo',
|
||||
'description' => 'Crédito para compra de coche o moto',
|
||||
'icon' => 'truck',
|
||||
'default_amortization' => 'price',
|
||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date', 'asset_value'],
|
||||
],
|
||||
self::CONTRACT_TYPE_MORTGAGE => [
|
||||
'name' => 'Hipoteca / Financiación Inmobiliaria',
|
||||
'description' => 'Crédito para compra de inmueble',
|
||||
'icon' => 'home',
|
||||
'default_amortization' => 'sac',
|
||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date', 'asset_value', 'insurance_amount'],
|
||||
],
|
||||
self::CONTRACT_TYPE_CREDIT_CARD => [
|
||||
'name' => 'Tarjeta de Crédito',
|
||||
'description' => 'Financiación de compras a plazos',
|
||||
'icon' => 'credit-card',
|
||||
'default_amortization' => 'price',
|
||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'first_due_date'],
|
||||
],
|
||||
self::CONTRACT_TYPE_CONSORTIUM => [
|
||||
'name' => 'Consorcio',
|
||||
'description' => 'Grupo de compras con cuotas variables',
|
||||
'icon' => 'users',
|
||||
'default_amortization' => 'consortium',
|
||||
'fields' => ['principal_amount', 'total_installments', 'admin_fee_percent', 'start_date', 'first_due_date'],
|
||||
],
|
||||
self::CONTRACT_TYPE_LEASING => [
|
||||
'name' => 'Leasing',
|
||||
'description' => 'Arrendamiento con opción de compra',
|
||||
'icon' => 'key',
|
||||
'default_amortization' => 'price',
|
||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date', 'residual_value'],
|
||||
],
|
||||
self::CONTRACT_TYPE_OVERDRAFT => [
|
||||
'name' => 'Descubierto / Cheque Especial',
|
||||
'description' => 'Línea de crédito rotativa',
|
||||
'icon' => 'arrow-trending-down',
|
||||
'default_amortization' => 'american',
|
||||
'fields' => ['principal_amount', 'monthly_interest_rate'],
|
||||
],
|
||||
self::CONTRACT_TYPE_PAYROLL_LOAN => [
|
||||
'name' => 'Préstamo con Nómina',
|
||||
'description' => 'Crédito con descuento en nómina',
|
||||
'icon' => 'briefcase',
|
||||
'default_amortization' => 'price',
|
||||
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date'],
|
||||
],
|
||||
self::CONTRACT_TYPE_OTHER => [
|
||||
'name' => 'Otro',
|
||||
'description' => 'Otro tipo de pasivo',
|
||||
'icon' => 'document-text',
|
||||
'default_amortization' => 'price',
|
||||
'fields' => ['principal_amount', 'total_installments', 'first_due_date'],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Sistemas de amortização
|
||||
*/
|
||||
public const AMORTIZATION_PRICE = 'price';
|
||||
public const AMORTIZATION_SAC = 'sac';
|
||||
public const AMORTIZATION_AMERICAN = 'american';
|
||||
public const AMORTIZATION_CONSORTIUM = 'consortium';
|
||||
|
||||
public const AMORTIZATION_SYSTEMS = [
|
||||
self::AMORTIZATION_PRICE => [
|
||||
'name' => 'PRICE (Cuota Fija)',
|
||||
'description' => 'Cuotas iguales. Intereses decrecientes, amortización creciente.',
|
||||
],
|
||||
self::AMORTIZATION_SAC => [
|
||||
'name' => 'SAC (Amortización Constante)',
|
||||
'description' => 'Amortización fija. Cuotas e intereses decrecientes.',
|
||||
],
|
||||
self::AMORTIZATION_AMERICAN => [
|
||||
'name' => 'Americano',
|
||||
'description' => 'Solo intereses durante el plazo, principal al final.',
|
||||
],
|
||||
self::AMORTIZATION_CONSORTIUM => [
|
||||
'name' => 'Consorcio',
|
||||
'description' => 'Cuotas variables según el grupo.',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Tipos de indexadores
|
||||
*/
|
||||
public const INDEX_TYPES = [
|
||||
'fixed' => ['name' => 'Tasa Fija', 'description' => 'Sin indexación'],
|
||||
'cdi' => ['name' => 'CDI', 'description' => 'Certificado de Depósito Interbancario (Brasil)'],
|
||||
'selic' => ['name' => 'SELIC', 'description' => 'Tasa básica de interés (Brasil)'],
|
||||
'ipca' => ['name' => 'IPCA', 'description' => 'Índice de precios al consumidor (Brasil)'],
|
||||
'igpm' => ['name' => 'IGP-M', 'description' => 'Índice general de precios (Brasil)'],
|
||||
'tr' => ['name' => 'TR', 'description' => 'Tasa referencial (Brasil)'],
|
||||
'euribor' => ['name' => 'Euribor', 'description' => 'Euro Interbank Offered Rate (UE)'],
|
||||
'libor' => ['name' => 'LIBOR', 'description' => 'London Interbank Offered Rate'],
|
||||
'sofr' => ['name' => 'SOFR', 'description' => 'Secured Overnight Financing Rate (EUA)'],
|
||||
'prime' => ['name' => 'Prime Rate', 'description' => 'Tasa preferencial (EUA)'],
|
||||
'ipc' => ['name' => 'IPC', 'description' => 'Índice de precios al consumidor (España)'],
|
||||
'other' => ['name' => 'Otro', 'description' => 'Otro indexador'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Tipos de garantia
|
||||
*/
|
||||
public const GUARANTEE_TYPES = [
|
||||
'none' => ['name' => 'Sin garantía', 'description' => 'Préstamo sin garantía'],
|
||||
'fiduciary_alienation' => ['name' => 'Alienación Fiduciaria', 'description' => 'El bien queda en garantía hasta el pago total'],
|
||||
'mortgage' => ['name' => 'Hipoteca', 'description' => 'Garantía sobre inmueble'],
|
||||
'pledge' => ['name' => 'Prenda', 'description' => 'Garantía sobre bien mueble'],
|
||||
'guarantor' => ['name' => 'Fiador/Avalista', 'description' => 'Persona que garantiza la deuda'],
|
||||
'payroll' => ['name' => 'Descuento en Nómina', 'description' => 'Descuento directo del salario'],
|
||||
'investment' => ['name' => 'Inversión', 'description' => 'Garantía con inversiones/aplicaciones'],
|
||||
'letter_of_credit' => ['name' => 'Carta de Crédito', 'description' => 'Garantía bancaria'],
|
||||
'surety_bond' => ['name' => 'Seguro Fianza', 'description' => 'Seguro que garantiza la obligación'],
|
||||
'other' => ['name' => 'Otra', 'description' => 'Otro tipo de garantía'],
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'account_id',
|
||||
'name',
|
||||
'contract_type',
|
||||
'amortization_system',
|
||||
'contract_number',
|
||||
'creditor',
|
||||
'description',
|
||||
@ -52,11 +194,39 @@ class LiabilityAccount extends Model
|
||||
'start_date',
|
||||
'end_date',
|
||||
'first_due_date',
|
||||
'has_grace_period',
|
||||
'grace_period_months',
|
||||
'currency',
|
||||
'color',
|
||||
'icon',
|
||||
'status',
|
||||
'is_active',
|
||||
// Campos avançados - Indexadores
|
||||
'index_type',
|
||||
'index_spread',
|
||||
'total_effective_cost',
|
||||
// Campos avançados - Garantias
|
||||
'guarantee_type',
|
||||
'guarantee_value',
|
||||
'guarantee_description',
|
||||
'guarantor_name',
|
||||
// Campos avançados - Penalidades
|
||||
'late_fee_percent',
|
||||
'daily_penalty_percent',
|
||||
'grace_days_for_penalty',
|
||||
// Campos avançados - Específicos por tipo
|
||||
'asset_value',
|
||||
'asset_description',
|
||||
'residual_value',
|
||||
'admin_fee_percent',
|
||||
'reserve_fund_percent',
|
||||
// Campos avançados - Covenants e gestão
|
||||
'covenants',
|
||||
'alert_days_before',
|
||||
'internal_responsible',
|
||||
'internal_notes',
|
||||
'document_number',
|
||||
'registry_office',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -76,6 +246,21 @@ class LiabilityAccount extends Model
|
||||
'end_date' => 'date',
|
||||
'first_due_date' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
'has_grace_period' => 'boolean',
|
||||
'grace_period_months' => 'integer',
|
||||
// Campos avançados
|
||||
'index_spread' => 'decimal:4',
|
||||
'total_effective_cost' => 'decimal:4',
|
||||
'guarantee_value' => 'decimal:2',
|
||||
'late_fee_percent' => 'decimal:2',
|
||||
'daily_penalty_percent' => 'decimal:4',
|
||||
'grace_days_for_penalty' => 'integer',
|
||||
'asset_value' => 'decimal:2',
|
||||
'residual_value' => 'decimal:2',
|
||||
'admin_fee_percent' => 'decimal:2',
|
||||
'reserve_fund_percent' => 'decimal:2',
|
||||
'covenants' => 'array',
|
||||
'alert_days_before' => 'integer',
|
||||
];
|
||||
|
||||
protected $appends = ['progress_percentage', 'remaining_balance'];
|
||||
|
||||
@ -142,7 +142,8 @@ public function scopePending($query)
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->where('status', 'completed');
|
||||
// Incluir 'completed' e 'effective' como transações efetivadas
|
||||
return $query->whereIn('status', ['completed', 'effective']);
|
||||
}
|
||||
|
||||
public function scopeCancelled($query)
|
||||
|
||||
@ -35,6 +35,7 @@ class User extends Authenticatable
|
||||
'currency',
|
||||
'password',
|
||||
'is_admin',
|
||||
'is_demo',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
@ -59,6 +60,7 @@ protected function casts(): array
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
'is_demo' => 'boolean',
|
||||
'accept_whatsapp' => 'boolean',
|
||||
'accept_emails' => 'boolean',
|
||||
];
|
||||
|
||||
291
backend/app/Services/LiabilityTemplateService.php
Normal file
291
backend/app/Services/LiabilityTemplateService.php
Normal file
@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
|
||||
|
||||
class LiabilityTemplateService
|
||||
{
|
||||
/**
|
||||
* Gerar template Excel para importação de passivos
|
||||
*/
|
||||
public function generateTemplate(): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
// Criar aba de Parcelas (principal)
|
||||
$installmentsSheet = $spreadsheet->getActiveSheet();
|
||||
$installmentsSheet->setTitle('Parcelas');
|
||||
$this->createInstallmentsSheet($installmentsSheet);
|
||||
|
||||
// Criar aba de Instruções
|
||||
$instructionsSheet = $spreadsheet->createSheet();
|
||||
$instructionsSheet->setTitle('Instrucciones');
|
||||
$this->createInstructionsSheet($instructionsSheet);
|
||||
|
||||
// Criar aba de Exemplo
|
||||
$exampleSheet = $spreadsheet->createSheet();
|
||||
$exampleSheet->setTitle('Ejemplo');
|
||||
$this->createExampleSheet($exampleSheet);
|
||||
|
||||
// Voltar para primeira aba
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar aba de parcelas com cabeçalho e validações
|
||||
*/
|
||||
private function createInstallmentsSheet($sheet): void
|
||||
{
|
||||
// Definir largura das colunas
|
||||
$sheet->getColumnDimension('A')->setWidth(12); // Nº
|
||||
$sheet->getColumnDimension('B')->setWidth(15); // Fecha
|
||||
$sheet->getColumnDimension('C')->setWidth(15); // Cuota
|
||||
$sheet->getColumnDimension('D')->setWidth(15); // Intereses
|
||||
$sheet->getColumnDimension('E')->setWidth(15); // Capital
|
||||
$sheet->getColumnDimension('F')->setWidth(15); // Tasas/Seguros
|
||||
$sheet->getColumnDimension('G')->setWidth(15); // Estado
|
||||
$sheet->getColumnDimension('H')->setWidth(30); // Observaciones
|
||||
|
||||
// Cabeçalho principal - título
|
||||
$sheet->setCellValue('A1', 'PLANTILLA DE IMPORTACIÓN - CUENTA PASIVO');
|
||||
$sheet->mergeCells('A1:H1');
|
||||
$sheet->getStyle('A1')->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '1E40AF']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(30);
|
||||
|
||||
// Subtítulo
|
||||
$sheet->setCellValue('A2', 'Rellene las columnas con los datos de sus cuotas. Las columnas marcadas con * son obligatorias.');
|
||||
$sheet->mergeCells('A2:H2');
|
||||
$sheet->getStyle('A2')->applyFromArray([
|
||||
'font' => ['italic' => true, 'size' => 10, 'color' => ['rgb' => '6B7280']],
|
||||
]);
|
||||
|
||||
// Cabeçalhos das colunas
|
||||
$headers = [
|
||||
'A3' => 'Nº Cuota *',
|
||||
'B3' => 'Fecha Venc. *',
|
||||
'C3' => 'Valor Cuota *',
|
||||
'D3' => 'Intereses',
|
||||
'E3' => 'Capital',
|
||||
'F3' => 'Tasas/Seguros',
|
||||
'G3' => 'Estado',
|
||||
'H3' => 'Observaciones',
|
||||
];
|
||||
|
||||
foreach ($headers as $cell => $value) {
|
||||
$sheet->setCellValue($cell, $value);
|
||||
}
|
||||
|
||||
// Estilo do cabeçalho
|
||||
$sheet->getStyle('A3:H3')->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '3B82F6']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
||||
'borders' => [
|
||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '1E40AF']],
|
||||
],
|
||||
]);
|
||||
|
||||
// Dicas sob o cabeçalho
|
||||
$tips = [
|
||||
'A4' => '1, 2, 3...',
|
||||
'B4' => 'DD/MM/AAAA',
|
||||
'C4' => '0.00',
|
||||
'D4' => '0.00',
|
||||
'E4' => '0.00',
|
||||
'F4' => '0.00',
|
||||
'G4' => 'Pendiente/Pagado',
|
||||
'H4' => 'Texto libre',
|
||||
];
|
||||
|
||||
foreach ($tips as $cell => $value) {
|
||||
$sheet->setCellValue($cell, $value);
|
||||
}
|
||||
|
||||
$sheet->getStyle('A4:H4')->applyFromArray([
|
||||
'font' => ['italic' => true, 'size' => 9, 'color' => ['rgb' => '9CA3AF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => 'F3F4F6']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
||||
]);
|
||||
|
||||
// Área de dados (linhas 5-64 para até 60 parcelas)
|
||||
for ($row = 5; $row <= 64; $row++) {
|
||||
// Número da parcela
|
||||
$sheet->setCellValue("A{$row}", $row - 4);
|
||||
|
||||
// Aplicar formato de número nas colunas de valores
|
||||
$sheet->getStyle("C{$row}:F{$row}")->getNumberFormat()
|
||||
->setFormatCode(NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1);
|
||||
|
||||
// Aplicar formato de data na coluna B
|
||||
$sheet->getStyle("B{$row}")->getNumberFormat()
|
||||
->setFormatCode('DD/MM/YYYY');
|
||||
|
||||
// Adicionar validação de lista para Estado
|
||||
$validation = $sheet->getCell("G{$row}")->getDataValidation();
|
||||
$validation->setType(DataValidation::TYPE_LIST);
|
||||
$validation->setErrorStyle(DataValidation::STYLE_INFORMATION);
|
||||
$validation->setAllowBlank(true);
|
||||
$validation->setShowDropDown(true);
|
||||
$validation->setFormula1('"Pendiente,Pagado,Vencido"');
|
||||
|
||||
// Bordas leves
|
||||
$sheet->getStyle("A{$row}:H{$row}")->applyFromArray([
|
||||
'borders' => [
|
||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'E5E7EB']],
|
||||
],
|
||||
]);
|
||||
|
||||
// Alternar cores das linhas
|
||||
if (($row - 5) % 2 == 1) {
|
||||
$sheet->getStyle("A{$row}:H{$row}")->applyFromArray([
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => 'F9FAFB']],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Congelar painel no cabeçalho
|
||||
$sheet->freezePane('A5');
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar aba de instruções
|
||||
*/
|
||||
private function createInstructionsSheet($sheet): void
|
||||
{
|
||||
$sheet->getColumnDimension('A')->setWidth(80);
|
||||
|
||||
$instructions = [
|
||||
['INSTRUCCIONES DE USO', true, '1E40AF'],
|
||||
['', false, 'FFFFFF'],
|
||||
['1. DATOS OBLIGATORIOS', true, '059669'],
|
||||
[' • Nº Cuota: Número secuencial de la cuota (1, 2, 3...)', false, '000000'],
|
||||
[' • Fecha Venc.: Fecha de vencimiento en formato DD/MM/AAAA', false, '000000'],
|
||||
[' • Valor Cuota: Valor total de la cuota a pagar', false, '000000'],
|
||||
['', false, 'FFFFFF'],
|
||||
['2. DATOS OPCIONALES (recomendados)', true, '059669'],
|
||||
[' • Intereses: Parte de la cuota correspondiente a intereses', false, '000000'],
|
||||
[' • Capital: Parte de la cuota correspondiente a amortización', false, '000000'],
|
||||
[' • Tasas/Seguros: Otros cargos incluidos en la cuota', false, '000000'],
|
||||
[' • Estado: Pendiente, Pagado o Vencido', false, '000000'],
|
||||
[' • Observaciones: Notas adicionales', false, '000000'],
|
||||
['', false, 'FFFFFF'],
|
||||
['3. TIPOS DE CONTRATOS SOPORTADOS', true, '059669'],
|
||||
[' • Préstamo Personal (Sistema PRICE - cuota fija)', false, '000000'],
|
||||
[' • Financiación de Vehículo', false, '000000'],
|
||||
[' • Financiación Inmobiliaria (Sistema SAC o PRICE)', false, '000000'],
|
||||
[' • Consorcio (cuotas variables)', false, '000000'],
|
||||
[' • Leasing', false, '000000'],
|
||||
['', false, 'FFFFFF'],
|
||||
['4. SISTEMA PRICE (Cuota Fija)', true, '059669'],
|
||||
[' En este sistema:', false, '000000'],
|
||||
[' • La cuota es CONSTANTE todos los meses', false, '000000'],
|
||||
[' • Los intereses DISMINUYEN cada mes', false, '000000'],
|
||||
[' • La amortización AUMENTA cada mes', false, '000000'],
|
||||
[' • Cuota = Intereses + Capital + Tasas', false, '000000'],
|
||||
['', false, 'FFFFFF'],
|
||||
['5. SISTEMA SAC (Amortización Constante)', true, '059669'],
|
||||
[' En este sistema:', false, '000000'],
|
||||
[' • La amortización es CONSTANTE todos los meses', false, '000000'],
|
||||
[' • Los intereses DISMINUYEN cada mes', false, '000000'],
|
||||
[' • La cuota DISMINUYE cada mes', false, '000000'],
|
||||
['', false, 'FFFFFF'],
|
||||
['6. CUOTA DE CARENCIA', true, '059669'],
|
||||
[' Algunos contratos tienen una primera cuota de carencia:', false, '000000'],
|
||||
[' • Solo se pagan intereses (sin amortización de capital)', false, '000000'],
|
||||
[' • El capital amortizado en esta cuota debe ser 0', false, '000000'],
|
||||
['', false, 'FFFFFF'],
|
||||
['7. ESTADOS VÁLIDOS', true, '059669'],
|
||||
[' • Pendiente: Cuota aún no pagada', false, '000000'],
|
||||
[' • Pagado: Cuota ya abonada', false, '000000'],
|
||||
[' • Vencido: Cuota no pagada y pasada la fecha de vencimiento', false, '000000'],
|
||||
];
|
||||
|
||||
$row = 1;
|
||||
foreach ($instructions as $item) {
|
||||
$sheet->setCellValue("A{$row}", $item[0]);
|
||||
|
||||
$style = ['font' => ['color' => ['rgb' => $item[2]]]];
|
||||
if ($item[1]) {
|
||||
$style['font']['bold'] = true;
|
||||
$style['font']['size'] = 12;
|
||||
}
|
||||
$sheet->getStyle("A{$row}")->applyFromArray($style);
|
||||
|
||||
$row++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar aba de exemplo preenchida
|
||||
*/
|
||||
private function createExampleSheet($sheet): void
|
||||
{
|
||||
// Copiar estrutura da aba de parcelas
|
||||
$this->createInstallmentsSheet($sheet);
|
||||
|
||||
// Dados de exemplo (Empréstimo PRICE típico)
|
||||
$exampleData = [
|
||||
[1, '05/06/2025', 20.85, 20.85, 0.00, 0.00, 'Pagado', 'Cuota de carencia (solo intereses)'],
|
||||
[2, '05/07/2025', 122.00, 48.33, 73.67, 0.00, 'Pagado', ''],
|
||||
[3, '05/08/2025', 122.00, 47.68, 74.32, 0.00, 'Pagado', ''],
|
||||
[4, '05/09/2025', 122.00, 47.01, 74.99, 0.00, 'Pagado', ''],
|
||||
[5, '05/10/2025', 122.00, 46.35, 75.65, 0.00, 'Pagado', ''],
|
||||
[6, '05/11/2025', 122.00, 45.68, 76.32, 0.00, 'Pagado', ''],
|
||||
[7, '05/12/2025', 122.00, 45.00, 77.00, 0.00, 'Pendiente', ''],
|
||||
[8, '05/01/2026', 122.00, 44.31, 77.69, 0.00, 'Pendiente', ''],
|
||||
[9, '05/02/2026', 122.00, 43.62, 78.38, 0.00, 'Pendiente', ''],
|
||||
[10, '05/03/2026', 122.00, 42.93, 79.07, 0.00, 'Pendiente', ''],
|
||||
];
|
||||
|
||||
$row = 5;
|
||||
foreach ($exampleData as $data) {
|
||||
$sheet->setCellValue("A{$row}", $data[0]);
|
||||
$sheet->setCellValue("B{$row}", $data[1]);
|
||||
$sheet->setCellValue("C{$row}", $data[2]);
|
||||
$sheet->setCellValue("D{$row}", $data[3]);
|
||||
$sheet->setCellValue("E{$row}", $data[4]);
|
||||
$sheet->setCellValue("F{$row}", $data[5]);
|
||||
$sheet->setCellValue("G{$row}", $data[6]);
|
||||
$sheet->setCellValue("H{$row}", $data[7]);
|
||||
$row++;
|
||||
}
|
||||
|
||||
// Destacar que é exemplo
|
||||
$sheet->setCellValue('A1', 'EJEMPLO - PRÉSTAMO PERSONAL (Sistema PRICE)');
|
||||
$sheet->getStyle('A1')->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '059669']],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar template em arquivo
|
||||
*/
|
||||
public function saveTemplate(string $path): void
|
||||
{
|
||||
$spreadsheet = $this->generateTemplate();
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter caminho do template
|
||||
*/
|
||||
public static function getTemplatePath(): string
|
||||
{
|
||||
return storage_path('app/templates/passivo_template.xlsx');
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,12 @@
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'plan.limits' => \App\Http\Middleware\CheckPlanLimits::class,
|
||||
'admin.only' => \App\Http\Middleware\AdminOnly::class,
|
||||
'demo.protect' => \App\Http\Middleware\DemoProtection::class,
|
||||
]);
|
||||
|
||||
// Aplicar proteção demo em todas as rotas de API autenticadas
|
||||
$middleware->api(append: [
|
||||
\App\Http\Middleware\DemoProtection::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('liability_accounts', function (Blueprint $table) {
|
||||
// Adicionar tipo de contrato e sistema de amortização após 'name'
|
||||
$table->string('contract_type', 50)->default('other')->after('name');
|
||||
$table->string('amortization_system', 20)->default('price')->after('contract_type');
|
||||
|
||||
// Adicionar campos de carência após end_date
|
||||
$table->boolean('has_grace_period')->default(false)->after('first_due_date');
|
||||
$table->unsignedTinyInteger('grace_period_months')->default(0)->after('has_grace_period');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('liability_accounts', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'contract_type',
|
||||
'amortization_system',
|
||||
'has_grace_period',
|
||||
'grace_period_months',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Adiciona campos avançados para gestão completa de passivos:
|
||||
* - Indexadores (CDI, IPCA, Euribor, etc.)
|
||||
* - Garantias (tipo, valor, descrição)
|
||||
* - Penalidades (multa, mora)
|
||||
* - Campos específicos por tipo de contrato
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('liability_accounts', function (Blueprint $table) {
|
||||
// ============================================
|
||||
// INDEXADORES E TAXAS
|
||||
// ============================================
|
||||
// Tipo de indexador (CDI, IPCA, SELIC, Euribor, Fixed, etc.)
|
||||
$table->string('index_type', 30)->nullable()->after('annual_interest_rate');
|
||||
// Spread sobre o indexador (ex: CDI + 2.5%)
|
||||
$table->decimal('index_spread', 6, 4)->nullable()->after('index_type');
|
||||
// CET - Custo Efetivo Total (%)
|
||||
$table->decimal('total_effective_cost', 8, 4)->nullable()->after('index_spread');
|
||||
|
||||
// ============================================
|
||||
// GARANTIAS
|
||||
// ============================================
|
||||
// Tipo de garantia (none, fiduciary_alienation, mortgage, guarantor, pledge, etc.)
|
||||
$table->string('guarantee_type', 50)->nullable()->after('total_effective_cost');
|
||||
// Valor da garantia
|
||||
$table->decimal('guarantee_value', 15, 2)->nullable()->after('guarantee_type');
|
||||
// Descrição da garantia (ex: "Imóvel Rua X, Matrícula 12345")
|
||||
$table->string('guarantee_description', 500)->nullable()->after('guarantee_value');
|
||||
// Nome do fiador (se aplicável)
|
||||
$table->string('guarantor_name', 150)->nullable()->after('guarantee_description');
|
||||
|
||||
// ============================================
|
||||
// PENALIDADES E MULTAS
|
||||
// ============================================
|
||||
// Multa por atraso (%)
|
||||
$table->decimal('late_fee_percent', 5, 2)->nullable()->after('guarantor_name');
|
||||
// Mora diária (%)
|
||||
$table->decimal('daily_penalty_percent', 5, 4)->nullable()->after('late_fee_percent');
|
||||
// Dias de carência antes de aplicar multa
|
||||
$table->unsignedTinyInteger('grace_days_for_penalty')->default(0)->after('daily_penalty_percent');
|
||||
|
||||
// ============================================
|
||||
// CAMPOS ESPECÍFICOS POR TIPO
|
||||
// ============================================
|
||||
// Valor do bem (para financiamentos e leasing)
|
||||
$table->decimal('asset_value', 15, 2)->nullable()->after('grace_days_for_penalty');
|
||||
// Descrição do bem
|
||||
$table->string('asset_description', 300)->nullable()->after('asset_value');
|
||||
// Valor residual (para leasing)
|
||||
$table->decimal('residual_value', 15, 2)->nullable()->after('asset_description');
|
||||
// Taxa de administração (% para consórcio)
|
||||
$table->decimal('admin_fee_percent', 5, 2)->nullable()->after('residual_value');
|
||||
// Fundo de reserva (% para consórcio)
|
||||
$table->decimal('reserve_fund_percent', 5, 2)->nullable()->after('admin_fee_percent');
|
||||
|
||||
// ============================================
|
||||
// COVENANTS E RESTRIÇÕES
|
||||
// ============================================
|
||||
// Covenants financeiros (JSON: [{name, condition, value}])
|
||||
$table->json('covenants')->nullable()->after('reserve_fund_percent');
|
||||
|
||||
// ============================================
|
||||
// ALERTAS E GESTÃO
|
||||
// ============================================
|
||||
// Dias de antecedência para alerta de vencimento
|
||||
$table->unsignedTinyInteger('alert_days_before')->default(5)->after('covenants');
|
||||
// Responsável interno
|
||||
$table->string('internal_responsible', 150)->nullable()->after('alert_days_before');
|
||||
// Notas/observações internas
|
||||
$table->text('internal_notes')->nullable()->after('internal_responsible');
|
||||
|
||||
// ============================================
|
||||
// DOCUMENTAÇÃO
|
||||
// ============================================
|
||||
// Número do documento/escritura
|
||||
$table->string('document_number', 100)->nullable()->after('internal_notes');
|
||||
// Cartório/Registro
|
||||
$table->string('registry_office', 200)->nullable()->after('document_number');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('liability_accounts', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'index_type',
|
||||
'index_spread',
|
||||
'total_effective_cost',
|
||||
'guarantee_type',
|
||||
'guarantee_value',
|
||||
'guarantee_description',
|
||||
'guarantor_name',
|
||||
'late_fee_percent',
|
||||
'daily_penalty_percent',
|
||||
'grace_days_for_penalty',
|
||||
'asset_value',
|
||||
'asset_description',
|
||||
'residual_value',
|
||||
'admin_fee_percent',
|
||||
'reserve_fund_percent',
|
||||
'covenants',
|
||||
'alert_days_before',
|
||||
'internal_responsible',
|
||||
'internal_notes',
|
||||
'document_number',
|
||||
'registry_office',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_demo')->default(false)->after('is_admin');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_demo');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('asset_accounts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->unsignedBigInteger('business_id')->nullable(); // Sem FK por enquanto
|
||||
|
||||
// Tipo de ativo
|
||||
$table->string('asset_type', 50); // real_estate, vehicle, investment, equipment, inventory, receivable, other
|
||||
|
||||
// Dados básicos
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('currency', 3)->default('EUR');
|
||||
$table->string('color', 7)->default('#3B82F6');
|
||||
|
||||
// Valores
|
||||
$table->decimal('acquisition_value', 15, 2)->default(0); // Valor de aquisição
|
||||
$table->decimal('current_value', 15, 2)->default(0); // Valor atual/de mercado
|
||||
$table->date('acquisition_date')->nullable();
|
||||
|
||||
// Depreciação (para ativos depreciáveis)
|
||||
$table->boolean('is_depreciable')->default(false);
|
||||
$table->string('depreciation_method', 30)->nullable(); // linear, declining_balance, units_production
|
||||
$table->decimal('useful_life_years', 5, 2)->nullable();
|
||||
$table->decimal('residual_value', 15, 2)->nullable();
|
||||
$table->decimal('accumulated_depreciation', 15, 2)->default(0);
|
||||
|
||||
// Para imóveis
|
||||
$table->string('property_type', 50)->nullable(); // house, apartment, land, commercial, industrial
|
||||
$table->text('address')->nullable();
|
||||
$table->string('city', 100)->nullable();
|
||||
$table->string('state', 100)->nullable();
|
||||
$table->string('postal_code', 20)->nullable();
|
||||
$table->string('country', 2)->nullable();
|
||||
$table->decimal('property_area_m2', 12, 2)->nullable();
|
||||
$table->string('registry_number', 100)->nullable();
|
||||
|
||||
// Para veículos
|
||||
$table->string('vehicle_brand', 100)->nullable();
|
||||
$table->string('vehicle_model', 100)->nullable();
|
||||
$table->year('vehicle_year')->nullable();
|
||||
$table->string('vehicle_plate', 20)->nullable();
|
||||
$table->string('vehicle_vin', 50)->nullable();
|
||||
$table->integer('vehicle_mileage')->nullable();
|
||||
|
||||
// Para investimentos
|
||||
$table->string('investment_type', 50)->nullable(); // stocks, bonds, funds, crypto, savings, fixed_income
|
||||
$table->string('institution', 100)->nullable();
|
||||
$table->string('account_number', 100)->nullable();
|
||||
$table->integer('quantity')->nullable(); // Quantidade de ativos (ações, cotas)
|
||||
$table->decimal('unit_price', 15, 6)->nullable();
|
||||
$table->string('ticker', 20)->nullable();
|
||||
$table->date('maturity_date')->nullable();
|
||||
$table->decimal('interest_rate', 8, 4)->nullable();
|
||||
$table->string('index_type', 30)->nullable();
|
||||
|
||||
// Para equipamentos/maquinário
|
||||
$table->string('equipment_brand', 100)->nullable();
|
||||
$table->string('equipment_model', 100)->nullable();
|
||||
$table->string('serial_number', 100)->nullable();
|
||||
$table->date('warranty_expiry')->nullable();
|
||||
|
||||
// Para recebíveis
|
||||
$table->string('debtor_name', 200)->nullable();
|
||||
$table->string('debtor_document', 50)->nullable();
|
||||
$table->date('receivable_due_date')->nullable();
|
||||
$table->decimal('receivable_amount', 15, 2)->nullable();
|
||||
|
||||
// Garantias (se o ativo serve como garantia)
|
||||
$table->boolean('is_collateral')->default(false);
|
||||
$table->string('collateral_for', 200)->nullable();
|
||||
$table->foreignId('linked_liability_id')->nullable()->constrained('liability_accounts')->onDelete('set null');
|
||||
|
||||
// Seguros
|
||||
$table->boolean('has_insurance')->default(false);
|
||||
$table->string('insurance_company', 100)->nullable();
|
||||
$table->string('insurance_policy', 100)->nullable();
|
||||
$table->decimal('insurance_value', 15, 2)->nullable();
|
||||
$table->date('insurance_expiry')->nullable();
|
||||
|
||||
// Gestão
|
||||
$table->integer('alert_days_before')->default(30);
|
||||
$table->string('internal_responsible', 200)->nullable();
|
||||
$table->text('internal_notes')->nullable();
|
||||
$table->string('document_number', 100)->nullable();
|
||||
|
||||
// Status
|
||||
$table->string('status', 30)->default('active'); // active, sold, depreciated, written_off
|
||||
$table->date('disposal_date')->nullable();
|
||||
$table->decimal('disposal_value', 15, 2)->nullable();
|
||||
$table->string('disposal_reason', 200)->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Índices
|
||||
$table->index(['user_id', 'asset_type']);
|
||||
$table->index(['user_id', 'status']);
|
||||
$table->index(['business_id', 'asset_type']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('asset_accounts');
|
||||
}
|
||||
};
|
||||
320
backend/database/seeders/DemoUserSeeder.php
Normal file
320
backend/database/seeders/DemoUserSeeder.php
Normal file
@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\LiabilityAccount;
|
||||
use App\Models\AssetAccount;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class DemoUserSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// 1. Criar usuário DEMO
|
||||
$user = User::updateOrCreate(
|
||||
['email' => 'demo@webmoney.com'],
|
||||
[
|
||||
'name' => 'Usuario Demo',
|
||||
'first_name' => 'Demo',
|
||||
'last_name' => 'WebMoney',
|
||||
'password' => Hash::make('DEMO'),
|
||||
'is_demo' => true,
|
||||
'is_admin' => false,
|
||||
'email_verified_at' => now(),
|
||||
'country' => 'ES',
|
||||
'timezone' => 'Europe/Madrid',
|
||||
'locale' => 'es_ES',
|
||||
'language' => 'es',
|
||||
'currency' => 'EUR',
|
||||
]
|
||||
);
|
||||
|
||||
$this->command->info("✓ Usuario demo creado: demo@webmoney.com / DEMO");
|
||||
|
||||
// 2. Criar passivos de exemplo
|
||||
$this->createSampleLiabilities($user);
|
||||
|
||||
// 3. Criar ativos de exemplo
|
||||
$this->createSampleAssets($user);
|
||||
}
|
||||
|
||||
protected function createSampleLiabilities(User $user): void
|
||||
{
|
||||
$liabilities = [
|
||||
[
|
||||
'contract_type' => 'personal_loan',
|
||||
'name' => 'Préstamo Personal Santander',
|
||||
'creditor' => 'Banco Santander',
|
||||
'contract_number' => 'PL-2024-001234',
|
||||
'description' => 'Préstamo personal para reforma del hogar',
|
||||
'currency' => 'EUR',
|
||||
'principal_amount' => 15000.00,
|
||||
'total_pending' => 12500.00,
|
||||
'total_paid' => 2500.00,
|
||||
'annual_interest_rate' => 7.5,
|
||||
'monthly_interest_rate' => 0.625,
|
||||
'amortization_system' => 'price',
|
||||
'total_installments' => 48,
|
||||
'paid_installments' => 12,
|
||||
'start_date' => '2024-01-15',
|
||||
'first_due_date' => '2024-02-15',
|
||||
'index_type' => 'fixed',
|
||||
'alert_days_before' => 5,
|
||||
'color' => '#3B82F6',
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'contract_type' => 'vehicle',
|
||||
'name' => 'Financiación Coche BMW X3',
|
||||
'creditor' => 'BMW Financial Services',
|
||||
'contract_number' => 'VF-2024-BMW-5678',
|
||||
'description' => 'Financiación vehículo BMW X3 xDrive20d',
|
||||
'currency' => 'EUR',
|
||||
'principal_amount' => 45000.00,
|
||||
'total_pending' => 38750.00,
|
||||
'total_paid' => 6250.00,
|
||||
'annual_interest_rate' => 5.9,
|
||||
'amortization_system' => 'price',
|
||||
'total_installments' => 60,
|
||||
'paid_installments' => 10,
|
||||
'start_date' => '2024-03-01',
|
||||
'first_due_date' => '2024-04-01',
|
||||
'guarantee_type' => 'fiduciary_alienation',
|
||||
'guarantee_value' => 45000.00,
|
||||
'index_type' => 'fixed',
|
||||
'color' => '#10B981',
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'contract_type' => 'mortgage',
|
||||
'name' => 'Hipoteca Vivienda Principal',
|
||||
'creditor' => 'CaixaBank',
|
||||
'contract_number' => 'HIP-2020-987654',
|
||||
'description' => 'Hipoteca vivienda familiar - Madrid Centro',
|
||||
'currency' => 'EUR',
|
||||
'principal_amount' => 250000.00,
|
||||
'total_pending' => 215000.00,
|
||||
'total_paid' => 35000.00,
|
||||
'annual_interest_rate' => 2.5,
|
||||
'amortization_system' => 'sac',
|
||||
'total_installments' => 300,
|
||||
'paid_installments' => 48,
|
||||
'start_date' => '2020-06-15',
|
||||
'first_due_date' => '2020-07-15',
|
||||
'guarantee_type' => 'mortgage_guarantee',
|
||||
'guarantee_value' => 350000.00,
|
||||
'index_type' => 'euribor',
|
||||
'index_spread' => 0.80,
|
||||
'color' => '#F59E0B',
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'contract_type' => 'credit_card',
|
||||
'name' => 'Tarjeta Visa Oro BBVA',
|
||||
'creditor' => 'BBVA',
|
||||
'contract_number' => 'CC-VISA-4242',
|
||||
'currency' => 'EUR',
|
||||
'principal_amount' => 5000.00,
|
||||
'total_pending' => 3250.00,
|
||||
'total_paid' => 1750.00,
|
||||
'annual_interest_rate' => 18.99,
|
||||
'amortization_system' => 'revolving',
|
||||
'total_installments' => 0,
|
||||
'paid_installments' => 0,
|
||||
'start_date' => '2023-01-01',
|
||||
'first_due_date' => '2023-02-01',
|
||||
'color' => '#EF4444',
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'contract_type' => 'leasing',
|
||||
'name' => 'Leasing Maquinaria Industrial',
|
||||
'creditor' => 'Banco Sabadell Leasing',
|
||||
'contract_number' => 'LEA-2024-MAQ-456',
|
||||
'currency' => 'EUR',
|
||||
'principal_amount' => 120000.00,
|
||||
'total_pending' => 105000.00,
|
||||
'total_paid' => 15000.00,
|
||||
'annual_interest_rate' => 4.5,
|
||||
'amortization_system' => 'price',
|
||||
'total_installments' => 48,
|
||||
'paid_installments' => 6,
|
||||
'start_date' => '2024-05-01',
|
||||
'first_due_date' => '2024-06-01',
|
||||
'color' => '#06B6D4',
|
||||
'status' => 'active',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($liabilities as $data) {
|
||||
LiabilityAccount::updateOrCreate(
|
||||
['user_id' => $user->id, 'contract_number' => $data['contract_number']],
|
||||
array_merge($data, ['user_id' => $user->id])
|
||||
);
|
||||
}
|
||||
|
||||
$this->command->info("✓ " . count($liabilities) . " pasivos de ejemplo creados");
|
||||
}
|
||||
|
||||
protected function createSampleAssets(User $user): void
|
||||
{
|
||||
$assets = [
|
||||
[
|
||||
'asset_type' => 'real_estate',
|
||||
'name' => 'Vivienda Principal Madrid',
|
||||
'description' => 'Piso de 120m² en el centro de Madrid',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 350000.00,
|
||||
'current_value' => 420000.00,
|
||||
'acquisition_date' => '2020-06-15',
|
||||
'property_type' => 'apartment',
|
||||
'address' => 'Calle Mayor 45, 3º A',
|
||||
'city' => 'Madrid',
|
||||
'postal_code' => '28013',
|
||||
'country' => 'ES',
|
||||
'property_area_m2' => 120.00,
|
||||
'color' => '#3B82F6',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'real_estate',
|
||||
'name' => 'Local Comercial Barcelona',
|
||||
'description' => 'Local comercial en zona turística',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 180000.00,
|
||||
'current_value' => 210000.00,
|
||||
'acquisition_date' => '2019-03-20',
|
||||
'property_type' => 'commercial',
|
||||
'city' => 'Barcelona',
|
||||
'property_area_m2' => 85.00,
|
||||
'color' => '#10B981',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'vehicle',
|
||||
'name' => 'BMW X3 xDrive20d',
|
||||
'description' => 'SUV premium para uso personal',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 55000.00,
|
||||
'current_value' => 42000.00,
|
||||
'acquisition_date' => '2022-09-01',
|
||||
'vehicle_brand' => 'BMW',
|
||||
'vehicle_model' => 'X3 xDrive20d',
|
||||
'vehicle_year' => 2022,
|
||||
'vehicle_plate' => '1234 ABC',
|
||||
'vehicle_mileage' => 45000,
|
||||
'color' => '#F59E0B',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'investment',
|
||||
'name' => 'Cartera Acciones IBEX',
|
||||
'description' => 'Cartera diversificada de acciones españolas',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 25000.00,
|
||||
'current_value' => 28500.00,
|
||||
'acquisition_date' => '2023-01-15',
|
||||
'investment_type' => 'stocks',
|
||||
'institution' => 'Interactive Brokers',
|
||||
'ticker' => 'IBEX35',
|
||||
'color' => '#8B5CF6',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'investment',
|
||||
'name' => 'Fondo Indexado S&P 500',
|
||||
'description' => 'ETF que replica el índice S&P 500',
|
||||
'currency' => 'USD',
|
||||
'acquisition_value' => 15000.00,
|
||||
'current_value' => 18200.00,
|
||||
'acquisition_date' => '2022-06-01',
|
||||
'investment_type' => 'etf',
|
||||
'institution' => 'Vanguard',
|
||||
'ticker' => 'VOO',
|
||||
'color' => '#06B6D4',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'investment',
|
||||
'name' => 'Depósito a Plazo Fijo',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 50000.00,
|
||||
'current_value' => 51500.00,
|
||||
'acquisition_date' => '2024-01-15',
|
||||
'investment_type' => 'fixed_deposit',
|
||||
'institution' => 'Openbank',
|
||||
'interest_rate' => 3.00,
|
||||
'maturity_date' => '2025-01-15',
|
||||
'color' => '#14B8A6',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'investment',
|
||||
'name' => 'Bitcoin (BTC)',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 10000.00,
|
||||
'current_value' => 15800.00,
|
||||
'acquisition_date' => '2023-08-01',
|
||||
'investment_type' => 'crypto',
|
||||
'institution' => 'Binance',
|
||||
'ticker' => 'BTC',
|
||||
'color' => '#F97316',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'equipment',
|
||||
'name' => 'MacBook Pro 16"',
|
||||
'description' => 'Ordenador portátil profesional',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 3500.00,
|
||||
'current_value' => 2800.00,
|
||||
'acquisition_date' => '2023-03-15',
|
||||
'equipment_brand' => 'Apple',
|
||||
'equipment_model' => 'MacBook Pro 16 M2 Pro',
|
||||
'serial_number' => 'C02XL234H1',
|
||||
'warranty_expiry' => '2026-03-15',
|
||||
'color' => '#64748B',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'receivable',
|
||||
'name' => 'Factura Cliente ABC Corp',
|
||||
'description' => 'Factura pendiente de cobro',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 12500.00,
|
||||
'current_value' => 12500.00,
|
||||
'acquisition_date' => '2024-11-15',
|
||||
'debtor_name' => 'ABC Corporation S.L.',
|
||||
'receivable_due_date' => '2025-01-15',
|
||||
'receivable_amount' => 12500.00,
|
||||
'color' => '#84CC16',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'cash',
|
||||
'name' => 'Reserva de Emergencia',
|
||||
'description' => 'Fondo de emergencia en cuenta de ahorro',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 20000.00,
|
||||
'current_value' => 20500.00,
|
||||
'acquisition_date' => '2023-01-01',
|
||||
'institution' => 'ING Direct',
|
||||
'interest_rate' => 2.50,
|
||||
'color' => '#22C55E',
|
||||
],
|
||||
[
|
||||
'asset_type' => 'other',
|
||||
'name' => 'Colección de Arte',
|
||||
'description' => 'Obras de arte y antigüedades',
|
||||
'currency' => 'EUR',
|
||||
'acquisition_value' => 35000.00,
|
||||
'current_value' => 42000.00,
|
||||
'acquisition_date' => '2021-01-01',
|
||||
'color' => '#A855F7',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($assets as $data) {
|
||||
AssetAccount::updateOrCreate(
|
||||
['user_id' => $user->id, 'name' => $data['name']],
|
||||
array_merge($data, ['user_id' => $user->id, 'status' => 'active'])
|
||||
);
|
||||
}
|
||||
|
||||
$this->command->info("✓ " . count($assets) . " activos de ejemplo creados");
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Http\Controllers\Api\CostCenterController;
|
||||
use App\Http\Controllers\Api\CategoryController;
|
||||
use App\Http\Controllers\Api\LiabilityAccountController;
|
||||
use App\Http\Controllers\Api\AssetAccountController;
|
||||
use App\Http\Controllers\Api\TransactionController;
|
||||
use App\Http\Controllers\Api\ImportController;
|
||||
use App\Http\Controllers\Api\TransferDetectionController;
|
||||
@ -119,7 +120,10 @@
|
||||
// ============================================
|
||||
// Rotas específicas ANTES do apiResource (para evitar conflito com {id})
|
||||
Route::get('liability-accounts/pending-reconciliation', [LiabilityAccountController::class, 'pendingReconciliation']);
|
||||
Route::get('liability-accounts/template', [LiabilityAccountController::class, 'downloadTemplate']);
|
||||
Route::get('liability-accounts/contract-types', [LiabilityAccountController::class, 'contractTypes']);
|
||||
Route::post('liability-accounts/import', [LiabilityAccountController::class, 'import']);
|
||||
Route::post('liability-accounts/wizard', [LiabilityAccountController::class, 'storeWithWizard']);
|
||||
Route::get('liability-summary', [LiabilityAccountController::class, 'summary']);
|
||||
|
||||
// Resource principal
|
||||
@ -134,6 +138,21 @@
|
||||
Route::post('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'reconcile']);
|
||||
Route::delete('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'unreconcile']);
|
||||
|
||||
// ============================================
|
||||
// Contas Ativo (Asset Accounts)
|
||||
// ============================================
|
||||
// Rotas específicas ANTES do apiResource
|
||||
Route::get('asset-accounts/asset-types', [AssetAccountController::class, 'assetTypes']);
|
||||
Route::post('asset-accounts/wizard', [AssetAccountController::class, 'storeWithWizard']);
|
||||
Route::get('asset-summary', [AssetAccountController::class, 'summary']);
|
||||
|
||||
// Resource principal
|
||||
Route::apiResource('asset-accounts', AssetAccountController::class);
|
||||
|
||||
// Rotas com parâmetros
|
||||
Route::put('asset-accounts/{assetAccount}/value', [AssetAccountController::class, 'updateValue']);
|
||||
Route::post('asset-accounts/{assetAccount}/dispose', [AssetAccountController::class, 'dispose']);
|
||||
|
||||
// ============================================
|
||||
// Transações (Transactions) - Com limite de plano
|
||||
// ============================================
|
||||
|
||||
832
frontend/src/components/AccountWizard.jsx
Normal file
832
frontend/src/components/AccountWizard.jsx
Normal file
@ -0,0 +1,832 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { accountService, liabilityAccountService, assetAccountService } from '../services/api';
|
||||
import { useToast } from './Toast';
|
||||
|
||||
const AccountWizard = ({ isOpen, onClose, onSuccess, account = null }) => {
|
||||
const toast = useToast();
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const isEditMode = !!account;
|
||||
|
||||
// Tipo de destino: 'account', 'asset' ou 'liability'
|
||||
const [destinationType, setDestinationType] = useState('account');
|
||||
|
||||
// Form data unificado
|
||||
const [formData, setFormData] = useState({
|
||||
// Tipo de conta (etapa 1)
|
||||
account_type: '', // checking, savings, credit_card, cash
|
||||
|
||||
// Dados básicos (etapa 2)
|
||||
name: '',
|
||||
description: '',
|
||||
currency: 'EUR',
|
||||
color: '#3B82F6',
|
||||
icon: 'bi-bank',
|
||||
|
||||
// Dados financeiros (etapa 3)
|
||||
initial_balance: '',
|
||||
credit_limit: '', // Para cartão de crédito
|
||||
|
||||
// Dados bancários (etapa 4) - opcional
|
||||
bank_name: '',
|
||||
account_number: '',
|
||||
|
||||
// Configurações
|
||||
is_active: true,
|
||||
include_in_total: true,
|
||||
|
||||
// Para poupança (ativo)
|
||||
interest_rate: '',
|
||||
|
||||
// Para cartão de crédito (passivo)
|
||||
closing_day: '',
|
||||
due_day: '',
|
||||
annual_interest_rate: '',
|
||||
});
|
||||
|
||||
// Definição dos tipos de conta
|
||||
const accountTypes = {
|
||||
checking: {
|
||||
name: 'Cuenta Corriente',
|
||||
description: 'Cuenta bancaria para operaciones diarias',
|
||||
icon: 'bi-bank',
|
||||
color: '#3B82F6',
|
||||
destination: 'account',
|
||||
},
|
||||
savings: {
|
||||
name: 'Cuenta de Ahorro',
|
||||
description: 'Dinero guardado que genera intereses',
|
||||
icon: 'bi-piggy-bank',
|
||||
color: '#10B981',
|
||||
destination: 'asset', // Poupança vira ativo
|
||||
},
|
||||
credit_card: {
|
||||
name: 'Tarjeta de Crédito',
|
||||
description: 'Línea de crédito rotativo',
|
||||
icon: 'bi-credit-card',
|
||||
color: '#EF4444',
|
||||
destination: 'liability', // Cartão vira passivo
|
||||
},
|
||||
cash: {
|
||||
name: 'Efectivo',
|
||||
description: 'Dinero en mano o caja chica',
|
||||
icon: 'bi-cash-stack',
|
||||
color: '#F59E0B',
|
||||
destination: 'account',
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (account) {
|
||||
loadAccountData(account);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
}, [isOpen, account]);
|
||||
|
||||
// Atualizar destino quando tipo muda
|
||||
useEffect(() => {
|
||||
if (formData.account_type && accountTypes[formData.account_type]) {
|
||||
const typeConfig = accountTypes[formData.account_type];
|
||||
setDestinationType(typeConfig.destination);
|
||||
|
||||
// Atualizar ícone e cor padrão
|
||||
if (!isEditMode) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
icon: typeConfig.icon,
|
||||
color: typeConfig.color,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [formData.account_type]);
|
||||
|
||||
const loadAccountData = (accountData) => {
|
||||
// Determinar o tipo baseado nos dados
|
||||
let accountType = accountData.type || accountData.account_type || 'checking';
|
||||
|
||||
// Se for um ativo de investimento/poupança
|
||||
if (accountData.asset_type === 'cash' || accountData.investment_type === 'savings') {
|
||||
accountType = 'savings';
|
||||
}
|
||||
|
||||
// Se for um passivo de cartão
|
||||
if (accountData.contract_type === 'credit_card') {
|
||||
accountType = 'credit_card';
|
||||
}
|
||||
|
||||
setStep(2);
|
||||
setFormData({
|
||||
account_type: accountType,
|
||||
name: accountData.name || '',
|
||||
description: accountData.description || '',
|
||||
currency: accountData.currency || 'EUR',
|
||||
color: accountData.color || '#3B82F6',
|
||||
icon: accountData.icon || 'bi-bank',
|
||||
initial_balance: accountData.initial_balance || accountData.current_balance || accountData.current_value || '',
|
||||
credit_limit: accountData.credit_limit || accountData.principal_amount || '',
|
||||
bank_name: accountData.bank_name || accountData.creditor || '',
|
||||
account_number: accountData.account_number || accountData.contract_number || '',
|
||||
is_active: accountData.is_active !== false,
|
||||
include_in_total: accountData.include_in_total !== false,
|
||||
interest_rate: accountData.interest_rate || '',
|
||||
closing_day: accountData.closing_day || '',
|
||||
due_day: accountData.due_day || '',
|
||||
annual_interest_rate: accountData.annual_interest_rate || '',
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setStep(1);
|
||||
setDestinationType('account');
|
||||
setFormData({
|
||||
account_type: '',
|
||||
name: '',
|
||||
description: '',
|
||||
currency: 'EUR',
|
||||
color: '#3B82F6',
|
||||
icon: 'bi-bank',
|
||||
initial_balance: '',
|
||||
credit_limit: '',
|
||||
bank_name: '',
|
||||
account_number: '',
|
||||
is_active: true,
|
||||
include_in_total: true,
|
||||
interest_rate: '',
|
||||
closing_day: '',
|
||||
due_day: '',
|
||||
annual_interest_rate: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const selectAccountType = (type) => {
|
||||
setFormData(prev => ({ ...prev, account_type: type }));
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const validateStep = () => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return !!formData.account_type;
|
||||
case 2:
|
||||
return !!formData.name?.trim();
|
||||
case 3:
|
||||
if (destinationType === 'liability') {
|
||||
return formData.credit_limit > 0;
|
||||
}
|
||||
return true; // Saldo inicial pode ser 0
|
||||
case 4:
|
||||
return true; // Dados bancários são opcionais
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (validateStep()) {
|
||||
setStep(prev => Math.min(prev + 1, 4));
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
setStep(prev => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (destinationType === 'asset') {
|
||||
// Criar como Ativo (Poupança)
|
||||
const assetData = {
|
||||
asset_type: 'cash', // Tipo cash para poupança
|
||||
investment_type: 'savings',
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
currency: formData.currency,
|
||||
color: formData.color,
|
||||
acquisition_value: parseFloat(formData.initial_balance) || 0,
|
||||
current_value: parseFloat(formData.initial_balance) || 0,
|
||||
acquisition_date: new Date().toISOString().split('T')[0],
|
||||
institution: formData.bank_name,
|
||||
account_number: formData.account_number,
|
||||
interest_rate: parseFloat(formData.interest_rate) || 0,
|
||||
};
|
||||
|
||||
if (isEditMode && account) {
|
||||
response = await assetAccountService.update(account.id, assetData);
|
||||
} else {
|
||||
response = await assetAccountService.createWithWizard(assetData);
|
||||
}
|
||||
} else if (destinationType === 'liability') {
|
||||
// Criar como Passivo (Cartão de Crédito)
|
||||
const liabilityData = {
|
||||
contract_type: 'credit_card',
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
currency: formData.currency,
|
||||
color: formData.color,
|
||||
icon: formData.icon,
|
||||
creditor: formData.bank_name,
|
||||
contract_number: formData.account_number,
|
||||
principal_amount: parseFloat(formData.credit_limit) || 0,
|
||||
total_pending: parseFloat(formData.initial_balance) || 0,
|
||||
annual_interest_rate: parseFloat(formData.annual_interest_rate) || 0,
|
||||
amortization_system: 'revolving',
|
||||
start_date: new Date().toISOString().split('T')[0],
|
||||
closing_day: parseInt(formData.closing_day) || null,
|
||||
due_day: parseInt(formData.due_day) || null,
|
||||
};
|
||||
|
||||
if (isEditMode && account) {
|
||||
response = await liabilityAccountService.update(account.id, liabilityData);
|
||||
} else {
|
||||
response = await liabilityAccountService.storeWithWizard(liabilityData);
|
||||
}
|
||||
} else {
|
||||
// Criar como Conta Normal
|
||||
const accountData = {
|
||||
type: formData.account_type,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
currency: formData.currency,
|
||||
color: formData.color,
|
||||
icon: formData.icon,
|
||||
bank_name: formData.bank_name,
|
||||
account_number: formData.account_number,
|
||||
initial_balance: parseFloat(formData.initial_balance) || 0,
|
||||
credit_limit: formData.account_type === 'credit_card' ? parseFloat(formData.credit_limit) || null : null,
|
||||
is_active: formData.is_active,
|
||||
include_in_total: formData.include_in_total,
|
||||
};
|
||||
|
||||
if (isEditMode && account) {
|
||||
response = await accountService.update(account.id, accountData);
|
||||
} else {
|
||||
response = await accountService.create(accountData);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
onSuccess?.(response.data, destinationType);
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving account:', error);
|
||||
toast.error(error.response?.data?.message || 'Error al guardar la cuenta');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalSteps = () => {
|
||||
return 4;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
||||
<div className={`modal-dialog ${isMobile ? 'modal-fullscreen' : 'modal-lg'} modal-dialog-centered modal-dialog-scrollable`}>
|
||||
<div className="modal-content" style={{ background: isMobile ? '#0f172a' : '#1e293b', border: '1px solid #334155' }}>
|
||||
{/* Header */}
|
||||
<div className={`modal-header border-0 ${isMobile ? 'py-2' : ''}`} style={{ backgroundColor: '#334155' }}>
|
||||
<h5 className={`modal-title text-white ${isMobile ? 'fs-6' : ''}`}>
|
||||
<i className={`bi ${isEditMode ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
|
||||
{isEditMode ? 'Editar Cuenta' : 'Nueva Cuenta'} - Paso {step}/{getTotalSteps()}
|
||||
</h5>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="progress" style={{ height: '4px', borderRadius: 0 }}>
|
||||
<div
|
||||
className="progress-bar bg-primary"
|
||||
style={{ width: `${(step / getTotalSteps()) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className={`modal-body ${isMobile ? 'p-3' : 'p-4'}`} style={{ color: '#fff' }}>
|
||||
|
||||
{/* Step 1: Tipo de Conta */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h5 className="mb-4">
|
||||
<i className="bi bi-wallet2 me-2"></i>
|
||||
¿Qué tipo de cuenta deseas crear?
|
||||
</h5>
|
||||
|
||||
<div className="row g-3">
|
||||
{Object.entries(accountTypes).map(([key, config]) => (
|
||||
<div key={key} className="col-md-6">
|
||||
<div
|
||||
className={`card h-100 cursor-pointer ${formData.account_type === key ? 'border-primary' : 'border-secondary'}`}
|
||||
style={{
|
||||
backgroundColor: formData.account_type === key ? config.color + '20' : '#0f172a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onClick={() => selectAccountType(key)}
|
||||
>
|
||||
<div className="card-body text-center p-4">
|
||||
<div
|
||||
className="rounded-circle d-inline-flex align-items-center justify-content-center mb-3"
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
backgroundColor: config.color + '30',
|
||||
}}
|
||||
>
|
||||
<i className={`bi ${config.icon} fs-3`} style={{ color: config.color }}></i>
|
||||
</div>
|
||||
<h6 className="text-white mb-2">{config.name}</h6>
|
||||
<small className="text-slate-400">{config.description}</small>
|
||||
|
||||
{/* Badge indicando destino */}
|
||||
<div className="mt-3">
|
||||
{config.destination === 'asset' && (
|
||||
<span className="badge bg-success">
|
||||
<i className="bi bi-graph-up me-1"></i>
|
||||
Se registra como Activo
|
||||
</span>
|
||||
)}
|
||||
{config.destination === 'liability' && (
|
||||
<span className="badge bg-danger">
|
||||
<i className="bi bi-graph-down me-1"></i>
|
||||
Se registra como Pasivo
|
||||
</span>
|
||||
)}
|
||||
{config.destination === 'account' && (
|
||||
<span className="badge bg-primary">
|
||||
<i className="bi bi-wallet2 me-1"></i>
|
||||
Cuenta Estándar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Dados Básicos */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h5 className="mb-4">
|
||||
<i className={`bi ${accountTypes[formData.account_type]?.icon || 'bi-info-circle'} me-2`}></i>
|
||||
Información Básica
|
||||
</h5>
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<label className="form-label">
|
||||
Nombre de la Cuenta <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder={`Ej: ${accountTypes[formData.account_type]?.name || 'Mi cuenta'}`}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Moneda</label>
|
||||
<select
|
||||
name="currency"
|
||||
className="form-select bg-dark text-white border-secondary"
|
||||
value={formData.currency}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
<option value="USD">USD - Dólar</option>
|
||||
<option value="BRL">BRL - Real</option>
|
||||
<option value="GBP">GBP - Libra</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Color</label>
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
{['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#6B7280'].map(color => (
|
||||
<div
|
||||
key={color}
|
||||
className={`rounded-circle ${formData.color === color ? 'ring ring-white' : ''}`}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
backgroundColor: color,
|
||||
cursor: 'pointer',
|
||||
border: formData.color === color ? '3px solid white' : '2px solid transparent',
|
||||
}}
|
||||
onClick={() => setFormData(prev => ({ ...prev, color }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<label className="form-label">Descripción (opcional)</label>
|
||||
<textarea
|
||||
name="description"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={2}
|
||||
placeholder="Descripción o notas adicionales..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Dados Financeiros */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h5 className="mb-4">
|
||||
<i className="bi bi-currency-euro me-2"></i>
|
||||
Información Financiera
|
||||
</h5>
|
||||
|
||||
<div className="row g-3">
|
||||
{/* Saldo/Valor Inicial */}
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">
|
||||
{destinationType === 'liability' ? 'Deuda Actual' : 'Saldo Inicial'}
|
||||
{destinationType === 'liability' && <span className="text-danger">*</span>}
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-dark text-white border-secondary">
|
||||
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
name="initial_balance"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
value={formData.initial_balance}
|
||||
onChange={handleChange}
|
||||
placeholder="0.00"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<small className="text-slate-400">
|
||||
{destinationType === 'liability'
|
||||
? 'Monto que debes actualmente en esta tarjeta'
|
||||
: 'Saldo actual de la cuenta'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{/* Limite de Crédito (só para cartão) */}
|
||||
{(formData.account_type === 'credit_card' || destinationType === 'liability') && (
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">
|
||||
Límite de Crédito <span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-dark text-white border-secondary">
|
||||
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
name="credit_limit"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
value={formData.credit_limit}
|
||||
onChange={handleChange}
|
||||
placeholder="5000.00"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Taxa de Juros (para poupança ou cartão) */}
|
||||
{(destinationType === 'asset' || destinationType === 'liability') && (
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">
|
||||
Tasa de Interés Anual (%)
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="number"
|
||||
name={destinationType === 'asset' ? 'interest_rate' : 'annual_interest_rate'}
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
value={destinationType === 'asset' ? formData.interest_rate : formData.annual_interest_rate}
|
||||
onChange={handleChange}
|
||||
placeholder={destinationType === 'asset' ? '2.5' : '18.99'}
|
||||
step="0.01"
|
||||
/>
|
||||
<span className="input-group-text bg-dark text-white border-secondary">%</span>
|
||||
</div>
|
||||
<small className="text-slate-400">
|
||||
{destinationType === 'asset'
|
||||
? 'Rendimiento anual de la cuenta'
|
||||
: 'Tasa de interés por financiamiento'}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dias de fechamento e vencimento (cartão) */}
|
||||
{destinationType === 'liability' && (
|
||||
<>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Día de Cierre</label>
|
||||
<input
|
||||
type="number"
|
||||
name="closing_day"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
value={formData.closing_day}
|
||||
onChange={handleChange}
|
||||
placeholder="15"
|
||||
min="1"
|
||||
max="31"
|
||||
/>
|
||||
<small className="text-slate-400">Día del mes que cierra la factura</small>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Día de Vencimiento</label>
|
||||
<input
|
||||
type="number"
|
||||
name="due_day"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
value={formData.due_day}
|
||||
onChange={handleChange}
|
||||
placeholder="25"
|
||||
min="1"
|
||||
max="31"
|
||||
/>
|
||||
<small className="text-slate-400">Día de pago de la factura</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Dados Bancários */}
|
||||
{step === 4 && (
|
||||
<div>
|
||||
<h5 className="mb-4">
|
||||
<i className="bi bi-building me-2"></i>
|
||||
Información Bancaria (Opcional)
|
||||
</h5>
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">
|
||||
{destinationType === 'liability' ? 'Emisor de la Tarjeta' : 'Nombre del Banco'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="bank_name"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
value={formData.bank_name}
|
||||
onChange={handleChange}
|
||||
placeholder={destinationType === 'liability' ? 'Ej: BBVA, Santander' : 'Ej: Santander, BBVA'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">
|
||||
{destinationType === 'liability' ? 'Últimos 4 Dígitos' : 'Número de Cuenta'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="account_number"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
value={formData.account_number}
|
||||
onChange={handleChange}
|
||||
placeholder={destinationType === 'liability' ? '****1234' : 'ES00 0000 0000 0000'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{destinationType === 'account' && (
|
||||
<>
|
||||
<div className="col-12">
|
||||
<hr className="border-secondary my-4" />
|
||||
<h6 className="text-white mb-3">
|
||||
<i className="bi bi-gear me-2"></i>
|
||||
Configuración
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
className="form-check-input"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
id="is_active"
|
||||
/>
|
||||
<label className="form-check-label text-white" htmlFor="is_active">
|
||||
Cuenta Activa
|
||||
</label>
|
||||
</div>
|
||||
<small className="text-slate-400">Cuentas inactivas no aparecen en transacciones</small>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="include_in_total"
|
||||
className="form-check-input"
|
||||
checked={formData.include_in_total}
|
||||
onChange={handleChange}
|
||||
id="include_in_total"
|
||||
/>
|
||||
<label className="form-check-label text-white" htmlFor="include_in_total">
|
||||
Incluir en Total
|
||||
</label>
|
||||
</div>
|
||||
<small className="text-slate-400">Suma el saldo al patrimonio total</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resumen */}
|
||||
<div className="mt-4 p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
|
||||
<h6 className="text-white mb-3">
|
||||
<i className="bi bi-check-circle me-2"></i>
|
||||
Resumen
|
||||
</h6>
|
||||
<div className="row g-2">
|
||||
<div className="col-6">
|
||||
<small className="text-slate-400">Tipo:</small>
|
||||
<div className="text-white">{accountTypes[formData.account_type]?.name}</div>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<small className="text-slate-400">Nombre:</small>
|
||||
<div className="text-white">{formData.name || '-'}</div>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<small className="text-slate-400">
|
||||
{destinationType === 'liability' ? 'Deuda:' : 'Saldo:'}
|
||||
</small>
|
||||
<div className="text-white">
|
||||
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
|
||||
{parseFloat(formData.initial_balance || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
{formData.credit_limit && (
|
||||
<div className="col-6">
|
||||
<small className="text-slate-400">Límite:</small>
|
||||
<div className="text-white">
|
||||
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
|
||||
{parseFloat(formData.credit_limit).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-12">
|
||||
<small className="text-slate-400">Se guardará como:</small>
|
||||
<div>
|
||||
{destinationType === 'asset' && (
|
||||
<span className="badge bg-success">
|
||||
<i className="bi bi-graph-up me-1"></i>
|
||||
Activo Financiero
|
||||
</span>
|
||||
)}
|
||||
{destinationType === 'liability' && (
|
||||
<span className="badge bg-danger">
|
||||
<i className="bi bi-graph-down me-1"></i>
|
||||
Pasivo (Deuda)
|
||||
</span>
|
||||
)}
|
||||
{destinationType === 'account' && (
|
||||
<span className="badge bg-primary">
|
||||
<i className="bi bi-wallet2 me-1"></i>
|
||||
Cuenta Estándar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`modal-footer border-0 ${isMobile ? 'flex-column' : ''}`}>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{step === getTotalSteps() && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-100 mb-2"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !validateStep()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-lg me-1"></i>
|
||||
{isEditMode ? 'Guardar Cambios' : 'Crear Cuenta'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="d-flex gap-2 w-100">
|
||||
{step > 1 && (
|
||||
<button type="button" className="btn btn-outline-secondary flex-fill" onClick={prevStep}>
|
||||
<i className="bi bi-arrow-left me-1"></i>
|
||||
Anterior
|
||||
</button>
|
||||
)}
|
||||
{step < getTotalSteps() && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary flex-fill"
|
||||
onClick={nextStep}
|
||||
disabled={!validateStep()}
|
||||
>
|
||||
Siguiente
|
||||
<i className="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-outline-secondary flex-fill" onClick={onClose}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Cancelar
|
||||
</button>
|
||||
{step > 1 && (
|
||||
<button type="button" className="btn btn-outline-light" onClick={prevStep}>
|
||||
<i className="bi bi-arrow-left me-1"></i>
|
||||
Anterior
|
||||
</button>
|
||||
)}
|
||||
{step < getTotalSteps() ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={nextStep}
|
||||
disabled={!validateStep()}
|
||||
>
|
||||
Siguiente
|
||||
<i className="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-success"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !validateStep()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-lg me-1"></i>
|
||||
{isEditMode ? 'Guardar Cambios' : 'Crear Cuenta'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountWizard;
|
||||
1202
frontend/src/components/AssetWizard.jsx
Normal file
1202
frontend/src/components/AssetWizard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1312
frontend/src/components/BudgetWizard.jsx
Normal file
1312
frontend/src/components/BudgetWizard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -51,9 +51,9 @@ const Layout = ({ children }) => {
|
||||
};
|
||||
|
||||
const [expandedGroups, setExpandedGroups] = useState({
|
||||
registrations: true,
|
||||
movements: true,
|
||||
planning: true,
|
||||
registrations: false,
|
||||
movements: false,
|
||||
planning: false,
|
||||
settings: false,
|
||||
});
|
||||
|
||||
|
||||
1338
frontend/src/components/LiabilityWizard.jsx
Normal file
1338
frontend/src/components/LiabilityWizard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -65,7 +65,10 @@
|
||||
"incomes": "Income",
|
||||
"expenses": "Expenses",
|
||||
"balance": "Balance",
|
||||
"current": "Current"
|
||||
"current": "Current",
|
||||
"continue": "Continue",
|
||||
"creating": "Creating...",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@ -558,6 +561,8 @@
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"pending": "Pending",
|
||||
"effective": "Effective",
|
||||
"scheduled": "Scheduled",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
@ -1753,6 +1758,7 @@
|
||||
"subcategory": "Subcategory",
|
||||
"allCategory": "All category",
|
||||
"selectCategory": "Select a category",
|
||||
"general": "General",
|
||||
"amount": "Amount",
|
||||
"month": "Month",
|
||||
"budgeted": "Budgeted",
|
||||
@ -1776,6 +1782,57 @@
|
||||
"yearly": "Yearly",
|
||||
"isCumulative": "Cumulative Budget",
|
||||
"isCumulativeHelp": "Accumulates expenses from the beginning of the year to the current period",
|
||||
"total": "Total",
|
||||
"wizard": {
|
||||
"title": "Budget Wizard",
|
||||
"button": "Wizard",
|
||||
"step1": "Mode",
|
||||
"step2": "Categories",
|
||||
"step3": "Values",
|
||||
"step4": "Confirm",
|
||||
"quickStart": "Quick Start with Templates",
|
||||
"manual": "Create Manually",
|
||||
"manualDesc": "Choose categories and amounts",
|
||||
"copy": "Copy from Another Month",
|
||||
"copyDesc": "Reuse existing budgets",
|
||||
"loadBudgets": "Load Budgets",
|
||||
"noSourceBudgets": "No budgets in this period to copy",
|
||||
"selectCategories": "Select Categories",
|
||||
"categoriesSelected": "categories selected",
|
||||
"setAmounts": "Set Amounts",
|
||||
"history": "History",
|
||||
"useHistory": "Use historical average",
|
||||
"confirm": "Confirm Budgets",
|
||||
"budgets": "Budgets",
|
||||
"periods": "Periods",
|
||||
"periodHelp": "Defines the automatic creation frequency for budgets",
|
||||
"createBudgets": "Create Budgets",
|
||||
"createBudget": "Create Budget",
|
||||
"successCount": "{{count}} budget(s) created successfully",
|
||||
"errorCount": "{{count}} budget(s) could not be created (already exist)",
|
||||
"fillRequired": "Please fill in the required fields",
|
||||
"updated": "Budget updated successfully",
|
||||
"created": "Budget created successfully",
|
||||
"selectAtLeast": "Select at least one category",
|
||||
"templates": {
|
||||
"basic": {
|
||||
"name": "Basic Budget",
|
||||
"desc": "Essential for monthly control"
|
||||
},
|
||||
"family": {
|
||||
"name": "Family Budget",
|
||||
"desc": "Complete for families"
|
||||
},
|
||||
"individual": {
|
||||
"name": "Individual Budget",
|
||||
"desc": "For single person"
|
||||
},
|
||||
"complete": {
|
||||
"name": "Complete Budget",
|
||||
"desc": "All categories"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alert": {
|
||||
"exceeded": "Budget exceeded!",
|
||||
"warning": "Warning: near limit",
|
||||
@ -2204,7 +2261,7 @@
|
||||
"hero": {
|
||||
"title": "Take Control of Your Finances",
|
||||
"subtitle": "Intelligent financial management for individuals and businesses. Track income, expenses, and achieve your financial goals.",
|
||||
"cta": "Start Free",
|
||||
"cta": "Start Now",
|
||||
"learnMore": "Learn More",
|
||||
"secure": "100% Secure"
|
||||
},
|
||||
|
||||
@ -66,7 +66,10 @@
|
||||
"incomes": "Ingresos",
|
||||
"expenses": "Gastos",
|
||||
"balance": "Balance",
|
||||
"current": "Actual"
|
||||
"current": "Actual",
|
||||
"continue": "Continuar",
|
||||
"creating": "Creando...",
|
||||
"remove": "Eliminar"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar Sesión",
|
||||
@ -566,6 +569,8 @@
|
||||
"status": {
|
||||
"label": "Estado",
|
||||
"pending": "Pendiente",
|
||||
"effective": "Efectiva",
|
||||
"scheduled": "Programada",
|
||||
"completed": "Completada",
|
||||
"cancelled": "Cancelada"
|
||||
},
|
||||
@ -1809,6 +1814,7 @@
|
||||
"subcategory": "Subcategoría",
|
||||
"allCategory": "Toda la categoría",
|
||||
"selectCategory": "Seleccionar categoría",
|
||||
"general": "General",
|
||||
"amount": "Monto",
|
||||
"spent": "Gastado",
|
||||
"budgeted": "Presupuestado",
|
||||
@ -1833,6 +1839,57 @@
|
||||
"yearly": "Anual",
|
||||
"isCumulative": "Presupuesto Acumulativo",
|
||||
"isCumulativeHelp": "Acumula gastos desde inicio de año hasta el período actual",
|
||||
"total": "Total",
|
||||
"wizard": {
|
||||
"title": "Asistente de Presupuestos",
|
||||
"button": "Asistente",
|
||||
"step1": "Modo",
|
||||
"step2": "Categorías",
|
||||
"step3": "Valores",
|
||||
"step4": "Confirmar",
|
||||
"quickStart": "Inicio Rápido con Plantillas",
|
||||
"manual": "Crear Manualmente",
|
||||
"manualDesc": "Elige categorías y valores",
|
||||
"copy": "Copiar de Otro Mes",
|
||||
"copyDesc": "Reutiliza presupuestos existentes",
|
||||
"loadBudgets": "Cargar Presupuestos",
|
||||
"noSourceBudgets": "No hay presupuestos en este período para copiar",
|
||||
"selectCategories": "Selecciona las Categorías",
|
||||
"categoriesSelected": "categorías seleccionadas",
|
||||
"setAmounts": "Define los Valores",
|
||||
"history": "Hist.",
|
||||
"useHistory": "Usar promedio histórico",
|
||||
"confirm": "Confirma los Presupuestos",
|
||||
"budgets": "Presupuestos",
|
||||
"periods": "Períodos",
|
||||
"periodHelp": "Define la frecuencia de creación automática de presupuestos",
|
||||
"createBudgets": "Crear Presupuestos",
|
||||
"createBudget": "Crear Presupuesto",
|
||||
"successCount": "{{count}} presupuesto(s) creado(s) con éxito",
|
||||
"errorCount": "{{count}} presupuesto(s) no pudieron ser creados (ya existen)",
|
||||
"fillRequired": "Complete los campos obligatorios",
|
||||
"updated": "Presupuesto actualizado con éxito",
|
||||
"created": "Presupuesto creado con éxito",
|
||||
"selectAtLeast": "Seleccione al menos una categoría",
|
||||
"templates": {
|
||||
"basic": {
|
||||
"name": "Presupuesto Básico",
|
||||
"desc": "Esencial para control mensual"
|
||||
},
|
||||
"family": {
|
||||
"name": "Presupuesto Familiar",
|
||||
"desc": "Completo para familias"
|
||||
},
|
||||
"individual": {
|
||||
"name": "Presupuesto Individual",
|
||||
"desc": "Para persona soltera"
|
||||
},
|
||||
"complete": {
|
||||
"name": "Presupuesto Completo",
|
||||
"desc": "Todas las categorías"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"totalBudget": "Presupuesto Total",
|
||||
"totalSpent": "Gastado",
|
||||
@ -2206,7 +2263,7 @@
|
||||
"hero": {
|
||||
"title": "Toma el Control de tus Finanzas",
|
||||
"subtitle": "Gestión financiera inteligente para personas y empresas. Controla ingresos, gastos y alcanza tus metas financieras.",
|
||||
"cta": "Comenzar Gratis",
|
||||
"cta": "Comenzar Ahora",
|
||||
"learnMore": "Saber Más",
|
||||
"secure": "100% Seguro"
|
||||
},
|
||||
@ -2247,7 +2304,7 @@
|
||||
"month": "mes",
|
||||
"year": "año",
|
||||
"free": "Gratis",
|
||||
"startFree": "Comenzar Gratis",
|
||||
"startFree": "Comenzar Ahora",
|
||||
"subscribe": "Suscribirse Ahora",
|
||||
"billedAnnually": "Facturado anualmente €{{price}}",
|
||||
"comingSoon": "Próximamente",
|
||||
@ -2287,7 +2344,7 @@
|
||||
"cta": {
|
||||
"title": "¿Listo para Transformar tus Finanzas?",
|
||||
"subtitle": "Únete a miles de usuarios que ya tomaron el control de su dinero.",
|
||||
"button": "Crear Cuenta Gratis"
|
||||
"button": "Comenzar Ahora"
|
||||
},
|
||||
"footer": {
|
||||
"rights": "Todos los derechos reservados.",
|
||||
|
||||
@ -67,7 +67,10 @@
|
||||
"incomes": "Receitas",
|
||||
"expenses": "Despesas",
|
||||
"balance": "Saldo",
|
||||
"current": "Atual"
|
||||
"current": "Atual",
|
||||
"continue": "Continuar",
|
||||
"creating": "Criando...",
|
||||
"remove": "Remover"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Entrar",
|
||||
@ -568,6 +571,8 @@
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"pending": "Pendente",
|
||||
"effective": "Efetivada",
|
||||
"scheduled": "Agendada",
|
||||
"completed": "Concluída",
|
||||
"cancelled": "Cancelada"
|
||||
},
|
||||
@ -1763,6 +1768,7 @@
|
||||
"subcategory": "Subcategoria",
|
||||
"allCategory": "Toda a categoria",
|
||||
"selectCategory": "Selecione uma categoria",
|
||||
"general": "Geral",
|
||||
"amount": "Valor",
|
||||
"month": "Mês",
|
||||
"budgeted": "Orçado",
|
||||
@ -1784,6 +1790,57 @@
|
||||
"yearly": "Anual",
|
||||
"isCumulative": "Orçamento Cumulativo",
|
||||
"isCumulativeHelp": "Acumula gastos desde o início do ano até o período atual",
|
||||
"total": "Total",
|
||||
"wizard": {
|
||||
"title": "Assistente de Orçamentos",
|
||||
"button": "Assistente",
|
||||
"step1": "Modo",
|
||||
"step2": "Categorias",
|
||||
"step3": "Valores",
|
||||
"step4": "Confirmar",
|
||||
"quickStart": "Início Rápido com Templates",
|
||||
"manual": "Criar Manualmente",
|
||||
"manualDesc": "Escolha categorias e valores",
|
||||
"copy": "Copiar de Outro Mês",
|
||||
"copyDesc": "Reutilize orçamentos existentes",
|
||||
"loadBudgets": "Carregar Orçamentos",
|
||||
"noSourceBudgets": "Não há orçamentos neste período para copiar",
|
||||
"selectCategories": "Selecione as Categorias",
|
||||
"categoriesSelected": "categorias selecionadas",
|
||||
"setAmounts": "Defina os Valores",
|
||||
"history": "Hist.",
|
||||
"useHistory": "Usar média histórica",
|
||||
"confirm": "Confirme os Orçamentos",
|
||||
"budgets": "Orçamentos",
|
||||
"periods": "Períodos",
|
||||
"periodHelp": "Define a frequência de criação automática dos orçamentos",
|
||||
"createBudgets": "Criar Orçamentos",
|
||||
"createBudget": "Criar Orçamento",
|
||||
"successCount": "{{count}} orçamento(s) criado(s) com sucesso",
|
||||
"errorCount": "{{count}} orçamento(s) não puderam ser criados (já existem)",
|
||||
"fillRequired": "Preencha os campos obrigatórios",
|
||||
"updated": "Orçamento atualizado com sucesso",
|
||||
"created": "Orçamento criado com sucesso",
|
||||
"selectAtLeast": "Selecione pelo menos uma categoria",
|
||||
"templates": {
|
||||
"basic": {
|
||||
"name": "Orçamento Básico",
|
||||
"desc": "Essencial para controle mensal"
|
||||
},
|
||||
"family": {
|
||||
"name": "Orçamento Familiar",
|
||||
"desc": "Completo para famílias"
|
||||
},
|
||||
"individual": {
|
||||
"name": "Orçamento Individual",
|
||||
"desc": "Para pessoa solteira"
|
||||
},
|
||||
"complete": {
|
||||
"name": "Orçamento Completo",
|
||||
"desc": "Todas as categorias"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alert": {
|
||||
"exceeded": "Orçamento excedido!",
|
||||
"warning": "Atenção: próximo do limite",
|
||||
@ -2224,7 +2281,7 @@
|
||||
"hero": {
|
||||
"title": "Assuma o Controle das suas Finanças",
|
||||
"subtitle": "Gestão financeira inteligente para pessoas e empresas. Acompanhe receitas, despesas e alcance seus objetivos financeiros.",
|
||||
"cta": "Começar Grátis",
|
||||
"cta": "Começar Agora",
|
||||
"learnMore": "Saiba Mais",
|
||||
"secure": "100% Seguro"
|
||||
},
|
||||
|
||||
@ -1899,6 +1899,16 @@ input[type="color"]::-webkit-color-swatch {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.txn-status-badge.effective {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.txn-status-badge.scheduled {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Actions dropdown */
|
||||
.txn-actions-btn {
|
||||
width: 28px;
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { accountService, liabilityAccountService } from '../services/api';
|
||||
import { accountService, liabilityAccountService, assetAccountService } from '../services/api';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { ConfirmModal } from '../components/Modal';
|
||||
import IconSelector from '../components/IconSelector';
|
||||
import CurrencySelector from '../components/CurrencySelector';
|
||||
import { useFormatters } from '../hooks';
|
||||
import AssetWizard from '../components/AssetWizard';
|
||||
import AccountWizard from '../components/AccountWizard';
|
||||
|
||||
const Accounts = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -15,10 +17,17 @@ const Accounts = () => {
|
||||
const { currency: formatCurrencyHook } = useFormatters();
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [liabilityAccounts, setLiabilityAccounts] = useState([]);
|
||||
const [assetAccounts, setAssetAccounts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showAdjustModal, setShowAdjustModal] = useState(false);
|
||||
const [showAssetWizard, setShowAssetWizard] = useState(false);
|
||||
const [showAccountWizard, setShowAccountWizard] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState(null);
|
||||
const [showAssetDetail, setShowAssetDetail] = useState(false);
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
const [editingAsset, setEditingAsset] = useState(null);
|
||||
const [adjustAccount, setAdjustAccount] = useState(null);
|
||||
const [targetBalance, setTargetBalance] = useState('');
|
||||
const [adjusting, setAdjusting] = useState(false);
|
||||
@ -27,7 +36,7 @@ const Accounts = () => {
|
||||
const [recalculating, setRecalculating] = useState(false);
|
||||
const [filter, setFilter] = useState({ type: '', is_active: '' });
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [activeTab, setActiveTab] = useState('accounts'); // 'accounts' ou 'liabilities'
|
||||
const [activeTab, setActiveTab] = useState('accounts'); // 'accounts', 'liabilities' ou 'assets'
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
@ -69,9 +78,10 @@ const Accounts = () => {
|
||||
if (filter.is_active !== '') params.is_active = filter.is_active;
|
||||
|
||||
// Carregar contas normais e passivas em paralelo
|
||||
const [accountsResponse, liabilityResponse] = await Promise.all([
|
||||
const [accountsResponse, liabilityResponse, assetResponse] = await Promise.all([
|
||||
accountService.getAll(params),
|
||||
liabilityAccountService.getAll({ is_active: filter.is_active || undefined })
|
||||
liabilityAccountService.getAll({ is_active: filter.is_active || undefined }),
|
||||
assetAccountService.getAll({ status: filter.is_active === '1' ? 'active' : undefined })
|
||||
]);
|
||||
|
||||
if (accountsResponse.success) {
|
||||
@ -80,6 +90,9 @@ const Accounts = () => {
|
||||
if (liabilityResponse.success) {
|
||||
setLiabilityAccounts(liabilityResponse.data);
|
||||
}
|
||||
if (assetResponse.success) {
|
||||
setAssetAccounts(assetResponse.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('accounts.loadError'));
|
||||
} finally {
|
||||
@ -254,7 +267,56 @@ const Accounts = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Calcula totais agrupados por moeda (incluindo passivos como valor negativo)
|
||||
// Abrir modal de detalhes do ativo
|
||||
const handleOpenAssetDetail = async (asset) => {
|
||||
try {
|
||||
const response = await assetAccountService.getById(asset.id);
|
||||
if (response.success) {
|
||||
setSelectedAsset(response.data);
|
||||
setShowAssetDetail(true);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao carregar detalhes do ativo');
|
||||
}
|
||||
};
|
||||
|
||||
// Fechar modal de detalhes do ativo
|
||||
const handleCloseAssetDetail = () => {
|
||||
setShowAssetDetail(false);
|
||||
setSelectedAsset(null);
|
||||
};
|
||||
|
||||
// Editar ativo
|
||||
const handleEditAsset = () => {
|
||||
setEditingAsset(selectedAsset);
|
||||
setShowAssetDetail(false);
|
||||
setShowAssetWizard(true);
|
||||
};
|
||||
|
||||
// Callback após salvar/criar ativo
|
||||
const handleAssetSuccess = (assetData) => {
|
||||
loadAccounts();
|
||||
setEditingAsset(null);
|
||||
toast.success(editingAsset ? 'Activo actualizado con éxito' : 'Activo creado con éxito');
|
||||
};
|
||||
|
||||
// Callback após salvar/criar conta via wizard
|
||||
const handleAccountWizardSuccess = (data, destinationType) => {
|
||||
loadAccounts();
|
||||
setEditingAccount(null);
|
||||
|
||||
const isEditing = !!editingAccount;
|
||||
|
||||
if (destinationType === 'asset') {
|
||||
toast.success(isEditing ? 'Cuenta de ahorro actualizada' : 'Cuenta de ahorro creada como activo');
|
||||
} else if (destinationType === 'liability') {
|
||||
toast.success(isEditing ? 'Tarjeta de crédito actualizada' : 'Tarjeta de crédito creada como pasivo');
|
||||
} else {
|
||||
toast.success(isEditing ? t('accounts.updateSuccess') : t('accounts.createSuccess'));
|
||||
}
|
||||
};
|
||||
|
||||
// Calcula totais agrupados por moeda (incluindo passivos como valor negativo e ativos como positivo)
|
||||
const getTotalsByCurrency = () => {
|
||||
const totals = {};
|
||||
|
||||
@ -281,19 +343,49 @@ const Accounts = () => {
|
||||
totals[currency] -= parseFloat(acc.remaining_balance || 0);
|
||||
});
|
||||
|
||||
// Ativos (como valor positivo - current_value)
|
||||
assetAccounts
|
||||
.filter(acc => acc.status === 'active')
|
||||
.forEach(acc => {
|
||||
const currency = acc.currency || 'EUR';
|
||||
if (!totals[currency]) {
|
||||
totals[currency] = 0;
|
||||
}
|
||||
// Somar o valor atual do ativo
|
||||
totals[currency] += parseFloat(acc.current_value || 0);
|
||||
});
|
||||
|
||||
return totals;
|
||||
};
|
||||
|
||||
// Total de contas ativas (normais + passivas)
|
||||
// Total de contas ativas (normais + passivas + ativos)
|
||||
const getTotalActiveAccounts = () => {
|
||||
const normalActive = accounts.filter(a => a.is_active).length;
|
||||
const liabilityActive = liabilityAccounts.filter(a => a.is_active).length;
|
||||
return normalActive + liabilityActive;
|
||||
const assetActive = assetAccounts.filter(a => a.status === 'active').length;
|
||||
return normalActive + liabilityActive + assetActive;
|
||||
};
|
||||
|
||||
// Total de todas as contas
|
||||
const getTotalAccounts = () => {
|
||||
return accounts.length + liabilityAccounts.length;
|
||||
return accounts.length + liabilityAccounts.length + assetAccounts.length;
|
||||
};
|
||||
|
||||
// Total de ativos por moeda
|
||||
const getAssetTotalsByCurrency = () => {
|
||||
const totals = {};
|
||||
assetAccounts
|
||||
.filter(acc => acc.status === 'active')
|
||||
.forEach(acc => {
|
||||
const currency = acc.currency || 'EUR';
|
||||
if (!totals[currency]) {
|
||||
totals[currency] = { current: 0, acquisition: 0, count: 0 };
|
||||
}
|
||||
totals[currency].current += parseFloat(acc.current_value || 0);
|
||||
totals[currency].acquisition += parseFloat(acc.acquisition_value || 0);
|
||||
totals[currency].count++;
|
||||
});
|
||||
return totals;
|
||||
};
|
||||
|
||||
return (
|
||||
@ -324,48 +416,74 @@ const Accounts = () => {
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'accounts' && (
|
||||
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => handleOpenModal()}>
|
||||
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => setShowAccountWizard(true)}>
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
{isMobile ? t('common.add') : t('accounts.newAccount')}
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'liabilities' && (
|
||||
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => navigate('/liabilities')}>
|
||||
<button className={`btn btn-warning ${isMobile ? 'btn-sm' : ''}`} onClick={() => navigate('/liabilities')}>
|
||||
<i className="bi bi-gear me-2"></i>
|
||||
{isMobile ? t('common.manage') : t('liabilities.manage')}
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'assets' && (
|
||||
<button className={`btn btn-success ${isMobile ? 'btn-sm' : ''}`} onClick={() => setShowAssetWizard(true)}>
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
{isMobile ? t('common.add') : 'Nuevo Activo'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<ul className="nav nav-tabs mb-4" style={{ borderBottom: '1px solid #334155' }}>
|
||||
{/* Tabs - Mobile Optimized */}
|
||||
<ul className={`nav nav-tabs mb-4 ${isMobile ? 'nav-fill flex-nowrap overflow-auto' : ''}`} style={{ borderBottom: '1px solid #334155' }}>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'accounts' ? 'active bg-primary text-white' : 'text-slate-400'}`}
|
||||
className={`nav-link ${activeTab === 'accounts' ? 'active bg-primary text-white' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
|
||||
onClick={() => setActiveTab('accounts')}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem 0.5rem 0 0',
|
||||
backgroundColor: activeTab === 'accounts' ? undefined : 'transparent'
|
||||
backgroundColor: activeTab === 'accounts' ? undefined : 'transparent',
|
||||
fontSize: isMobile ? '0.75rem' : undefined,
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-wallet2 me-2"></i>
|
||||
{t('nav.accounts')} ({accounts.length})
|
||||
<i className={`bi bi-wallet2 ${isMobile ? '' : 'me-2'}`}></i>
|
||||
{isMobile ? '' : t('nav.accounts')} ({accounts.length})
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'liabilities' ? 'active bg-warning text-dark' : 'text-slate-400'}`}
|
||||
className={`nav-link ${activeTab === 'liabilities' ? 'active bg-warning text-dark' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
|
||||
onClick={() => setActiveTab('liabilities')}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem 0.5rem 0 0',
|
||||
backgroundColor: activeTab === 'liabilities' ? undefined : 'transparent'
|
||||
backgroundColor: activeTab === 'liabilities' ? undefined : 'transparent',
|
||||
fontSize: isMobile ? '0.75rem' : undefined,
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-bank me-2"></i>
|
||||
{t('nav.liabilities')} ({liabilityAccounts.length})
|
||||
<i className={`bi bi-bank ${isMobile ? '' : 'me-2'}`}></i>
|
||||
{isMobile ? '' : t('nav.liabilities')} ({liabilityAccounts.length})
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'assets' ? 'active bg-success text-white' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
|
||||
onClick={() => setActiveTab('assets')}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem 0.5rem 0 0',
|
||||
backgroundColor: activeTab === 'assets' ? undefined : 'transparent',
|
||||
fontSize: isMobile ? '0.75rem' : undefined,
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<i className={`bi bi-graph-up-arrow ${isMobile ? '' : 'me-2'}`}></i>
|
||||
{isMobile ? '' : 'Activos'} ({assetAccounts.length})
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@ -485,7 +603,7 @@ const Accounts = () => {
|
||||
<div className="text-center py-5">
|
||||
<i className="bi bi-wallet2 display-1 text-slate-600"></i>
|
||||
<p className="text-slate-400 mt-3">{t('accounts.noAccounts')}</p>
|
||||
<button className="btn btn-primary" onClick={() => handleOpenModal()}>
|
||||
<button className="btn btn-primary" onClick={() => setShowAccountWizard(true)}>
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
{t('accounts.newAccount')}
|
||||
</button>
|
||||
@ -531,7 +649,10 @@ const Accounts = () => {
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-link text-info p-1"
|
||||
onClick={() => handleOpenModal(account)}
|
||||
onClick={() => {
|
||||
setEditingAccount(account);
|
||||
setShowAccountWizard(true);
|
||||
}}
|
||||
style={{ fontSize: '1rem' }}
|
||||
>
|
||||
<i className="bi bi-pencil"></i>
|
||||
@ -633,7 +754,10 @@ const Accounts = () => {
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-link text-info p-1 me-1"
|
||||
onClick={() => handleOpenModal(account)}
|
||||
onClick={() => {
|
||||
setEditingAccount(account);
|
||||
setShowAccountWizard(true);
|
||||
}}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<i className="bi bi-pencil"></i>
|
||||
@ -848,6 +972,230 @@ const Accounts = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tab de Ativos */}
|
||||
{activeTab === 'assets' && (
|
||||
<>
|
||||
{/* Summary Cards de Ativos */}
|
||||
<div className={`row ${isMobile ? 'g-2 mb-3' : 'mb-4'}`}>
|
||||
{Object.entries(getAssetTotalsByCurrency()).map(([currency, data]) => (
|
||||
<div className="col-md-4" key={currency}>
|
||||
<div className={`card border-0 ${!isMobile ? 'h-100' : ''}`} style={{ background: '#1e293b' }}>
|
||||
<div className={`card-body ${isMobile ? 'p-3' : ''}`}>
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<p className={`text-slate-400 mb-1 ${isMobile ? '' : 'small'}`} style={isMobile ? { fontSize: '0.7rem' } : undefined}>
|
||||
Activos ({currency}) - {data.count} items
|
||||
</p>
|
||||
<h4 className={`mb-0 text-success ${isMobile ? 'fs-5' : ''}`}>
|
||||
{formatCurrency(data.current, currency)}
|
||||
</h4>
|
||||
{data.current !== data.acquisition && (
|
||||
<small className={`${data.current > data.acquisition ? 'text-success' : 'text-danger'}`}>
|
||||
<i className={`bi ${data.current > data.acquisition ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
|
||||
{((data.current - data.acquisition) / data.acquisition * 100).toFixed(1)}% desde compra
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className={`rounded-circle bg-success bg-opacity-25 ${isMobile ? 'p-2' : 'p-3'}`}>
|
||||
<i className={`bi bi-graph-up-arrow text-success ${isMobile ? 'fs-5' : 'fs-4'}`}></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(getAssetTotalsByCurrency()).length === 0 && (
|
||||
<div className="col-12">
|
||||
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||
<div className={`card-body ${isMobile ? 'p-3' : ''} text-center`}>
|
||||
<i className="bi bi-graph-up-arrow text-slate-600 fs-1 mb-2"></i>
|
||||
<p className="text-slate-400 mb-0">No hay activos registrados</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de Ativos */}
|
||||
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||
<div className={`card-body ${isMobile ? 'p-2' : 'p-0'}`}>
|
||||
{assetAccounts.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<i className="bi bi-graph-up-arrow display-1 text-slate-600"></i>
|
||||
<p className="text-slate-400 mt-3">No hay activos registrados</p>
|
||||
<button className="btn btn-success" onClick={() => setShowAssetWizard(true)}>
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
Crear Activo
|
||||
</button>
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
// Mobile: Cards Layout para Ativos
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{assetAccounts.map((asset) => (
|
||||
<div
|
||||
key={`asset-${asset.id}`}
|
||||
className="card border-secondary"
|
||||
style={{ cursor: 'pointer', background: '#0f172a' }}
|
||||
>
|
||||
<div className="card-body p-3">
|
||||
{/* Header with Icon and Name */}
|
||||
<div className="d-flex align-items-start gap-2 mb-2">
|
||||
<div
|
||||
className="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
||||
style={{
|
||||
width: '35px',
|
||||
height: '35px',
|
||||
backgroundColor: (asset.color || '#10B981') + '25',
|
||||
}}
|
||||
>
|
||||
<i className={`bi bi-${asset.asset_type === 'real_estate' ? 'house' : asset.asset_type === 'vehicle' ? 'truck' : asset.asset_type === 'investment' ? 'graph-up' : 'box'} fs-6`} style={{ color: asset.color || '#10B981' }}></i>
|
||||
</div>
|
||||
<div className="flex-grow-1" style={{ minWidth: 0 }}>
|
||||
<div className="text-white fw-medium mb-1" style={{ fontSize: '0.85rem' }}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>
|
||||
{assetAccountService.statuses[asset.asset_type] || asset.asset_type}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`badge ${asset.status === 'active' ? 'bg-success' : 'bg-secondary'}`} style={{ fontSize: '0.65rem' }}>
|
||||
{asset.status === 'active' ? 'Activo' : asset.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
<div className="d-flex justify-content-between align-items-center pt-2" style={{ borderTop: '1px solid #334155' }}>
|
||||
<div>
|
||||
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>Valor Actual</div>
|
||||
<div className="text-success fw-bold" style={{ fontSize: '0.9rem' }}>
|
||||
{formatCurrency(asset.current_value, asset.currency)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>Rentabilidad</div>
|
||||
<div className={`fw-medium ${parseFloat(asset.current_value) >= parseFloat(asset.acquisition_value) ? 'text-success' : 'text-danger'}`} style={{ fontSize: '0.8rem' }}>
|
||||
{asset.acquisition_value > 0 ? (
|
||||
<>
|
||||
<i className={`bi ${parseFloat(asset.current_value) >= parseFloat(asset.acquisition_value) ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
|
||||
{(((parseFloat(asset.current_value) - parseFloat(asset.acquisition_value)) / parseFloat(asset.acquisition_value)) * 100).toFixed(1)}%
|
||||
</>
|
||||
) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Desktop: Table Layout para Ativos
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover mb-0" style={{ '--bs-table-bg': 'transparent', backgroundColor: 'transparent' }}>
|
||||
<thead style={{ backgroundColor: 'transparent' }}>
|
||||
<tr style={{ borderBottom: '1px solid #334155', backgroundColor: 'transparent' }}>
|
||||
<th className="text-slate-400 fw-normal py-3 ps-4" style={{ backgroundColor: 'transparent' }}>Nombre</th>
|
||||
<th className="text-slate-400 fw-normal py-3" style={{ backgroundColor: 'transparent' }}>Tipo</th>
|
||||
<th className="text-slate-400 fw-normal py-3 text-end" style={{ backgroundColor: 'transparent' }}>Adquisición</th>
|
||||
<th className="text-slate-400 fw-normal py-3 text-end" style={{ backgroundColor: 'transparent' }}>Valor Actual</th>
|
||||
<th className="text-slate-400 fw-normal py-3 text-center" style={{ backgroundColor: 'transparent' }}>Rentabilidad</th>
|
||||
<th className="text-slate-400 fw-normal py-3 text-center" style={{ backgroundColor: 'transparent' }}>{t('common.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody style={{ backgroundColor: 'transparent' }}>
|
||||
{assetAccounts.map((asset) => {
|
||||
const gainLoss = parseFloat(asset.current_value) - parseFloat(asset.acquisition_value);
|
||||
const gainLossPercent = asset.acquisition_value > 0 ? (gainLoss / parseFloat(asset.acquisition_value)) * 100 : 0;
|
||||
return (
|
||||
<tr
|
||||
key={`asset-${asset.id}`}
|
||||
style={{ borderBottom: '1px solid #334155', backgroundColor: 'transparent', cursor: 'pointer' }}
|
||||
onClick={() => handleOpenAssetDetail(asset)}
|
||||
className="asset-row-hover"
|
||||
>
|
||||
<td className="py-3 ps-4" style={{ backgroundColor: 'transparent' }}>
|
||||
<div className="d-flex align-items-center">
|
||||
<div
|
||||
className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: (asset.color || '#10B981') + '25',
|
||||
}}
|
||||
>
|
||||
<i className={`bi bi-${asset.asset_type === 'real_estate' ? 'house' : asset.asset_type === 'vehicle' ? 'truck' : asset.asset_type === 'investment' ? 'graph-up' : 'box'}`} style={{ color: asset.color || '#10B981' }}></i>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white fw-medium">{asset.name}</div>
|
||||
{asset.description && (
|
||||
<small className="text-slate-400">{asset.description.substring(0, 30)}</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3" style={{ backgroundColor: 'transparent' }}>
|
||||
<span className="badge" style={{ backgroundColor: '#334155' }}>
|
||||
{asset.asset_type === 'real_estate' ? 'Inmueble' :
|
||||
asset.asset_type === 'vehicle' ? 'Vehículo' :
|
||||
asset.asset_type === 'investment' ? 'Inversión' :
|
||||
asset.asset_type === 'equipment' ? 'Equipamiento' :
|
||||
asset.asset_type === 'receivable' ? 'Crédito' :
|
||||
asset.asset_type === 'cash' ? 'Efectivo' :
|
||||
asset.asset_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-end text-slate-300" style={{ backgroundColor: 'transparent' }}>
|
||||
{formatCurrency(asset.acquisition_value, asset.currency)}
|
||||
</td>
|
||||
<td className="py-3 text-end fw-medium text-success" style={{ backgroundColor: 'transparent' }}>
|
||||
{formatCurrency(asset.current_value, asset.currency)}
|
||||
</td>
|
||||
<td className="py-3 text-center" style={{ backgroundColor: 'transparent' }}>
|
||||
<span className={`fw-medium ${gainLoss >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||
<i className={`bi ${gainLoss >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
|
||||
{gainLossPercent.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-center" style={{ backgroundColor: 'transparent' }}>
|
||||
<span className={`badge ${asset.status === 'active' ? 'bg-success' : 'bg-secondary'}`}>
|
||||
{asset.status === 'active' ? 'Activo' : asset.status === 'disposed' ? 'Vendido' : asset.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Asset Wizard Modal */}
|
||||
<AssetWizard
|
||||
isOpen={showAssetWizard}
|
||||
onClose={() => {
|
||||
setShowAssetWizard(false);
|
||||
setEditingAsset(null);
|
||||
}}
|
||||
onSuccess={handleAssetSuccess}
|
||||
asset={editingAsset}
|
||||
/>
|
||||
|
||||
{/* Account Wizard Modal */}
|
||||
<AccountWizard
|
||||
isOpen={showAccountWizard}
|
||||
onClose={() => {
|
||||
setShowAccountWizard(false);
|
||||
setEditingAccount(null);
|
||||
}}
|
||||
onSuccess={handleAccountWizardSuccess}
|
||||
account={editingAccount}
|
||||
/>
|
||||
|
||||
{/* Modal de Criar/Editar */}
|
||||
{showModal && (
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||
@ -1136,6 +1484,259 @@ const Accounts = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Detalhes do Ativo */}
|
||||
{showAssetDetail && selectedAsset && (
|
||||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div className="modal-content" style={{ backgroundColor: '#1e293b', border: '1px solid #334155' }}>
|
||||
<div className="modal-header border-0">
|
||||
<div className="d-flex align-items-center">
|
||||
<div
|
||||
className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
backgroundColor: (selectedAsset.color || '#10B981') + '25',
|
||||
}}
|
||||
>
|
||||
<i className={`bi bi-${selectedAsset.asset_type === 'real_estate' ? 'house' : selectedAsset.asset_type === 'vehicle' ? 'truck' : selectedAsset.asset_type === 'investment' ? 'graph-up' : 'box'} fs-4`} style={{ color: selectedAsset.color || '#10B981' }}></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="modal-title text-white mb-0">{selectedAsset.name}</h5>
|
||||
{selectedAsset.description && (
|
||||
<small className="text-slate-400">{selectedAsset.description}</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={handleCloseAssetDetail}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{/* Valores principais */}
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-md-4">
|
||||
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
|
||||
<div className="text-slate-400 small mb-1">Valor de Adquisición</div>
|
||||
<div className="text-white fs-5 fw-bold">
|
||||
{formatCurrency(selectedAsset.acquisition_value, selectedAsset.currency)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
|
||||
<div className="text-slate-400 small mb-1">Valor Actual</div>
|
||||
<div className="text-success fs-5 fw-bold">
|
||||
{formatCurrency(selectedAsset.current_value, selectedAsset.currency)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
|
||||
<div className="text-slate-400 small mb-1">Rentabilidad</div>
|
||||
{(() => {
|
||||
const gainLoss = parseFloat(selectedAsset.current_value) - parseFloat(selectedAsset.acquisition_value);
|
||||
const gainLossPercent = selectedAsset.acquisition_value > 0 ? (gainLoss / parseFloat(selectedAsset.acquisition_value)) * 100 : 0;
|
||||
return (
|
||||
<div className={`fs-5 fw-bold ${gainLoss >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||
<i className={`bi ${gainLoss >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
|
||||
{formatCurrency(Math.abs(gainLoss), selectedAsset.currency)} ({gainLossPercent.toFixed(1)}%)
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informações detalhadas */}
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<h6 className="text-white mb-3">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
Información del Activo
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Tipo de Activo</span>
|
||||
<div className="text-white">
|
||||
{selectedAsset.asset_type === 'real_estate' ? 'Inmueble' :
|
||||
selectedAsset.asset_type === 'vehicle' ? 'Vehículo' :
|
||||
selectedAsset.asset_type === 'investment' ? 'Inversión' :
|
||||
selectedAsset.asset_type === 'equipment' ? 'Equipamiento' :
|
||||
selectedAsset.asset_type === 'receivable' ? 'Crédito por Cobrar' :
|
||||
selectedAsset.asset_type === 'cash' ? 'Efectivo' :
|
||||
selectedAsset.asset_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Estado</span>
|
||||
<div>
|
||||
<span className={`badge ${selectedAsset.status === 'active' ? 'bg-success' : 'bg-secondary'}`}>
|
||||
{selectedAsset.status === 'active' ? 'Activo' : selectedAsset.status === 'disposed' ? 'Vendido' : selectedAsset.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAsset.acquisition_date && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Fecha de Adquisición</span>
|
||||
<div className="text-white">
|
||||
{new Date(selectedAsset.acquisition_date).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campos específicos por tipo */}
|
||||
{selectedAsset.asset_type === 'real_estate' && (
|
||||
<>
|
||||
{selectedAsset.address && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Dirección</span>
|
||||
<div className="text-white">{selectedAsset.address}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.city && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Ciudad</span>
|
||||
<div className="text-white">{selectedAsset.city}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.property_area_m2 && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Área</span>
|
||||
<div className="text-white">{selectedAsset.property_area_m2} m²</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedAsset.asset_type === 'vehicle' && (
|
||||
<>
|
||||
{selectedAsset.vehicle_brand && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Marca/Modelo</span>
|
||||
<div className="text-white">{selectedAsset.vehicle_brand} {selectedAsset.vehicle_model}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.vehicle_year && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Año</span>
|
||||
<div className="text-white">{selectedAsset.vehicle_year}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.vehicle_plate && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Matrícula</span>
|
||||
<div className="text-white">{selectedAsset.vehicle_plate}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.vehicle_mileage && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Kilometraje</span>
|
||||
<div className="text-white">{selectedAsset.vehicle_mileage.toLocaleString()} km</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedAsset.asset_type === 'investment' && (
|
||||
<>
|
||||
{selectedAsset.investment_type && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Tipo de Inversión</span>
|
||||
<div className="text-white">
|
||||
{selectedAsset.investment_type === 'stocks' ? 'Acciones' :
|
||||
selectedAsset.investment_type === 'bonds' ? 'Bonos' :
|
||||
selectedAsset.investment_type === 'etf' ? 'ETF' :
|
||||
selectedAsset.investment_type === 'mutual_fund' ? 'Fondo Mutuo' :
|
||||
selectedAsset.investment_type === 'crypto' ? 'Criptomoneda' :
|
||||
selectedAsset.investment_type === 'fixed_deposit' ? 'Depósito a Plazo' :
|
||||
selectedAsset.investment_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.institution && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Institución</span>
|
||||
<div className="text-white">{selectedAsset.institution}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.ticker && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Ticker/Símbolo</span>
|
||||
<div className="text-white">{selectedAsset.ticker}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.quantity && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Cantidad</span>
|
||||
<div className="text-white">{selectedAsset.quantity}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.interest_rate && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Tasa de Interés</span>
|
||||
<div className="text-white">{selectedAsset.interest_rate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.maturity_date && (
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-slate-400 small">Fecha de Vencimiento</span>
|
||||
<div className="text-white">
|
||||
{new Date(selectedAsset.maturity_date).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer border-0">
|
||||
<button type="button" className="btn btn-primary me-2" onClick={handleEditAsset}>
|
||||
<i className="bi bi-pencil me-2"></i>
|
||||
Editar
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleCloseAssetDetail}>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Confirmação de Exclusão */}
|
||||
<ConfirmModal
|
||||
show={showDeleteModal}
|
||||
|
||||
@ -4,6 +4,7 @@ import { budgetService, categoryService, costCenterService } from '../services/a
|
||||
import useFormatters from '../hooks/useFormatters';
|
||||
import { getCurrencyByCode } from '../config/currencies';
|
||||
import ConfirmModal from '../components/ConfirmModal';
|
||||
import BudgetWizard from '../components/BudgetWizard';
|
||||
|
||||
const Budgets = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -21,14 +22,7 @@ const Budgets = () => {
|
||||
const [deleteBudget, setDeleteBudget] = useState(null);
|
||||
const [yearSummary, setYearSummary] = useState(null);
|
||||
const [primaryCurrency, setPrimaryCurrency] = useState('EUR');
|
||||
const [formData, setFormData] = useState({
|
||||
category_id: '',
|
||||
subcategory_id: '',
|
||||
cost_center_id: '',
|
||||
amount: '',
|
||||
period_type: 'monthly',
|
||||
is_cumulative: false,
|
||||
});
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
|
||||
// Meses con i18n
|
||||
const getMonths = () => [
|
||||
@ -92,29 +86,6 @@ const Budgets = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const data = {
|
||||
...formData,
|
||||
year,
|
||||
month,
|
||||
};
|
||||
|
||||
if (editingBudget) {
|
||||
await budgetService.update(editingBudget.id, data);
|
||||
} else {
|
||||
await budgetService.create(data);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingBudget(null);
|
||||
resetForm();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Error saving budget:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteBudget) return;
|
||||
try {
|
||||
@ -128,11 +99,6 @@ const Budgets = () => {
|
||||
|
||||
const handleEdit = (budget) => {
|
||||
setEditingBudget(budget);
|
||||
setFormData({
|
||||
category_id: budget.category_id,
|
||||
subcategory_id: budget.subcategory_id || '',
|
||||
amount: budget.amount,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
@ -151,20 +117,8 @@ const Budgets = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
category_id: '',
|
||||
subcategory_id: '',
|
||||
cost_center_id: '',
|
||||
amount: '',
|
||||
period_type: 'monthly',
|
||||
is_cumulative: false,
|
||||
});
|
||||
};
|
||||
|
||||
const openNewBudget = () => {
|
||||
setEditingBudget(null);
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
@ -246,6 +200,14 @@ const Budgets = () => {
|
||||
<i className="bi bi-copy me-1"></i>
|
||||
{t('budgets.copyToNext')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={() => setShowWizard(true)}
|
||||
title={t('budgets.wizard.title') || 'Assistente de Orçamentos'}
|
||||
>
|
||||
<i className="bi bi-magic me-1"></i>
|
||||
{t('budgets.wizard.button') || 'Assistente'}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={openNewBudget}>
|
||||
<i className="bi bi-plus-lg me-1"></i>
|
||||
{t('budgets.addBudget')}
|
||||
@ -586,280 +548,19 @@ const Budgets = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget Form Modal */}
|
||||
{showModal && (
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||
<div className="modal-dialog modal-dialog-centered">
|
||||
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
|
||||
<div className="modal-header border-0">
|
||||
<h5 className="modal-title text-white">
|
||||
<i className={`bi ${editingBudget ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
|
||||
{editingBudget ? t('budgets.editBudget') : t('budgets.newBudget')}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white"
|
||||
onClick={() => setShowModal(false)}
|
||||
></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<p className="text-slate-400 small mb-3">
|
||||
<i className="bi bi-calendar3 me-1"></i>
|
||||
{months.find(m => m.value === month)?.label} {year}
|
||||
</p>
|
||||
|
||||
{/* Category Selection - Grid style like transactions */}
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-slate-400">{t('budgets.category')} *</label>
|
||||
{editingBudget ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control bg-dark border-secondary text-white"
|
||||
value={editingBudget.category?.name || ''}
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{availableCategories.length === 0 ? (
|
||||
<div className="alert alert-warning py-2 mb-0">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
{t('budgets.allCategoriesUsed')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="category-grid" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: '8px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
padding: '4px'
|
||||
}}>
|
||||
{availableCategories.map(cat => (
|
||||
<div
|
||||
key={cat.id}
|
||||
onClick={() => setFormData({...formData, category_id: cat.id, subcategory_id: ''})}
|
||||
className={`p-2 rounded text-center cursor-pointer ${
|
||||
formData.category_id == cat.id
|
||||
? 'border border-primary'
|
||||
: 'border border-secondary'
|
||||
}`}
|
||||
style={{
|
||||
background: formData.category_id == cat.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={`bi ${cat.icon || 'bi-tag'} d-block mb-1`}
|
||||
style={{
|
||||
fontSize: '1.5rem',
|
||||
color: cat.color || '#6b7280'
|
||||
}}
|
||||
></i>
|
||||
<small className="text-white d-block text-truncate" title={cat.name}>
|
||||
{cat.name}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subcategory Selection - Only if category selected and has subcategories */}
|
||||
{formData.category_id && !editingBudget && (() => {
|
||||
const selectedCategory = availableCategories.find(c => c.id == formData.category_id);
|
||||
const subcategories = selectedCategory?.subcategories || [];
|
||||
|
||||
if (subcategories.length > 0) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-slate-400">
|
||||
{t('budgets.subcategory')}
|
||||
<small className="text-muted ms-2">({t('common.optional')})</small>
|
||||
</label>
|
||||
<div className="category-grid" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: '8px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
padding: '4px'
|
||||
}}>
|
||||
{/* Option: All category (no subcategory) */}
|
||||
<div
|
||||
onClick={() => setFormData({...formData, subcategory_id: ''})}
|
||||
className={`p-2 rounded text-center cursor-pointer ${
|
||||
!formData.subcategory_id
|
||||
? 'border border-primary'
|
||||
: 'border border-secondary'
|
||||
}`}
|
||||
style={{
|
||||
background: !formData.subcategory_id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={`bi ${selectedCategory?.icon || 'bi-tag'} d-block mb-1`}
|
||||
style={{
|
||||
fontSize: '1.5rem',
|
||||
color: selectedCategory?.color || '#6b7280'
|
||||
}}
|
||||
></i>
|
||||
<small className="text-white d-block text-truncate">
|
||||
{t('budgets.allCategory')}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{/* Subcategories */}
|
||||
{subcategories.map(sub => (
|
||||
<div
|
||||
key={sub.id}
|
||||
onClick={() => setFormData({...formData, subcategory_id: sub.id})}
|
||||
className={`p-2 rounded text-center cursor-pointer ${
|
||||
formData.subcategory_id == sub.id
|
||||
? 'border border-primary'
|
||||
: 'border border-secondary'
|
||||
}`}
|
||||
style={{
|
||||
background: formData.subcategory_id == sub.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={`bi ${sub.icon || 'bi-tag'} d-block mb-1`}
|
||||
style={{
|
||||
fontSize: '1.5rem',
|
||||
color: sub.color || '#6b7280'
|
||||
}}
|
||||
></i>
|
||||
<small className="text-white d-block text-truncate" title={sub.name}>
|
||||
{sub.name}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Cost Center Selection - Only if editing or creating new */}
|
||||
{!editingBudget && costCenters.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-slate-400">
|
||||
{t('budgets.costCenter')}
|
||||
<small className="text-muted ms-2">({t('common.optional')})</small>
|
||||
</label>
|
||||
<select
|
||||
className="form-select bg-dark border-secondary text-white"
|
||||
value={formData.cost_center_id || ''}
|
||||
onChange={(e) => setFormData({...formData, cost_center_id: e.target.value})}
|
||||
>
|
||||
<option value="">{t('budgets.noCostCenter')}</option>
|
||||
{costCenters.map(cc => (
|
||||
<option key={cc.id} value={cc.id}>
|
||||
{cc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Amount */}
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-slate-400">{t('budgets.amount')} *</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-dark border-secondary text-white">
|
||||
{getCurrencyByCode(primaryCurrency)?.symbol || '€'}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
className="form-control bg-dark border-secondary text-white"
|
||||
value={formData.amount}
|
||||
onChange={(e) => setFormData({...formData, amount: e.target.value})}
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period Type */}
|
||||
{!editingBudget && (
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-slate-400">{t('budgets.periodType')}</label>
|
||||
<select
|
||||
className="form-select bg-dark border-secondary text-white"
|
||||
value={formData.period_type || 'monthly'}
|
||||
onChange={(e) => setFormData({...formData, period_type: e.target.value})}
|
||||
>
|
||||
<option value="monthly">{t('budgets.monthly')}</option>
|
||||
<option value="bimestral">{t('budgets.bimestral')}</option>
|
||||
<option value="trimestral">{t('budgets.trimestral')}</option>
|
||||
<option value="semestral">{t('budgets.semestral')}</option>
|
||||
<option value="yearly">{t('budgets.yearly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cumulative Option */}
|
||||
{!editingBudget && (
|
||||
<div className="mb-3">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="isCumulativeCheck"
|
||||
checked={formData.is_cumulative || false}
|
||||
onChange={(e) => setFormData({...formData, is_cumulative: e.target.checked})}
|
||||
/>
|
||||
<label className="form-check-label text-slate-400" htmlFor="isCumulativeCheck">
|
||||
{t('budgets.isCumulative')}
|
||||
<small className="d-block text-muted">{t('budgets.isCumulativeHelp')}</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info about auto-propagation */}
|
||||
{!editingBudget && (
|
||||
<div className="alert alert-info py-2 mb-0">
|
||||
<small>
|
||||
<i className="bi bi-info-circle me-1"></i>
|
||||
{t('budgets.autoPropagateInfo')}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer border-0">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!editingBudget && (!formData.category_id || !formData.amount)}
|
||||
>
|
||||
<i className="bi bi-check-lg me-1"></i>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Budget Form Modal - Using BudgetWizard with mode='single' */}
|
||||
<BudgetWizard
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingBudget(null);
|
||||
}}
|
||||
onSuccess={loadData}
|
||||
year={year}
|
||||
month={month}
|
||||
mode="single"
|
||||
editBudget={editingBudget}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmModal
|
||||
@ -871,6 +572,15 @@ const Budgets = () => {
|
||||
confirmText={t('common.delete')}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* Budget Wizard */}
|
||||
<BudgetWizard
|
||||
isOpen={showWizard}
|
||||
onClose={() => setShowWizard(false)}
|
||||
onSuccess={loadData}
|
||||
year={year}
|
||||
month={month}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -473,40 +473,84 @@ const Categories = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de Criar/Editar */}
|
||||
{/* Modal de Criar/Editar - Design Elegante */}
|
||||
{showModal && (
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div className="modal-content" style={{ background: '#1e293b' }}>
|
||||
<div className="modal-header border-bottom" style={{ borderColor: '#334155 !important' }}>
|
||||
<h5 className="modal-title text-white">
|
||||
<i className={`bi ${selectedItem ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
|
||||
{selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')}
|
||||
</h5>
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
||||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
|
||||
{/* Header elegante */}
|
||||
<div className="modal-header border-0 pb-0">
|
||||
<div>
|
||||
<h5 className="modal-title text-white mb-1">
|
||||
<i className={`bi ${selectedItem ? 'bi-pencil-square' : formData.parent_id ? 'bi-diagram-3' : 'bi-plus-circle-dotted'} me-2 text-info`}></i>
|
||||
{selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')}
|
||||
</h5>
|
||||
<p className="text-slate-400 mb-0 small">
|
||||
{formData.parent_id
|
||||
? `${t('categories.parentCategory')}: ${flatCategories.find(c => c.id == formData.parent_id)?.name || ''}`
|
||||
: t('categories.title')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
<div className="row g-3">
|
||||
{/* Nome */}
|
||||
<div className="col-md-6">
|
||||
<label className="form-label text-slate-300">{t('common.name')} *</label>
|
||||
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
|
||||
|
||||
{/* Preview Card */}
|
||||
<div className="mb-4 p-3 rounded-3" style={{ background: '#0f172a' }}>
|
||||
<div className="d-flex align-items-center">
|
||||
<div
|
||||
className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
background: `${formData.color}25`,
|
||||
border: `2px solid ${formData.color}`,
|
||||
}}
|
||||
>
|
||||
<i className={`bi ${formData.icon}`} style={{ fontSize: '1.3rem', color: formData.color }}></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-white mb-0">{formData.name || t('categories.newCategory')}</h6>
|
||||
<small className="text-slate-400">{formData.description || t('common.description')}</small>
|
||||
</div>
|
||||
<div className="ms-auto">
|
||||
<span className={`badge bg-${getTypeColor(formData.type)} bg-opacity-25 text-${getTypeColor(formData.type)}`}>
|
||||
{categoryTypes[formData.type]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nome e Tipo - Linha principal */}
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-md-8">
|
||||
<label className="form-label text-white fw-medium mb-2">
|
||||
<i className="bi bi-type me-2 text-primary"></i>
|
||||
{t('common.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
className="form-control bg-dark text-white border-0"
|
||||
style={{ background: '#0f172a' }}
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: Alimentação, Moradia..."
|
||||
placeholder={t('categories.namePlaceholder') || 'Ex: Alimentación, Transporte...'}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tipo */}
|
||||
<div className="col-md-3">
|
||||
<label className="form-label text-slate-300">{t('common.type')} *</label>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label text-white fw-medium mb-2">
|
||||
<i className="bi bi-arrow-left-right me-2 text-warning"></i>
|
||||
{t('common.type')} *
|
||||
</label>
|
||||
<select
|
||||
className="form-select bg-dark text-white border-secondary"
|
||||
className="form-select bg-dark text-white border-0"
|
||||
style={{ background: '#0f172a' }}
|
||||
name="type"
|
||||
value={formData.type}
|
||||
onChange={handleChange}
|
||||
@ -517,145 +561,218 @@ const Categories = () => {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categoria Pai */}
|
||||
<div className="col-md-3">
|
||||
<label className="form-label text-slate-300">{t('categories.parentCategory')}</label>
|
||||
<select
|
||||
className="form-select bg-dark text-white border-secondary"
|
||||
name="parent_id"
|
||||
value={formData.parent_id}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">{t('categories.noParent')}</option>
|
||||
{flatCategories
|
||||
.filter(c => c.id !== selectedItem?.id)
|
||||
.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Cor */}
|
||||
<div className="col-md-3">
|
||||
<label className="form-label text-slate-300">{t('common.color')}</label>
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color bg-dark border-secondary w-100"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ícone */}
|
||||
<div className="col-md-5">
|
||||
<label className="form-label text-slate-300">{t('common.icon')}</label>
|
||||
<IconSelector
|
||||
value={formData.icon}
|
||||
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
|
||||
type="category"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="col-md-4">
|
||||
<label className="form-label text-slate-300"> </label>
|
||||
<div className="form-check mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label text-slate-300" htmlFor="is_active">
|
||||
{t('common.active')}
|
||||
</label>
|
||||
{/* Visual - Cor e Ícone */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label text-white fw-medium mb-2">
|
||||
<i className="bi bi-palette me-2 text-success"></i>
|
||||
{t('categories.visualSettings') || 'Aparência'}
|
||||
</label>
|
||||
<div className="row g-3">
|
||||
<div className="col-4">
|
||||
<div className="p-3 rounded text-center" style={{ background: '#0f172a' }}>
|
||||
<label className="text-slate-400 small d-block mb-2">{t('common.color')}</label>
|
||||
<input
|
||||
type="color"
|
||||
className="form-control form-control-color mx-auto border-0"
|
||||
style={{ width: 50, height: 50, cursor: 'pointer', background: 'transparent' }}
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-8">
|
||||
<div className="p-3 rounded h-100" style={{ background: '#0f172a' }}>
|
||||
<label className="text-slate-400 small d-block mb-2">{t('common.icon')}</label>
|
||||
<IconSelector
|
||||
value={formData.icon}
|
||||
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
|
||||
type="category"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descrição */}
|
||||
<div className="col-12">
|
||||
<label className="form-label text-slate-300">{t('common.description')}</label>
|
||||
<textarea
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows="2"
|
||||
placeholder="Descreva esta categoria..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{/* Palavras-chave */}
|
||||
<div className="col-12">
|
||||
<label className="form-label text-slate-300">
|
||||
<i className="bi bi-key me-1"></i>
|
||||
{t('categories.keywordHelp')}
|
||||
{/* Categoria Pai (se não for edição de uma categoria raiz) */}
|
||||
{!selectedItem?.children?.length && (
|
||||
<div className="mb-4">
|
||||
<label className="form-label text-white fw-medium mb-2">
|
||||
<i className="bi bi-diagram-2 me-2 text-info"></i>
|
||||
{t('categories.parentCategory')}
|
||||
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
|
||||
</label>
|
||||
<div className="row g-2">
|
||||
<div className="col-4 col-md-3">
|
||||
<div
|
||||
onClick={() => setFormData({...formData, parent_id: ''})}
|
||||
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
|
||||
style={{
|
||||
background: !formData.parent_id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
|
||||
cursor: 'pointer',
|
||||
border: !formData.parent_id ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
minHeight: 60
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-app d-block mb-1 text-slate-400"></i>
|
||||
<small className="text-white">{t('categories.noParent')}</small>
|
||||
</div>
|
||||
</div>
|
||||
{flatCategories
|
||||
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
|
||||
.slice(0, 7)
|
||||
.map(cat => (
|
||||
<div key={cat.id} className="col-4 col-md-3">
|
||||
<div
|
||||
onClick={() => setFormData({...formData, parent_id: cat.id.toString()})}
|
||||
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
|
||||
style={{
|
||||
background: formData.parent_id == cat.id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
|
||||
cursor: 'pointer',
|
||||
border: formData.parent_id == cat.id ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
minHeight: 60
|
||||
}}
|
||||
>
|
||||
<i className={`bi ${cat.icon || 'bi-tag'} d-block mb-1`} style={{ color: cat.color || '#6b7280' }}></i>
|
||||
<small className="text-white text-truncate" title={cat.name}>{cat.name}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{flatCategories.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both')).length > 7 && (
|
||||
<select
|
||||
className="form-select bg-dark text-white border-0 mt-2"
|
||||
style={{ background: '#0f172a' }}
|
||||
name="parent_id"
|
||||
value={formData.parent_id}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">{t('categories.selectParent') || 'Mais categorias...'}</option>
|
||||
{flatCategories
|
||||
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
|
||||
.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Descrição */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label text-white fw-medium mb-2">
|
||||
<i className="bi bi-text-paragraph me-2 text-secondary"></i>
|
||||
{t('common.description')}
|
||||
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control bg-dark text-white border-0"
|
||||
style={{ background: '#0f172a' }}
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows="2"
|
||||
placeholder={t('categories.descPlaceholder') || 'Descreva esta categoria...'}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{/* Palavras-chave - Seção destacada */}
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-white fw-medium mb-2">
|
||||
<i className="bi bi-key me-2 text-warning"></i>
|
||||
{t('categories.keywords')}
|
||||
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: '0.65rem' }}>
|
||||
{t('categories.autoCategorizationLabel') || 'Auto-categorização'}
|
||||
</span>
|
||||
</label>
|
||||
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
|
||||
<div className="input-group mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control bg-dark text-white border-secondary"
|
||||
className="form-control bg-dark text-white border-0"
|
||||
style={{ background: '#1e293b' }}
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
onKeyPress={handleKeywordKeyPress}
|
||||
placeholder="Digite uma palavra-chave e pressione Enter..."
|
||||
placeholder={t('categories.keywordPlaceholder') || 'Digite e pressione Enter...'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-info"
|
||||
className="btn btn-info px-3"
|
||||
onClick={handleAddKeyword}
|
||||
>
|
||||
<i className="bi bi-plus"></i>
|
||||
<i className="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
{formData.keywords.map((keyword, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="badge d-flex align-items-center"
|
||||
className="badge d-flex align-items-center py-2 px-3"
|
||||
style={{
|
||||
backgroundColor: formData.color + '25',
|
||||
color: formData.color,
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
>
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white ms-2"
|
||||
style={{ fontSize: '8px' }}
|
||||
className="btn-close ms-2"
|
||||
style={{ fontSize: '8px', filter: 'brightness(1.5)' }}
|
||||
onClick={() => handleRemoveKeyword(keyword)}
|
||||
></button>
|
||||
</span>
|
||||
))}
|
||||
{formData.keywords.length === 0 && (
|
||||
<small className="text-slate-500">
|
||||
{t('common.noData')}
|
||||
<i className="bi bi-info-circle me-1"></i>
|
||||
{t('categories.noKeywords') || 'Nenhuma palavra-chave. Transações serão categorizadas manualmente.'}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<small className="text-slate-500 mt-2 d-block">
|
||||
Ex: "RESTAURANTE", "PIZZA", "HAMBURGUER" - Para a categoria Alimentação
|
||||
</small>
|
||||
</div>
|
||||
<small className="text-slate-500 mt-2 d-block">
|
||||
<i className="bi bi-lightbulb me-1"></i>
|
||||
{t('categories.keywordHelp') || 'Ex: "RESTAURANTE", "PIZZA" - Transações com essas palavras serão categorizadas automaticamente'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
role="switch"
|
||||
/>
|
||||
<label className="form-check-label text-white" htmlFor="is_active">
|
||||
<i className={`bi ${formData.is_active ? 'bi-check-circle text-success' : 'bi-x-circle text-secondary'} me-2`}></i>
|
||||
{formData.is_active ? t('common.active') : t('common.inactive')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer border-top" style={{ borderColor: '#334155 !important' }}>
|
||||
<button type="button" className="btn btn-outline-light" onClick={handleCloseModal}>
|
||||
|
||||
{/* Footer elegante */}
|
||||
<div className="modal-footer border-0">
|
||||
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseModal}>
|
||||
<i className="bi bi-x-lg me-2"></i>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-info" disabled={saving}>
|
||||
<button type="submit" className="btn btn-info px-4" disabled={saving || !formData.name.trim()}>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
{t('common.loading')}
|
||||
{t('common.saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-lg me-2"></i>
|
||||
<i className={`bi ${selectedItem ? 'bi-check-lg' : 'bi-plus-lg'} me-2`}></i>
|
||||
{selectedItem ? t('common.save') : t('common.create')}
|
||||
</>
|
||||
)}
|
||||
@ -678,116 +795,145 @@ const Categories = () => {
|
||||
loading={saving}
|
||||
/>
|
||||
|
||||
{/* Modal de Categorização em Lote */}
|
||||
{/* Modal de Categorização em Lote - Design Elegante */}
|
||||
{showBatchModal && (
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
|
||||
<div className="modal-header border-secondary">
|
||||
<h5 className="modal-title text-white">
|
||||
<i className="bi bi-lightning-charge me-2 text-warning"></i>
|
||||
{t('categories.batchCategorize') || 'Categorização em Lote'}
|
||||
</h5>
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" style={{ maxWidth: '700px' }}>
|
||||
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
|
||||
{/* Header elegante */}
|
||||
<div className="modal-header border-0 pb-0">
|
||||
<div>
|
||||
<h5 className="modal-title text-white mb-1">
|
||||
<i className="bi bi-lightning-charge-fill me-2 text-warning"></i>
|
||||
{t('categories.batchCategorize') || 'Categorização Automática'}
|
||||
</h5>
|
||||
<p className="text-slate-400 mb-0 small">
|
||||
{t('categories.batchDescription') || 'Categorize transações automaticamente usando palavras-chave'}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={handleCloseBatchModal}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
|
||||
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
|
||||
{loadingBatch ? (
|
||||
<div className="text-center py-5">
|
||||
<div className="spinner-border text-info" role="status">
|
||||
<div className="spinner-border text-warning" role="status" style={{ width: '3rem', height: '3rem' }}>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="text-slate-400 mt-3">{t('common.loading') || 'Carregando...'}</p>
|
||||
<p className="text-slate-400 mt-3 mb-0">{t('categories.analyzingTransactions') || 'Analisando transações...'}</p>
|
||||
</div>
|
||||
) : batchPreview ? (
|
||||
<>
|
||||
{/* Resumo */}
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-3">
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-body text-center">
|
||||
<h3 className="text-warning mb-1">{batchPreview.total_uncategorized}</h3>
|
||||
<small className="text-slate-400">{t('categories.uncategorized') || 'Sem categoria'}</small>
|
||||
{/* Cards de Resumo */}
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
||||
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
||||
style={{ width: 40, height: 40, background: 'rgba(234, 179, 8, 0.2)' }}>
|
||||
<i className="bi bi-question-circle text-warning"></i>
|
||||
</div>
|
||||
<h4 className="text-warning mb-0">{batchPreview.total_uncategorized}</h4>
|
||||
<small className="text-slate-500">{t('categories.uncategorized') || 'Sem categoria'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-body text-center">
|
||||
<h3 className="text-success mb-1">{batchPreview.would_categorize}</h3>
|
||||
<small className="text-slate-400">{t('categories.willCategorize') || 'Serão categorizadas'}</small>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
||||
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
||||
style={{ width: 40, height: 40, background: 'rgba(34, 197, 94, 0.2)' }}>
|
||||
<i className="bi bi-check-circle text-success"></i>
|
||||
</div>
|
||||
<h4 className="text-success mb-0">{batchPreview.would_categorize}</h4>
|
||||
<small className="text-slate-500">{t('categories.willCategorize') || 'Serão categorizadas'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-body text-center">
|
||||
<h3 className="text-secondary mb-1">{batchPreview.would_skip}</h3>
|
||||
<small className="text-slate-400">{t('categories.willSkip') || 'Sem correspondência'}</small>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
||||
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
||||
style={{ width: 40, height: 40, background: 'rgba(148, 163, 184, 0.2)' }}>
|
||||
<i className="bi bi-dash-circle text-slate-400"></i>
|
||||
</div>
|
||||
<h4 className="text-slate-400 mb-0">{batchPreview.would_skip}</h4>
|
||||
<small className="text-slate-500">{t('categories.willSkip') || 'Sem correspondência'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-body text-center">
|
||||
<h3 className="text-info mb-1">{batchPreview.total_keywords}</h3>
|
||||
<small className="text-slate-400">{t('categories.totalKeywords') || 'Palavras-chave'}</small>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
||||
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
||||
style={{ width: 40, height: 40, background: 'rgba(59, 130, 246, 0.2)' }}>
|
||||
<i className="bi bi-key text-info"></i>
|
||||
</div>
|
||||
<h4 className="text-info mb-0">{batchPreview.total_keywords}</h4>
|
||||
<small className="text-slate-500">{t('categories.totalKeywords') || 'Palavras-chave'}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{/* Preview Table */}
|
||||
{batchPreview.preview.length > 0 ? (
|
||||
<>
|
||||
<h6 className="text-white mb-3">
|
||||
<i className="bi bi-eye me-2"></i>
|
||||
{t('categories.previewTitle') || 'Preview das categorizações'}
|
||||
</h6>
|
||||
<div className="table-responsive" style={{ maxHeight: '300px' }}>
|
||||
<table className="table table-dark table-striped table-hover mb-0">
|
||||
<thead style={{ position: 'sticky', top: 0, background: '#1e293b' }}>
|
||||
<tr>
|
||||
<th>{t('transactions.description') || 'Descrição'}</th>
|
||||
<th>{t('categories.matchedKeyword') || 'Keyword'}</th>
|
||||
<th>{t('categories.category') || 'Categoria'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batchPreview.preview.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td className="text-truncate" style={{ maxWidth: '300px' }}>
|
||||
{item.description}
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge bg-warning">{item.matched_keyword}</span>
|
||||
</td>
|
||||
<td className="text-info">{item.category_name}</td>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<i className="bi bi-eye me-2 text-info"></i>
|
||||
<h6 className="text-white mb-0">
|
||||
{t('categories.previewTitle') || 'Preview das categorizações'}
|
||||
</h6>
|
||||
<span className="badge bg-info bg-opacity-25 text-info ms-2">
|
||||
{batchPreview.preview.length} {t('common.items') || 'itens'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-3 overflow-hidden" style={{ background: '#0f172a' }}>
|
||||
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
|
||||
<table className="table table-dark mb-0" style={{ background: 'transparent' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: '#0f172a', zIndex: 1 }}>
|
||||
<tr>
|
||||
<th className="border-0 text-slate-400 fw-normal small">{t('transactions.description') || 'Descrição'}</th>
|
||||
<th className="border-0 text-slate-400 fw-normal small text-center" style={{ width: '120px' }}>{t('categories.matchedKeyword') || 'Keyword'}</th>
|
||||
<th className="border-0 text-slate-400 fw-normal small" style={{ width: '140px' }}>{t('categories.category') || 'Categoria'}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batchPreview.preview.map((item, index) => (
|
||||
<tr key={index} style={{ borderColor: '#334155' }}>
|
||||
<td className="text-white text-truncate border-secondary" style={{ maxWidth: '200px' }}>
|
||||
{item.description}
|
||||
</td>
|
||||
<td className="text-center border-secondary">
|
||||
<span className="badge bg-warning bg-opacity-25 text-warning">{item.matched_keyword}</span>
|
||||
</td>
|
||||
<td className="text-info border-secondary">{item.category_name}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="alert alert-warning">
|
||||
<i className="bi bi-exclamation-triangle me-2"></i>
|
||||
{t('categories.noMatchesFound') || 'Nenhuma transação corresponde às palavras-chave configuradas'}
|
||||
<div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
||||
<i className="bi bi-search display-4 text-slate-600 mb-3 d-block"></i>
|
||||
<h6 className="text-white mb-2">{t('categories.noMatchesFoundTitle') || 'Nenhuma correspondência encontrada'}</h6>
|
||||
<p className="text-slate-400 mb-0 small">
|
||||
{t('categories.noMatchesFound') || 'Adicione palavras-chave às categorias para permitir categorização automática'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="alert alert-danger">
|
||||
{t('categories.previewError') || 'Erro ao carregar preview'}
|
||||
<div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
||||
<i className="bi bi-exclamation-triangle display-4 text-danger mb-3 d-block"></i>
|
||||
<p className="text-slate-400 mb-0">{t('categories.previewError') || 'Erro ao carregar preview'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer border-secondary">
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={handleCloseBatchModal}>
|
||||
|
||||
{/* Footer elegante */}
|
||||
<div className="modal-footer border-0">
|
||||
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseBatchModal}>
|
||||
<i className="bi bi-x-lg me-2"></i>
|
||||
{t('common.cancel') || 'Cancelar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-warning"
|
||||
className="btn btn-warning px-4"
|
||||
onClick={handleExecuteBatch}
|
||||
disabled={executingBatch || !batchPreview || batchPreview.would_categorize === 0}
|
||||
>
|
||||
@ -798,8 +944,8 @@ const Categories = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-lightning-charge me-2"></i>
|
||||
{t('categories.executeBatch') || 'Executar Categorização'}
|
||||
<i className="bi bi-lightning-charge-fill me-2"></i>
|
||||
{t('categories.executeBatch') || 'Categorizar'} ({batchPreview?.would_categorize || 0})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@ -63,6 +63,59 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Hero Title Animation */
|
||||
.hero-title-wrapper {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-title-wrapper h1 {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-title-wrapper h1:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.hero-title-wrapper h1:last-child {
|
||||
padding-left: 3.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hero-title-wrapper h1:last-child {
|
||||
padding-left: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-title-highlight {
|
||||
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: glow 3s ease-in-out infinite, colorShift 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.3));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 20px rgba(16, 185, 129, 0.5)) drop-shadow(0 0 40px rgba(59, 130, 246, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes colorShift {
|
||||
0%, 100% {
|
||||
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
50% {
|
||||
background: linear-gradient(135deg, var(--landing-secondary), var(--landing-primary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dashboard Preview */
|
||||
.hero-image {
|
||||
perspective: 1000px;
|
||||
|
||||
@ -149,9 +149,14 @@ export default function Landing() {
|
||||
<div className="container">
|
||||
<div className="row align-items-center min-vh-100 py-5">
|
||||
<div className="col-lg-6">
|
||||
<h1 className="display-3 fw-bold text-white mb-4">
|
||||
{t('landing.hero.title')}
|
||||
</h1>
|
||||
<div className="hero-title-wrapper mb-4">
|
||||
<h1 className="display-3 fw-bold text-white mb-0">
|
||||
{i18n.language === 'es' ? 'Toma el Control' : i18n.language === 'pt-BR' ? 'Assuma o Controle' : 'Take Control'}
|
||||
</h1>
|
||||
<h1 className="display-3 fw-bold mb-0 hero-title-highlight">
|
||||
{i18n.language === 'es' ? 'de tus Finanzas' : i18n.language === 'pt-BR' ? 'das suas Finanças' : 'of Your Finances'}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="lead text-white-50 mb-4">
|
||||
{t('landing.hero.subtitle')}
|
||||
</p>
|
||||
|
||||
@ -4,6 +4,7 @@ import { liabilityAccountService } from '../services/api';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { ConfirmModal } from '../components/Modal';
|
||||
import { useFormatters } from '../hooks';
|
||||
import LiabilityWizard from '../components/LiabilityWizard';
|
||||
|
||||
const LiabilityAccounts = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -24,6 +25,7 @@ const LiabilityAccounts = () => {
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showWizardModal, setShowWizardModal] = useState(false);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showReconcileModal, setShowReconcileModal] = useState(false);
|
||||
@ -70,6 +72,31 @@ const LiabilityAccounts = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Download template Excel
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const blob = await liabilityAccountService.downloadTemplate();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'plantilla_importacion_pasivo.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Plantilla descargada');
|
||||
} catch (error) {
|
||||
toast.error('Error al descargar plantilla');
|
||||
}
|
||||
};
|
||||
|
||||
// Wizard success handler
|
||||
const handleWizardSuccess = (newAccount) => {
|
||||
loadAccounts();
|
||||
setSelectedAccount(newAccount);
|
||||
setShowDetailModal(true);
|
||||
};
|
||||
|
||||
const handleOpenImportModal = () => {
|
||||
setImportForm({
|
||||
file: null,
|
||||
@ -366,13 +393,44 @@ const LiabilityAccounts = () => {
|
||||
{t('liabilities.subtitle')}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-primary ${isMobile ? 'w-100' : ''}`}
|
||||
onClick={handleOpenImportModal}
|
||||
>
|
||||
<i className="bi bi-upload me-2"></i>
|
||||
{isMobile ? t('common.import') : t('liabilities.importContract')}
|
||||
</button>
|
||||
<div className={`d-flex gap-2 ${isMobile ? 'flex-column' : ''}`}>
|
||||
{/* Botão Criar com Wizard */}
|
||||
<button
|
||||
className={`btn btn-success ${isMobile ? 'w-100' : ''}`}
|
||||
onClick={() => setShowWizardModal(true)}
|
||||
>
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
{isMobile ? 'Crear' : 'Crear Pasivo'}
|
||||
</button>
|
||||
|
||||
{/* Dropdown para importação */}
|
||||
<div className="dropdown">
|
||||
<button
|
||||
className={`btn btn-primary dropdown-toggle ${isMobile ? 'w-100' : ''}`}
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i className="bi bi-upload me-2"></i>
|
||||
{isMobile ? t('common.import') : t('liabilities.importContract')}
|
||||
</button>
|
||||
<ul className="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button className="dropdown-item" onClick={handleOpenImportModal}>
|
||||
<i className="bi bi-file-earmark-excel me-2 text-success"></i>
|
||||
Importar desde Excel
|
||||
</button>
|
||||
</li>
|
||||
<li><hr className="dropdown-divider" /></li>
|
||||
<li>
|
||||
<button className="dropdown-item" onClick={handleDownloadTemplate}>
|
||||
<i className="bi bi-download me-2 text-primary"></i>
|
||||
Descargar Plantilla
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
@ -488,10 +546,20 @@ const LiabilityAccounts = () => {
|
||||
<i className="bi bi-inbox fs-1 text-muted mb-3 d-block"></i>
|
||||
<h5 className="text-muted">{t('liabilities.noContracts')}</h5>
|
||||
<p className="text-muted mb-3">{t('liabilities.importHint')}</p>
|
||||
<button className="btn btn-primary" onClick={handleOpenImportModal}>
|
||||
<i className="bi bi-upload me-2"></i>
|
||||
{t('liabilities.importContract')}
|
||||
</button>
|
||||
<div className="d-flex gap-2 justify-content-center flex-wrap">
|
||||
<button className="btn btn-success" onClick={() => setShowWizardModal(true)}>
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Crear Pasivo
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleOpenImportModal}>
|
||||
<i className="bi bi-upload me-2"></i>
|
||||
{t('liabilities.importContract')}
|
||||
</button>
|
||||
<button className="btn btn-outline-primary" onClick={handleDownloadTemplate}>
|
||||
<i className="bi bi-download me-2"></i>
|
||||
Descargar Plantilla
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@ -1465,6 +1533,13 @@ const LiabilityAccounts = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liability Wizard Modal */}
|
||||
<LiabilityWizard
|
||||
isOpen={showWizardModal}
|
||||
onClose={() => setShowWizardModal(false)}
|
||||
onSuccess={handleWizardSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -928,11 +928,15 @@ export default function Transactions() {
|
||||
pending: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'secondary',
|
||||
effective: 'success',
|
||||
scheduled: 'primary',
|
||||
};
|
||||
const labels = {
|
||||
pending: t('transactions.status.pending'),
|
||||
completed: t('transactions.status.completed'),
|
||||
cancelled: t('transactions.status.cancelled'),
|
||||
effective: t('transactions.status.effective'),
|
||||
scheduled: t('transactions.status.scheduled'),
|
||||
};
|
||||
return <span className={`badge bg-${colors[status]}`}>{labels[status]}</span>;
|
||||
};
|
||||
|
||||
@ -417,20 +417,116 @@ export const liabilityAccountService = {
|
||||
|
||||
// Status disponíveis para contas
|
||||
statuses: {
|
||||
active: 'Ativo',
|
||||
paid_off: 'Quitado',
|
||||
defaulted: 'Inadimplente',
|
||||
active: 'Activo',
|
||||
paid_off: 'Liquidado',
|
||||
defaulted: 'En mora',
|
||||
renegotiated: 'Renegociado',
|
||||
},
|
||||
|
||||
// Status disponíveis para parcelas
|
||||
installmentStatuses: {
|
||||
pending: 'Pendente',
|
||||
paid: 'Pago',
|
||||
pending: 'Pendiente',
|
||||
paid: 'Pagado',
|
||||
partial: 'Parcial',
|
||||
overdue: 'Atrasado',
|
||||
overdue: 'Vencido',
|
||||
cancelled: 'Cancelado',
|
||||
},
|
||||
|
||||
// Download template Excel
|
||||
downloadTemplate: async () => {
|
||||
const response = await api.get('/liability-accounts/template', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Obter tipos de contrato
|
||||
getContractTypes: async () => {
|
||||
const response = await api.get('/liability-accounts/contract-types');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Criar conta passivo via wizard
|
||||
createWithWizard: async (data) => {
|
||||
const response = await api.post('/liability-accounts/wizard', data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Asset Account Services (Contas Ativo)
|
||||
// ============================================
|
||||
export const assetAccountService = {
|
||||
// Listar todos os ativos
|
||||
getAll: async (params = {}) => {
|
||||
const response = await api.get('/asset-accounts', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Obter um ativo específico
|
||||
getById: async (id) => {
|
||||
const response = await api.get(`/asset-accounts/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Criar novo ativo manualmente
|
||||
create: async (data) => {
|
||||
const response = await api.post('/asset-accounts', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Atualizar ativo
|
||||
update: async (id, data) => {
|
||||
const response = await api.put(`/asset-accounts/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Excluir ativo
|
||||
delete: async (id) => {
|
||||
const response = await api.delete(`/asset-accounts/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Obter tipos de ativos e opções
|
||||
getAssetTypes: async () => {
|
||||
const response = await api.get('/asset-accounts/asset-types');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Criar ativo via wizard
|
||||
createWithWizard: async (data) => {
|
||||
const response = await api.post('/asset-accounts/wizard', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Obter resumo geral
|
||||
getSummary: async () => {
|
||||
const response = await api.get('/asset-summary');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Atualizar valor de mercado
|
||||
updateValue: async (id, currentValue, note = null) => {
|
||||
const response = await api.put(`/asset-accounts/${id}/value`, {
|
||||
current_value: currentValue,
|
||||
note: note,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Registrar venda/baixa
|
||||
dispose: async (id, data) => {
|
||||
const response = await api.post(`/asset-accounts/${id}/dispose`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Status disponíveis
|
||||
statuses: {
|
||||
active: 'Activo',
|
||||
sold: 'Vendido',
|
||||
depreciated: 'Depreciado',
|
||||
written_off: 'Dado de baja',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
|
||||
@ -130,6 +130,34 @@
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Efeito de animação para o título */
|
||||
.typing-effect {
|
||||
position: relative;
|
||||
animation: glow 3s ease-in-out infinite, colorShift 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.3));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 20px rgba(16, 185, 129, 0.5)) drop-shadow(0 0 40px rgba(59, 130, 246, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes colorShift {
|
||||
0%, 100% {
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
50% {
|
||||
background: linear-gradient(135deg, var(--secondary), var(--primary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
@ -656,7 +684,7 @@
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-6">
|
||||
<h1 class="hero-title fade-in">
|
||||
Soluciones Digitales para <span>impulsar tu negocio</span>
|
||||
Soluciones Digitales para <span class="typing-effect">impulsar tu negocio</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle fade-in">
|
||||
Desarrollamos plataformas digitales innovadoras para simplificar la gestión de tu negocio. Finanzas, piscinas, turismo y más.
|
||||
@ -741,7 +769,7 @@
|
||||
</div>
|
||||
<p class="text-muted small mb-3">Plan Free disponible con funciones limitadas</p>
|
||||
<a href="https://webmoney.cnxifly.com/register" class="btn btn-webmoney btn-product text-white">
|
||||
<i class="bi bi-rocket-takeoff me-2"></i>Comenzar Gratis
|
||||
<i class="bi bi-rocket-takeoff me-2"></i>Comenzar Ahora
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -853,7 +881,7 @@
|
||||
<p class="cta-text fade-in">Únete a miles de usuarios que ya confían en ConneXiFly</p>
|
||||
<div class="d-flex justify-content-center gap-3 flex-wrap fade-in">
|
||||
<a href="https://webmoney.cnxifly.com/register" class="btn btn-light btn-lg px-4">
|
||||
<i class="bi bi-person-plus me-2"></i>Crear Cuenta Gratis
|
||||
<i class="bi bi-rocket-takeoff me-2"></i>Comenzar Ahora
|
||||
</a>
|
||||
<a href="mailto:admin@cnxifly.com" class="btn btn-outline-light btn-lg px-4">
|
||||
<i class="bi bi-envelope me-2"></i>Contactar
|
||||
|
||||
Loading…
Reference in New Issue
Block a user