diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0fb00a1..529a226 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 `` 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 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc784d..c8b8161 100644 --- a/CHANGELOG.md +++ b/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 +``` --- diff --git a/VERSION b/VERSION index b7921ae..373aea9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.54.0 +1.57.0 diff --git a/Wanna.xlsx b/Wanna.xlsx new file mode 100644 index 0000000..7267e32 Binary files /dev/null and b/Wanna.xlsx differ diff --git a/backend/app/Console/Commands/GenerateDemoInstallments.php b/backend/app/Console/Commands/GenerateDemoInstallments.php new file mode 100644 index 0000000..533175e --- /dev/null +++ b/backend/app/Console/Commands/GenerateDemoInstallments.php @@ -0,0 +1,119 @@ +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(); + } +} diff --git a/backend/app/Console/Commands/PopulateDemoData.php b/backend/app/Console/Commands/PopulateDemoData.php new file mode 100644 index 0000000..fe89829 --- /dev/null +++ b/backend/app/Console/Commands/PopulateDemoData.php @@ -0,0 +1,661 @@ +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)); + } + } +} diff --git a/backend/app/Http/Controllers/Api/AssetAccountController.php b/backend/app/Http/Controllers/Api/AssetAccountController.php new file mode 100644 index 0000000..624c827 --- /dev/null +++ b/backend/app/Http/Controllers/Api/AssetAccountController.php @@ -0,0 +1,420 @@ +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(), + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php index e7fc6b6..2a84efd 100644 --- a/backend/app/Http/Controllers/Api/AuthController.php +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -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, ] diff --git a/backend/app/Http/Controllers/Api/DashboardController.php b/backend/app/Http/Controllers/Api/DashboardController.php index 46db130..3738533 100644 --- a/backend/app/Http/Controllers/Api/DashboardController.php +++ b/backend/app/Http/Controllers/Api/DashboardController.php @@ -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, diff --git a/backend/app/Http/Controllers/Api/LiabilityAccountController.php b/backend/app/Http/Controllers/Api/LiabilityAccountController.php index ad4c4e3..092bf0a 100644 --- a/backend/app/Http/Controllers/Api/LiabilityAccountController.php +++ b/backend/app/Http/Controllers/Api/LiabilityAccountController.php @@ -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', + ]); + } + } } diff --git a/backend/app/Http/Controllers/Api/TransactionController.php b/backend/app/Http/Controllers/Api/TransactionController.php index fbb7bcc..39a3bcf 100644 --- a/backend/app/Http/Controllers/Api/TransactionController.php +++ b/backend/app/Http/Controllers/Api/TransactionController.php @@ -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 diff --git a/backend/app/Http/Middleware/DemoProtection.php b/backend/app/Http/Middleware/DemoProtection.php new file mode 100644 index 0000000..2605ead --- /dev/null +++ b/backend/app/Http/Middleware/DemoProtection.php @@ -0,0 +1,78 @@ + ['*'], // 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); + } +} diff --git a/backend/app/Models/AssetAccount.php b/backend/app/Models/AssetAccount.php new file mode 100644 index 0000000..359778e --- /dev/null +++ b/backend/app/Models/AssetAccount.php @@ -0,0 +1,351 @@ + '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; + } +} diff --git a/backend/app/Models/LiabilityAccount.php b/backend/app/Models/LiabilityAccount.php index d8ab854..04edef0 100644 --- a/backend/app/Models/LiabilityAccount.php +++ b/backend/app/Models/LiabilityAccount.php @@ -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']; diff --git a/backend/app/Models/Transaction.php b/backend/app/Models/Transaction.php index 9761c6a..dedb391 100644 --- a/backend/app/Models/Transaction.php +++ b/backend/app/Models/Transaction.php @@ -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) diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 5bd0152..1a131c1 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -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', ]; diff --git a/backend/app/Services/LiabilityTemplateService.php b/backend/app/Services/LiabilityTemplateService.php new file mode 100644 index 0000000..7e618f3 --- /dev/null +++ b/backend/app/Services/LiabilityTemplateService.php @@ -0,0 +1,291 @@ +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'); + } +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index 4250cc7..5b210d4 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -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 { diff --git a/backend/database/migrations/2025_06_18_120000_add_contract_type_to_liability_accounts_table.php b/backend/database/migrations/2025_06_18_120000_add_contract_type_to_liability_accounts_table.php new file mode 100644 index 0000000..f8fff18 --- /dev/null +++ b/backend/database/migrations/2025_06_18_120000_add_contract_type_to_liability_accounts_table.php @@ -0,0 +1,39 @@ +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', + ]); + }); + } +}; diff --git a/backend/database/migrations/2025_06_18_170000_add_advanced_fields_to_liability_accounts_table.php b/backend/database/migrations/2025_06_18_170000_add_advanced_fields_to_liability_accounts_table.php new file mode 100644 index 0000000..8305de0 --- /dev/null +++ b/backend/database/migrations/2025_06_18_170000_add_advanced_fields_to_liability_accounts_table.php @@ -0,0 +1,124 @@ +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', + ]); + }); + } +}; diff --git a/backend/database/migrations/2025_12_18_160430_add_is_demo_to_users_table.php b/backend/database/migrations/2025_12_18_160430_add_is_demo_to_users_table.php new file mode 100644 index 0000000..ee31aa9 --- /dev/null +++ b/backend/database/migrations/2025_12_18_160430_add_is_demo_to_users_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/backend/database/migrations/2025_12_18_170000_create_asset_accounts_table.php b/backend/database/migrations/2025_12_18_170000_create_asset_accounts_table.php new file mode 100644 index 0000000..6077cbb --- /dev/null +++ b/backend/database/migrations/2025_12_18_170000_create_asset_accounts_table.php @@ -0,0 +1,122 @@ +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'); + } +}; diff --git a/backend/database/seeders/DemoUserSeeder.php b/backend/database/seeders/DemoUserSeeder.php new file mode 100644 index 0000000..766d619 --- /dev/null +++ b/backend/database/seeders/DemoUserSeeder.php @@ -0,0 +1,320 @@ + '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"); + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 5620f9b..f67cc48 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -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 // ============================================ diff --git a/frontend/src/components/AccountWizard.jsx b/frontend/src/components/AccountWizard.jsx new file mode 100644 index 0000000..1e9adea --- /dev/null +++ b/frontend/src/components/AccountWizard.jsx @@ -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 ( +
+
+
+ {/* Header */} +
+
+ + {isEditMode ? 'Editar Cuenta' : 'Nueva Cuenta'} - Paso {step}/{getTotalSteps()} +
+ +
+ + {/* Progress */} +
+
+
+ + {/* Body */} +
+ + {/* Step 1: Tipo de Conta */} + {step === 1 && ( +
+
+ + ¿Qué tipo de cuenta deseas crear? +
+ +
+ {Object.entries(accountTypes).map(([key, config]) => ( +
+
selectAccountType(key)} + > +
+
+ +
+
{config.name}
+ {config.description} + + {/* Badge indicando destino */} +
+ {config.destination === 'asset' && ( + + + Se registra como Activo + + )} + {config.destination === 'liability' && ( + + + Se registra como Pasivo + + )} + {config.destination === 'account' && ( + + + Cuenta Estándar + + )} +
+
+
+
+ ))} +
+
+ )} + + {/* Step 2: Dados Básicos */} + {step === 2 && ( +
+
+ + Información Básica +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ {['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#6B7280'].map(color => ( +
setFormData(prev => ({ ...prev, color }))} + /> + ))} +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ )} + + {/* Step 3: Valores */} + {step === 3 && ( +
+
+ + Valores y fecha +
+
+
+ +
+ {formData.currency} + +
+ Valor de compra/inversión inicial +
+
+ +
+ {formData.currency} + +
+ Valor estimado actual +
+
+ + +
+ + {/* Ganancia/Pérdida */} + {formData.acquisition_value && formData.current_value && ( +
+ +
= parseFloat(formData.acquisition_value) ? 'bg-success bg-opacity-10' : 'bg-danger bg-opacity-10'}`}> + = parseFloat(formData.acquisition_value) ? 'text-success' : 'text-danger'}> + = parseFloat(formData.acquisition_value) ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}> + {isMobile ? '' : formData.currency + ' '}{(parseFloat(formData.current_value) - parseFloat(formData.acquisition_value)).toLocaleString('es-ES', { minimumFractionDigits: 2 })} + + ({(((parseFloat(formData.current_value) - parseFloat(formData.acquisition_value)) / parseFloat(formData.acquisition_value)) * 100).toFixed(1)}%) + + +
+
+ )} +
+ + {/* Campos específicos por tipo */} + {formData.asset_type === 'real_estate' && ( +
+
+ + Datos del Inmueble +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} + + {formData.asset_type === 'vehicle' && ( +
+
+ + Datos del Vehículo +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} + + {formData.asset_type === 'investment' && ( +
+
+ + Datos de la Inversión +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {formData.currency} + +
+
+
+ + +
+
+
+ )} + + {formData.asset_type === 'equipment' && ( +
+
+ + Datos del Equipamiento +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} + + {formData.asset_type === 'receivable' && ( +
+
+ + Datos del Crédito a Cobrar +
+
+
+ + +
+
+ +
+ {formData.currency} + +
+
+
+ + +
+
+
+ )} +
+ )} + + {/* Step 4: Opções Avançadas */} + {step === 4 && ( +
+
+ + Opciones adicionales +
+ + {/* Depreciação */} + {assetTypes[formData.asset_type]?.is_depreciable && ( +
+
+
+ + +
+ {formData.is_depreciable && ( +
+
+ + +
+
+ + +
+
+ +
+ {formData.currency} + +
+
+
+ )} +
+
+ )} + + {/* Seguro */} +
+
+
+ + +
+ {formData.has_insurance && ( +
+
+ + +
+
+ + +
+
+ +
+ {formData.currency} + +
+
+
+ + +
+
+ )} +
+
+ + {/* Gestión */} +
+
+
+ + Gestión Interna +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ )} + + {/* Step 5: Confirmação */} + {step === 5 && ( +
+
+ + Confirme los datos del activo +
+ +
+
+
+
+

+ Tipo:
+ {assetTypes[formData.asset_type]?.name} +

+

+ Nombre:
+ {formData.name} +

+ {formData.description && ( +

+ Descripción:
+ {formData.description} +

+ )} +
+
+

+ Valor de Adquisición:
+ + {formData.currency} {parseFloat(formData.acquisition_value || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} + +

+

+ Valor Actual:
+ + {formData.currency} {parseFloat(formData.current_value || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} + +

+

+ Fecha Adquisición:
+ {formData.acquisition_date ? new Date(formData.acquisition_date).toLocaleDateString() : '-'} +

+
+
+
+
+ + {/* Ganancia/Pérdida */} + {formData.acquisition_value && formData.current_value && ( +
= parseFloat(formData.acquisition_value) ? 'alert-success' : 'alert-danger'}`}> +
+ + = parseFloat(formData.acquisition_value) ? 'bi-arrow-up-circle' : 'bi-arrow-down-circle'} me-2`}> + {parseFloat(formData.current_value) >= parseFloat(formData.acquisition_value) ? 'Ganancia' : 'Pérdida'} + + + {formData.currency} {Math.abs(parseFloat(formData.current_value) - parseFloat(formData.acquisition_value)).toLocaleString('es-ES', { minimumFractionDigits: 2 })} + + ({(((parseFloat(formData.current_value) - parseFloat(formData.acquisition_value)) / parseFloat(formData.acquisition_value)) * 100).toFixed(1)}%) + + +
+
+ )} +
+ )} +
+ + {/* Footer */} +
+ {isMobile ? ( + // Mobile: botões empilhados + <> + {step < 5 ? ( + + ) : ( + + )} +
+ {step > 1 && ( + + )} + +
+ + ) : ( + // Desktop: botões horizontais + <> + + + {step > 1 && ( + + )} + + {step < 5 ? ( + + ) : ( + + )} + + )} +
+
+
+
+ ); +}; + +export default AssetWizard; diff --git a/frontend/src/components/BudgetWizard.jsx b/frontend/src/components/BudgetWizard.jsx new file mode 100644 index 0000000..8b76ac9 --- /dev/null +++ b/frontend/src/components/BudgetWizard.jsx @@ -0,0 +1,1312 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { budgetService, categoryService, costCenterService, reportService } from '../services/api'; +import { useToast } from './Toast'; +import { getCurrencyByCode } from '../config/currencies'; +import useFormatters from '../hooks/useFormatters'; + +/** + * BudgetWizard - Wizard avançado para criação de orçamentos + * + * Modos: + * - 'wizard': Modo wizard completo (templates, batch, copiar) + * - 'single': Modo simples para criar/editar um único orçamento (substitui modal antigo) + * + * Features: + * - Multi-step wizard com indicador de progresso + * - Templates predefinidos (básico, familiar, individual) + * - Criação em lote de múltiplos orçamentos + * - Sugestões baseadas em histórico de gastos + * - Seleção de subcategorias + * - Seleção de centro de custos + * - Período flexível (mensal, bimestral, trimestral, semestral, anual) + */ +const BudgetWizard = ({ + isOpen, + onClose, + onSuccess, + year, + month, + mode = 'wizard', // 'wizard' ou 'single' + editBudget = null, // Para edição no modo 'single' +}) => { + const { t } = useTranslation(); + const toast = useToast(); + const { currency } = useFormatters(); + + // Estados do wizard + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [categories, setCategories] = useState([]); + const [costCenters, setCostCenters] = useState([]); + const [availableCategories, setAvailableCategories] = useState([]); + const [expenseHistory, setExpenseHistory] = useState({}); + const [primaryCurrency, setPrimaryCurrency] = useState('EUR'); + + // Modo de criação (para wizard) + const [creationMode, setCreationMode] = useState(''); // 'template', 'manual', 'copy' + + // Template selecionado + const [selectedTemplate, setSelectedTemplate] = useState(''); + + // Orçamentos a criar (modo wizard) + const [budgetItems, setBudgetItems] = useState([]); + + // Dados do formulário single + const [singleForm, setSingleForm] = useState({ + category_id: '', + subcategory_id: '', + cost_center_id: '', + amount: '', + period_type: 'monthly', + is_cumulative: false, + }); + + // Opções globais (modo wizard) + const [globalOptions, setGlobalOptions] = useState({ + period_type: 'monthly', + is_cumulative: false, + }); + + // Período para copiar + const [copySource, setCopySource] = useState({ + year: year || new Date().getFullYear(), + month: (month || new Date().getMonth() + 1) - 1 || 12, + }); + + // Templates predefinidos + const budgetTemplates = { + basico: { + name: t('budgets.wizard.templates.basic.name') || 'Presupuesto Básico', + description: t('budgets.wizard.templates.basic.desc') || 'Esencial para control mensual', + icon: 'bi-wallet', + color: '#3b82f6', + categories: ['Vivienda', 'Alimentación', 'Transporte', 'Servicios'], + suggested_total: 1500, + }, + familiar: { + name: t('budgets.wizard.templates.family.name') || 'Presupuesto Familiar', + description: t('budgets.wizard.templates.family.desc') || 'Completo para familias', + icon: 'bi-house-heart', + color: '#10b981', + categories: ['Vivienda', 'Alimentación', 'Transporte', 'Servicios', 'Salud', 'Educación', 'Ocio', 'Ropa'], + suggested_total: 3000, + }, + individual: { + name: t('budgets.wizard.templates.individual.name') || 'Presupuesto Individual', + description: t('budgets.wizard.templates.individual.desc') || 'Para persona soltera', + icon: 'bi-person', + color: '#8b5cf6', + categories: ['Vivienda', 'Alimentación', 'Transporte', 'Ocio', 'Salud'], + suggested_total: 1200, + }, + completo: { + name: t('budgets.wizard.templates.complete.name') || 'Presupuesto Completo', + description: t('budgets.wizard.templates.complete.desc') || 'Todas las categorías', + icon: 'bi-clipboard-data', + color: '#f59e0b', + categories: 'all', + suggested_total: 4000, + }, + }; + + // Carregar dados ao abrir + useEffect(() => { + if (isOpen) { + loadData(); + resetWizard(); + } + }, [isOpen]); + + // Preencher form para edição + useEffect(() => { + if (mode === 'single' && editBudget) { + setSingleForm({ + category_id: editBudget.category_id || '', + subcategory_id: editBudget.subcategory_id || '', + cost_center_id: editBudget.cost_center_id || '', + amount: editBudget.amount || '', + period_type: editBudget.period_type || 'monthly', + is_cumulative: editBudget.is_cumulative || false, + }); + } + }, [mode, editBudget]); + + const loadData = async () => { + try { + setLoading(true); + const [categoriesData, costCentersData, availableData] = await Promise.all([ + categoryService.getAll(), + costCenterService.getAll(), + budgetService.getAvailableCategories({ year, month }), + ]); + + const cats = categoriesData?.data || categoriesData; + // Filtrar apenas categorias de gastos (com subcategorias aninhadas) + const expenseCategories = Array.isArray(cats) + ? cats.filter(c => c.type === 'expense' || c.type === 'both') + : []; + setCategories(expenseCategories); + + // Categorias disponíveis (não usadas ainda) + const available = Array.isArray(availableData) ? availableData : []; + setAvailableCategories(available); + + // Centros de custo + const centers = costCentersData?.data || costCentersData; + setCostCenters(Array.isArray(centers) ? centers : []); + + // Detectar moeda do usuário + const user = JSON.parse(localStorage.getItem('user') || '{}'); + setPrimaryCurrency(user.primary_currency || 'EUR'); + + // Carregar histórico de gastos (em paralelo) + loadExpenseHistory(); + } catch (error) { + console.error('Error loading data:', error); + } finally { + setLoading(false); + } + }; + + const loadExpenseHistory = async () => { + try { + const lastMonthStart = new Date(); + lastMonthStart.setMonth(lastMonthStart.getMonth() - 3); + + const data = await reportService.getByCategory({ + start_date: lastMonthStart.toISOString().split('T')[0], + end_date: new Date().toISOString().split('T')[0], + type: 'expense', + }); + + const history = {}; + if (data && Array.isArray(data)) { + data.forEach(item => { + history[item.category_id] = { + average: item.total / 3, + total: item.total, + category_name: item.category_name, + }; + }); + } + setExpenseHistory(history); + } catch (error) { + console.error('Error loading expense history:', error); + } + }; + + const resetWizard = () => { + setStep(1); + setCreationMode(''); + setSelectedTemplate(''); + setBudgetItems([]); + setGlobalOptions({ + period_type: 'monthly', + is_cumulative: false, + }); + setSingleForm({ + category_id: '', + subcategory_id: '', + cost_center_id: '', + amount: '', + period_type: 'monthly', + is_cumulative: false, + }); + }; + + // ==================== MODO SINGLE ==================== + + const handleSingleSubmit = async (e) => { + e.preventDefault(); + + if (!singleForm.category_id || !singleForm.amount) { + toast.warning(t('budgets.wizard.fillRequired') || 'Complete los campos obligatorios'); + return; + } + + setLoading(true); + try { + const data = { + ...singleForm, + year: year || new Date().getFullYear(), + month: month || new Date().getMonth() + 1, + }; + + if (editBudget) { + await budgetService.update(editBudget.id, data); + toast.success(t('budgets.wizard.updated') || 'Presupuesto actualizado'); + } else { + await budgetService.create(data); + toast.success(t('budgets.wizard.created') || 'Presupuesto creado'); + } + + onSuccess && onSuccess(); + onClose(); + } catch (error) { + console.error('Error saving budget:', error); + toast.error(error.response?.data?.message || t('common.error')); + } finally { + setLoading(false); + } + }; + + // Obter subcategorias da categoria selecionada + const getSubcategories = (categoryId) => { + if (!categoryId) return []; + const category = availableCategories.find(c => c.id == categoryId) || + categories.find(c => c.id == categoryId); + return category?.subcategories || []; + }; + + // ==================== MODO WIZARD ==================== + + const applyTemplate = (templateKey) => { + setSelectedTemplate(templateKey); + setCreationMode('template'); + const template = budgetTemplates[templateKey]; + if (!template) return; + + let selectedCategories = []; + + if (template.categories === 'all') { + selectedCategories = categories; + } else { + selectedCategories = categories.filter(cat => + template.categories.includes(cat.name) || + template.categories.some(tc => cat.name.toLowerCase().includes(tc.toLowerCase())) + ); + } + + const totalCategories = selectedCategories.length; + const baseAmount = Math.round(template.suggested_total / totalCategories); + + const weightMap = { + 'Vivienda': 2.5, + 'Alimentación': 1.5, + 'Transporte': 1.0, + 'Servicios': 0.8, + 'Salud': 0.5, + 'Educación': 0.4, + 'Ocio': 0.6, + 'Ropa': 0.4, + }; + + const items = selectedCategories.map(cat => { + const weight = weightMap[cat.name] || 1; + const suggestedAmount = Math.round(baseAmount * weight / 10) * 10; + const historyAmount = expenseHistory[cat.id]?.average || 0; + + return { + id: cat.id, + category_id: cat.id, + subcategory_id: null, + cost_center_id: null, + name: cat.name, + icon: cat.icon || 'bi-tag', + color: cat.color || '#6b7280', + subcategories: cat.subcategories || [], + amount: historyAmount > 0 ? Math.round(historyAmount) : suggestedAmount, + suggested: suggestedAmount, + history_avg: Math.round(historyAmount), + selected: true, + }; + }); + + setBudgetItems(items); + setStep(3); + }; + + const startManualMode = () => { + setCreationMode('manual'); + + const items = categories.map(cat => ({ + id: cat.id, + category_id: cat.id, + subcategory_id: null, + cost_center_id: null, + name: cat.name, + icon: cat.icon || 'bi-tag', + color: cat.color || '#6b7280', + subcategories: cat.subcategories || [], + amount: Math.round(expenseHistory[cat.id]?.average || 0), + suggested: 0, + history_avg: Math.round(expenseHistory[cat.id]?.average || 0), + selected: false, + })); + + setBudgetItems(items); + setStep(2); + }; + + const startCopyMode = async () => { + setCreationMode('copy'); + setLoading(true); + + try { + const sourceData = await budgetService.getAll({ + year: copySource.year, + month: copySource.month, + }); + + const budgets = sourceData?.data || sourceData || []; + + if (budgets.length === 0) { + toast.warning(t('budgets.wizard.noSourceBudgets') || 'No hay presupuestos en este período'); + setStep(1); + setLoading(false); + return; + } + + const items = budgets.map(b => ({ + id: b.id, + category_id: b.category_id, + subcategory_id: b.subcategory_id, + cost_center_id: b.cost_center_id, + name: b.subcategory?.name || b.category?.name || 'Presupuesto', + icon: (b.subcategory || b.category)?.icon || 'bi-tag', + color: (b.subcategory || b.category)?.color || '#6b7280', + subcategories: [], + amount: parseFloat(b.amount), + suggested: parseFloat(b.amount), + history_avg: 0, + selected: true, + })); + + setBudgetItems(items); + setStep(3); + } catch (error) { + console.error('Error loading source budgets:', error); + toast.error(t('common.error')); + } finally { + setLoading(false); + } + }; + + const toggleCategory = (categoryId) => { + setBudgetItems(prev => prev.map(item => + item.category_id === categoryId + ? { ...item, selected: !item.selected } + : item + )); + }; + + const updateItemAmount = (categoryId, amount) => { + setBudgetItems(prev => prev.map(item => + item.category_id === categoryId + ? { ...item, amount: parseFloat(amount) || 0 } + : item + )); + }; + + const updateItemSubcategory = (categoryId, subcategoryId) => { + setBudgetItems(prev => prev.map(item => + item.category_id === categoryId + ? { ...item, subcategory_id: subcategoryId || null } + : item + )); + }; + + const updateItemCostCenter = (categoryId, costCenterId) => { + setBudgetItems(prev => prev.map(item => + item.category_id === categoryId + ? { ...item, cost_center_id: costCenterId || null } + : item + )); + }; + + const selectedItems = budgetItems.filter(item => item.selected); + const totalBudget = selectedItems.reduce((sum, item) => sum + (item.amount || 0), 0); + + const handleWizardSubmit = async () => { + if (selectedItems.length === 0) { + toast.warning(t('budgets.wizard.selectCategories') || 'Seleccione al menos una categoría'); + return; + } + + setLoading(true); + let successCount = 0; + let errorCount = 0; + + try { + for (const item of selectedItems) { + try { + await budgetService.create({ + category_id: item.category_id, + subcategory_id: item.subcategory_id, + cost_center_id: item.cost_center_id, + amount: item.amount, + year: year || new Date().getFullYear(), + month: month || new Date().getMonth() + 1, + period_type: globalOptions.period_type, + is_cumulative: globalOptions.is_cumulative, + }); + successCount++; + } catch (err) { + console.error(`Error creating budget for ${item.name}:`, err); + errorCount++; + } + } + + if (successCount > 0) { + toast.success( + t('budgets.wizard.successCount', { count: successCount }) || + `${successCount} presupuesto(s) creado(s)` + ); + onSuccess && onSuccess(); + onClose(); + } + + if (errorCount > 0) { + toast.warning( + t('budgets.wizard.errorCount', { count: errorCount }) || + `${errorCount} presupuesto(s) ya existían` + ); + } + } catch (error) { + console.error('Error creating budgets:', error); + toast.error(t('common.error')); + } finally { + setLoading(false); + } + }; + + const months = [ + { value: 1, label: t('months.january') || 'Enero' }, + { value: 2, label: t('months.february') || 'Febrero' }, + { value: 3, label: t('months.march') || 'Marzo' }, + { value: 4, label: t('months.april') || 'Abril' }, + { value: 5, label: t('months.may') || 'Mayo' }, + { value: 6, label: t('months.june') || 'Junio' }, + { value: 7, label: t('months.july') || 'Julio' }, + { value: 8, label: t('months.august') || 'Agosto' }, + { value: 9, label: t('months.september') || 'Septiembre' }, + { value: 10, label: t('months.october') || 'Octubre' }, + { value: 11, label: t('months.november') || 'Noviembre' }, + { value: 12, label: t('months.december') || 'Diciembre' }, + ]; + + if (!isOpen) return null; + + // ==================== RENDER MODO SINGLE ==================== + if (mode === 'single') { + const subcategories = getSubcategories(singleForm.category_id); + const selectedCategory = availableCategories.find(c => c.id == singleForm.category_id); + + return ( +
+
+
+ {/* Header elegante como o wizard */} +
+
+
+ + {editBudget ? t('budgets.editBudget') : t('budgets.newBudget')} +
+

+ + {months.find(m => m.value === (month || new Date().getMonth() + 1))?.label} {year || new Date().getFullYear()} +

+
+ +
+ +
+
+ + {/* Category Selection - Cards bonitos */} +
+ + {editBudget ? ( +
+
+ +
+ {editBudget.category?.name} +
+ ) : ( + <> + {availableCategories.length === 0 && !loading ? ( +
+ + {t('budgets.allCategoriesUsed')} +
+ ) : ( +
+ {availableCategories.map(cat => ( +
+
setSingleForm({...singleForm, category_id: cat.id, subcategory_id: ''})} + className={`card border-0 h-100 ${singleForm.category_id == cat.id ? 'ring-2 ring-primary' : ''}`} + style={{ + background: singleForm.category_id == cat.id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a', + cursor: 'pointer', + transition: 'all 0.2s', + border: singleForm.category_id == cat.id ? '2px solid #3b82f6' : '2px solid transparent' + }} + > +
+
+ +
+ + {cat.name} + + {singleForm.category_id == cat.id && ( + + )} +
+
+
+ ))} +
+ )} + + )} +
+ + {/* Subcategory Selection - Visual melhorado */} + {singleForm.category_id && !editBudget && subcategories.length > 0 && ( +
+ +
+ {/* Opção "Toda la categoría" */} +
+
setSingleForm({...singleForm, subcategory_id: ''})} + className="p-2 rounded text-center" + style={{ + background: !singleForm.subcategory_id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a', + cursor: 'pointer', + border: !singleForm.subcategory_id ? '2px solid #3b82f6' : '2px solid transparent' + }} + > + + {t('budgets.allCategory')} +
+
+ {subcategories.map(sub => ( +
+
setSingleForm({...singleForm, subcategory_id: sub.id})} + className="p-2 rounded text-center" + style={{ + background: singleForm.subcategory_id == sub.id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a', + cursor: 'pointer', + border: singleForm.subcategory_id == sub.id ? '2px solid #3b82f6' : '2px solid transparent' + }} + > + + {sub.name} +
+
+ ))} +
+
+ )} + + {/* Cost Center Selection - Visual melhorado */} + {!editBudget && costCenters.length > 0 && ( +
+ +
+
+
setSingleForm({...singleForm, cost_center_id: ''})} + className="p-2 rounded text-center" + style={{ + background: !singleForm.cost_center_id ? 'rgba(234, 179, 8, 0.15)' : '#0f172a', + cursor: 'pointer', + border: !singleForm.cost_center_id ? '2px solid #eab308' : '2px solid transparent' + }} + > + + {t('budgets.noCostCenter') || 'Sin centro'} +
+
+ {costCenters.map(cc => ( +
+
setSingleForm({...singleForm, cost_center_id: cc.id})} + className="p-2 rounded text-center" + style={{ + background: singleForm.cost_center_id == cc.id ? 'rgba(234, 179, 8, 0.15)' : '#0f172a', + cursor: 'pointer', + border: singleForm.cost_center_id == cc.id ? '2px solid #eab308' : '2px solid transparent' + }} + > + + {cc.name} +
+
+ ))} +
+
+ )} + + {/* Amount - Card destacado */} +
+ +
+
+ + {getCurrencyByCode(primaryCurrency)?.symbol || '€'} + + setSingleForm({...singleForm, amount: e.target.value})} + placeholder="0,00" + required + /> +
+
+
+ + {/* Period Type & Cumulative - Linha compacta */} + {!editBudget && ( +
+
+ + +
+
+
+ setSingleForm({...singleForm, is_cumulative: e.target.checked})} + /> + +
+
+
+ )} + + {/* Info - Discreto */} + {!editBudget && ( +
+ + {t('budgets.autoPropagateInfo')} +
+ )} +
+ + {/* Footer elegante */} +
+ + +
+
+
+
+
+ ); + } + + // ==================== RENDER MODO WIZARD ==================== + return ( +
+
+
+ {/* Header */} +
+
+
+ + {t('budgets.wizard.title') || 'Asistente de Presupuestos'} +
+

+ {months.find(m => m.value === (month || new Date().getMonth() + 1))?.label} {year || new Date().getFullYear()} +

+
+ +
+ + {/* Progress Steps */} +
+
+ {[1, 2, 3, 4].map((s) => ( + +
= s ? 'bg-primary' : 'bg-slate-600' + }`} + style={{ width: 32, height: 32, transition: 'all 0.3s' }} + > + {step > s ? ( + + ) : ( + {s} + )} +
+ {s < 4 && ( +
s ? 'bg-primary' : 'bg-slate-600'}`} + style={{ height: 2, transition: 'all 0.3s' }} + >
+ )} +
+ ))} +
+
+ + {t('budgets.wizard.step1') || 'Modo'} + + + {t('budgets.wizard.step2') || 'Categorías'} + + + {t('budgets.wizard.step3') || 'Valores'} + + + {t('budgets.wizard.step4') || 'Confirmar'} + +
+
+ + {/* Body */} +
+ {loading ? ( +
+
+ Loading... +
+
+ ) : ( + <> + {/* Step 1: Escolher modo */} + {step === 1 && ( +
+ {/* Templates */} +
+
+ + {t('budgets.wizard.quickStart') || 'Inicio Rápido con Plantillas'} +
+
+ {Object.entries(budgetTemplates).map(([key, template]) => ( +
+
applyTemplate(key)} + className="card border-0 h-100" + style={{ + background: '#0f172a', + cursor: 'pointer', + transition: 'all 0.2s', + }} + onMouseEnter={(e) => e.currentTarget.style.transform = 'translateY(-2px)'} + onMouseLeave={(e) => e.currentTarget.style.transform = 'translateY(0)'} + > +
+
+ +
+
{template.name}
+ + {template.description} + + + ≈ {currency(template.suggested_total, primaryCurrency)} + +
+
+
+ ))} +
+
+ +
+
+
+ + {/* Manual & Copy */} +
+
e.currentTarget.style.transform = 'translateY(-2px)'} + onMouseLeave={(e) => e.currentTarget.style.transform = 'translateY(0)'} + > +
+
+
+ +
+
+
{t('budgets.wizard.manual') || 'Crear Manualmente'}
+ {t('budgets.wizard.manualDesc') || 'Elige categorías y valores'} +
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+
{t('budgets.wizard.copy') || 'Copiar de Otro Mes'}
+ {t('budgets.wizard.copyDesc') || 'Reutiliza presupuestos'} +
+
+
+ + +
+ +
+
+
+
+ )} + + {/* Step 2: Selecionar categorias (modo manual) */} + {step === 2 && ( +
+
+
+ + {t('budgets.wizard.selectCategories') || 'Selecciona las Categorías'} +
+
+ + +
+
+ +
+ {budgetItems.map(item => ( +
+
toggleCategory(item.category_id)} + className={`card border-0 h-100 ${item.selected ? 'border border-primary' : ''}`} + style={{ + background: item.selected ? 'rgba(59, 130, 246, 0.15)' : '#0f172a', + cursor: 'pointer', + transition: 'all 0.2s', + }} + > +
+
+ +
+
+ {item.name} +
+ {item.history_avg > 0 && ( + + + {currency(item.history_avg, primaryCurrency)} + + )} + {item.selected && ( + + )} +
+
+
+ ))} +
+ +
+
+ + {selectedItems.length} {t('budgets.wizard.categoriesSelected') || 'categorías seleccionadas'} + +
+ + +
+
+
+
+ )} + + {/* Step 3: Definir valores */} + {step === 3 && ( +
+
+ + {t('budgets.wizard.setAmounts') || 'Define los Valores'} +
+ +
+ + + + + + {costCenters.length > 0 && ( + + )} + + + + + + + {selectedItems.map(item => ( + + + + {costCenters.length > 0 && ( + + )} + + + + + ))} + + + + + + + + +
{t('budgets.category') || 'Categoría'}{t('budgets.subcategory') || 'Subcategoría'}{t('budgets.costCenter') || 'Centro'}{t('budgets.wizard.history') || 'Hist.'}{t('budgets.amount') || 'Valor'}
+
+ + {item.name} +
+
+ {item.subcategories && item.subcategories.length > 0 ? ( + + ) : ( + - + )} + + + + {item.history_avg > 0 ? ( + + ) : ( + - + )} + +
+ + {getCurrencyByCode(primaryCurrency)?.symbol || '€'} + + updateItemAmount(item.category_id, e.target.value)} + /> +
+
+ +
0 ? 4 : 3} className="text-white fw-bold"> + {t('budgets.total') || 'Total'} + + {currency(totalBudget, primaryCurrency)} +
+
+ +
+ + +
+
+ )} + + {/* Step 4: Confirmar */} + {step === 4 && ( +
+
+ + {t('budgets.wizard.confirm') || 'Confirma los Presupuestos'} +
+ + {/* Summary */} +
+
+
+
+

{selectedItems.length}

+ {t('budgets.wizard.budgets') || 'Presupuestos'} +
+
+

{currency(totalBudget, primaryCurrency)}

+ {t('budgets.totalBudgeted') || 'Total'} +
+
+

+ {globalOptions.period_type === 'monthly' ? '12' : + globalOptions.period_type === 'bimestral' ? '6' : + globalOptions.period_type === 'trimestral' ? '4' : + globalOptions.period_type === 'semestral' ? '2' : '1'} +

+ {t('budgets.wizard.periods') || 'Períodos'} +
+
+
+
+ + {/* Options */} +
+
+ + +
+
+
+ setGlobalOptions({ ...globalOptions, is_cumulative: e.target.checked })} + /> + +
+
+
+ + {/* List */} +
+ {selectedItems.map(item => ( +
+
+ + {item.name} + {item.subcategory_id && ( + + {item.subcategories?.find(s => s.id == item.subcategory_id)?.name} + + )} +
+ {currency(item.amount, primaryCurrency)} +
+ ))} +
+ +
+ + + {t('budgets.autoPropagateInfo') || 'Los presupuestos se propagarán automáticamente'} + +
+ +
+ + +
+
+ )} + + )} +
+ + {/* Footer */} + {step === 1 && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default BudgetWizard; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index fa9cfe3..8d57ada 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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, }); diff --git a/frontend/src/components/LiabilityWizard.jsx b/frontend/src/components/LiabilityWizard.jsx new file mode 100644 index 0000000..8b5278b --- /dev/null +++ b/frontend/src/components/LiabilityWizard.jsx @@ -0,0 +1,1338 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { liabilityAccountService } from '../services/api'; +import { useToast } from './Toast'; + +/** + * LiabilityWizard - Wizard para criação de conta passivo + * + * Suporta diferentes tipos de contrato: + * - Préstamo Personal (PRICE) + * - Financiación de Vehículo + * - Hipoteca (SAC ou PRICE) + * - Tarjeta de Crédito + * - Consorcio + * - Leasing + * - Cheque Especial + * - Préstamo con Nómina + * - Outro + */ +const LiabilityWizard = ({ isOpen, onClose, onSuccess }) => { + const { t } = useTranslation(); + const toast = useToast(); + + // Estados do wizard + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [contractTypes, setContractTypes] = useState({}); + const [amortizationSystems, setAmortizationSystems] = useState({}); + const [indexTypes, setIndexTypes] = useState({}); + const [guaranteeTypes, setGuaranteeTypes] = useState({}); + + // Form data + const [formData, setFormData] = useState({ + // Step 1 - Tipo de contrato + contract_type: '', + + // Step 2 - Dados básicos + name: '', + creditor: '', + contract_number: '', + description: '', + currency: 'EUR', + color: '#3B82F6', + + // Step 3 - Valores e taxas + principal_amount: '', + annual_interest_rate: '', + monthly_interest_rate: '', + amortization_system: 'price', + total_installments: '', + + // Step 4 - Datas + start_date: new Date().toISOString().split('T')[0], + first_due_date: '', + + // Opções extras + has_grace_period: false, + grace_period_months: 0, + include_insurance: false, + insurance_amount: 0, + include_admin_fee: false, + admin_fee_amount: 0, + + // 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 - Leasing/Veículo + asset_value: '', + asset_description: '', + residual_value: '', + + // Campos avançados - Gestão + alert_days_before: 7, + internal_responsible: '', + internal_notes: '', + document_number: '', + }); + + // Estados para seções avançadas + const [showAdvanced, setShowAdvanced] = useState(false); + + // Preview das parcelas + const [installmentPreview, setInstallmentPreview] = useState([]); + const [showPreview, setShowPreview] = useState(false); + + // Carregar tipos de contrato ao abrir + useEffect(() => { + if (isOpen) { + loadContractTypes(); + resetForm(); + } + }, [isOpen]); + + // Atualizar sistema de amortização quando tipo de contrato muda + useEffect(() => { + if (formData.contract_type && contractTypes[formData.contract_type]) { + const defaultAmort = contractTypes[formData.contract_type].default_amortization; + setFormData(prev => ({ ...prev, amortization_system: defaultAmort || 'price' })); + } + }, [formData.contract_type, contractTypes]); + + // Calcular taxa mensal quando taxa anual muda + useEffect(() => { + if (formData.annual_interest_rate && !formData.monthly_interest_rate) { + const monthly = parseFloat(formData.annual_interest_rate) / 12; + setFormData(prev => ({ ...prev, monthly_interest_rate: monthly.toFixed(4) })); + } + }, [formData.annual_interest_rate]); + + const loadContractTypes = async () => { + try { + const response = await liabilityAccountService.getContractTypes(); + if (response.success) { + setContractTypes(response.data); + setAmortizationSystems(response.amortization_systems); + if (response.index_types) setIndexTypes(response.index_types); + if (response.guarantee_types) setGuaranteeTypes(response.guarantee_types); + } + } catch (error) { + console.error('Error loading contract types:', error); + } + }; + + const resetForm = () => { + setStep(1); + setFormData({ + contract_type: '', + name: '', + creditor: '', + contract_number: '', + description: '', + currency: 'EUR', + color: '#3B82F6', + principal_amount: '', + annual_interest_rate: '', + monthly_interest_rate: '', + amortization_system: 'price', + total_installments: '', + start_date: new Date().toISOString().split('T')[0], + first_due_date: '', + has_grace_period: false, + grace_period_months: 0, + include_insurance: false, + insurance_amount: 0, + include_admin_fee: false, + admin_fee_amount: 0, + }); + setInstallmentPreview([]); + setShowPreview(false); + }; + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value + })); + }; + + const handleContractTypeSelect = (type) => { + setFormData(prev => ({ ...prev, contract_type: type })); + setStep(2); + }; + + const validateStep = () => { + switch (step) { + case 1: + return !!formData.contract_type; + case 2: + return formData.name.trim() !== ''; + case 3: + return formData.principal_amount > 0 && formData.total_installments > 0; + case 4: + return !!formData.first_due_date; + default: + return true; + } + }; + + const nextStep = () => { + if (!validateStep()) { + toast.error(t('validation.required')); + return; + } + setStep(prev => Math.min(prev + 1, 5)); + }; + + const prevStep = () => { + setStep(prev => Math.max(prev - 1, 1)); + }; + + // Gerar preview das parcelas + const generatePreview = () => { + const principal = parseFloat(formData.principal_amount) || 0; + const monthlyRate = (parseFloat(formData.monthly_interest_rate) || 0) / 100; + const totalInstallments = parseInt(formData.total_installments) || 12; + const amortSystem = formData.amortization_system; + const hasGrace = formData.has_grace_period; + const gracePeriod = parseInt(formData.grace_period_months) || 0; + const insuranceAmt = parseFloat(formData.insurance_amount) || 0; + const adminFeeAmt = parseFloat(formData.admin_fee_amount) || 0; + + const firstDueDate = new Date(formData.first_due_date); + let remainingPrincipal = principal; + const preview = []; + + // Calcular parcela fixa (PRICE) + let fixedInstallment = 0; + if (amortSystem === 'price' && monthlyRate > 0) { + fixedInstallment = principal * (monthlyRate * Math.pow(1 + monthlyRate, totalInstallments)) / + (Math.pow(1 + monthlyRate, totalInstallments) - 1); + } else if (amortSystem === 'price') { + fixedInstallment = principal / totalInstallments; + } + + // Amortização fixa (SAC) + const fixedAmortization = principal / totalInstallments; + + for (let i = 1; i <= totalInstallments; i++) { + const dueDate = new Date(firstDueDate); + dueDate.setMonth(dueDate.getMonth() + (i - 1)); + + let installmentAmount, interestAmount, principalAmount; + + // Carência + if (hasGrace && i <= gracePeriod) { + interestAmount = remainingPrincipal * monthlyRate; + principalAmount = 0; + installmentAmount = interestAmount + insuranceAmt + adminFeeAmt; + } else { + if (amortSystem === 'price') { + interestAmount = remainingPrincipal * monthlyRate; + principalAmount = fixedInstallment - interestAmount; + installmentAmount = fixedInstallment + insuranceAmt + adminFeeAmt; + } else if (amortSystem === 'sac') { + principalAmount = fixedAmortization; + interestAmount = remainingPrincipal * monthlyRate; + installmentAmount = principalAmount + interestAmount + insuranceAmt + adminFeeAmt; + } else { + // Americano + interestAmount = remainingPrincipal * monthlyRate; + principalAmount = (i === totalInstallments) ? remainingPrincipal : 0; + installmentAmount = interestAmount + principalAmount + insuranceAmt + adminFeeAmt; + } + + remainingPrincipal -= principalAmount; + } + + preview.push({ + installment_number: i, + due_date: dueDate.toISOString().split('T')[0], + installment_amount: installmentAmount.toFixed(2), + interest_amount: interestAmount.toFixed(2), + principal_amount: Math.max(0, principalAmount).toFixed(2), + fee_amount: (insuranceAmt + adminFeeAmt).toFixed(2), + remaining_balance: Math.max(0, remainingPrincipal).toFixed(2), + }); + } + + setInstallmentPreview(preview); + setShowPreview(true); + }; + + const handleSubmit = async () => { + setLoading(true); + try { + const response = await liabilityAccountService.createWithWizard(formData); + + if (response.success) { + toast.success(t('liabilities.createSuccess')); + onSuccess && onSuccess(response.data); + onClose(); + } + } catch (error) { + toast.error(error.response?.data?.message || t('liabilities.createError')); + } finally { + setLoading(false); + } + }; + + if (!isOpen) return null; + + // Icons para tipos de contrato + const getContractIcon = (type) => { + const icons = { + personal_loan: ( + + + + ), + vehicle_financing: ( + + + + ), + mortgage: ( + + + + ), + credit_card: ( + + + + ), + consortium: ( + + + + ), + leasing: ( + + + + ), + overdraft: ( + + + + ), + payroll_loan: ( + + + + ), + other: ( + + + + ), + }; + return icons[type] || icons.other; + }; + + return ( +
+
+
+ {/* Header */} +
+
+ + {t('liabilities.createWizard') || 'Crear Cuenta Pasivo'} +
+ +
+ + {/* Progress Steps */} +
+
+ {[1, 2, 3, 4, 5].map((s) => ( +
+
+ {s < step ? : s} +
+ {s < 5 && ( +
+ )} +
+ ))} +
+
+ Tipo + Datos + Valores + Fechas + Confirmar +
+
+ + {/* Body */} +
+ {/* Step 1: Tipo de Contrato */} + {step === 1 && ( +
+
+ + ¿Qué tipo de pasivo desea registrar? +
+
+ {Object.entries(contractTypes).map(([key, value]) => ( +
+
handleContractTypeSelect(key)} + > +
+
+ {getContractIcon(key)} +
+
+ {value.name} +
+ {value.description} +
+
+
+ ))} +
+
+ )} + + {/* Step 2: Dados Básicos */} + {step === 2 && ( +
+
+ + Información básica del contrato +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} + + {/* Step 3: Valores e Taxas */} + {step === 3 && ( +
+
+ + Valores y condiciones del préstamo +
+
+
+ +
+ {formData.currency} + +
+ Valor total financiado +
+
+ + + Cantidad total de cuotas +
+
+ +
+ + % a.a. +
+
+
+ +
+ + % a.m. +
+ Se calcula automáticamente si informa la tasa anual +
+
+ + + + {amortizationSystems[formData.amortization_system]?.description} + +
+ + {/* Carência */} +
+
+
+
+ + +
+ {formData.has_grace_period && ( +
+ + +
+ )} +
+
+
+ + {/* Seguro e Taxas */} +
+
+
+
+ + +
+ {formData.include_insurance && ( +
+ +
+ {formData.currency} + +
+
+ )} +
+
+
+ +
+
+
+
+ + +
+ {formData.include_admin_fee && ( +
+ +
+ {formData.currency} + +
+
+ )} +
+
+
+
+ + {/* Seção Avançada - Acordeão */} +
+
+

+ +

+
+
+ + {/* Indexadores */} + {Object.keys(indexTypes).length > 0 && ( +
+
+ + Indexador de Tasa +
+
+
+ + + {formData.index_type && indexTypes[formData.index_type] && ( + + {indexTypes[formData.index_type].description} + + )} +
+
+ + + Adicional al indexador +
+
+ + + Costo Efectivo Total +
+
+
+ )} + + {/* Garantias */} + {Object.keys(guaranteeTypes).length > 0 && ( +
+
+ + Garantías +
+
+
+ + +
+
+ +
+ {formData.currency} + +
+
+
+ + +
+
+ + +
+
+
+ )} + + {/* Penalidades */} +
+
+ + Penalidades por Atraso +
+
+
+ + + % sobre el valor vencido +
+
+ + + % por día de atraso +
+
+ + + Antes de aplicar multa +
+
+
+ + {/* Campos específicos para Leasing/Veículo */} + {['leasing', 'vehicle'].includes(formData.contract_type) && ( +
+
+ + Datos del Bien +
+
+
+ +
+ {formData.currency} + +
+
+
+ +
+ {formData.currency} + +
+
+
+ + +
+
+
+ )} + + {/* Gestión Interna */} +
+
+ + Gestión Interna +
+
+
+ + + Del vencimiento +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+ )} + + {/* Step 4: Datas */} + {step === 4 && ( +
+
+ + Fechas del contrato +
+
+
+ + + Fecha en que se firmó el contrato +
+
+ + + Fecha de vencimiento de la primera cuota +
+
+ + {/* Preview Button */} + {formData.principal_amount && formData.total_installments && formData.first_due_date && ( +
+ +
+ )} + + {/* Preview Table */} + {showPreview && installmentPreview.length > 0 && ( +
+
+ + Vista previa de cuotas (primeras 6 y últimas 2) +
+
+ + + + + + + + + + + + + {installmentPreview.slice(0, 6).map((inst) => ( + + + + + + + + + ))} + {installmentPreview.length > 8 && ( + + + + )} + {installmentPreview.slice(-2).map((inst) => ( + + + + + + + + + ))} + + + + + + + + + + +
VencimientoCuotaInterésCapitalSaldo
{inst.installment_number}{new Date(inst.due_date).toLocaleDateString()}{formData.currency} {inst.installment_amount}{formData.currency} {inst.interest_amount}{formData.currency} {inst.principal_amount}{formData.currency} {inst.remaining_balance}
+ ... {installmentPreview.length - 8} cuotas más ... +
{inst.installment_number}{new Date(inst.due_date).toLocaleDateString()}{formData.currency} {inst.installment_amount}{formData.currency} {inst.interest_amount}{formData.currency} {inst.principal_amount}{formData.currency} {inst.remaining_balance}
TOTAL + {formData.currency} {installmentPreview.reduce((s, i) => s + parseFloat(i.installment_amount), 0).toFixed(2)} + + {formData.currency} {installmentPreview.reduce((s, i) => s + parseFloat(i.interest_amount), 0).toFixed(2)} + + {formData.currency} {installmentPreview.reduce((s, i) => s + parseFloat(i.principal_amount), 0).toFixed(2)} +
+
+
+ )} +
+ )} + + {/* Step 5: Confirmação */} + {step === 5 && ( +
+
+ + Confirme los datos del contrato +
+ +
+
+
+
+

+ Tipo:
+ {contractTypes[formData.contract_type]?.name} +

+

+ Nombre:
+ {formData.name} +

+

+ Acreedor:
+ {formData.creditor || '-'} +

+

+ Contrato:
+ {formData.contract_number || '-'} +

+
+
+

+ Valor Principal:
+ + {formData.currency} {parseFloat(formData.principal_amount || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} + +

+

+ Tasa Anual:
+ {formData.annual_interest_rate || 0}% a.a. +

+

+ Sistema:
+ {amortizationSystems[formData.amortization_system]?.name} +

+

+ Cuotas:
+ {formData.total_installments} cuotas +

+
+
+
+
+
+

+ Primer Vencimiento:
+ {formData.first_due_date ? new Date(formData.first_due_date).toLocaleDateString() : '-'} +

+
+
+ {formData.has_grace_period && ( +

+ Carencia:
+ {formData.grace_period_months} meses +

+ )} +
+
+
+
+ + {/* Sumário estimado */} + {installmentPreview.length > 0 && ( +
+
+ + Resumen Estimado +
+
+
+ Total a Pagar + + {formData.currency} {installmentPreview.reduce((s, i) => s + parseFloat(i.installment_amount), 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} + +
+
+ Total Intereses + + {formData.currency} {installmentPreview.reduce((s, i) => s + parseFloat(i.interest_amount), 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} + +
+
+ Cuota Media + + {formData.currency} {(installmentPreview.reduce((s, i) => s + parseFloat(i.installment_amount), 0) / installmentPreview.length).toLocaleString('es-ES', { minimumFractionDigits: 2 })} + +
+
+
+ )} + + {/* Opciones avanzadas en resumen (si hay alguna) */} + {(formData.index_type || formData.guarantee_type || formData.late_fee_percent || formData.internal_responsible) && ( +
+
+
+ + Opciones Avanzadas +
+
+ {formData.index_type && ( +
+

+ Indexador: {indexTypes[formData.index_type]?.name} + {formData.index_spread && ` + ${formData.index_spread}%`} +

+
+ )} + {formData.guarantee_type && ( +
+

+ Garantía: {guaranteeTypes[formData.guarantee_type]?.name} + {formData.guarantee_value && ` (${formData.currency} ${parseFloat(formData.guarantee_value).toLocaleString('es-ES')})`} +

+
+ )} + {formData.late_fee_percent && ( +
+

+ Multa: {formData.late_fee_percent}% + {formData.daily_penalty_percent && ` + ${formData.daily_penalty_percent}%/día`} +

+
+ )} + {formData.internal_responsible && ( +
+

+ Responsable: {formData.internal_responsible} +

+
+ )} +
+
+
+ )} +
+ )} +
+ + {/* Footer */} +
+ + + {step > 1 && ( + + )} + + {step < 5 ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +export default LiabilityWizard; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7ec0f47..89dc044 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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" }, diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index aad0982..cb8c3a9 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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.", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index f6c4f71..3d67f87 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -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" }, diff --git a/frontend/src/index.css b/frontend/src/index.css index a88c1ab..69304f9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/pages/Accounts.jsx b/frontend/src/pages/Accounts.jsx index 9ae8c7f..29a6b36 100644 --- a/frontend/src/pages/Accounts.jsx +++ b/frontend/src/pages/Accounts.jsx @@ -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 = () => { )} {activeTab === 'accounts' && ( - )} {activeTab === 'liabilities' && ( - )} + {activeTab === 'assets' && ( + + )}
- {/* Tabs */} -