v1.57.0: Redesign category modals + i18n updates + demo transactions fix

- Redesigned category create/edit modal with elegant wizard-style UI
- Redesigned batch categorization modal with visual cards and better preview
- Added missing i18n translations (common.continue, creating, remove)
- Added budgets.general and wizard translations for ES, PT-BR, EN
- Fixed 3 demo user transactions that were missing categories
This commit is contained in:
marcoitaloesp-ai 2025-12-18 19:06:07 +00:00 committed by GitHub
parent 6292b62315
commit 9c9d6443e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 9435 additions and 623 deletions

View File

@ -58,6 +58,30 @@ Os scripts de deploy:
- Novos comandos artisan - Novos comandos artisan
- Mudança na estrutura do projeto - Mudança na estrutura do projeto
## 🚫 Regras de UI/UX
**NUNCA use alert(), confirm() ou prompt() do navegador.**
Sempre usar componentes modais ou toast:
- Para erros: `toast.error('mensagem')`
- Para sucesso: `toast.success('mensagem')`
- Para confirmação: Usar `<ConfirmModal />` component
- Para formulários: Criar modal customizado
```jsx
// ❌ PROIBIDO
alert('Erro!');
confirm('Tem certeza?');
// ✅ CORRETO
import { useToast } from '../components/Toast';
import { ConfirmModal } from '../components/Modal';
const toast = useToast();
toast.error('Erro!');
toast.success('Sucesso!');
```
## Estrutura do Servidor ## Estrutura do Servidor
``` ```

View File

@ -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/). 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 ### Added
- 🏠 **Landing Page Pública WebMoney** - Nova página inicial institucional - 🧙 **Novo Wizard de Contas Unificado** - Wizard inteligente para criar diferentes tipos de contas com roteamento automático:
- **Navbar** com links para seções e botões login/registro - **Cuenta Corriente** → Criada como conta normal (Account)
- **Hero Section** com animação de preview do dashboard - **Cuenta de Ahorro** → Automaticamente criada como **Activo** (poupança é um investimento)
- **Features Section** com 6 recursos principais: - **Tarjeta de Crédito** → Automaticamente criada como **Pasivo** (dívida de cartão)
- Múltiplas Contas, Categorias Inteligentes, Importação Bancária - **Efectivo** → Criada como conta normal de caixa (Account)
- 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
- 📝 **Registro com Seleção de Plano** ### Features do AccountWizard
- Cards de planos no formulário de registro - Interface de 4 etapas com feedback visual claro
- Suporte a parâmetro `?plan=slug` na URL - Etapa 1: Seleção visual do tipo de conta com badges indicando destino (Activo/Pasivo/Cuenta)
- Redirecionamento para PayPal após registro (planos pagos) - Etapa 2: Informações básicas (nome, moeda, cor)
- Texto dinâmico do botão baseado no plano selecionado - 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 ### Changed
- 🔄 **Rota inicial alterada** - "/" agora mostra Landing Page em vez de redirecionar para login - 🎨 **UI do Wizard Melhorada**:
- 🌍 **Traduções** adicionadas para Landing em EN, ES e PT-BR - Nova seção "Opciones Avanzadas" em acordeão colapsável
- Namespace `landing.*` com todas as seções - 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 ### Technical Details
- Novo componente: `frontend/src/pages/Landing.jsx` - **Backend**:
- Novo CSS: `frontend/src/pages/Landing.css` - `LiabilityTemplateService.php` - Gera template Excel com PhpSpreadsheet
- Atualizado: `frontend/src/App.jsx` (rota "/" e import Register) - `LiabilityAccount::CONTRACT_TYPES` - Constantes com metadata de cada tipo
- Atualizado: `frontend/src/pages/Register.jsx` (seleção de plano) - `LiabilityAccount::AMORTIZATION_SYSTEMS` - Sistemas de amortização suportados
- Arquivos de tradução atualizados: `en.json`, `es.json`, `pt-BR.json` - Migration: `add_contract_type_to_liability_accounts_table`
- PayPal configurado no servidor (sandbox mode) - 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
```
--- ---

View File

@ -1 +1 @@
1.54.0 1.57.0

BIN
Wanna.xlsx Normal file

Binary file not shown.

View File

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Models\LiabilityAccount;
use App\Models\LiabilityInstallment;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class GenerateDemoInstallments extends Command
{
protected $signature = 'demo:generate-installments';
protected $description = 'Gerar parcelas de exemplo para passivos DEMO que não têm parcelas';
public function handle(): int
{
$this->info('Verificando passivos sem parcelas...');
// Buscar passivos que têm total_installments > 0 mas não têm parcelas
$accounts = LiabilityAccount::withCount('installments')
->having('installments_count', '=', 0)
->where('total_installments', '>', 0)
->get();
if ($accounts->isEmpty()) {
$this->info('Todos os passivos já têm parcelas geradas.');
return Command::SUCCESS;
}
$this->info("Encontrados {$accounts->count()} passivos sem parcelas.");
DB::beginTransaction();
try {
foreach ($accounts as $account) {
$this->generateInstallments($account);
}
DB::commit();
$this->info('✓ Parcelas geradas com sucesso!');
return Command::SUCCESS;
} catch (\Exception $e) {
DB::rollBack();
$this->error('Erro: ' . $e->getMessage());
return Command::FAILURE;
}
}
private function generateInstallments(LiabilityAccount $account): void
{
$this->info("Gerando parcelas para: {$account->name}");
$totalInstallments = $account->total_installments;
$paidInstallments = $account->paid_installments ?? 0;
$principal = $account->principal_amount;
$annualRate = $account->annual_interest_rate ?? 0;
$monthlyRate = $annualRate / 12 / 100;
$startDate = $account->first_due_date ?? $account->start_date ?? now();
if (is_string($startDate)) {
$startDate = Carbon::parse($startDate);
}
// Calcular parcela mensal (sistema PRICE)
if ($monthlyRate > 0) {
$pmt = $principal * ($monthlyRate * pow(1 + $monthlyRate, $totalInstallments)) /
(pow(1 + $monthlyRate, $totalInstallments) - 1);
} else {
$pmt = $principal / $totalInstallments;
}
$balance = $principal;
$installments = [];
for ($i = 1; $i <= $totalInstallments; $i++) {
$dueDate = $startDate->copy()->addMonths($i - 1);
// Calcular juros e capital
$interestAmount = $balance * $monthlyRate;
$principalPaid = $pmt - $interestAmount;
// Última parcela ajusta para zerar o saldo
if ($i === $totalInstallments) {
$principalPaid = $balance;
$pmt = $principalPaid + $interestAmount;
}
$balance -= $principalPaid;
// Determinar status
$isPaid = $i <= $paidInstallments;
$status = $isPaid ? 'paid' : ($dueDate->isPast() ? 'overdue' : 'pending');
$installments[] = [
'liability_account_id' => $account->id,
'installment_number' => $i,
'due_date' => $dueDate->format('Y-m-d'),
'installment_amount' => round($pmt, 2),
'principal_amount' => round($principalPaid, 2),
'interest_amount' => round($interestAmount, 2),
'fee_amount' => 0,
'paid_amount' => $isPaid ? round($pmt, 2) : 0,
'paid_date' => $isPaid ? $dueDate->format('Y-m-d') : null,
'status' => $status,
'created_at' => now(),
'updated_at' => now(),
];
}
// Inserir em lotes
foreach (array_chunk($installments, 50) as $chunk) {
LiabilityInstallment::insert($chunk);
}
$this->info("{$totalInstallments} parcelas criadas");
// Recalcular totais
$account->recalculateTotals();
}
}

View File

@ -0,0 +1,661 @@
<?php
namespace App\Console\Commands;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PopulateDemoData extends Command
{
protected $signature = 'demo:populate {--fresh : Limpar dados existentes antes de popular}';
protected $description = 'Popular dados de demonstração (contas, categorias, transações) para o usuário DEMO';
private User $user;
private array $accounts = [];
private array $categories = [];
private array $subcategories = [];
public function handle(): int
{
$this->user = User::where('email', 'demo@webmoney.com')->first();
if (!$this->user) {
$this->error('Usuário demo@webmoney.com não encontrado!');
return Command::FAILURE;
}
$this->info("Populando dados para usuário DEMO (ID: {$this->user->id})...");
if ($this->option('fresh')) {
$this->clearExistingData();
}
DB::beginTransaction();
try {
$this->createAccounts();
$this->createCategories();
$this->createTransactions();
// Recalcular saldos das contas
$this->recalculateBalances();
DB::commit();
$this->newLine();
$this->info('✓ Dados DEMO populados com sucesso!');
$this->showSummary();
return Command::SUCCESS;
} catch (\Exception $e) {
DB::rollBack();
$this->error('Erro: ' . $e->getMessage());
$this->error($e->getTraceAsString());
return Command::FAILURE;
}
}
private function clearExistingData(): void
{
$this->info('Limpando dados existentes...');
Transaction::where('user_id', $this->user->id)->delete();
// Subcategorias são Category com parent_id
Category::where('user_id', $this->user->id)->whereNotNull('parent_id')->delete();
Category::where('user_id', $this->user->id)->whereNull('parent_id')->delete();
Account::where('user_id', $this->user->id)->delete();
}
private function createAccounts(): void
{
$this->info('Criando contas...');
$accountsData = [
[
'name' => 'Cuenta Corriente Principal',
'type' => 'checking',
'bank_name' => 'Santander',
'account_number' => 'ES12 3456 7890 1234',
'initial_balance' => 5000.00,
'currency' => 'EUR',
'color' => '#3B82F6',
'icon' => 'bi-bank',
'is_active' => true,
'include_in_total' => true,
],
[
'name' => 'Cuenta de Ahorro',
'type' => 'savings',
'bank_name' => 'BBVA',
'account_number' => 'ES98 7654 3210 9876',
'initial_balance' => 15000.00,
'currency' => 'EUR',
'color' => '#10B981',
'icon' => 'bi-piggy-bank',
'is_active' => true,
'include_in_total' => true,
],
[
'name' => 'Efectivo',
'type' => 'cash',
'bank_name' => null,
'account_number' => null,
'initial_balance' => 500.00,
'currency' => 'EUR',
'color' => '#F59E0B',
'icon' => 'bi-cash-stack',
'is_active' => true,
'include_in_total' => true,
],
];
foreach ($accountsData as $data) {
$account = Account::create([
'user_id' => $this->user->id,
'name' => $data['name'],
'type' => $data['type'],
'bank_name' => $data['bank_name'],
'account_number' => $data['account_number'],
'initial_balance' => $data['initial_balance'],
'current_balance' => $data['initial_balance'],
'currency' => $data['currency'],
'color' => $data['color'],
'icon' => $data['icon'],
'is_active' => $data['is_active'],
'include_in_total' => $data['include_in_total'],
]);
$this->accounts[$data['type']] = $account;
$this->info("{$data['name']}");
}
}
private function createCategories(): void
{
$this->info('Criando categorias e subcategorias...');
$categoriesData = [
// DESPESAS (expense)
[
'name' => 'Vivienda',
'type' => 'debit',
'icon' => 'bi-house',
'color' => '#EF4444',
'subcategories' => ['Alquiler', 'Hipoteca', 'Comunidad', 'Seguro Hogar', 'Reparaciones', 'Muebles'],
],
[
'name' => 'Alimentación',
'type' => 'debit',
'icon' => 'bi-cart',
'color' => '#F97316',
'subcategories' => ['Supermercado', 'Restaurantes', 'Cafeterías', 'Delivery', 'Panadería'],
],
[
'name' => 'Transporte',
'type' => 'debit',
'icon' => 'bi-car-front',
'color' => '#8B5CF6',
'subcategories' => ['Combustible', 'Transporte Público', 'Taxi/Uber', 'Mantenimiento Coche', 'Parking', 'Seguro Coche'],
],
[
'name' => 'Servicios',
'type' => 'debit',
'icon' => 'bi-lightning',
'color' => '#EC4899',
'subcategories' => ['Electricidad', 'Gas', 'Agua', 'Internet', 'Teléfono', 'Streaming'],
],
[
'name' => 'Salud',
'type' => 'debit',
'icon' => 'bi-heart-pulse',
'color' => '#14B8A6',
'subcategories' => ['Médico', 'Farmacia', 'Dentista', 'Óptica', 'Gimnasio', 'Seguro Médico'],
],
[
'name' => 'Ocio',
'type' => 'debit',
'icon' => 'bi-controller',
'color' => '#6366F1',
'subcategories' => ['Cine', 'Conciertos', 'Viajes', 'Hobbies', 'Libros', 'Videojuegos'],
],
[
'name' => 'Ropa',
'type' => 'debit',
'icon' => 'bi-bag',
'color' => '#A855F7',
'subcategories' => ['Ropa', 'Calzado', 'Accesorios', 'Ropa Deportiva'],
],
[
'name' => 'Educación',
'type' => 'debit',
'icon' => 'bi-book',
'color' => '#0EA5E9',
'subcategories' => ['Cursos', 'Material Escolar', 'Idiomas', 'Certificaciones'],
],
[
'name' => 'Mascotas',
'type' => 'debit',
'icon' => 'bi-piggy-bank',
'color' => '#84CC16',
'subcategories' => ['Comida Mascota', 'Veterinario', 'Accesorios Mascota'],
],
[
'name' => 'Otros Gastos',
'type' => 'debit',
'icon' => 'bi-three-dots',
'color' => '#64748B',
'subcategories' => ['Regalos', 'Donaciones', 'Imprevistos', 'Varios'],
],
// INGRESOS (income)
[
'name' => 'Salario',
'type' => 'credit',
'icon' => 'bi-briefcase',
'color' => '#22C55E',
'subcategories' => ['Nómina', 'Horas Extra', 'Bonus', 'Comisiones'],
],
[
'name' => 'Inversiones',
'type' => 'credit',
'icon' => 'bi-graph-up-arrow',
'color' => '#10B981',
'subcategories' => ['Dividendos', 'Intereses', 'Plusvalías', 'Alquileres'],
],
[
'name' => 'Freelance',
'type' => 'credit',
'icon' => 'bi-laptop',
'color' => '#06B6D4',
'subcategories' => ['Proyectos', 'Consultoría', 'Clases Particulares'],
],
[
'name' => 'Otros Ingresos',
'type' => 'credit',
'icon' => 'bi-plus-circle',
'color' => '#84CC16',
'subcategories' => ['Reembolsos', 'Ventas', 'Premios', 'Herencias'],
],
];
foreach ($categoriesData as $catData) {
$category = Category::create([
'user_id' => $this->user->id,
'name' => $catData['name'],
'type' => $catData['type'],
'icon' => $catData['icon'],
'color' => $catData['color'],
'is_active' => true,
'parent_id' => null,
]);
$this->categories[$catData['name']] = $category;
// Subcategorias são Category com parent_id
foreach ($catData['subcategories'] as $subName) {
$sub = Category::create([
'user_id' => $this->user->id,
'parent_id' => $category->id,
'name' => $subName,
'type' => $catData['type'],
'icon' => $catData['icon'],
'color' => $catData['color'],
'is_active' => true,
]);
$this->subcategories[$subName] = $sub;
}
$this->info("{$catData['name']} ({$catData['type']}) - " . count($catData['subcategories']) . " subcategorias");
}
}
private function createTransactions(): void
{
$this->info('Criando transações de 2025-2026...');
$checkingAccount = $this->accounts['checking'];
$savingsAccount = $this->accounts['savings'];
$cashAccount = $this->accounts['cash'];
$transactionCount = 0;
$today = Carbon::today();
// Helper para determinar status baseado na data
$getStatus = function(Carbon $date) use ($today) {
return $date->isAfter($today) ? 'pending' : 'effective';
};
// Gerar transações de Janeiro 2025 a Março 2026
for ($i = 1; $i <= 15; $i++) { // 12 meses de 2025 + 3 de 2026
$year = $i <= 12 ? 2025 : 2026;
$month = $i <= 12 ? $i : $i - 12;
$daysInMonth = Carbon::create($year, $month)->daysInMonth;
// RECEITAS FIXAS (mensais)
// Salário - dia 28 ou último dia útil
$salaryDay = min(28, $daysInMonth);
$salaryDate = Carbon::create($year, $month, $salaryDay);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Salario',
'subcategory' => 'Nómina',
'type' => 'credit',
'amount' => 3200.00,
'date' => $salaryDate,
'status' => $getStatus($salaryDate),
'description' => 'Salario mensual',
]);
$transactionCount++;
// DESPESAS FIXAS (mensais)
// Aluguel - dia 1
$rentDate = Carbon::create($year, $month, 1);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Vivienda',
'subcategory' => 'Alquiler',
'type' => 'debit',
'amount' => 850.00,
'date' => $rentDate,
'status' => $getStatus($rentDate),
'description' => 'Alquiler apartamento',
]);
$transactionCount++;
// Serviços - vários dias do mês
$services = [
['subcategory' => 'Electricidad', 'amount' => rand(45, 85), 'day' => 5],
['subcategory' => 'Gas', 'amount' => rand(25, 55), 'day' => 8],
['subcategory' => 'Agua', 'amount' => rand(20, 35), 'day' => 10],
['subcategory' => 'Internet', 'amount' => 49.99, 'day' => 15],
['subcategory' => 'Teléfono', 'amount' => 25.00, 'day' => 15],
['subcategory' => 'Streaming', 'amount' => 17.99, 'day' => 20],
];
foreach ($services as $service) {
if ($service['day'] <= $daysInMonth) {
$serviceDate = Carbon::create($year, $month, $service['day']);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Servicios',
'subcategory' => $service['subcategory'],
'type' => 'debit',
'amount' => $service['amount'],
'date' => $serviceDate,
'status' => $getStatus($serviceDate),
'description' => $service['subcategory'],
]);
$transactionCount++;
}
}
// Supermercado - várias vezes por mês
$supermarketDays = [3, 10, 17, 24];
foreach ($supermarketDays as $day) {
if ($day <= $daysInMonth) {
$marketDate = Carbon::create($year, $month, $day);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Alimentación',
'subcategory' => 'Supermercado',
'type' => 'debit',
'amount' => rand(60, 120),
'date' => $marketDate,
'status' => $getStatus($marketDate),
'description' => 'Compra supermercado',
]);
$transactionCount++;
}
}
// Transporte - combustível e outros
$fuelDate = Carbon::create($year, $month, rand(1, min(15, $daysInMonth)));
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Transporte',
'subcategory' => 'Combustible',
'type' => 'debit',
'amount' => rand(50, 80),
'date' => $fuelDate,
'status' => $getStatus($fuelDate),
'description' => 'Gasolina',
]);
$transactionCount++;
// Restaurantes - algumas vezes por mês
$restaurantCount = rand(2, 4);
for ($j = 0; $j < $restaurantCount; $j++) {
$restaurantDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$this->createTransaction([
'account_id' => $cashAccount->id,
'category' => 'Alimentación',
'subcategory' => 'Restaurantes',
'type' => 'debit',
'amount' => rand(25, 60),
'date' => $restaurantDate,
'status' => $getStatus($restaurantDate),
'description' => 'Cena/Almuerzo fuera',
]);
$transactionCount++;
}
// Café - várias vezes
$coffeeCount = rand(5, 10);
for ($j = 0; $j < $coffeeCount; $j++) {
$coffeeDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$this->createTransaction([
'account_id' => $cashAccount->id,
'category' => 'Alimentación',
'subcategory' => 'Cafeterías',
'type' => 'debit',
'amount' => rand(3, 8),
'date' => $coffeeDate,
'status' => $getStatus($coffeeDate),
'description' => 'Café',
]);
$transactionCount++;
}
// Retirada de cajero para efectivo (do banco para cash)
$atmDate = Carbon::create($year, $month, rand(1, min(5, $daysInMonth)));
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Otros Gastos',
'subcategory' => 'Varios',
'type' => 'debit',
'amount' => 200.00,
'date' => $atmDate,
'status' => $getStatus($atmDate),
'description' => 'Retiro cajero automático',
]);
$transactionCount++;
$this->createTransaction([
'account_id' => $cashAccount->id,
'category' => 'Otros Ingresos',
'subcategory' => 'Reembolsos',
'type' => 'credit',
'amount' => 200.00,
'date' => $atmDate,
'status' => $getStatus($atmDate),
'description' => 'Retiro cajero automático',
]);
$transactionCount++;
// Saúde - eventual
if (rand(1, 3) == 1) {
$healthDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$healthSubs = ['Farmacia', 'Médico', 'Gimnasio'];
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Salud',
'subcategory' => $healthSubs[array_rand($healthSubs)],
'type' => 'debit',
'amount' => rand(15, 80),
'date' => $healthDate,
'status' => $getStatus($healthDate),
'description' => 'Gasto salud',
]);
$transactionCount++;
}
// Ginásio - mensal
$gymDate = Carbon::create($year, $month, 1);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Salud',
'subcategory' => 'Gimnasio',
'type' => 'debit',
'amount' => 35.00,
'date' => $gymDate,
'status' => $getStatus($gymDate),
'description' => 'Cuota gimnasio',
]);
$transactionCount++;
// Lazer - algumas vezes
if (rand(1, 2) == 1) {
$leisureDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$leisureSubs = ['Cine', 'Conciertos', 'Hobbies', 'Libros', 'Videojuegos'];
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Ocio',
'subcategory' => $leisureSubs[array_rand($leisureSubs)],
'type' => 'debit',
'amount' => rand(15, 60),
'date' => $leisureDate,
'status' => $getStatus($leisureDate),
'description' => 'Entretenimiento',
]);
$transactionCount++;
}
// Roupa - eventual
if (rand(1, 4) == 1) {
$clothesDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Ropa',
'subcategory' => 'Ropa',
'type' => 'debit',
'amount' => rand(30, 120),
'date' => $clothesDate,
'status' => $getStatus($clothesDate),
'description' => 'Compra ropa',
]);
$transactionCount++;
}
// Freelance - eventual (2-3 vezes por trimestre)
if ($month % 3 == 0 || rand(1, 5) == 1) {
$freelanceDate = Carbon::create($year, $month, rand(10, min(25, $daysInMonth)));
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Freelance',
'subcategory' => 'Proyectos',
'type' => 'credit',
'amount' => rand(200, 800),
'date' => $freelanceDate,
'status' => $getStatus($freelanceDate),
'description' => 'Proyecto freelance',
]);
$transactionCount++;
}
// Dividendos - trimestral
if ($month % 3 == 0) {
$dividendDate = Carbon::create($year, $month, 15);
$this->createTransaction([
'account_id' => $savingsAccount->id,
'category' => 'Inversiones',
'subcategory' => 'Dividendos',
'type' => 'credit',
'amount' => rand(50, 150),
'date' => $dividendDate,
'status' => $getStatus($dividendDate),
'description' => 'Dividendos trimestre',
]);
$transactionCount++;
}
// Juros poupança - mensal
$interestDate = Carbon::create($year, $month, $daysInMonth);
$this->createTransaction([
'account_id' => $savingsAccount->id,
'category' => 'Inversiones',
'subcategory' => 'Intereses',
'type' => 'credit',
'amount' => round(rand(15, 35) + (rand(0, 99) / 100), 2),
'date' => $interestDate,
'status' => $getStatus($interestDate),
'description' => 'Intereses cuenta ahorro',
]);
$transactionCount++;
// Transferência para poupança - mensal
if (rand(1, 2) == 1) {
$transferDate = Carbon::create($year, $month, rand(25, min(28, $daysInMonth)));
$transferAmount = rand(200, 500);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Otros Gastos',
'subcategory' => 'Varios',
'type' => 'debit',
'amount' => $transferAmount,
'date' => $transferDate,
'status' => $getStatus($transferDate),
'description' => 'Transferencia a cuenta ahorro',
]);
$transactionCount++;
$this->createTransaction([
'account_id' => $savingsAccount->id,
'category' => 'Otros Ingresos',
'subcategory' => 'Reembolsos',
'type' => 'credit',
'amount' => $transferAmount,
'date' => $transferDate,
'status' => $getStatus($transferDate),
'description' => 'Transferencia desde cuenta corriente',
]);
$transactionCount++;
}
$this->info(" ✓ Mes $month/$year procesado");
}
$this->info(" Total: $transactionCount transacciones creadas");
}
private function createTransaction(array $data): Transaction
{
$category = $this->categories[$data['category']] ?? null;
// Subcategoria é usada diretamente como category_id (pois são Categories com parent_id)
$subcategory = $this->subcategories[$data['subcategory']] ?? null;
// Se tiver subcategoria, usa ela; senão usa a categoria pai
$categoryId = $subcategory?->id ?? $category?->id;
// Status vem do data ou default 'effective'
$status = $data['status'] ?? 'effective';
// Para transações pendentes, não definir effective_date
$effectiveDate = $status === 'pending' ? null : $data['date'];
return Transaction::create([
'user_id' => $this->user->id,
'account_id' => $data['account_id'],
'category_id' => $categoryId,
'type' => $data['type'],
'amount' => $status === 'pending' ? null : $data['amount'],
'planned_amount' => $data['amount'],
'planned_date' => $data['date'],
'effective_date' => $effectiveDate,
'description' => $data['description'],
'notes' => null,
'is_recurring' => false,
'status' => $status,
]);
}
private function recalculateBalances(): void
{
$this->info('Recalculando saldos das contas...');
foreach ($this->accounts as $account) {
// Apenas transações efetivas afetam o saldo atual
$income = Transaction::where('account_id', $account->id)
->where('type', 'credit')
->where('status', 'effective')
->sum('amount');
$expense = Transaction::where('account_id', $account->id)
->where('type', 'debit')
->where('status', 'effective')
->sum('amount');
$newBalance = $account->initial_balance + $income - $expense;
$account->update(['current_balance' => $newBalance]);
$this->info("{$account->name}: €" . number_format($newBalance, 2));
}
}
private function showSummary(): void
{
$this->newLine();
$this->info('=== RESUMO ===');
$this->info('Contas: ' . count($this->accounts));
$this->info('Categorias: ' . count($this->categories));
$this->info('Subcategorias: ' . count($this->subcategories));
$this->info('Transações: ' . Transaction::where('user_id', $this->user->id)->count());
$this->newLine();
$this->info('Saldos finais:');
foreach ($this->accounts as $account) {
$account->refresh();
$this->info(" {$account->name}: €" . number_format($account->current_balance, 2));
}
}
}

View File

@ -0,0 +1,420 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AssetAccount;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class AssetAccountController extends Controller
{
/**
* Listar todos os ativos do usuário
*/
public function index(Request $request): JsonResponse
{
$query = AssetAccount::where('user_id', Auth::id());
// Filtros
if ($request->has('asset_type') && $request->asset_type) {
$query->where('asset_type', $request->asset_type);
}
if ($request->has('status') && $request->status) {
$query->where('status', $request->status);
}
if ($request->has('search') && $request->search) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('document_number', 'like', "%{$search}%");
});
}
// Ordenação
$sortBy = $request->get('sort_by', 'created_at');
$sortDir = $request->get('sort_dir', 'desc');
$query->orderBy($sortBy, $sortDir);
// Paginação ou todos
if ($request->has('per_page')) {
$assets = $query->paginate($request->per_page);
} else {
$assets = $query->get();
}
return response()->json([
'success' => true,
'data' => $assets,
]);
}
/**
* Criar novo ativo
*/
public function store(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'asset_type' => ['required', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'currency' => 'required|string|size:3',
'color' => 'nullable|string|max:7',
'acquisition_value' => 'required|numeric|min:0',
'current_value' => 'required|numeric|min:0',
'acquisition_date' => 'nullable|date',
// Campos opcionais conforme tipo
'property_type' => 'nullable|string',
'investment_type' => 'nullable|string',
'depreciation_method' => 'nullable|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
$data = $request->all();
$data['user_id'] = Auth::id();
$data['business_id'] = $request->business_id ?? Auth::user()->businesses()->first()?->id;
$asset = AssetAccount::create($data);
return response()->json([
'success' => true,
'message' => 'Activo creado con éxito',
'data' => $asset,
], 201);
}
/**
* Ver um ativo específico
*/
public function show(AssetAccount $assetAccount): JsonResponse
{
// Verificar se pertence ao usuário
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
return response()->json([
'success' => true,
'data' => $assetAccount->load('linkedLiability'),
]);
}
/**
* Atualizar ativo
*/
public function update(Request $request, AssetAccount $assetAccount): JsonResponse
{
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
$validator = Validator::make($request->all(), [
'asset_type' => ['nullable', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
'name' => 'nullable|string|max:255',
'current_value' => 'nullable|numeric|min:0',
'status' => ['nullable', Rule::in(array_keys(AssetAccount::STATUSES))],
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
$assetAccount->update($request->all());
return response()->json([
'success' => true,
'message' => 'Activo actualizado con éxito',
'data' => $assetAccount->fresh(),
]);
}
/**
* Deletar ativo
*/
public function destroy(AssetAccount $assetAccount): JsonResponse
{
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
$assetAccount->delete();
return response()->json([
'success' => true,
'message' => 'Activo eliminado con éxito',
]);
}
/**
* Retornar tipos de ativos e opções para o wizard
*/
public function assetTypes(): JsonResponse
{
return response()->json([
'success' => true,
'data' => AssetAccount::ASSET_TYPES,
'property_types' => AssetAccount::PROPERTY_TYPES,
'investment_types' => AssetAccount::INVESTMENT_TYPES,
'depreciation_methods' => AssetAccount::DEPRECIATION_METHODS,
'index_types' => AssetAccount::INDEX_TYPES,
'statuses' => AssetAccount::STATUSES,
]);
}
/**
* Criar ativo via wizard
*/
public function storeWithWizard(Request $request): JsonResponse
{
$rules = [
// Step 1 - Tipo
'asset_type' => ['required', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
// Step 2 - Dados básicos
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'currency' => 'required|string|size:3',
'color' => 'nullable|string|max:7',
// Step 3 - Valores
'acquisition_value' => 'required|numeric|min:0',
'current_value' => 'required|numeric|min:0',
'acquisition_date' => 'nullable|date',
// Depreciação
'is_depreciable' => 'nullable|boolean',
'depreciation_method' => ['nullable', Rule::in(array_keys(AssetAccount::DEPRECIATION_METHODS))],
'useful_life_years' => 'nullable|numeric|min:0.5|max:100',
'residual_value' => 'nullable|numeric|min:0',
// Imóveis
'property_type' => ['nullable', Rule::in(array_keys(AssetAccount::PROPERTY_TYPES))],
'address' => 'nullable|string|max:500',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|size:2',
'property_area_m2' => 'nullable|numeric|min:0',
'registry_number' => 'nullable|string|max:100',
// Veículos
'vehicle_brand' => 'nullable|string|max:100',
'vehicle_model' => 'nullable|string|max:100',
'vehicle_year' => 'nullable|integer|min:1900|max:2100',
'vehicle_plate' => 'nullable|string|max:20',
'vehicle_vin' => 'nullable|string|max:50',
'vehicle_mileage' => 'nullable|integer|min:0',
// Investimentos
'investment_type' => ['nullable', Rule::in(array_keys(AssetAccount::INVESTMENT_TYPES))],
'institution' => 'nullable|string|max:100',
'account_number' => 'nullable|string|max:100',
'quantity' => 'nullable|integer|min:0',
'unit_price' => 'nullable|numeric|min:0',
'ticker' => 'nullable|string|max:20',
'maturity_date' => 'nullable|date',
'interest_rate' => 'nullable|numeric|min:0|max:100',
'index_type' => ['nullable', Rule::in(array_keys(AssetAccount::INDEX_TYPES))],
// Equipamentos
'equipment_brand' => 'nullable|string|max:100',
'equipment_model' => 'nullable|string|max:100',
'serial_number' => 'nullable|string|max:100',
'warranty_expiry' => 'nullable|date',
// Recebíveis
'debtor_name' => 'nullable|string|max:200',
'debtor_document' => 'nullable|string|max:50',
'receivable_due_date' => 'nullable|date',
'receivable_amount' => 'nullable|numeric|min:0',
// Garantias
'is_collateral' => 'nullable|boolean',
'collateral_for' => 'nullable|string|max:200',
'linked_liability_id' => 'nullable|integer|exists:liability_accounts,id',
// Seguros
'has_insurance' => 'nullable|boolean',
'insurance_company' => 'nullable|string|max:100',
'insurance_policy' => 'nullable|string|max:100',
'insurance_value' => 'nullable|numeric|min:0',
'insurance_expiry' => 'nullable|date',
// Gestão
'alert_days_before' => 'nullable|integer|min:0|max:365',
'internal_responsible' => 'nullable|string|max:200',
'internal_notes' => 'nullable|string|max:2000',
'document_number' => 'nullable|string|max:100',
];
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
// Criar o ativo
$data = $validator->validated();
$data['user_id'] = Auth::id();
$data['business_id'] = $request->business_id ?? Auth::user()->businesses()->first()?->id;
$data['status'] = 'active';
$asset = AssetAccount::create($data);
return response()->json([
'success' => true,
'message' => 'Activo creado con éxito',
'data' => $asset,
], 201);
}
/**
* Resumo dos ativos do usuário
*/
public function summary(): JsonResponse
{
$userId = Auth::id();
$summary = [
'total_assets' => AssetAccount::where('user_id', $userId)->active()->count(),
'total_value' => AssetAccount::where('user_id', $userId)->active()->sum('current_value'),
'total_acquisition' => AssetAccount::where('user_id', $userId)->active()->sum('acquisition_value'),
'by_type' => [],
];
// Agrupar por tipo
$byType = AssetAccount::where('user_id', $userId)
->active()
->selectRaw('asset_type, COUNT(*) as count, SUM(current_value) as total_value')
->groupBy('asset_type')
->get();
foreach ($byType as $item) {
$summary['by_type'][$item->asset_type] = [
'name' => AssetAccount::ASSET_TYPES[$item->asset_type]['name'] ?? $item->asset_type,
'count' => $item->count,
'total_value' => $item->total_value,
];
}
// Ganho/perda total
$summary['total_gain_loss'] = $summary['total_value'] - $summary['total_acquisition'];
$summary['total_gain_loss_percent'] = $summary['total_acquisition'] > 0
? (($summary['total_value'] - $summary['total_acquisition']) / $summary['total_acquisition']) * 100
: 0;
return response()->json([
'success' => true,
'data' => $summary,
]);
}
/**
* Atualizar valor de mercado de um ativo
*/
public function updateValue(Request $request, AssetAccount $assetAccount): JsonResponse
{
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
$validator = Validator::make($request->all(), [
'current_value' => 'required|numeric|min:0',
'note' => 'nullable|string|max:500',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
$assetAccount->update([
'current_value' => $request->current_value,
]);
// Adicionar nota se fornecida
if ($request->note) {
$notes = $assetAccount->internal_notes ?? '';
$notes .= "\n[" . now()->format('d/m/Y') . "] Valor actualizado: {$request->note}";
$assetAccount->update(['internal_notes' => trim($notes)]);
}
return response()->json([
'success' => true,
'message' => 'Valor actualizado con éxito',
'data' => $assetAccount->fresh(),
]);
}
/**
* Registrar venda/baixa de ativo
*/
public function dispose(Request $request, AssetAccount $assetAccount): JsonResponse
{
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
$validator = Validator::make($request->all(), [
'disposal_date' => 'required|date',
'disposal_value' => 'required|numeric|min:0',
'disposal_reason' => 'nullable|string|max:200',
'status' => ['required', Rule::in(['sold', 'written_off', 'depreciated'])],
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
$assetAccount->update([
'status' => $request->status,
'disposal_date' => $request->disposal_date,
'disposal_value' => $request->disposal_value,
'disposal_reason' => $request->disposal_reason,
]);
return response()->json([
'success' => true,
'message' => 'Baja registrada con éxito',
'data' => $assetAccount->fresh(),
]);
}
}

View File

@ -133,7 +133,8 @@ public function login(Request $request): JsonResponse
], 403); ], 403);
} }
// Check if user has an active subscription // Check if user has an active subscription (skip for demo users)
if (!$user->is_demo) {
$hasActiveSubscription = $user->subscriptions()->active()->exists(); $hasActiveSubscription = $user->subscriptions()->active()->exists();
if (!$hasActiveSubscription) { if (!$hasActiveSubscription) {
return response()->json([ return response()->json([
@ -146,18 +147,20 @@ public function login(Request $request): JsonResponse
] ]
], 403); ], 403);
} }
}
$token = $user->createToken('auth-token')->plainTextToken; $token = $user->createToken('auth-token')->plainTextToken;
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Inicio de sesión exitoso', 'message' => $user->is_demo ? 'Bienvenido al modo demostración' : 'Inicio de sesión exitoso',
'data' => [ 'data' => [
'user' => [ 'user' => [
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'email_verified' => true, 'email_verified' => true,
'is_demo' => $user->is_demo ?? false,
], ],
'token' => $token, 'token' => $token,
] ]

View File

@ -133,7 +133,7 @@ public function summary(Request $request): JsonResponse
]; ];
$monthlyStatsByCurrency = Transaction::where('transactions.user_id', $userId) $monthlyStatsByCurrency = Transaction::where('transactions.user_id', $userId)
->where('transactions.status', 'completed') ->whereIn('transactions.status', ['completed', 'effective'])
->where('transactions.is_transfer', false) ->where('transactions.is_transfer', false)
->whereBetween('transactions.effective_date', $currentMonth) ->whereBetween('transactions.effective_date', $currentMonth)
->whereNull('transactions.deleted_at') ->whereNull('transactions.deleted_at')
@ -465,18 +465,20 @@ public function calendar(Request $request): JsonResponse
$endDate = $startDate->copy()->endOfMonth(); $endDate = $startDate->copy()->endOfMonth();
// Buscar transações do período // Buscar transações do período
// Usar planned_date para todas as transações (funciona para efetivadas e pendentes)
$transactions = Transaction::ofUser($userId) $transactions = Transaction::ofUser($userId)
->whereBetween('effective_date', [$startDate, $endDate]) ->whereBetween('planned_date', [$startDate, $endDate])
->with(['account:id,name,currency', 'category:id,name,color,icon']) ->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('effective_date') ->orderBy('planned_date')
->get() ->get()
->map(function ($t) { ->map(function ($t) {
$date = $t->effective_date ?? $t->planned_date;
return [ return [
'id' => $t->id, 'id' => $t->id,
'type' => 'transaction', 'type' => 'transaction',
'date' => $t->effective_date->format('Y-m-d'), 'date' => $date->format('Y-m-d'),
'description' => $t->description, 'description' => $t->description,
'amount' => (float) $t->amount, 'amount' => (float) ($t->amount ?? $t->planned_amount),
'transaction_type' => $t->type, 'transaction_type' => $t->type,
'status' => $t->status, 'status' => $t->status,
'is_transfer' => $t->is_transfer, 'is_transfer' => $t->is_transfer,
@ -573,20 +575,21 @@ public function calendarDay(Request $request): JsonResponse
$userId = $request->user()->id; $userId = $request->user()->id;
$date = Carbon::parse($request->get('date', now()->format('Y-m-d'))); $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) $transactions = Transaction::ofUser($userId)
->whereDate('effective_date', $date) ->whereDate('planned_date', $date)
->with(['account:id,name,currency', 'category:id,name,color,icon']) ->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('effective_date') ->orderBy('planned_date')
->orderBy('created_at') ->orderBy('created_at')
->get() ->get()
->map(function ($t) { ->map(function ($t) {
$txDate = $t->effective_date ?? $t->planned_date;
return [ return [
'id' => $t->id, 'id' => $t->id,
'type' => 'transaction', 'type' => 'transaction',
'date' => $t->effective_date->format('Y-m-d'), 'date' => $txDate->format('Y-m-d'),
'description' => $t->description, 'description' => $t->description,
'amount' => (float) $t->amount, 'amount' => (float) ($t->amount ?? $t->planned_amount),
'transaction_type' => $t->type, 'transaction_type' => $t->type,
'status' => $t->status, 'status' => $t->status,
'is_transfer' => $t->is_transfer, 'is_transfer' => $t->is_transfer,
@ -670,26 +673,28 @@ public function upcomingTransactions(Request $request): JsonResponse
$endDate = now()->addDays($days - 1)->endOfDay(); $endDate = now()->addDays($days - 1)->endOfDay();
// Buscar transações pendentes do período // Buscar transações pendentes do período
// Para pendentes: usar planned_date (effective_date é NULL)
$transactions = Transaction::ofUser($userId) $transactions = Transaction::ofUser($userId)
->whereIn('status', ['pending', 'scheduled']) ->whereIn('status', ['pending', 'scheduled'])
->whereBetween('effective_date', [$startDate, $endDate]) ->whereBetween('planned_date', [$startDate, $endDate])
->with(['account:id,name,currency', 'category:id,name,color,icon']) ->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('effective_date') ->orderBy('planned_date')
->orderBy('created_at') ->orderBy('created_at')
->get() ->get()
->map(function ($t) { ->map(function ($t) {
$date = $t->effective_date ?? $t->planned_date;
return [ return [
'id' => $t->id, 'id' => $t->id,
'type' => 'transaction', 'type' => 'transaction',
'date' => $t->effective_date->format('Y-m-d'), 'date' => $date->format('Y-m-d'),
'date_formatted' => $t->effective_date->translatedFormat('D, d M'), 'date_formatted' => $date->translatedFormat('D, d M'),
'description' => $t->description, 'description' => $t->description,
'amount' => (float) $t->amount, 'amount' => (float) ($t->amount ?? $t->planned_amount),
'currency' => $t->account->currency ?? 'EUR', 'currency' => $t->account->currency ?? 'EUR',
'transaction_type' => $t->type, 'transaction_type' => $t->type,
'status' => $t->status, 'status' => $t->status,
'is_transfer' => $t->is_transfer, '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 ? [ 'account' => $t->account ? [
'id' => $t->account->id, 'id' => $t->account->id,
'name' => $t->account->name, 'name' => $t->account->name,
@ -769,6 +774,8 @@ public function upcomingTransactions(Request $request): JsonResponse
'recurring_count' => $recurringInstances->count(), 'recurring_count' => $recurringInstances->count(),
'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'), 'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'),
'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->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([ return response()->json([
@ -891,7 +898,7 @@ public function overdueTransactions(Request $request): JsonResponse
'planned_date' => $li->due_date->format('Y-m-d'), 'planned_date' => $li->due_date->format('Y-m-d'),
'planned_date_formatted' => $li->due_date->translatedFormat('D, d M Y'), 'planned_date_formatted' => $li->due_date->translatedFormat('D, d M Y'),
'description' => $li->liabilityAccount->name . ' - Parcela ' . $li->installment_number, 'description' => $li->liabilityAccount->name . ' - Parcela ' . $li->installment_number,
'amount' => (float) $li->amount, 'amount' => (float) $li->installment_amount,
'currency' => $li->liabilityAccount->currency ?? 'EUR', 'currency' => $li->liabilityAccount->currency ?? 'EUR',
'transaction_type' => 'debit', 'transaction_type' => 'debit',
'status' => $li->status, 'status' => $li->status,

View File

@ -683,4 +683,290 @@ public function pendingReconciliation(): JsonResponse
'count' => $installments->count(), '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',
]);
}
}
} }

View File

@ -44,6 +44,9 @@ public function index(Request $request): JsonResponse
if ($request->has('start_date') && $request->has('end_date')) { if ($request->has('start_date') && $request->has('end_date')) {
$dateField = $request->get('date_field', 'planned_date'); $dateField = $request->get('date_field', 'planned_date');
$query->inPeriod($request->start_date, $request->end_date, $dateField); $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 // Busca por descrição e valores
@ -424,6 +427,9 @@ public function byWeek(Request $request): JsonResponse
// Filtro por período // Filtro por período
if ($request->has('start_date') && $request->has('end_date')) { if ($request->has('start_date') && $request->has('end_date')) {
$query->inPeriod($request->start_date, $request->end_date, $dateField); $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 // Busca por descrição e valores

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class DemoProtection
{
/**
* Rotas e métodos permitidos para usuário demo (whitelist)
* Estas rotas são operações de leitura/cálculo que não modificam dados reais
*/
protected array $allowedRoutes = [
'GET' => ['*'], // Permite todos os GETs
'POST' => [
'api/login',
'api/logout',
'api/refresh',
'api/accounts/recalculate-all', // Recálculo de saldos (não modifica dados)
'api/accounts/*/recalculate', // Recálculo de conta específica
'api/categories/match', // Matching de categorias (leitura)
'api/cost-centers/match', // Matching de centros de custo (leitura)
],
];
/**
* Handle an incoming request.
* Bloqueia operações de escrita (POST/PUT/PATCH/DELETE) para usuário demo
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
// Se não está logado ou não é demo, permite tudo
if (!$user || !$user->is_demo) {
return $next($request);
}
$method = $request->method();
$path = $request->path();
// GET sempre permitido para demo
if ($method === 'GET') {
return $next($request);
}
// Verifica se a rota está na whitelist
if (isset($this->allowedRoutes[$method])) {
foreach ($this->allowedRoutes[$method] as $allowedPath) {
if ($allowedPath === '*' || $this->pathMatches($path, $allowedPath)) {
return $next($request);
}
}
}
// Bloqueia a operação para usuário demo
return response()->json([
'success' => false,
'message' => 'Usuario DEMO: Esta acción no está permitida en modo demostración. Los datos no pueden ser modificados.',
'demo_mode' => true,
], 403);
}
/**
* Verifica se o path corresponde ao padrão
*/
protected function pathMatches(string $path, string $pattern): bool
{
if ($pattern === '*') {
return true;
}
$pattern = str_replace('*', '.*', $pattern);
return (bool) preg_match('#^' . $pattern . '$#', $path);
}
}

View File

@ -0,0 +1,351 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AssetAccount extends Model
{
use HasFactory, SoftDeletes;
/**
* Status do ativo
*/
public const STATUS_ACTIVE = 'active';
public const STATUS_SOLD = 'sold';
public const STATUS_DEPRECIATED = 'depreciated';
public const STATUS_WRITTEN_OFF = 'written_off';
public const STATUSES = [
self::STATUS_ACTIVE => 'Activo',
self::STATUS_SOLD => 'Vendido',
self::STATUS_DEPRECIATED => 'Depreciado',
self::STATUS_WRITTEN_OFF => 'Dado de baja',
];
/**
* Tipos de ativo
*/
public const ASSET_TYPE_REAL_ESTATE = 'real_estate';
public const ASSET_TYPE_VEHICLE = 'vehicle';
public const ASSET_TYPE_INVESTMENT = 'investment';
public const ASSET_TYPE_EQUIPMENT = 'equipment';
public const ASSET_TYPE_INVENTORY = 'inventory';
public const ASSET_TYPE_RECEIVABLE = 'receivable';
public const ASSET_TYPE_CASH = 'cash';
public const ASSET_TYPE_OTHER = 'other';
public const ASSET_TYPES = [
self::ASSET_TYPE_REAL_ESTATE => [
'name' => 'Inmueble',
'description' => 'Casa, apartamento, terreno, local comercial',
'icon' => 'home',
'is_depreciable' => true,
'fields' => ['property_type', 'address', 'property_area_m2', 'registry_number'],
],
self::ASSET_TYPE_VEHICLE => [
'name' => 'Vehículo',
'description' => 'Coche, moto, camión, maquinaria móvil',
'icon' => 'truck',
'is_depreciable' => true,
'fields' => ['vehicle_brand', 'vehicle_model', 'vehicle_year', 'vehicle_plate', 'vehicle_mileage'],
],
self::ASSET_TYPE_INVESTMENT => [
'name' => 'Inversión',
'description' => 'Acciones, fondos, bonos, criptomonedas',
'icon' => 'chart-bar',
'is_depreciable' => false,
'fields' => ['investment_type', 'institution', 'ticker', 'quantity', 'unit_price'],
],
self::ASSET_TYPE_EQUIPMENT => [
'name' => 'Equipamiento',
'description' => 'Maquinaria, herramientas, ordenadores',
'icon' => 'cog',
'is_depreciable' => true,
'fields' => ['equipment_brand', 'equipment_model', 'serial_number', 'warranty_expiry'],
],
self::ASSET_TYPE_INVENTORY => [
'name' => 'Inventario',
'description' => 'Stock, mercancías, materias primas',
'icon' => 'archive-box',
'is_depreciable' => false,
'fields' => ['quantity'],
],
self::ASSET_TYPE_RECEIVABLE => [
'name' => 'Cuenta por Cobrar',
'description' => 'Créditos, préstamos otorgados',
'icon' => 'banknotes',
'is_depreciable' => false,
'fields' => ['debtor_name', 'receivable_due_date', 'receivable_amount'],
],
self::ASSET_TYPE_CASH => [
'name' => 'Efectivo/Caja',
'description' => 'Dinero en efectivo, caja chica',
'icon' => 'currency-euro',
'is_depreciable' => false,
'fields' => [],
],
self::ASSET_TYPE_OTHER => [
'name' => 'Otro',
'description' => 'Otros tipos de activos',
'icon' => 'document-text',
'is_depreciable' => false,
'fields' => [],
],
];
/**
* Tipos de imóvel
*/
public const PROPERTY_TYPES = [
'house' => 'Casa',
'apartment' => 'Apartamento/Piso',
'land' => 'Terreno',
'commercial' => 'Local Comercial',
'industrial' => 'Nave Industrial',
'office' => 'Oficina',
'parking' => 'Plaza de Garaje',
'warehouse' => 'Almacén',
'rural' => 'Finca Rústica',
];
/**
* Tipos de investimento
*/
public const INVESTMENT_TYPES = [
'stocks' => ['name' => 'Acciones', 'description' => 'Participaciones en empresas cotizadas'],
'bonds' => ['name' => 'Bonos', 'description' => 'Títulos de deuda pública o privada'],
'funds' => ['name' => 'Fondos de Inversión', 'description' => 'Fondos mutuos, ETFs'],
'fixed_income' => ['name' => 'Renta Fija', 'description' => 'Depósitos, letras del tesoro'],
'crypto' => ['name' => 'Criptomonedas', 'description' => 'Bitcoin, Ethereum, etc.'],
'real_estate_fund' => ['name' => 'Fondo Inmobiliario', 'description' => 'REITs, SOCIMIs'],
'pension' => ['name' => 'Plan de Pensiones', 'description' => 'Ahorro para jubilación'],
'savings' => ['name' => 'Cuenta de Ahorro', 'description' => 'Cuentas remuneradas'],
];
/**
* Métodos de depreciação
*/
public const DEPRECIATION_METHODS = [
'linear' => ['name' => 'Lineal', 'description' => 'Depreciación constante cada año'],
'declining_balance' => ['name' => 'Saldo Decreciente', 'description' => 'Mayor depreciación al inicio'],
'units_production' => ['name' => 'Unidades Producidas', 'description' => 'Según uso/producción'],
'sum_years' => ['name' => 'Suma de Años', 'description' => 'Depreciación acelerada'],
];
/**
* Tipos de indexadores (para investimentos)
*/
public const INDEX_TYPES = [
'fixed' => ['name' => 'Tasa Fija', 'description' => 'Rentabilidad fija'],
'cdi' => ['name' => 'CDI', 'description' => '% del CDI (Brasil)'],
'selic' => ['name' => 'SELIC', 'description' => 'Tasa SELIC (Brasil)'],
'ipca' => ['name' => 'IPCA+', 'description' => 'Inflación + spread (Brasil)'],
'euribor' => ['name' => 'Euribor+', 'description' => 'Euribor + spread (UE)'],
'ibex' => ['name' => 'IBEX 35', 'description' => 'Índice bursátil español'],
'sp500' => ['name' => 'S&P 500', 'description' => 'Índice bursátil americano'],
];
protected $fillable = [
'user_id',
'business_id',
'asset_type',
'name',
'description',
'currency',
'color',
// Valores
'acquisition_value',
'current_value',
'acquisition_date',
// Depreciação
'is_depreciable',
'depreciation_method',
'useful_life_years',
'residual_value',
'accumulated_depreciation',
// Imóveis
'property_type',
'address',
'city',
'state',
'postal_code',
'country',
'property_area_m2',
'registry_number',
// Veículos
'vehicle_brand',
'vehicle_model',
'vehicle_year',
'vehicle_plate',
'vehicle_vin',
'vehicle_mileage',
// Investimentos
'investment_type',
'institution',
'account_number',
'quantity',
'unit_price',
'ticker',
'maturity_date',
'interest_rate',
'index_type',
// Equipamentos
'equipment_brand',
'equipment_model',
'serial_number',
'warranty_expiry',
// Recebíveis
'debtor_name',
'debtor_document',
'receivable_due_date',
'receivable_amount',
// Garantias
'is_collateral',
'collateral_for',
'linked_liability_id',
// Seguros
'has_insurance',
'insurance_company',
'insurance_policy',
'insurance_value',
'insurance_expiry',
// Gestão
'alert_days_before',
'internal_responsible',
'internal_notes',
'document_number',
// Status
'status',
'disposal_date',
'disposal_value',
'disposal_reason',
];
protected $casts = [
'acquisition_value' => 'decimal:2',
'current_value' => 'decimal:2',
'residual_value' => 'decimal:2',
'accumulated_depreciation' => 'decimal:2',
'property_area_m2' => 'decimal:2',
'useful_life_years' => 'decimal:2',
'unit_price' => 'decimal:6',
'insurance_value' => 'decimal:2',
'receivable_amount' => 'decimal:2',
'disposal_value' => 'decimal:2',
'interest_rate' => 'decimal:4',
'is_depreciable' => 'boolean',
'is_collateral' => 'boolean',
'has_insurance' => 'boolean',
'acquisition_date' => 'date',
'maturity_date' => 'date',
'warranty_expiry' => 'date',
'receivable_due_date' => 'date',
'insurance_expiry' => 'date',
'disposal_date' => 'date',
];
/**
* Relações
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function linkedLiability(): BelongsTo
{
return $this->belongsTo(LiabilityAccount::class, 'linked_liability_id');
}
/**
* Scopes
*/
public function scopeActive($query)
{
return $query->where('status', self::STATUS_ACTIVE);
}
public function scopeOfType($query, string $type)
{
return $query->where('asset_type', $type);
}
public function scopeDepreciable($query)
{
return $query->where('is_depreciable', true);
}
/**
* Accessors
*/
public function getAssetTypeNameAttribute(): string
{
return self::ASSET_TYPES[$this->asset_type]['name'] ?? $this->asset_type;
}
public function getStatusNameAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
public function getNetValueAttribute(): float
{
return $this->current_value - $this->accumulated_depreciation;
}
public function getGainLossAttribute(): float
{
return $this->current_value - $this->acquisition_value;
}
public function getGainLossPercentAttribute(): float
{
if ($this->acquisition_value == 0) return 0;
return (($this->current_value - $this->acquisition_value) / $this->acquisition_value) * 100;
}
/**
* Calcular depreciação anual (método linear)
*/
public function calculateAnnualDepreciation(): float
{
if (!$this->is_depreciable || !$this->useful_life_years || $this->useful_life_years == 0) {
return 0;
}
$depreciableValue = $this->acquisition_value - ($this->residual_value ?? 0);
return $depreciableValue / $this->useful_life_years;
}
/**
* Verificar se seguro está próximo do vencimento
*/
public function isInsuranceExpiringSoon(int $days = 30): bool
{
if (!$this->has_insurance || !$this->insurance_expiry) {
return false;
}
return $this->insurance_expiry->diffInDays(now()) <= $days;
}
}

View File

@ -21,16 +21,158 @@ class LiabilityAccount extends Model
public const STATUS_RENEGOTIATED = 'renegotiated'; public const STATUS_RENEGOTIATED = 'renegotiated';
public const STATUSES = [ public const STATUSES = [
self::STATUS_ACTIVE => 'Ativo', self::STATUS_ACTIVE => 'Activo',
self::STATUS_PAID_OFF => 'Quitado', self::STATUS_PAID_OFF => 'Liquidado',
self::STATUS_DEFAULTED => 'Inadimplente', self::STATUS_DEFAULTED => 'En mora',
self::STATUS_RENEGOTIATED => 'Renegociado', 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 = [ protected $fillable = [
'user_id', 'user_id',
'account_id', 'account_id',
'name', 'name',
'contract_type',
'amortization_system',
'contract_number', 'contract_number',
'creditor', 'creditor',
'description', 'description',
@ -52,11 +194,39 @@ class LiabilityAccount extends Model
'start_date', 'start_date',
'end_date', 'end_date',
'first_due_date', 'first_due_date',
'has_grace_period',
'grace_period_months',
'currency', 'currency',
'color', 'color',
'icon', 'icon',
'status', 'status',
'is_active', '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 = [ protected $casts = [
@ -76,6 +246,21 @@ class LiabilityAccount extends Model
'end_date' => 'date', 'end_date' => 'date',
'first_due_date' => 'date', 'first_due_date' => 'date',
'is_active' => 'boolean', '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']; protected $appends = ['progress_percentage', 'remaining_balance'];

View File

@ -142,7 +142,8 @@ public function scopePending($query)
public function scopeCompleted($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) public function scopeCancelled($query)

View File

@ -35,6 +35,7 @@ class User extends Authenticatable
'currency', 'currency',
'password', 'password',
'is_admin', 'is_admin',
'is_demo',
'email_verified_at', 'email_verified_at',
]; ];
@ -59,6 +60,7 @@ protected function casts(): array
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'is_admin' => 'boolean', 'is_admin' => 'boolean',
'is_demo' => 'boolean',
'accept_whatsapp' => 'boolean', 'accept_whatsapp' => 'boolean',
'accept_emails' => 'boolean', 'accept_emails' => 'boolean',
]; ];

View File

@ -0,0 +1,291 @@
<?php
namespace App\Services;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
class LiabilityTemplateService
{
/**
* Gerar template Excel para importação de passivos
*/
public function generateTemplate(): Spreadsheet
{
$spreadsheet = new Spreadsheet();
// Criar aba de Parcelas (principal)
$installmentsSheet = $spreadsheet->getActiveSheet();
$installmentsSheet->setTitle('Parcelas');
$this->createInstallmentsSheet($installmentsSheet);
// Criar aba de Instruções
$instructionsSheet = $spreadsheet->createSheet();
$instructionsSheet->setTitle('Instrucciones');
$this->createInstructionsSheet($instructionsSheet);
// Criar aba de Exemplo
$exampleSheet = $spreadsheet->createSheet();
$exampleSheet->setTitle('Ejemplo');
$this->createExampleSheet($exampleSheet);
// Voltar para primeira aba
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
/**
* Criar aba de parcelas com cabeçalho e validações
*/
private function createInstallmentsSheet($sheet): void
{
// Definir largura das colunas
$sheet->getColumnDimension('A')->setWidth(12); // Nº
$sheet->getColumnDimension('B')->setWidth(15); // Fecha
$sheet->getColumnDimension('C')->setWidth(15); // Cuota
$sheet->getColumnDimension('D')->setWidth(15); // Intereses
$sheet->getColumnDimension('E')->setWidth(15); // Capital
$sheet->getColumnDimension('F')->setWidth(15); // Tasas/Seguros
$sheet->getColumnDimension('G')->setWidth(15); // Estado
$sheet->getColumnDimension('H')->setWidth(30); // Observaciones
// Cabeçalho principal - título
$sheet->setCellValue('A1', 'PLANTILLA DE IMPORTACIÓN - CUENTA PASIVO');
$sheet->mergeCells('A1:H1');
$sheet->getStyle('A1')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '1E40AF']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(30);
// Subtítulo
$sheet->setCellValue('A2', 'Rellene las columnas con los datos de sus cuotas. Las columnas marcadas con * son obligatorias.');
$sheet->mergeCells('A2:H2');
$sheet->getStyle('A2')->applyFromArray([
'font' => ['italic' => true, 'size' => 10, 'color' => ['rgb' => '6B7280']],
]);
// Cabeçalhos das colunas
$headers = [
'A3' => 'Nº Cuota *',
'B3' => 'Fecha Venc. *',
'C3' => 'Valor Cuota *',
'D3' => 'Intereses',
'E3' => 'Capital',
'F3' => 'Tasas/Seguros',
'G3' => 'Estado',
'H3' => 'Observaciones',
];
foreach ($headers as $cell => $value) {
$sheet->setCellValue($cell, $value);
}
// Estilo do cabeçalho
$sheet->getStyle('A3:H3')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '3B82F6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
'borders' => [
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '1E40AF']],
],
]);
// Dicas sob o cabeçalho
$tips = [
'A4' => '1, 2, 3...',
'B4' => 'DD/MM/AAAA',
'C4' => '0.00',
'D4' => '0.00',
'E4' => '0.00',
'F4' => '0.00',
'G4' => 'Pendiente/Pagado',
'H4' => 'Texto libre',
];
foreach ($tips as $cell => $value) {
$sheet->setCellValue($cell, $value);
}
$sheet->getStyle('A4:H4')->applyFromArray([
'font' => ['italic' => true, 'size' => 9, 'color' => ['rgb' => '9CA3AF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => 'F3F4F6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
]);
// Área de dados (linhas 5-64 para até 60 parcelas)
for ($row = 5; $row <= 64; $row++) {
// Número da parcela
$sheet->setCellValue("A{$row}", $row - 4);
// Aplicar formato de número nas colunas de valores
$sheet->getStyle("C{$row}:F{$row}")->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1);
// Aplicar formato de data na coluna B
$sheet->getStyle("B{$row}")->getNumberFormat()
->setFormatCode('DD/MM/YYYY');
// Adicionar validação de lista para Estado
$validation = $sheet->getCell("G{$row}")->getDataValidation();
$validation->setType(DataValidation::TYPE_LIST);
$validation->setErrorStyle(DataValidation::STYLE_INFORMATION);
$validation->setAllowBlank(true);
$validation->setShowDropDown(true);
$validation->setFormula1('"Pendiente,Pagado,Vencido"');
// Bordas leves
$sheet->getStyle("A{$row}:H{$row}")->applyFromArray([
'borders' => [
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'E5E7EB']],
],
]);
// Alternar cores das linhas
if (($row - 5) % 2 == 1) {
$sheet->getStyle("A{$row}:H{$row}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => 'F9FAFB']],
]);
}
}
// Congelar painel no cabeçalho
$sheet->freezePane('A5');
}
/**
* Criar aba de instruções
*/
private function createInstructionsSheet($sheet): void
{
$sheet->getColumnDimension('A')->setWidth(80);
$instructions = [
['INSTRUCCIONES DE USO', true, '1E40AF'],
['', false, 'FFFFFF'],
['1. DATOS OBLIGATORIOS', true, '059669'],
[' • Nº Cuota: Número secuencial de la cuota (1, 2, 3...)', false, '000000'],
[' • Fecha Venc.: Fecha de vencimiento en formato DD/MM/AAAA', false, '000000'],
[' • Valor Cuota: Valor total de la cuota a pagar', false, '000000'],
['', false, 'FFFFFF'],
['2. DATOS OPCIONALES (recomendados)', true, '059669'],
[' • Intereses: Parte de la cuota correspondiente a intereses', false, '000000'],
[' • Capital: Parte de la cuota correspondiente a amortización', false, '000000'],
[' • Tasas/Seguros: Otros cargos incluidos en la cuota', false, '000000'],
[' • Estado: Pendiente, Pagado o Vencido', false, '000000'],
[' • Observaciones: Notas adicionales', false, '000000'],
['', false, 'FFFFFF'],
['3. TIPOS DE CONTRATOS SOPORTADOS', true, '059669'],
[' • Préstamo Personal (Sistema PRICE - cuota fija)', false, '000000'],
[' • Financiación de Vehículo', false, '000000'],
[' • Financiación Inmobiliaria (Sistema SAC o PRICE)', false, '000000'],
[' • Consorcio (cuotas variables)', false, '000000'],
[' • Leasing', false, '000000'],
['', false, 'FFFFFF'],
['4. SISTEMA PRICE (Cuota Fija)', true, '059669'],
[' En este sistema:', false, '000000'],
[' • La cuota es CONSTANTE todos los meses', false, '000000'],
[' • Los intereses DISMINUYEN cada mes', false, '000000'],
[' • La amortización AUMENTA cada mes', false, '000000'],
[' • Cuota = Intereses + Capital + Tasas', false, '000000'],
['', false, 'FFFFFF'],
['5. SISTEMA SAC (Amortización Constante)', true, '059669'],
[' En este sistema:', false, '000000'],
[' • La amortización es CONSTANTE todos los meses', false, '000000'],
[' • Los intereses DISMINUYEN cada mes', false, '000000'],
[' • La cuota DISMINUYE cada mes', false, '000000'],
['', false, 'FFFFFF'],
['6. CUOTA DE CARENCIA', true, '059669'],
[' Algunos contratos tienen una primera cuota de carencia:', false, '000000'],
[' • Solo se pagan intereses (sin amortización de capital)', false, '000000'],
[' • El capital amortizado en esta cuota debe ser 0', false, '000000'],
['', false, 'FFFFFF'],
['7. ESTADOS VÁLIDOS', true, '059669'],
[' • Pendiente: Cuota aún no pagada', false, '000000'],
[' • Pagado: Cuota ya abonada', false, '000000'],
[' • Vencido: Cuota no pagada y pasada la fecha de vencimiento', false, '000000'],
];
$row = 1;
foreach ($instructions as $item) {
$sheet->setCellValue("A{$row}", $item[0]);
$style = ['font' => ['color' => ['rgb' => $item[2]]]];
if ($item[1]) {
$style['font']['bold'] = true;
$style['font']['size'] = 12;
}
$sheet->getStyle("A{$row}")->applyFromArray($style);
$row++;
}
}
/**
* Criar aba de exemplo preenchida
*/
private function createExampleSheet($sheet): void
{
// Copiar estrutura da aba de parcelas
$this->createInstallmentsSheet($sheet);
// Dados de exemplo (Empréstimo PRICE típico)
$exampleData = [
[1, '05/06/2025', 20.85, 20.85, 0.00, 0.00, 'Pagado', 'Cuota de carencia (solo intereses)'],
[2, '05/07/2025', 122.00, 48.33, 73.67, 0.00, 'Pagado', ''],
[3, '05/08/2025', 122.00, 47.68, 74.32, 0.00, 'Pagado', ''],
[4, '05/09/2025', 122.00, 47.01, 74.99, 0.00, 'Pagado', ''],
[5, '05/10/2025', 122.00, 46.35, 75.65, 0.00, 'Pagado', ''],
[6, '05/11/2025', 122.00, 45.68, 76.32, 0.00, 'Pagado', ''],
[7, '05/12/2025', 122.00, 45.00, 77.00, 0.00, 'Pendiente', ''],
[8, '05/01/2026', 122.00, 44.31, 77.69, 0.00, 'Pendiente', ''],
[9, '05/02/2026', 122.00, 43.62, 78.38, 0.00, 'Pendiente', ''],
[10, '05/03/2026', 122.00, 42.93, 79.07, 0.00, 'Pendiente', ''],
];
$row = 5;
foreach ($exampleData as $data) {
$sheet->setCellValue("A{$row}", $data[0]);
$sheet->setCellValue("B{$row}", $data[1]);
$sheet->setCellValue("C{$row}", $data[2]);
$sheet->setCellValue("D{$row}", $data[3]);
$sheet->setCellValue("E{$row}", $data[4]);
$sheet->setCellValue("F{$row}", $data[5]);
$sheet->setCellValue("G{$row}", $data[6]);
$sheet->setCellValue("H{$row}", $data[7]);
$row++;
}
// Destacar que é exemplo
$sheet->setCellValue('A1', 'EJEMPLO - PRÉSTAMO PERSONAL (Sistema PRICE)');
$sheet->getStyle('A1')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '059669']],
]);
}
/**
* Salvar template em arquivo
*/
public function saveTemplate(string $path): void
{
$spreadsheet = $this->generateTemplate();
$writer = new Xlsx($spreadsheet);
$writer->save($path);
}
/**
* Obter caminho do template
*/
public static function getTemplatePath(): string
{
return storage_path('app/templates/passivo_template.xlsx');
}
}

View File

@ -20,6 +20,12 @@
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'plan.limits' => \App\Http\Middleware\CheckPlanLimits::class, 'plan.limits' => \App\Http\Middleware\CheckPlanLimits::class,
'admin.only' => \App\Http\Middleware\AdminOnly::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 { ->withExceptions(function (Exceptions $exceptions): void {

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('liability_accounts', function (Blueprint $table) {
// Adicionar tipo de contrato e sistema de amortização após 'name'
$table->string('contract_type', 50)->default('other')->after('name');
$table->string('amortization_system', 20)->default('price')->after('contract_type');
// Adicionar campos de carência após end_date
$table->boolean('has_grace_period')->default(false)->after('first_due_date');
$table->unsignedTinyInteger('grace_period_months')->default(0)->after('has_grace_period');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('liability_accounts', function (Blueprint $table) {
$table->dropColumn([
'contract_type',
'amortization_system',
'has_grace_period',
'grace_period_months',
]);
});
}
};

View File

@ -0,0 +1,124 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Adiciona campos avançados para gestão completa de passivos:
* - Indexadores (CDI, IPCA, Euribor, etc.)
* - Garantias (tipo, valor, descrição)
* - Penalidades (multa, mora)
* - Campos específicos por tipo de contrato
*/
public function up(): void
{
Schema::table('liability_accounts', function (Blueprint $table) {
// ============================================
// INDEXADORES E TAXAS
// ============================================
// Tipo de indexador (CDI, IPCA, SELIC, Euribor, Fixed, etc.)
$table->string('index_type', 30)->nullable()->after('annual_interest_rate');
// Spread sobre o indexador (ex: CDI + 2.5%)
$table->decimal('index_spread', 6, 4)->nullable()->after('index_type');
// CET - Custo Efetivo Total (%)
$table->decimal('total_effective_cost', 8, 4)->nullable()->after('index_spread');
// ============================================
// GARANTIAS
// ============================================
// Tipo de garantia (none, fiduciary_alienation, mortgage, guarantor, pledge, etc.)
$table->string('guarantee_type', 50)->nullable()->after('total_effective_cost');
// Valor da garantia
$table->decimal('guarantee_value', 15, 2)->nullable()->after('guarantee_type');
// Descrição da garantia (ex: "Imóvel Rua X, Matrícula 12345")
$table->string('guarantee_description', 500)->nullable()->after('guarantee_value');
// Nome do fiador (se aplicável)
$table->string('guarantor_name', 150)->nullable()->after('guarantee_description');
// ============================================
// PENALIDADES E MULTAS
// ============================================
// Multa por atraso (%)
$table->decimal('late_fee_percent', 5, 2)->nullable()->after('guarantor_name');
// Mora diária (%)
$table->decimal('daily_penalty_percent', 5, 4)->nullable()->after('late_fee_percent');
// Dias de carência antes de aplicar multa
$table->unsignedTinyInteger('grace_days_for_penalty')->default(0)->after('daily_penalty_percent');
// ============================================
// CAMPOS ESPECÍFICOS POR TIPO
// ============================================
// Valor do bem (para financiamentos e leasing)
$table->decimal('asset_value', 15, 2)->nullable()->after('grace_days_for_penalty');
// Descrição do bem
$table->string('asset_description', 300)->nullable()->after('asset_value');
// Valor residual (para leasing)
$table->decimal('residual_value', 15, 2)->nullable()->after('asset_description');
// Taxa de administração (% para consórcio)
$table->decimal('admin_fee_percent', 5, 2)->nullable()->after('residual_value');
// Fundo de reserva (% para consórcio)
$table->decimal('reserve_fund_percent', 5, 2)->nullable()->after('admin_fee_percent');
// ============================================
// COVENANTS E RESTRIÇÕES
// ============================================
// Covenants financeiros (JSON: [{name, condition, value}])
$table->json('covenants')->nullable()->after('reserve_fund_percent');
// ============================================
// ALERTAS E GESTÃO
// ============================================
// Dias de antecedência para alerta de vencimento
$table->unsignedTinyInteger('alert_days_before')->default(5)->after('covenants');
// Responsável interno
$table->string('internal_responsible', 150)->nullable()->after('alert_days_before');
// Notas/observações internas
$table->text('internal_notes')->nullable()->after('internal_responsible');
// ============================================
// DOCUMENTAÇÃO
// ============================================
// Número do documento/escritura
$table->string('document_number', 100)->nullable()->after('internal_notes');
// Cartório/Registro
$table->string('registry_office', 200)->nullable()->after('document_number');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('liability_accounts', function (Blueprint $table) {
$table->dropColumn([
'index_type',
'index_spread',
'total_effective_cost',
'guarantee_type',
'guarantee_value',
'guarantee_description',
'guarantor_name',
'late_fee_percent',
'daily_penalty_percent',
'grace_days_for_penalty',
'asset_value',
'asset_description',
'residual_value',
'admin_fee_percent',
'reserve_fund_percent',
'covenants',
'alert_days_before',
'internal_responsible',
'internal_notes',
'document_number',
'registry_office',
]);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_demo')->default(false)->after('is_admin');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_demo');
});
}
};

View File

@ -0,0 +1,122 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('asset_accounts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->unsignedBigInteger('business_id')->nullable(); // Sem FK por enquanto
// Tipo de ativo
$table->string('asset_type', 50); // real_estate, vehicle, investment, equipment, inventory, receivable, other
// Dados básicos
$table->string('name');
$table->text('description')->nullable();
$table->string('currency', 3)->default('EUR');
$table->string('color', 7)->default('#3B82F6');
// Valores
$table->decimal('acquisition_value', 15, 2)->default(0); // Valor de aquisição
$table->decimal('current_value', 15, 2)->default(0); // Valor atual/de mercado
$table->date('acquisition_date')->nullable();
// Depreciação (para ativos depreciáveis)
$table->boolean('is_depreciable')->default(false);
$table->string('depreciation_method', 30)->nullable(); // linear, declining_balance, units_production
$table->decimal('useful_life_years', 5, 2)->nullable();
$table->decimal('residual_value', 15, 2)->nullable();
$table->decimal('accumulated_depreciation', 15, 2)->default(0);
// Para imóveis
$table->string('property_type', 50)->nullable(); // house, apartment, land, commercial, industrial
$table->text('address')->nullable();
$table->string('city', 100)->nullable();
$table->string('state', 100)->nullable();
$table->string('postal_code', 20)->nullable();
$table->string('country', 2)->nullable();
$table->decimal('property_area_m2', 12, 2)->nullable();
$table->string('registry_number', 100)->nullable();
// Para veículos
$table->string('vehicle_brand', 100)->nullable();
$table->string('vehicle_model', 100)->nullable();
$table->year('vehicle_year')->nullable();
$table->string('vehicle_plate', 20)->nullable();
$table->string('vehicle_vin', 50)->nullable();
$table->integer('vehicle_mileage')->nullable();
// Para investimentos
$table->string('investment_type', 50)->nullable(); // stocks, bonds, funds, crypto, savings, fixed_income
$table->string('institution', 100)->nullable();
$table->string('account_number', 100)->nullable();
$table->integer('quantity')->nullable(); // Quantidade de ativos (ações, cotas)
$table->decimal('unit_price', 15, 6)->nullable();
$table->string('ticker', 20)->nullable();
$table->date('maturity_date')->nullable();
$table->decimal('interest_rate', 8, 4)->nullable();
$table->string('index_type', 30)->nullable();
// Para equipamentos/maquinário
$table->string('equipment_brand', 100)->nullable();
$table->string('equipment_model', 100)->nullable();
$table->string('serial_number', 100)->nullable();
$table->date('warranty_expiry')->nullable();
// Para recebíveis
$table->string('debtor_name', 200)->nullable();
$table->string('debtor_document', 50)->nullable();
$table->date('receivable_due_date')->nullable();
$table->decimal('receivable_amount', 15, 2)->nullable();
// Garantias (se o ativo serve como garantia)
$table->boolean('is_collateral')->default(false);
$table->string('collateral_for', 200)->nullable();
$table->foreignId('linked_liability_id')->nullable()->constrained('liability_accounts')->onDelete('set null');
// Seguros
$table->boolean('has_insurance')->default(false);
$table->string('insurance_company', 100)->nullable();
$table->string('insurance_policy', 100)->nullable();
$table->decimal('insurance_value', 15, 2)->nullable();
$table->date('insurance_expiry')->nullable();
// Gestão
$table->integer('alert_days_before')->default(30);
$table->string('internal_responsible', 200)->nullable();
$table->text('internal_notes')->nullable();
$table->string('document_number', 100)->nullable();
// Status
$table->string('status', 30)->default('active'); // active, sold, depreciated, written_off
$table->date('disposal_date')->nullable();
$table->decimal('disposal_value', 15, 2)->nullable();
$table->string('disposal_reason', 200)->nullable();
$table->timestamps();
$table->softDeletes();
// Índices
$table->index(['user_id', 'asset_type']);
$table->index(['user_id', 'status']);
$table->index(['business_id', 'asset_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('asset_accounts');
}
};

View File

@ -0,0 +1,320 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use App\Models\LiabilityAccount;
use App\Models\AssetAccount;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Carbon\Carbon;
class DemoUserSeeder extends Seeder
{
public function run(): void
{
// 1. Criar usuário DEMO
$user = User::updateOrCreate(
['email' => 'demo@webmoney.com'],
[
'name' => 'Usuario Demo',
'first_name' => 'Demo',
'last_name' => 'WebMoney',
'password' => Hash::make('DEMO'),
'is_demo' => true,
'is_admin' => false,
'email_verified_at' => now(),
'country' => 'ES',
'timezone' => 'Europe/Madrid',
'locale' => 'es_ES',
'language' => 'es',
'currency' => 'EUR',
]
);
$this->command->info("✓ Usuario demo creado: demo@webmoney.com / DEMO");
// 2. Criar passivos de exemplo
$this->createSampleLiabilities($user);
// 3. Criar ativos de exemplo
$this->createSampleAssets($user);
}
protected function createSampleLiabilities(User $user): void
{
$liabilities = [
[
'contract_type' => 'personal_loan',
'name' => 'Préstamo Personal Santander',
'creditor' => 'Banco Santander',
'contract_number' => 'PL-2024-001234',
'description' => 'Préstamo personal para reforma del hogar',
'currency' => 'EUR',
'principal_amount' => 15000.00,
'total_pending' => 12500.00,
'total_paid' => 2500.00,
'annual_interest_rate' => 7.5,
'monthly_interest_rate' => 0.625,
'amortization_system' => 'price',
'total_installments' => 48,
'paid_installments' => 12,
'start_date' => '2024-01-15',
'first_due_date' => '2024-02-15',
'index_type' => 'fixed',
'alert_days_before' => 5,
'color' => '#3B82F6',
'status' => 'active',
],
[
'contract_type' => 'vehicle',
'name' => 'Financiación Coche BMW X3',
'creditor' => 'BMW Financial Services',
'contract_number' => 'VF-2024-BMW-5678',
'description' => 'Financiación vehículo BMW X3 xDrive20d',
'currency' => 'EUR',
'principal_amount' => 45000.00,
'total_pending' => 38750.00,
'total_paid' => 6250.00,
'annual_interest_rate' => 5.9,
'amortization_system' => 'price',
'total_installments' => 60,
'paid_installments' => 10,
'start_date' => '2024-03-01',
'first_due_date' => '2024-04-01',
'guarantee_type' => 'fiduciary_alienation',
'guarantee_value' => 45000.00,
'index_type' => 'fixed',
'color' => '#10B981',
'status' => 'active',
],
[
'contract_type' => 'mortgage',
'name' => 'Hipoteca Vivienda Principal',
'creditor' => 'CaixaBank',
'contract_number' => 'HIP-2020-987654',
'description' => 'Hipoteca vivienda familiar - Madrid Centro',
'currency' => 'EUR',
'principal_amount' => 250000.00,
'total_pending' => 215000.00,
'total_paid' => 35000.00,
'annual_interest_rate' => 2.5,
'amortization_system' => 'sac',
'total_installments' => 300,
'paid_installments' => 48,
'start_date' => '2020-06-15',
'first_due_date' => '2020-07-15',
'guarantee_type' => 'mortgage_guarantee',
'guarantee_value' => 350000.00,
'index_type' => 'euribor',
'index_spread' => 0.80,
'color' => '#F59E0B',
'status' => 'active',
],
[
'contract_type' => 'credit_card',
'name' => 'Tarjeta Visa Oro BBVA',
'creditor' => 'BBVA',
'contract_number' => 'CC-VISA-4242',
'currency' => 'EUR',
'principal_amount' => 5000.00,
'total_pending' => 3250.00,
'total_paid' => 1750.00,
'annual_interest_rate' => 18.99,
'amortization_system' => 'revolving',
'total_installments' => 0,
'paid_installments' => 0,
'start_date' => '2023-01-01',
'first_due_date' => '2023-02-01',
'color' => '#EF4444',
'status' => 'active',
],
[
'contract_type' => 'leasing',
'name' => 'Leasing Maquinaria Industrial',
'creditor' => 'Banco Sabadell Leasing',
'contract_number' => 'LEA-2024-MAQ-456',
'currency' => 'EUR',
'principal_amount' => 120000.00,
'total_pending' => 105000.00,
'total_paid' => 15000.00,
'annual_interest_rate' => 4.5,
'amortization_system' => 'price',
'total_installments' => 48,
'paid_installments' => 6,
'start_date' => '2024-05-01',
'first_due_date' => '2024-06-01',
'color' => '#06B6D4',
'status' => 'active',
],
];
foreach ($liabilities as $data) {
LiabilityAccount::updateOrCreate(
['user_id' => $user->id, 'contract_number' => $data['contract_number']],
array_merge($data, ['user_id' => $user->id])
);
}
$this->command->info("" . count($liabilities) . " pasivos de ejemplo creados");
}
protected function createSampleAssets(User $user): void
{
$assets = [
[
'asset_type' => 'real_estate',
'name' => 'Vivienda Principal Madrid',
'description' => 'Piso de 120m² en el centro de Madrid',
'currency' => 'EUR',
'acquisition_value' => 350000.00,
'current_value' => 420000.00,
'acquisition_date' => '2020-06-15',
'property_type' => 'apartment',
'address' => 'Calle Mayor 45, 3º A',
'city' => 'Madrid',
'postal_code' => '28013',
'country' => 'ES',
'property_area_m2' => 120.00,
'color' => '#3B82F6',
],
[
'asset_type' => 'real_estate',
'name' => 'Local Comercial Barcelona',
'description' => 'Local comercial en zona turística',
'currency' => 'EUR',
'acquisition_value' => 180000.00,
'current_value' => 210000.00,
'acquisition_date' => '2019-03-20',
'property_type' => 'commercial',
'city' => 'Barcelona',
'property_area_m2' => 85.00,
'color' => '#10B981',
],
[
'asset_type' => 'vehicle',
'name' => 'BMW X3 xDrive20d',
'description' => 'SUV premium para uso personal',
'currency' => 'EUR',
'acquisition_value' => 55000.00,
'current_value' => 42000.00,
'acquisition_date' => '2022-09-01',
'vehicle_brand' => 'BMW',
'vehicle_model' => 'X3 xDrive20d',
'vehicle_year' => 2022,
'vehicle_plate' => '1234 ABC',
'vehicle_mileage' => 45000,
'color' => '#F59E0B',
],
[
'asset_type' => 'investment',
'name' => 'Cartera Acciones IBEX',
'description' => 'Cartera diversificada de acciones españolas',
'currency' => 'EUR',
'acquisition_value' => 25000.00,
'current_value' => 28500.00,
'acquisition_date' => '2023-01-15',
'investment_type' => 'stocks',
'institution' => 'Interactive Brokers',
'ticker' => 'IBEX35',
'color' => '#8B5CF6',
],
[
'asset_type' => 'investment',
'name' => 'Fondo Indexado S&P 500',
'description' => 'ETF que replica el índice S&P 500',
'currency' => 'USD',
'acquisition_value' => 15000.00,
'current_value' => 18200.00,
'acquisition_date' => '2022-06-01',
'investment_type' => 'etf',
'institution' => 'Vanguard',
'ticker' => 'VOO',
'color' => '#06B6D4',
],
[
'asset_type' => 'investment',
'name' => 'Depósito a Plazo Fijo',
'currency' => 'EUR',
'acquisition_value' => 50000.00,
'current_value' => 51500.00,
'acquisition_date' => '2024-01-15',
'investment_type' => 'fixed_deposit',
'institution' => 'Openbank',
'interest_rate' => 3.00,
'maturity_date' => '2025-01-15',
'color' => '#14B8A6',
],
[
'asset_type' => 'investment',
'name' => 'Bitcoin (BTC)',
'currency' => 'EUR',
'acquisition_value' => 10000.00,
'current_value' => 15800.00,
'acquisition_date' => '2023-08-01',
'investment_type' => 'crypto',
'institution' => 'Binance',
'ticker' => 'BTC',
'color' => '#F97316',
],
[
'asset_type' => 'equipment',
'name' => 'MacBook Pro 16"',
'description' => 'Ordenador portátil profesional',
'currency' => 'EUR',
'acquisition_value' => 3500.00,
'current_value' => 2800.00,
'acquisition_date' => '2023-03-15',
'equipment_brand' => 'Apple',
'equipment_model' => 'MacBook Pro 16 M2 Pro',
'serial_number' => 'C02XL234H1',
'warranty_expiry' => '2026-03-15',
'color' => '#64748B',
],
[
'asset_type' => 'receivable',
'name' => 'Factura Cliente ABC Corp',
'description' => 'Factura pendiente de cobro',
'currency' => 'EUR',
'acquisition_value' => 12500.00,
'current_value' => 12500.00,
'acquisition_date' => '2024-11-15',
'debtor_name' => 'ABC Corporation S.L.',
'receivable_due_date' => '2025-01-15',
'receivable_amount' => 12500.00,
'color' => '#84CC16',
],
[
'asset_type' => 'cash',
'name' => 'Reserva de Emergencia',
'description' => 'Fondo de emergencia en cuenta de ahorro',
'currency' => 'EUR',
'acquisition_value' => 20000.00,
'current_value' => 20500.00,
'acquisition_date' => '2023-01-01',
'institution' => 'ING Direct',
'interest_rate' => 2.50,
'color' => '#22C55E',
],
[
'asset_type' => 'other',
'name' => 'Colección de Arte',
'description' => 'Obras de arte y antigüedades',
'currency' => 'EUR',
'acquisition_value' => 35000.00,
'current_value' => 42000.00,
'acquisition_date' => '2021-01-01',
'color' => '#A855F7',
],
];
foreach ($assets as $data) {
AssetAccount::updateOrCreate(
['user_id' => $user->id, 'name' => $data['name']],
array_merge($data, ['user_id' => $user->id, 'status' => 'active'])
);
}
$this->command->info("" . count($assets) . " activos de ejemplo creados");
}
}

View File

@ -8,6 +8,7 @@
use App\Http\Controllers\Api\CostCenterController; use App\Http\Controllers\Api\CostCenterController;
use App\Http\Controllers\Api\CategoryController; use App\Http\Controllers\Api\CategoryController;
use App\Http\Controllers\Api\LiabilityAccountController; use App\Http\Controllers\Api\LiabilityAccountController;
use App\Http\Controllers\Api\AssetAccountController;
use App\Http\Controllers\Api\TransactionController; use App\Http\Controllers\Api\TransactionController;
use App\Http\Controllers\Api\ImportController; use App\Http\Controllers\Api\ImportController;
use App\Http\Controllers\Api\TransferDetectionController; use App\Http\Controllers\Api\TransferDetectionController;
@ -119,7 +120,10 @@
// ============================================ // ============================================
// Rotas específicas ANTES do apiResource (para evitar conflito com {id}) // Rotas específicas ANTES do apiResource (para evitar conflito com {id})
Route::get('liability-accounts/pending-reconciliation', [LiabilityAccountController::class, 'pendingReconciliation']); 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/import', [LiabilityAccountController::class, 'import']);
Route::post('liability-accounts/wizard', [LiabilityAccountController::class, 'storeWithWizard']);
Route::get('liability-summary', [LiabilityAccountController::class, 'summary']); Route::get('liability-summary', [LiabilityAccountController::class, 'summary']);
// Resource principal // Resource principal
@ -134,6 +138,21 @@
Route::post('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'reconcile']); Route::post('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'reconcile']);
Route::delete('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'unreconcile']); 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 // Transações (Transactions) - Com limite de plano
// ============================================ // ============================================

View File

@ -0,0 +1,832 @@
import React, { useState, useEffect } from 'react';
import { accountService, liabilityAccountService, assetAccountService } from '../services/api';
import { useToast } from './Toast';
const AccountWizard = ({ isOpen, onClose, onSuccess, account = null }) => {
const toast = useToast();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const isEditMode = !!account;
// Tipo de destino: 'account', 'asset' ou 'liability'
const [destinationType, setDestinationType] = useState('account');
// Form data unificado
const [formData, setFormData] = useState({
// Tipo de conta (etapa 1)
account_type: '', // checking, savings, credit_card, cash
// Dados básicos (etapa 2)
name: '',
description: '',
currency: 'EUR',
color: '#3B82F6',
icon: 'bi-bank',
// Dados financeiros (etapa 3)
initial_balance: '',
credit_limit: '', // Para cartão de crédito
// Dados bancários (etapa 4) - opcional
bank_name: '',
account_number: '',
// Configurações
is_active: true,
include_in_total: true,
// Para poupança (ativo)
interest_rate: '',
// Para cartão de crédito (passivo)
closing_day: '',
due_day: '',
annual_interest_rate: '',
});
// Definição dos tipos de conta
const accountTypes = {
checking: {
name: 'Cuenta Corriente',
description: 'Cuenta bancaria para operaciones diarias',
icon: 'bi-bank',
color: '#3B82F6',
destination: 'account',
},
savings: {
name: 'Cuenta de Ahorro',
description: 'Dinero guardado que genera intereses',
icon: 'bi-piggy-bank',
color: '#10B981',
destination: 'asset', // Poupança vira ativo
},
credit_card: {
name: 'Tarjeta de Crédito',
description: 'Línea de crédito rotativo',
icon: 'bi-credit-card',
color: '#EF4444',
destination: 'liability', // Cartão vira passivo
},
cash: {
name: 'Efectivo',
description: 'Dinero en mano o caja chica',
icon: 'bi-cash-stack',
color: '#F59E0B',
destination: 'account',
},
};
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
if (isOpen) {
if (account) {
loadAccountData(account);
} else {
resetForm();
}
}
}, [isOpen, account]);
// Atualizar destino quando tipo muda
useEffect(() => {
if (formData.account_type && accountTypes[formData.account_type]) {
const typeConfig = accountTypes[formData.account_type];
setDestinationType(typeConfig.destination);
// Atualizar ícone e cor padrão
if (!isEditMode) {
setFormData(prev => ({
...prev,
icon: typeConfig.icon,
color: typeConfig.color,
}));
}
}
}, [formData.account_type]);
const loadAccountData = (accountData) => {
// Determinar o tipo baseado nos dados
let accountType = accountData.type || accountData.account_type || 'checking';
// Se for um ativo de investimento/poupança
if (accountData.asset_type === 'cash' || accountData.investment_type === 'savings') {
accountType = 'savings';
}
// Se for um passivo de cartão
if (accountData.contract_type === 'credit_card') {
accountType = 'credit_card';
}
setStep(2);
setFormData({
account_type: accountType,
name: accountData.name || '',
description: accountData.description || '',
currency: accountData.currency || 'EUR',
color: accountData.color || '#3B82F6',
icon: accountData.icon || 'bi-bank',
initial_balance: accountData.initial_balance || accountData.current_balance || accountData.current_value || '',
credit_limit: accountData.credit_limit || accountData.principal_amount || '',
bank_name: accountData.bank_name || accountData.creditor || '',
account_number: accountData.account_number || accountData.contract_number || '',
is_active: accountData.is_active !== false,
include_in_total: accountData.include_in_total !== false,
interest_rate: accountData.interest_rate || '',
closing_day: accountData.closing_day || '',
due_day: accountData.due_day || '',
annual_interest_rate: accountData.annual_interest_rate || '',
});
};
const resetForm = () => {
setStep(1);
setDestinationType('account');
setFormData({
account_type: '',
name: '',
description: '',
currency: 'EUR',
color: '#3B82F6',
icon: 'bi-bank',
initial_balance: '',
credit_limit: '',
bank_name: '',
account_number: '',
is_active: true,
include_in_total: true,
interest_rate: '',
closing_day: '',
due_day: '',
annual_interest_rate: '',
});
};
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const selectAccountType = (type) => {
setFormData(prev => ({ ...prev, account_type: type }));
setStep(2);
};
const validateStep = () => {
switch (step) {
case 1:
return !!formData.account_type;
case 2:
return !!formData.name?.trim();
case 3:
if (destinationType === 'liability') {
return formData.credit_limit > 0;
}
return true; // Saldo inicial pode ser 0
case 4:
return true; // Dados bancários são opcionais
default:
return true;
}
};
const nextStep = () => {
if (validateStep()) {
setStep(prev => Math.min(prev + 1, 4));
}
};
const prevStep = () => {
setStep(prev => Math.max(prev - 1, 1));
};
const handleSubmit = async () => {
setLoading(true);
try {
let response;
if (destinationType === 'asset') {
// Criar como Ativo (Poupança)
const assetData = {
asset_type: 'cash', // Tipo cash para poupança
investment_type: 'savings',
name: formData.name,
description: formData.description,
currency: formData.currency,
color: formData.color,
acquisition_value: parseFloat(formData.initial_balance) || 0,
current_value: parseFloat(formData.initial_balance) || 0,
acquisition_date: new Date().toISOString().split('T')[0],
institution: formData.bank_name,
account_number: formData.account_number,
interest_rate: parseFloat(formData.interest_rate) || 0,
};
if (isEditMode && account) {
response = await assetAccountService.update(account.id, assetData);
} else {
response = await assetAccountService.createWithWizard(assetData);
}
} else if (destinationType === 'liability') {
// Criar como Passivo (Cartão de Crédito)
const liabilityData = {
contract_type: 'credit_card',
name: formData.name,
description: formData.description,
currency: formData.currency,
color: formData.color,
icon: formData.icon,
creditor: formData.bank_name,
contract_number: formData.account_number,
principal_amount: parseFloat(formData.credit_limit) || 0,
total_pending: parseFloat(formData.initial_balance) || 0,
annual_interest_rate: parseFloat(formData.annual_interest_rate) || 0,
amortization_system: 'revolving',
start_date: new Date().toISOString().split('T')[0],
closing_day: parseInt(formData.closing_day) || null,
due_day: parseInt(formData.due_day) || null,
};
if (isEditMode && account) {
response = await liabilityAccountService.update(account.id, liabilityData);
} else {
response = await liabilityAccountService.storeWithWizard(liabilityData);
}
} else {
// Criar como Conta Normal
const accountData = {
type: formData.account_type,
name: formData.name,
description: formData.description,
currency: formData.currency,
color: formData.color,
icon: formData.icon,
bank_name: formData.bank_name,
account_number: formData.account_number,
initial_balance: parseFloat(formData.initial_balance) || 0,
credit_limit: formData.account_type === 'credit_card' ? parseFloat(formData.credit_limit) || null : null,
is_active: formData.is_active,
include_in_total: formData.include_in_total,
};
if (isEditMode && account) {
response = await accountService.update(account.id, accountData);
} else {
response = await accountService.create(accountData);
}
}
if (response.success) {
onSuccess?.(response.data, destinationType);
onClose();
}
} catch (error) {
console.error('Error saving account:', error);
toast.error(error.response?.data?.message || 'Error al guardar la cuenta');
} finally {
setLoading(false);
}
};
const getTotalSteps = () => {
return 4;
};
if (!isOpen) return null;
return (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className={`modal-dialog ${isMobile ? 'modal-fullscreen' : 'modal-lg'} modal-dialog-centered modal-dialog-scrollable`}>
<div className="modal-content" style={{ background: isMobile ? '#0f172a' : '#1e293b', border: '1px solid #334155' }}>
{/* Header */}
<div className={`modal-header border-0 ${isMobile ? 'py-2' : ''}`} style={{ backgroundColor: '#334155' }}>
<h5 className={`modal-title text-white ${isMobile ? 'fs-6' : ''}`}>
<i className={`bi ${isEditMode ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
{isEditMode ? 'Editar Cuenta' : 'Nueva Cuenta'} - Paso {step}/{getTotalSteps()}
</h5>
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
</div>
{/* Progress */}
<div className="progress" style={{ height: '4px', borderRadius: 0 }}>
<div
className="progress-bar bg-primary"
style={{ width: `${(step / getTotalSteps()) * 100}%` }}
></div>
</div>
{/* Body */}
<div className={`modal-body ${isMobile ? 'p-3' : 'p-4'}`} style={{ color: '#fff' }}>
{/* Step 1: Tipo de Conta */}
{step === 1 && (
<div>
<h5 className="mb-4">
<i className="bi bi-wallet2 me-2"></i>
¿Qué tipo de cuenta deseas crear?
</h5>
<div className="row g-3">
{Object.entries(accountTypes).map(([key, config]) => (
<div key={key} className="col-md-6">
<div
className={`card h-100 cursor-pointer ${formData.account_type === key ? 'border-primary' : 'border-secondary'}`}
style={{
backgroundColor: formData.account_type === key ? config.color + '20' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onClick={() => selectAccountType(key)}
>
<div className="card-body text-center p-4">
<div
className="rounded-circle d-inline-flex align-items-center justify-content-center mb-3"
style={{
width: '60px',
height: '60px',
backgroundColor: config.color + '30',
}}
>
<i className={`bi ${config.icon} fs-3`} style={{ color: config.color }}></i>
</div>
<h6 className="text-white mb-2">{config.name}</h6>
<small className="text-slate-400">{config.description}</small>
{/* Badge indicando destino */}
<div className="mt-3">
{config.destination === 'asset' && (
<span className="badge bg-success">
<i className="bi bi-graph-up me-1"></i>
Se registra como Activo
</span>
)}
{config.destination === 'liability' && (
<span className="badge bg-danger">
<i className="bi bi-graph-down me-1"></i>
Se registra como Pasivo
</span>
)}
{config.destination === 'account' && (
<span className="badge bg-primary">
<i className="bi bi-wallet2 me-1"></i>
Cuenta Estándar
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Step 2: Dados Básicos */}
{step === 2 && (
<div>
<h5 className="mb-4">
<i className={`bi ${accountTypes[formData.account_type]?.icon || 'bi-info-circle'} me-2`}></i>
Información Básica
</h5>
<div className="row g-3">
<div className="col-12">
<label className="form-label">
Nombre de la Cuenta <span className="text-danger">*</span>
</label>
<input
type="text"
name="name"
className="form-control bg-dark text-white border-secondary"
value={formData.name}
onChange={handleChange}
placeholder={`Ej: ${accountTypes[formData.account_type]?.name || 'Mi cuenta'}`}
autoFocus
/>
</div>
<div className="col-md-6">
<label className="form-label">Moneda</label>
<select
name="currency"
className="form-select bg-dark text-white border-secondary"
value={formData.currency}
onChange={handleChange}
>
<option value="EUR">EUR - Euro</option>
<option value="USD">USD - Dólar</option>
<option value="BRL">BRL - Real</option>
<option value="GBP">GBP - Libra</option>
</select>
</div>
<div className="col-md-6">
<label className="form-label">Color</label>
<div className="d-flex gap-2 flex-wrap">
{['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#6B7280'].map(color => (
<div
key={color}
className={`rounded-circle ${formData.color === color ? 'ring ring-white' : ''}`}
style={{
width: '32px',
height: '32px',
backgroundColor: color,
cursor: 'pointer',
border: formData.color === color ? '3px solid white' : '2px solid transparent',
}}
onClick={() => setFormData(prev => ({ ...prev, color }))}
/>
))}
</div>
</div>
<div className="col-12">
<label className="form-label">Descripción (opcional)</label>
<textarea
name="description"
className="form-control bg-dark text-white border-secondary"
value={formData.description}
onChange={handleChange}
rows={2}
placeholder="Descripción o notas adicionales..."
/>
</div>
</div>
</div>
)}
{/* Step 3: Dados Financeiros */}
{step === 3 && (
<div>
<h5 className="mb-4">
<i className="bi bi-currency-euro me-2"></i>
Información Financiera
</h5>
<div className="row g-3">
{/* Saldo/Valor Inicial */}
<div className="col-md-6">
<label className="form-label">
{destinationType === 'liability' ? 'Deuda Actual' : 'Saldo Inicial'}
{destinationType === 'liability' && <span className="text-danger">*</span>}
</label>
<div className="input-group">
<span className="input-group-text bg-dark text-white border-secondary">
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
</span>
<input
type="number"
name="initial_balance"
className="form-control bg-dark text-white border-secondary"
value={formData.initial_balance}
onChange={handleChange}
placeholder="0.00"
step="0.01"
/>
</div>
<small className="text-slate-400">
{destinationType === 'liability'
? 'Monto que debes actualmente en esta tarjeta'
: 'Saldo actual de la cuenta'}
</small>
</div>
{/* Limite de Crédito (só para cartão) */}
{(formData.account_type === 'credit_card' || destinationType === 'liability') && (
<div className="col-md-6">
<label className="form-label">
Límite de Crédito <span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text bg-dark text-white border-secondary">
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
</span>
<input
type="number"
name="credit_limit"
className="form-control bg-dark text-white border-secondary"
value={formData.credit_limit}
onChange={handleChange}
placeholder="5000.00"
step="0.01"
/>
</div>
</div>
)}
{/* Taxa de Juros (para poupança ou cartão) */}
{(destinationType === 'asset' || destinationType === 'liability') && (
<div className="col-md-6">
<label className="form-label">
Tasa de Interés Anual (%)
</label>
<div className="input-group">
<input
type="number"
name={destinationType === 'asset' ? 'interest_rate' : 'annual_interest_rate'}
className="form-control bg-dark text-white border-secondary"
value={destinationType === 'asset' ? formData.interest_rate : formData.annual_interest_rate}
onChange={handleChange}
placeholder={destinationType === 'asset' ? '2.5' : '18.99'}
step="0.01"
/>
<span className="input-group-text bg-dark text-white border-secondary">%</span>
</div>
<small className="text-slate-400">
{destinationType === 'asset'
? 'Rendimiento anual de la cuenta'
: 'Tasa de interés por financiamiento'}
</small>
</div>
)}
{/* Dias de fechamento e vencimento (cartão) */}
{destinationType === 'liability' && (
<>
<div className="col-md-6">
<label className="form-label">Día de Cierre</label>
<input
type="number"
name="closing_day"
className="form-control bg-dark text-white border-secondary"
value={formData.closing_day}
onChange={handleChange}
placeholder="15"
min="1"
max="31"
/>
<small className="text-slate-400">Día del mes que cierra la factura</small>
</div>
<div className="col-md-6">
<label className="form-label">Día de Vencimiento</label>
<input
type="number"
name="due_day"
className="form-control bg-dark text-white border-secondary"
value={formData.due_day}
onChange={handleChange}
placeholder="25"
min="1"
max="31"
/>
<small className="text-slate-400">Día de pago de la factura</small>
</div>
</>
)}
</div>
</div>
)}
{/* Step 4: Dados Bancários */}
{step === 4 && (
<div>
<h5 className="mb-4">
<i className="bi bi-building me-2"></i>
Información Bancaria (Opcional)
</h5>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">
{destinationType === 'liability' ? 'Emisor de la Tarjeta' : 'Nombre del Banco'}
</label>
<input
type="text"
name="bank_name"
className="form-control bg-dark text-white border-secondary"
value={formData.bank_name}
onChange={handleChange}
placeholder={destinationType === 'liability' ? 'Ej: BBVA, Santander' : 'Ej: Santander, BBVA'}
/>
</div>
<div className="col-md-6">
<label className="form-label">
{destinationType === 'liability' ? 'Últimos 4 Dígitos' : 'Número de Cuenta'}
</label>
<input
type="text"
name="account_number"
className="form-control bg-dark text-white border-secondary"
value={formData.account_number}
onChange={handleChange}
placeholder={destinationType === 'liability' ? '****1234' : 'ES00 0000 0000 0000'}
/>
</div>
{destinationType === 'account' && (
<>
<div className="col-12">
<hr className="border-secondary my-4" />
<h6 className="text-white mb-3">
<i className="bi bi-gear me-2"></i>
Configuración
</h6>
</div>
<div className="col-md-6">
<div className="form-check form-switch">
<input
type="checkbox"
name="is_active"
className="form-check-input"
checked={formData.is_active}
onChange={handleChange}
id="is_active"
/>
<label className="form-check-label text-white" htmlFor="is_active">
Cuenta Activa
</label>
</div>
<small className="text-slate-400">Cuentas inactivas no aparecen en transacciones</small>
</div>
<div className="col-md-6">
<div className="form-check form-switch">
<input
type="checkbox"
name="include_in_total"
className="form-check-input"
checked={formData.include_in_total}
onChange={handleChange}
id="include_in_total"
/>
<label className="form-check-label text-white" htmlFor="include_in_total">
Incluir en Total
</label>
</div>
<small className="text-slate-400">Suma el saldo al patrimonio total</small>
</div>
</>
)}
</div>
{/* Resumen */}
<div className="mt-4 p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
<h6 className="text-white mb-3">
<i className="bi bi-check-circle me-2"></i>
Resumen
</h6>
<div className="row g-2">
<div className="col-6">
<small className="text-slate-400">Tipo:</small>
<div className="text-white">{accountTypes[formData.account_type]?.name}</div>
</div>
<div className="col-6">
<small className="text-slate-400">Nombre:</small>
<div className="text-white">{formData.name || '-'}</div>
</div>
<div className="col-6">
<small className="text-slate-400">
{destinationType === 'liability' ? 'Deuda:' : 'Saldo:'}
</small>
<div className="text-white">
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
{parseFloat(formData.initial_balance || 0).toFixed(2)}
</div>
</div>
{formData.credit_limit && (
<div className="col-6">
<small className="text-slate-400">Límite:</small>
<div className="text-white">
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
{parseFloat(formData.credit_limit).toFixed(2)}
</div>
</div>
)}
<div className="col-12">
<small className="text-slate-400">Se guardará como:</small>
<div>
{destinationType === 'asset' && (
<span className="badge bg-success">
<i className="bi bi-graph-up me-1"></i>
Activo Financiero
</span>
)}
{destinationType === 'liability' && (
<span className="badge bg-danger">
<i className="bi bi-graph-down me-1"></i>
Pasivo (Deuda)
</span>
)}
{destinationType === 'account' && (
<span className="badge bg-primary">
<i className="bi bi-wallet2 me-1"></i>
Cuenta Estándar
</span>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className={`modal-footer border-0 ${isMobile ? 'flex-column' : ''}`}>
{isMobile ? (
<>
{step === getTotalSteps() && (
<button
type="button"
className="btn btn-primary w-100 mb-2"
onClick={handleSubmit}
disabled={loading || !validateStep()}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Guardando...
</>
) : (
<>
<i className="bi bi-check-lg me-1"></i>
{isEditMode ? 'Guardar Cambios' : 'Crear Cuenta'}
</>
)}
</button>
)}
<div className="d-flex gap-2 w-100">
{step > 1 && (
<button type="button" className="btn btn-outline-secondary flex-fill" onClick={prevStep}>
<i className="bi bi-arrow-left me-1"></i>
Anterior
</button>
)}
{step < getTotalSteps() && (
<button
type="button"
className="btn btn-primary flex-fill"
onClick={nextStep}
disabled={!validateStep()}
>
Siguiente
<i className="bi bi-arrow-right ms-1"></i>
</button>
)}
<button type="button" className="btn btn-outline-secondary flex-fill" onClick={onClose}>
Cancelar
</button>
</div>
</>
) : (
<>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Cancelar
</button>
{step > 1 && (
<button type="button" className="btn btn-outline-light" onClick={prevStep}>
<i className="bi bi-arrow-left me-1"></i>
Anterior
</button>
)}
{step < getTotalSteps() ? (
<button
type="button"
className="btn btn-primary"
onClick={nextStep}
disabled={!validateStep()}
>
Siguiente
<i className="bi bi-arrow-right ms-1"></i>
</button>
) : (
<button
type="button"
className="btn btn-success"
onClick={handleSubmit}
disabled={loading || !validateStep()}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Guardando...
</>
) : (
<>
<i className="bi bi-check-lg me-1"></i>
{isEditMode ? 'Guardar Cambios' : 'Crear Cuenta'}
</>
)}
</button>
)}
</>
)}
</div>
</div>
</div>
</div>
);
};
export default AccountWizard;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -51,9 +51,9 @@ const Layout = ({ children }) => {
}; };
const [expandedGroups, setExpandedGroups] = useState({ const [expandedGroups, setExpandedGroups] = useState({
registrations: true, registrations: false,
movements: true, movements: false,
planning: true, planning: false,
settings: false, settings: false,
}); });

File diff suppressed because it is too large Load Diff

View File

@ -65,7 +65,10 @@
"incomes": "Income", "incomes": "Income",
"expenses": "Expenses", "expenses": "Expenses",
"balance": "Balance", "balance": "Balance",
"current": "Current" "current": "Current",
"continue": "Continue",
"creating": "Creating...",
"remove": "Remove"
}, },
"auth": { "auth": {
"login": "Login", "login": "Login",
@ -558,6 +561,8 @@
"status": { "status": {
"label": "Status", "label": "Status",
"pending": "Pending", "pending": "Pending",
"effective": "Effective",
"scheduled": "Scheduled",
"completed": "Completed", "completed": "Completed",
"cancelled": "Cancelled" "cancelled": "Cancelled"
}, },
@ -1753,6 +1758,7 @@
"subcategory": "Subcategory", "subcategory": "Subcategory",
"allCategory": "All category", "allCategory": "All category",
"selectCategory": "Select a category", "selectCategory": "Select a category",
"general": "General",
"amount": "Amount", "amount": "Amount",
"month": "Month", "month": "Month",
"budgeted": "Budgeted", "budgeted": "Budgeted",
@ -1776,6 +1782,57 @@
"yearly": "Yearly", "yearly": "Yearly",
"isCumulative": "Cumulative Budget", "isCumulative": "Cumulative Budget",
"isCumulativeHelp": "Accumulates expenses from the beginning of the year to the current period", "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": { "alert": {
"exceeded": "Budget exceeded!", "exceeded": "Budget exceeded!",
"warning": "Warning: near limit", "warning": "Warning: near limit",
@ -2204,7 +2261,7 @@
"hero": { "hero": {
"title": "Take Control of Your Finances", "title": "Take Control of Your Finances",
"subtitle": "Intelligent financial management for individuals and businesses. Track income, expenses, and achieve your financial goals.", "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", "learnMore": "Learn More",
"secure": "100% Secure" "secure": "100% Secure"
}, },

View File

@ -66,7 +66,10 @@
"incomes": "Ingresos", "incomes": "Ingresos",
"expenses": "Gastos", "expenses": "Gastos",
"balance": "Balance", "balance": "Balance",
"current": "Actual" "current": "Actual",
"continue": "Continuar",
"creating": "Creando...",
"remove": "Eliminar"
}, },
"auth": { "auth": {
"login": "Iniciar Sesión", "login": "Iniciar Sesión",
@ -566,6 +569,8 @@
"status": { "status": {
"label": "Estado", "label": "Estado",
"pending": "Pendiente", "pending": "Pendiente",
"effective": "Efectiva",
"scheduled": "Programada",
"completed": "Completada", "completed": "Completada",
"cancelled": "Cancelada" "cancelled": "Cancelada"
}, },
@ -1809,6 +1814,7 @@
"subcategory": "Subcategoría", "subcategory": "Subcategoría",
"allCategory": "Toda la categoría", "allCategory": "Toda la categoría",
"selectCategory": "Seleccionar categoría", "selectCategory": "Seleccionar categoría",
"general": "General",
"amount": "Monto", "amount": "Monto",
"spent": "Gastado", "spent": "Gastado",
"budgeted": "Presupuestado", "budgeted": "Presupuestado",
@ -1833,6 +1839,57 @@
"yearly": "Anual", "yearly": "Anual",
"isCumulative": "Presupuesto Acumulativo", "isCumulative": "Presupuesto Acumulativo",
"isCumulativeHelp": "Acumula gastos desde inicio de año hasta el período actual", "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": { "summary": {
"totalBudget": "Presupuesto Total", "totalBudget": "Presupuesto Total",
"totalSpent": "Gastado", "totalSpent": "Gastado",
@ -2206,7 +2263,7 @@
"hero": { "hero": {
"title": "Toma el Control de tus Finanzas", "title": "Toma el Control de tus Finanzas",
"subtitle": "Gestión financiera inteligente para personas y empresas. Controla ingresos, gastos y alcanza tus metas financieras.", "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", "learnMore": "Saber Más",
"secure": "100% Seguro" "secure": "100% Seguro"
}, },
@ -2247,7 +2304,7 @@
"month": "mes", "month": "mes",
"year": "año", "year": "año",
"free": "Gratis", "free": "Gratis",
"startFree": "Comenzar Gratis", "startFree": "Comenzar Ahora",
"subscribe": "Suscribirse Ahora", "subscribe": "Suscribirse Ahora",
"billedAnnually": "Facturado anualmente €{{price}}", "billedAnnually": "Facturado anualmente €{{price}}",
"comingSoon": "Próximamente", "comingSoon": "Próximamente",
@ -2287,7 +2344,7 @@
"cta": { "cta": {
"title": "¿Listo para Transformar tus Finanzas?", "title": "¿Listo para Transformar tus Finanzas?",
"subtitle": "Únete a miles de usuarios que ya tomaron el control de su dinero.", "subtitle": "Únete a miles de usuarios que ya tomaron el control de su dinero.",
"button": "Crear Cuenta Gratis" "button": "Comenzar Ahora"
}, },
"footer": { "footer": {
"rights": "Todos los derechos reservados.", "rights": "Todos los derechos reservados.",

View File

@ -67,7 +67,10 @@
"incomes": "Receitas", "incomes": "Receitas",
"expenses": "Despesas", "expenses": "Despesas",
"balance": "Saldo", "balance": "Saldo",
"current": "Atual" "current": "Atual",
"continue": "Continuar",
"creating": "Criando...",
"remove": "Remover"
}, },
"auth": { "auth": {
"login": "Entrar", "login": "Entrar",
@ -568,6 +571,8 @@
"status": { "status": {
"label": "Status", "label": "Status",
"pending": "Pendente", "pending": "Pendente",
"effective": "Efetivada",
"scheduled": "Agendada",
"completed": "Concluída", "completed": "Concluída",
"cancelled": "Cancelada" "cancelled": "Cancelada"
}, },
@ -1763,6 +1768,7 @@
"subcategory": "Subcategoria", "subcategory": "Subcategoria",
"allCategory": "Toda a categoria", "allCategory": "Toda a categoria",
"selectCategory": "Selecione uma categoria", "selectCategory": "Selecione uma categoria",
"general": "Geral",
"amount": "Valor", "amount": "Valor",
"month": "Mês", "month": "Mês",
"budgeted": "Orçado", "budgeted": "Orçado",
@ -1784,6 +1790,57 @@
"yearly": "Anual", "yearly": "Anual",
"isCumulative": "Orçamento Cumulativo", "isCumulative": "Orçamento Cumulativo",
"isCumulativeHelp": "Acumula gastos desde o início do ano até o período atual", "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": { "alert": {
"exceeded": "Orçamento excedido!", "exceeded": "Orçamento excedido!",
"warning": "Atenção: próximo do limite", "warning": "Atenção: próximo do limite",
@ -2224,7 +2281,7 @@
"hero": { "hero": {
"title": "Assuma o Controle das suas Finanças", "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.", "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", "learnMore": "Saiba Mais",
"secure": "100% Seguro" "secure": "100% Seguro"
}, },

View File

@ -1899,6 +1899,16 @@ input[type="color"]::-webkit-color-swatch {
color: #94a3b8; 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 */ /* Actions dropdown */
.txn-actions-btn { .txn-actions-btn {
width: 28px; width: 28px;

View File

@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; 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 { useToast } from '../components/Toast';
import { ConfirmModal } from '../components/Modal'; import { ConfirmModal } from '../components/Modal';
import IconSelector from '../components/IconSelector'; import IconSelector from '../components/IconSelector';
import CurrencySelector from '../components/CurrencySelector'; import CurrencySelector from '../components/CurrencySelector';
import { useFormatters } from '../hooks'; import { useFormatters } from '../hooks';
import AssetWizard from '../components/AssetWizard';
import AccountWizard from '../components/AccountWizard';
const Accounts = () => { const Accounts = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -15,10 +17,17 @@ const Accounts = () => {
const { currency: formatCurrencyHook } = useFormatters(); const { currency: formatCurrencyHook } = useFormatters();
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [liabilityAccounts, setLiabilityAccounts] = useState([]); const [liabilityAccounts, setLiabilityAccounts] = useState([]);
const [assetAccounts, setAssetAccounts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAdjustModal, setShowAdjustModal] = 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 [adjustAccount, setAdjustAccount] = useState(null);
const [targetBalance, setTargetBalance] = useState(''); const [targetBalance, setTargetBalance] = useState('');
const [adjusting, setAdjusting] = useState(false); const [adjusting, setAdjusting] = useState(false);
@ -27,7 +36,7 @@ const Accounts = () => {
const [recalculating, setRecalculating] = useState(false); const [recalculating, setRecalculating] = useState(false);
const [filter, setFilter] = useState({ type: '', is_active: '' }); const [filter, setFilter] = useState({ type: '', is_active: '' });
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); 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({ const [formData, setFormData] = useState({
name: '', name: '',
@ -69,9 +78,10 @@ const Accounts = () => {
if (filter.is_active !== '') params.is_active = filter.is_active; if (filter.is_active !== '') params.is_active = filter.is_active;
// Carregar contas normais e passivas em paralelo // Carregar contas normais e passivas em paralelo
const [accountsResponse, liabilityResponse] = await Promise.all([ const [accountsResponse, liabilityResponse, assetResponse] = await Promise.all([
accountService.getAll(params), 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) { if (accountsResponse.success) {
@ -80,6 +90,9 @@ const Accounts = () => {
if (liabilityResponse.success) { if (liabilityResponse.success) {
setLiabilityAccounts(liabilityResponse.data); setLiabilityAccounts(liabilityResponse.data);
} }
if (assetResponse.success) {
setAssetAccounts(assetResponse.data);
}
} catch (error) { } catch (error) {
toast.error(t('accounts.loadError')); toast.error(t('accounts.loadError'));
} finally { } 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 getTotalsByCurrency = () => {
const totals = {}; const totals = {};
@ -281,19 +343,49 @@ const Accounts = () => {
totals[currency] -= parseFloat(acc.remaining_balance || 0); 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; return totals;
}; };
// Total de contas ativas (normais + passivas) // Total de contas ativas (normais + passivas + ativos)
const getTotalActiveAccounts = () => { const getTotalActiveAccounts = () => {
const normalActive = accounts.filter(a => a.is_active).length; const normalActive = accounts.filter(a => a.is_active).length;
const liabilityActive = liabilityAccounts.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 // Total de todas as contas
const getTotalAccounts = () => { 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 ( return (
@ -324,48 +416,74 @@ const Accounts = () => {
</button> </button>
)} )}
{activeTab === 'accounts' && ( {activeTab === 'accounts' && (
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => handleOpenModal()}> <button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => setShowAccountWizard(true)}>
<i className="bi bi-plus-lg me-2"></i> <i className="bi bi-plus-lg me-2"></i>
{isMobile ? t('common.add') : t('accounts.newAccount')} {isMobile ? t('common.add') : t('accounts.newAccount')}
</button> </button>
)} )}
{activeTab === 'liabilities' && ( {activeTab === 'liabilities' && (
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => navigate('/liabilities')}> <button className={`btn btn-warning ${isMobile ? 'btn-sm' : ''}`} onClick={() => navigate('/liabilities')}>
<i className="bi bi-gear me-2"></i> <i className="bi bi-gear me-2"></i>
{isMobile ? t('common.manage') : t('liabilities.manage')} {isMobile ? t('common.manage') : t('liabilities.manage')}
</button> </button>
)} )}
{activeTab === 'assets' && (
<button className={`btn btn-success ${isMobile ? 'btn-sm' : ''}`} onClick={() => setShowAssetWizard(true)}>
<i className="bi bi-plus-lg me-2"></i>
{isMobile ? t('common.add') : 'Nuevo Activo'}
</button>
)}
</div> </div>
</div> </div>
{/* Tabs */} {/* Tabs - Mobile Optimized */}
<ul className="nav nav-tabs mb-4" style={{ borderBottom: '1px solid #334155' }}> <ul className={`nav nav-tabs mb-4 ${isMobile ? 'nav-fill flex-nowrap overflow-auto' : ''}`} style={{ borderBottom: '1px solid #334155' }}>
<li className="nav-item"> <li className="nav-item">
<button <button
className={`nav-link ${activeTab === 'accounts' ? 'active bg-primary text-white' : 'text-slate-400'}`} className={`nav-link ${activeTab === 'accounts' ? 'active bg-primary text-white' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
onClick={() => setActiveTab('accounts')} onClick={() => setActiveTab('accounts')}
style={{ style={{
border: 'none', border: 'none',
borderRadius: '0.5rem 0.5rem 0 0', borderRadius: '0.5rem 0.5rem 0 0',
backgroundColor: activeTab === 'accounts' ? undefined : 'transparent' backgroundColor: activeTab === 'accounts' ? undefined : 'transparent',
fontSize: isMobile ? '0.75rem' : undefined,
whiteSpace: 'nowrap'
}} }}
> >
<i className="bi bi-wallet2 me-2"></i> <i className={`bi bi-wallet2 ${isMobile ? '' : 'me-2'}`}></i>
{t('nav.accounts')} ({accounts.length}) {isMobile ? '' : t('nav.accounts')} ({accounts.length})
</button> </button>
</li> </li>
<li className="nav-item"> <li className="nav-item">
<button <button
className={`nav-link ${activeTab === 'liabilities' ? 'active bg-warning text-dark' : 'text-slate-400'}`} className={`nav-link ${activeTab === 'liabilities' ? 'active bg-warning text-dark' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
onClick={() => setActiveTab('liabilities')} onClick={() => setActiveTab('liabilities')}
style={{ style={{
border: 'none', border: 'none',
borderRadius: '0.5rem 0.5rem 0 0', borderRadius: '0.5rem 0.5rem 0 0',
backgroundColor: activeTab === 'liabilities' ? undefined : 'transparent' backgroundColor: activeTab === 'liabilities' ? undefined : 'transparent',
fontSize: isMobile ? '0.75rem' : undefined,
whiteSpace: 'nowrap'
}} }}
> >
<i className="bi bi-bank me-2"></i> <i className={`bi bi-bank ${isMobile ? '' : 'me-2'}`}></i>
{t('nav.liabilities')} ({liabilityAccounts.length}) {isMobile ? '' : t('nav.liabilities')} ({liabilityAccounts.length})
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'assets' ? 'active bg-success text-white' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
onClick={() => setActiveTab('assets')}
style={{
border: 'none',
borderRadius: '0.5rem 0.5rem 0 0',
backgroundColor: activeTab === 'assets' ? undefined : 'transparent',
fontSize: isMobile ? '0.75rem' : undefined,
whiteSpace: 'nowrap'
}}
>
<i className={`bi bi-graph-up-arrow ${isMobile ? '' : 'me-2'}`}></i>
{isMobile ? '' : 'Activos'} ({assetAccounts.length})
</button> </button>
</li> </li>
</ul> </ul>
@ -485,7 +603,7 @@ const Accounts = () => {
<div className="text-center py-5"> <div className="text-center py-5">
<i className="bi bi-wallet2 display-1 text-slate-600"></i> <i className="bi bi-wallet2 display-1 text-slate-600"></i>
<p className="text-slate-400 mt-3">{t('accounts.noAccounts')}</p> <p className="text-slate-400 mt-3">{t('accounts.noAccounts')}</p>
<button className="btn btn-primary" onClick={() => handleOpenModal()}> <button className="btn btn-primary" onClick={() => setShowAccountWizard(true)}>
<i className="bi bi-plus-lg me-2"></i> <i className="bi bi-plus-lg me-2"></i>
{t('accounts.newAccount')} {t('accounts.newAccount')}
</button> </button>
@ -531,7 +649,10 @@ const Accounts = () => {
</button> </button>
<button <button
className="btn btn-link text-info p-1" className="btn btn-link text-info p-1"
onClick={() => handleOpenModal(account)} onClick={() => {
setEditingAccount(account);
setShowAccountWizard(true);
}}
style={{ fontSize: '1rem' }} style={{ fontSize: '1rem' }}
> >
<i className="bi bi-pencil"></i> <i className="bi bi-pencil"></i>
@ -633,7 +754,10 @@ const Accounts = () => {
</button> </button>
<button <button
className="btn btn-link text-info p-1 me-1" className="btn btn-link text-info p-1 me-1"
onClick={() => handleOpenModal(account)} onClick={() => {
setEditingAccount(account);
setShowAccountWizard(true);
}}
title={t('common.edit')} title={t('common.edit')}
> >
<i className="bi bi-pencil"></i> <i className="bi bi-pencil"></i>
@ -848,6 +972,230 @@ const Accounts = () => {
</> </>
)} )}
{/* Tab de Ativos */}
{activeTab === 'assets' && (
<>
{/* Summary Cards de Ativos */}
<div className={`row ${isMobile ? 'g-2 mb-3' : 'mb-4'}`}>
{Object.entries(getAssetTotalsByCurrency()).map(([currency, data]) => (
<div className="col-md-4" key={currency}>
<div className={`card border-0 ${!isMobile ? 'h-100' : ''}`} style={{ background: '#1e293b' }}>
<div className={`card-body ${isMobile ? 'p-3' : ''}`}>
<div className="d-flex justify-content-between align-items-start">
<div>
<p className={`text-slate-400 mb-1 ${isMobile ? '' : 'small'}`} style={isMobile ? { fontSize: '0.7rem' } : undefined}>
Activos ({currency}) - {data.count} items
</p>
<h4 className={`mb-0 text-success ${isMobile ? 'fs-5' : ''}`}>
{formatCurrency(data.current, currency)}
</h4>
{data.current !== data.acquisition && (
<small className={`${data.current > data.acquisition ? 'text-success' : 'text-danger'}`}>
<i className={`bi ${data.current > data.acquisition ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
{((data.current - data.acquisition) / data.acquisition * 100).toFixed(1)}% desde compra
</small>
)}
</div>
<div className={`rounded-circle bg-success bg-opacity-25 ${isMobile ? 'p-2' : 'p-3'}`}>
<i className={`bi bi-graph-up-arrow text-success ${isMobile ? 'fs-5' : 'fs-4'}`}></i>
</div>
</div>
</div>
</div>
</div>
))}
{Object.keys(getAssetTotalsByCurrency()).length === 0 && (
<div className="col-12">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className={`card-body ${isMobile ? 'p-3' : ''} text-center`}>
<i className="bi bi-graph-up-arrow text-slate-600 fs-1 mb-2"></i>
<p className="text-slate-400 mb-0">No hay activos registrados</p>
</div>
</div>
</div>
)}
</div>
{/* Lista de Ativos */}
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className={`card-body ${isMobile ? 'p-2' : 'p-0'}`}>
{assetAccounts.length === 0 ? (
<div className="text-center py-5">
<i className="bi bi-graph-up-arrow display-1 text-slate-600"></i>
<p className="text-slate-400 mt-3">No hay activos registrados</p>
<button className="btn btn-success" onClick={() => setShowAssetWizard(true)}>
<i className="bi bi-plus-lg me-2"></i>
Crear Activo
</button>
</div>
) : isMobile ? (
// Mobile: Cards Layout para Ativos
<div className="d-flex flex-column gap-2">
{assetAccounts.map((asset) => (
<div
key={`asset-${asset.id}`}
className="card border-secondary"
style={{ cursor: 'pointer', background: '#0f172a' }}
>
<div className="card-body p-3">
{/* Header with Icon and Name */}
<div className="d-flex align-items-start gap-2 mb-2">
<div
className="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
style={{
width: '35px',
height: '35px',
backgroundColor: (asset.color || '#10B981') + '25',
}}
>
<i className={`bi bi-${asset.asset_type === 'real_estate' ? 'house' : asset.asset_type === 'vehicle' ? 'truck' : asset.asset_type === 'investment' ? 'graph-up' : 'box'} fs-6`} style={{ color: asset.color || '#10B981' }}></i>
</div>
<div className="flex-grow-1" style={{ minWidth: 0 }}>
<div className="text-white fw-medium mb-1" style={{ fontSize: '0.85rem' }}>
{asset.name}
</div>
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>
{assetAccountService.statuses[asset.asset_type] || asset.asset_type}
</div>
</div>
<div>
<span className={`badge ${asset.status === 'active' ? 'bg-success' : 'bg-secondary'}`} style={{ fontSize: '0.65rem' }}>
{asset.status === 'active' ? 'Activo' : asset.status}
</span>
</div>
</div>
{/* Values */}
<div className="d-flex justify-content-between align-items-center pt-2" style={{ borderTop: '1px solid #334155' }}>
<div>
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>Valor Actual</div>
<div className="text-success fw-bold" style={{ fontSize: '0.9rem' }}>
{formatCurrency(asset.current_value, asset.currency)}
</div>
</div>
<div className="text-end">
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>Rentabilidad</div>
<div className={`fw-medium ${parseFloat(asset.current_value) >= parseFloat(asset.acquisition_value) ? 'text-success' : 'text-danger'}`} style={{ fontSize: '0.8rem' }}>
{asset.acquisition_value > 0 ? (
<>
<i className={`bi ${parseFloat(asset.current_value) >= parseFloat(asset.acquisition_value) ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
{(((parseFloat(asset.current_value) - parseFloat(asset.acquisition_value)) / parseFloat(asset.acquisition_value)) * 100).toFixed(1)}%
</>
) : '-'}
</div>
</div>
</div>
</div>
</div>
))}
</div>
) : (
// Desktop: Table Layout para Ativos
<div className="table-responsive">
<table className="table table-hover mb-0" style={{ '--bs-table-bg': 'transparent', backgroundColor: 'transparent' }}>
<thead style={{ backgroundColor: 'transparent' }}>
<tr style={{ borderBottom: '1px solid #334155', backgroundColor: 'transparent' }}>
<th className="text-slate-400 fw-normal py-3 ps-4" style={{ backgroundColor: 'transparent' }}>Nombre</th>
<th className="text-slate-400 fw-normal py-3" style={{ backgroundColor: 'transparent' }}>Tipo</th>
<th className="text-slate-400 fw-normal py-3 text-end" style={{ backgroundColor: 'transparent' }}>Adquisición</th>
<th className="text-slate-400 fw-normal py-3 text-end" style={{ backgroundColor: 'transparent' }}>Valor Actual</th>
<th className="text-slate-400 fw-normal py-3 text-center" style={{ backgroundColor: 'transparent' }}>Rentabilidad</th>
<th className="text-slate-400 fw-normal py-3 text-center" style={{ backgroundColor: 'transparent' }}>{t('common.status')}</th>
</tr>
</thead>
<tbody style={{ backgroundColor: 'transparent' }}>
{assetAccounts.map((asset) => {
const gainLoss = parseFloat(asset.current_value) - parseFloat(asset.acquisition_value);
const gainLossPercent = asset.acquisition_value > 0 ? (gainLoss / parseFloat(asset.acquisition_value)) * 100 : 0;
return (
<tr
key={`asset-${asset.id}`}
style={{ borderBottom: '1px solid #334155', backgroundColor: 'transparent', cursor: 'pointer' }}
onClick={() => handleOpenAssetDetail(asset)}
className="asset-row-hover"
>
<td className="py-3 ps-4" style={{ backgroundColor: 'transparent' }}>
<div className="d-flex align-items-center">
<div
className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{
width: '40px',
height: '40px',
backgroundColor: (asset.color || '#10B981') + '25',
}}
>
<i className={`bi bi-${asset.asset_type === 'real_estate' ? 'house' : asset.asset_type === 'vehicle' ? 'truck' : asset.asset_type === 'investment' ? 'graph-up' : 'box'}`} style={{ color: asset.color || '#10B981' }}></i>
</div>
<div>
<div className="text-white fw-medium">{asset.name}</div>
{asset.description && (
<small className="text-slate-400">{asset.description.substring(0, 30)}</small>
)}
</div>
</div>
</td>
<td className="py-3" style={{ backgroundColor: 'transparent' }}>
<span className="badge" style={{ backgroundColor: '#334155' }}>
{asset.asset_type === 'real_estate' ? 'Inmueble' :
asset.asset_type === 'vehicle' ? 'Vehículo' :
asset.asset_type === 'investment' ? 'Inversión' :
asset.asset_type === 'equipment' ? 'Equipamiento' :
asset.asset_type === 'receivable' ? 'Crédito' :
asset.asset_type === 'cash' ? 'Efectivo' :
asset.asset_type}
</span>
</td>
<td className="py-3 text-end text-slate-300" style={{ backgroundColor: 'transparent' }}>
{formatCurrency(asset.acquisition_value, asset.currency)}
</td>
<td className="py-3 text-end fw-medium text-success" style={{ backgroundColor: 'transparent' }}>
{formatCurrency(asset.current_value, asset.currency)}
</td>
<td className="py-3 text-center" style={{ backgroundColor: 'transparent' }}>
<span className={`fw-medium ${gainLoss >= 0 ? 'text-success' : 'text-danger'}`}>
<i className={`bi ${gainLoss >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
{gainLossPercent.toFixed(1)}%
</span>
</td>
<td className="py-3 text-center" style={{ backgroundColor: 'transparent' }}>
<span className={`badge ${asset.status === 'active' ? 'bg-success' : 'bg-secondary'}`}>
{asset.status === 'active' ? 'Activo' : asset.status === 'disposed' ? 'Vendido' : asset.status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</>
)}
{/* Asset Wizard Modal */}
<AssetWizard
isOpen={showAssetWizard}
onClose={() => {
setShowAssetWizard(false);
setEditingAsset(null);
}}
onSuccess={handleAssetSuccess}
asset={editingAsset}
/>
{/* Account Wizard Modal */}
<AccountWizard
isOpen={showAccountWizard}
onClose={() => {
setShowAccountWizard(false);
setEditingAccount(null);
}}
onSuccess={handleAccountWizardSuccess}
account={editingAccount}
/>
{/* Modal de Criar/Editar */} {/* Modal de Criar/Editar */}
{showModal && ( {showModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}> <div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
@ -1136,6 +1484,259 @@ const Accounts = () => {
</div> </div>
)} )}
{/* Modal de Detalhes do Ativo */}
{showAssetDetail && selectedAsset && (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content" style={{ backgroundColor: '#1e293b', border: '1px solid #334155' }}>
<div className="modal-header border-0">
<div className="d-flex align-items-center">
<div
className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{
width: '48px',
height: '48px',
backgroundColor: (selectedAsset.color || '#10B981') + '25',
}}
>
<i className={`bi bi-${selectedAsset.asset_type === 'real_estate' ? 'house' : selectedAsset.asset_type === 'vehicle' ? 'truck' : selectedAsset.asset_type === 'investment' ? 'graph-up' : 'box'} fs-4`} style={{ color: selectedAsset.color || '#10B981' }}></i>
</div>
<div>
<h5 className="modal-title text-white mb-0">{selectedAsset.name}</h5>
{selectedAsset.description && (
<small className="text-slate-400">{selectedAsset.description}</small>
)}
</div>
</div>
<button type="button" className="btn-close btn-close-white" onClick={handleCloseAssetDetail}></button>
</div>
<div className="modal-body">
{/* Valores principais */}
<div className="row g-3 mb-4">
<div className="col-md-4">
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
<div className="text-slate-400 small mb-1">Valor de Adquisición</div>
<div className="text-white fs-5 fw-bold">
{formatCurrency(selectedAsset.acquisition_value, selectedAsset.currency)}
</div>
</div>
</div>
<div className="col-md-4">
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
<div className="text-slate-400 small mb-1">Valor Actual</div>
<div className="text-success fs-5 fw-bold">
{formatCurrency(selectedAsset.current_value, selectedAsset.currency)}
</div>
</div>
</div>
<div className="col-md-4">
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
<div className="text-slate-400 small mb-1">Rentabilidad</div>
{(() => {
const gainLoss = parseFloat(selectedAsset.current_value) - parseFloat(selectedAsset.acquisition_value);
const gainLossPercent = selectedAsset.acquisition_value > 0 ? (gainLoss / parseFloat(selectedAsset.acquisition_value)) * 100 : 0;
return (
<div className={`fs-5 fw-bold ${gainLoss >= 0 ? 'text-success' : 'text-danger'}`}>
<i className={`bi ${gainLoss >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
{formatCurrency(Math.abs(gainLoss), selectedAsset.currency)} ({gainLossPercent.toFixed(1)}%)
</div>
);
})()}
</div>
</div>
</div>
{/* Informações detalhadas */}
<div className="row g-3">
<div className="col-12">
<h6 className="text-white mb-3">
<i className="bi bi-info-circle me-2"></i>
Información del Activo
</h6>
</div>
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Tipo de Activo</span>
<div className="text-white">
{selectedAsset.asset_type === 'real_estate' ? 'Inmueble' :
selectedAsset.asset_type === 'vehicle' ? 'Vehículo' :
selectedAsset.asset_type === 'investment' ? 'Inversión' :
selectedAsset.asset_type === 'equipment' ? 'Equipamiento' :
selectedAsset.asset_type === 'receivable' ? 'Crédito por Cobrar' :
selectedAsset.asset_type === 'cash' ? 'Efectivo' :
selectedAsset.asset_type}
</div>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Estado</span>
<div>
<span className={`badge ${selectedAsset.status === 'active' ? 'bg-success' : 'bg-secondary'}`}>
{selectedAsset.status === 'active' ? 'Activo' : selectedAsset.status === 'disposed' ? 'Vendido' : selectedAsset.status}
</span>
</div>
</div>
</div>
{selectedAsset.acquisition_date && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Fecha de Adquisición</span>
<div className="text-white">
{new Date(selectedAsset.acquisition_date).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}
</div>
</div>
</div>
)}
{/* Campos específicos por tipo */}
{selectedAsset.asset_type === 'real_estate' && (
<>
{selectedAsset.address && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Dirección</span>
<div className="text-white">{selectedAsset.address}</div>
</div>
</div>
)}
{selectedAsset.city && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Ciudad</span>
<div className="text-white">{selectedAsset.city}</div>
</div>
</div>
)}
{selectedAsset.property_area_m2 && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Área</span>
<div className="text-white">{selectedAsset.property_area_m2} </div>
</div>
</div>
)}
</>
)}
{selectedAsset.asset_type === 'vehicle' && (
<>
{selectedAsset.vehicle_brand && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Marca/Modelo</span>
<div className="text-white">{selectedAsset.vehicle_brand} {selectedAsset.vehicle_model}</div>
</div>
</div>
)}
{selectedAsset.vehicle_year && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Año</span>
<div className="text-white">{selectedAsset.vehicle_year}</div>
</div>
</div>
)}
{selectedAsset.vehicle_plate && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Matrícula</span>
<div className="text-white">{selectedAsset.vehicle_plate}</div>
</div>
</div>
)}
{selectedAsset.vehicle_mileage && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Kilometraje</span>
<div className="text-white">{selectedAsset.vehicle_mileage.toLocaleString()} km</div>
</div>
</div>
)}
</>
)}
{selectedAsset.asset_type === 'investment' && (
<>
{selectedAsset.investment_type && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Tipo de Inversión</span>
<div className="text-white">
{selectedAsset.investment_type === 'stocks' ? 'Acciones' :
selectedAsset.investment_type === 'bonds' ? 'Bonos' :
selectedAsset.investment_type === 'etf' ? 'ETF' :
selectedAsset.investment_type === 'mutual_fund' ? 'Fondo Mutuo' :
selectedAsset.investment_type === 'crypto' ? 'Criptomoneda' :
selectedAsset.investment_type === 'fixed_deposit' ? 'Depósito a Plazo' :
selectedAsset.investment_type}
</div>
</div>
</div>
)}
{selectedAsset.institution && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Institución</span>
<div className="text-white">{selectedAsset.institution}</div>
</div>
</div>
)}
{selectedAsset.ticker && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Ticker/Símbolo</span>
<div className="text-white">{selectedAsset.ticker}</div>
</div>
</div>
)}
{selectedAsset.quantity && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Cantidad</span>
<div className="text-white">{selectedAsset.quantity}</div>
</div>
</div>
)}
{selectedAsset.interest_rate && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Tasa de Interés</span>
<div className="text-white">{selectedAsset.interest_rate}%</div>
</div>
</div>
)}
{selectedAsset.maturity_date && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Fecha de Vencimiento</span>
<div className="text-white">
{new Date(selectedAsset.maturity_date).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
<div className="modal-footer border-0">
<button type="button" className="btn btn-primary me-2" onClick={handleEditAsset}>
<i className="bi bi-pencil me-2"></i>
Editar
</button>
<button type="button" className="btn btn-secondary" onClick={handleCloseAssetDetail}>
Cerrar
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal de Confirmação de Exclusão */} {/* Modal de Confirmação de Exclusão */}
<ConfirmModal <ConfirmModal
show={showDeleteModal} show={showDeleteModal}

View File

@ -4,6 +4,7 @@ import { budgetService, categoryService, costCenterService } from '../services/a
import useFormatters from '../hooks/useFormatters'; import useFormatters from '../hooks/useFormatters';
import { getCurrencyByCode } from '../config/currencies'; import { getCurrencyByCode } from '../config/currencies';
import ConfirmModal from '../components/ConfirmModal'; import ConfirmModal from '../components/ConfirmModal';
import BudgetWizard from '../components/BudgetWizard';
const Budgets = () => { const Budgets = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -21,14 +22,7 @@ const Budgets = () => {
const [deleteBudget, setDeleteBudget] = useState(null); const [deleteBudget, setDeleteBudget] = useState(null);
const [yearSummary, setYearSummary] = useState(null); const [yearSummary, setYearSummary] = useState(null);
const [primaryCurrency, setPrimaryCurrency] = useState('EUR'); const [primaryCurrency, setPrimaryCurrency] = useState('EUR');
const [formData, setFormData] = useState({ const [showWizard, setShowWizard] = useState(false);
category_id: '',
subcategory_id: '',
cost_center_id: '',
amount: '',
period_type: 'monthly',
is_cumulative: false,
});
// Meses con i18n // Meses con i18n
const getMonths = () => [ const getMonths = () => [
@ -92,29 +86,6 @@ const Budgets = () => {
} }
}; };
const handleSubmit = async (e) => {
e.preventDefault();
try {
const data = {
...formData,
year,
month,
};
if (editingBudget) {
await budgetService.update(editingBudget.id, data);
} else {
await budgetService.create(data);
}
setShowModal(false);
setEditingBudget(null);
resetForm();
loadData();
} catch (error) {
console.error('Error saving budget:', error);
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteBudget) return; if (!deleteBudget) return;
try { try {
@ -128,11 +99,6 @@ const Budgets = () => {
const handleEdit = (budget) => { const handleEdit = (budget) => {
setEditingBudget(budget); setEditingBudget(budget);
setFormData({
category_id: budget.category_id,
subcategory_id: budget.subcategory_id || '',
amount: budget.amount,
});
setShowModal(true); setShowModal(true);
}; };
@ -151,20 +117,8 @@ const Budgets = () => {
} }
}; };
const resetForm = () => {
setFormData({
category_id: '',
subcategory_id: '',
cost_center_id: '',
amount: '',
period_type: 'monthly',
is_cumulative: false,
});
};
const openNewBudget = () => { const openNewBudget = () => {
setEditingBudget(null); setEditingBudget(null);
resetForm();
setShowModal(true); setShowModal(true);
}; };
@ -246,6 +200,14 @@ const Budgets = () => {
<i className="bi bi-copy me-1"></i> <i className="bi bi-copy me-1"></i>
{t('budgets.copyToNext')} {t('budgets.copyToNext')}
</button> </button>
<button
className="btn btn-outline-primary"
onClick={() => setShowWizard(true)}
title={t('budgets.wizard.title') || 'Assistente de Orçamentos'}
>
<i className="bi bi-magic me-1"></i>
{t('budgets.wizard.button') || 'Assistente'}
</button>
<button className="btn btn-primary" onClick={openNewBudget}> <button className="btn btn-primary" onClick={openNewBudget}>
<i className="bi bi-plus-lg me-1"></i> <i className="bi bi-plus-lg me-1"></i>
{t('budgets.addBudget')} {t('budgets.addBudget')}
@ -586,280 +548,19 @@ const Budgets = () => {
</div> </div>
)} )}
{/* Budget Form Modal */} {/* Budget Form Modal - Using BudgetWizard with mode='single' */}
{showModal && ( <BudgetWizard
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}> isOpen={showModal}
<div className="modal-dialog modal-dialog-centered"> onClose={() => {
<div className="modal-content border-0" style={{ background: '#1e293b' }}> setShowModal(false);
<div className="modal-header border-0"> setEditingBudget(null);
<h5 className="modal-title text-white"> }}
<i className={`bi ${editingBudget ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i> onSuccess={loadData}
{editingBudget ? t('budgets.editBudget') : t('budgets.newBudget')} year={year}
</h5> month={month}
<button mode="single"
type="button" editBudget={editingBudget}
className="btn-close btn-close-white"
onClick={() => setShowModal(false)}
></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<p className="text-slate-400 small mb-3">
<i className="bi bi-calendar3 me-1"></i>
{months.find(m => m.value === month)?.label} {year}
</p>
{/* Category Selection - Grid style like transactions */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.category')} *</label>
{editingBudget ? (
<input
type="text"
className="form-control bg-dark border-secondary text-white"
value={editingBudget.category?.name || ''}
disabled
/> />
) : (
<>
{availableCategories.length === 0 ? (
<div className="alert alert-warning py-2 mb-0">
<i className="bi bi-info-circle me-2"></i>
{t('budgets.allCategoriesUsed')}
</div>
) : (
<div className="category-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: '8px',
maxHeight: '200px',
overflowY: 'auto',
padding: '4px'
}}>
{availableCategories.map(cat => (
<div
key={cat.id}
onClick={() => setFormData({...formData, category_id: cat.id, subcategory_id: ''})}
className={`p-2 rounded text-center cursor-pointer ${
formData.category_id == cat.id
? 'border border-primary'
: 'border border-secondary'
}`}
style={{
background: formData.category_id == cat.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<i
className={`bi ${cat.icon || 'bi-tag'} d-block mb-1`}
style={{
fontSize: '1.5rem',
color: cat.color || '#6b7280'
}}
></i>
<small className="text-white d-block text-truncate" title={cat.name}>
{cat.name}
</small>
</div>
))}
</div>
)}
</>
)}
</div>
{/* Subcategory Selection - Only if category selected and has subcategories */}
{formData.category_id && !editingBudget && (() => {
const selectedCategory = availableCategories.find(c => c.id == formData.category_id);
const subcategories = selectedCategory?.subcategories || [];
if (subcategories.length > 0) {
return (
<div className="mb-3">
<label className="form-label text-slate-400">
{t('budgets.subcategory')}
<small className="text-muted ms-2">({t('common.optional')})</small>
</label>
<div className="category-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: '8px',
maxHeight: '200px',
overflowY: 'auto',
padding: '4px'
}}>
{/* Option: All category (no subcategory) */}
<div
onClick={() => setFormData({...formData, subcategory_id: ''})}
className={`p-2 rounded text-center cursor-pointer ${
!formData.subcategory_id
? 'border border-primary'
: 'border border-secondary'
}`}
style={{
background: !formData.subcategory_id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<i
className={`bi ${selectedCategory?.icon || 'bi-tag'} d-block mb-1`}
style={{
fontSize: '1.5rem',
color: selectedCategory?.color || '#6b7280'
}}
></i>
<small className="text-white d-block text-truncate">
{t('budgets.allCategory')}
</small>
</div>
{/* Subcategories */}
{subcategories.map(sub => (
<div
key={sub.id}
onClick={() => setFormData({...formData, subcategory_id: sub.id})}
className={`p-2 rounded text-center cursor-pointer ${
formData.subcategory_id == sub.id
? 'border border-primary'
: 'border border-secondary'
}`}
style={{
background: formData.subcategory_id == sub.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<i
className={`bi ${sub.icon || 'bi-tag'} d-block mb-1`}
style={{
fontSize: '1.5rem',
color: sub.color || '#6b7280'
}}
></i>
<small className="text-white d-block text-truncate" title={sub.name}>
{sub.name}
</small>
</div>
))}
</div>
</div>
);
}
return null;
})()}
{/* Cost Center Selection - Only if editing or creating new */}
{!editingBudget && costCenters.length > 0 && (
<div className="mb-3">
<label className="form-label text-slate-400">
{t('budgets.costCenter')}
<small className="text-muted ms-2">({t('common.optional')})</small>
</label>
<select
className="form-select bg-dark border-secondary text-white"
value={formData.cost_center_id || ''}
onChange={(e) => setFormData({...formData, cost_center_id: e.target.value})}
>
<option value="">{t('budgets.noCostCenter')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>
{cc.name}
</option>
))}
</select>
</div>
)}
{/* Amount */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.amount')} *</label>
<div className="input-group">
<span className="input-group-text bg-dark border-secondary text-white">
{getCurrencyByCode(primaryCurrency)?.symbol || '€'}
</span>
<input
type="number"
step="0.01"
min="0.01"
className="form-control bg-dark border-secondary text-white"
value={formData.amount}
onChange={(e) => setFormData({...formData, amount: e.target.value})}
placeholder="0.00"
required
/>
</div>
</div>
{/* Period Type */}
{!editingBudget && (
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.periodType')}</label>
<select
className="form-select bg-dark border-secondary text-white"
value={formData.period_type || 'monthly'}
onChange={(e) => setFormData({...formData, period_type: e.target.value})}
>
<option value="monthly">{t('budgets.monthly')}</option>
<option value="bimestral">{t('budgets.bimestral')}</option>
<option value="trimestral">{t('budgets.trimestral')}</option>
<option value="semestral">{t('budgets.semestral')}</option>
<option value="yearly">{t('budgets.yearly')}</option>
</select>
</div>
)}
{/* Cumulative Option */}
{!editingBudget && (
<div className="mb-3">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
id="isCumulativeCheck"
checked={formData.is_cumulative || false}
onChange={(e) => setFormData({...formData, is_cumulative: e.target.checked})}
/>
<label className="form-check-label text-slate-400" htmlFor="isCumulativeCheck">
{t('budgets.isCumulative')}
<small className="d-block text-muted">{t('budgets.isCumulativeHelp')}</small>
</label>
</div>
</div>
)}
{/* Info about auto-propagation */}
{!editingBudget && (
<div className="alert alert-info py-2 mb-0">
<small>
<i className="bi bi-info-circle me-1"></i>
{t('budgets.autoPropagateInfo')}
</small>
</div>
)}
</div>
<div className="modal-footer border-0">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => setShowModal(false)}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={!editingBudget && (!formData.category_id || !formData.amount)}
>
<i className="bi bi-check-lg me-1"></i>
{t('common.save')}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Delete Confirmation */} {/* Delete Confirmation */}
<ConfirmModal <ConfirmModal
@ -871,6 +572,15 @@ const Budgets = () => {
confirmText={t('common.delete')} confirmText={t('common.delete')}
variant="danger" variant="danger"
/> />
{/* Budget Wizard */}
<BudgetWizard
isOpen={showWizard}
onClose={() => setShowWizard(false)}
onSuccess={loadData}
year={year}
month={month}
/>
</div> </div>
); );
}; };

View File

@ -473,40 +473,84 @@ const Categories = () => {
)} )}
</div> </div>
{/* Modal de Criar/Editar */} {/* Modal de Criar/Editar - Design Elegante */}
{showModal && ( {showModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}> <div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered"> <div className="modal-dialog modal-dialog-centered modal-lg">
<div className="modal-content" style={{ background: '#1e293b' }}> <div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
<div className="modal-header border-bottom" style={{ borderColor: '#334155 !important' }}> {/* Header elegante */}
<h5 className="modal-title text-white"> <div className="modal-header border-0 pb-0">
<i className={`bi ${selectedItem ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i> <div>
<h5 className="modal-title text-white mb-1">
<i className={`bi ${selectedItem ? 'bi-pencil-square' : formData.parent_id ? 'bi-diagram-3' : 'bi-plus-circle-dotted'} me-2 text-info`}></i>
{selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')} {selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')}
</h5> </h5>
<p className="text-slate-400 mb-0 small">
{formData.parent_id
? `${t('categories.parentCategory')}: ${flatCategories.find(c => c.id == formData.parent_id)?.name || ''}`
: t('categories.title')
}
</p>
</div>
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button> <button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="modal-body"> <div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
<div className="row g-3">
{/* Nome */} {/* Preview Card */}
<div className="col-md-6"> <div className="mb-4 p-3 rounded-3" style={{ background: '#0f172a' }}>
<label className="form-label text-slate-300">{t('common.name')} *</label> <div className="d-flex align-items-center">
<div
className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{
width: 50,
height: 50,
background: `${formData.color}25`,
border: `2px solid ${formData.color}`,
}}
>
<i className={`bi ${formData.icon}`} style={{ fontSize: '1.3rem', color: formData.color }}></i>
</div>
<div>
<h6 className="text-white mb-0">{formData.name || t('categories.newCategory')}</h6>
<small className="text-slate-400">{formData.description || t('common.description')}</small>
</div>
<div className="ms-auto">
<span className={`badge bg-${getTypeColor(formData.type)} bg-opacity-25 text-${getTypeColor(formData.type)}`}>
{categoryTypes[formData.type]}
</span>
</div>
</div>
</div>
{/* Nome e Tipo - Linha principal */}
<div className="row g-3 mb-4">
<div className="col-md-8">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-type me-2 text-primary"></i>
{t('common.name')} *
</label>
<input <input
type="text" type="text"
className="form-control bg-dark text-white border-secondary" className="form-control bg-dark text-white border-0"
style={{ background: '#0f172a' }}
name="name" name="name"
value={formData.name} value={formData.name}
onChange={handleChange} onChange={handleChange}
placeholder="Ex: Alimentação, Moradia..." placeholder={t('categories.namePlaceholder') || 'Ex: Alimentación, Transporte...'}
required required
autoFocus
/> />
</div> </div>
<div className="col-md-4">
{/* Tipo */} <label className="form-label text-white fw-medium mb-2">
<div className="col-md-3"> <i className="bi bi-arrow-left-right me-2 text-warning"></i>
<label className="form-label text-slate-300">{t('common.type')} *</label> {t('common.type')} *
</label>
<select <select
className="form-select bg-dark text-white border-secondary" className="form-select bg-dark text-white border-0"
style={{ background: '#0f172a' }}
name="type" name="type"
value={formData.type} value={formData.type}
onChange={handleChange} onChange={handleChange}
@ -517,51 +561,187 @@ const Categories = () => {
))} ))}
</select> </select>
</div> </div>
{/* Categoria Pai */}
<div className="col-md-3">
<label className="form-label text-slate-300">{t('categories.parentCategory')}</label>
<select
className="form-select bg-dark text-white border-secondary"
name="parent_id"
value={formData.parent_id}
onChange={handleChange}
>
<option value="">{t('categories.noParent')}</option>
{flatCategories
.filter(c => c.id !== selectedItem?.id)
.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div> </div>
{/* Cor */} {/* Visual - Cor e Ícone */}
<div className="col-md-3"> <div className="mb-4">
<label className="form-label text-slate-300">{t('common.color')}</label> <label className="form-label text-white fw-medium mb-2">
<i className="bi bi-palette me-2 text-success"></i>
{t('categories.visualSettings') || 'Aparência'}
</label>
<div className="row g-3">
<div className="col-4">
<div className="p-3 rounded text-center" style={{ background: '#0f172a' }}>
<label className="text-slate-400 small d-block mb-2">{t('common.color')}</label>
<input <input
type="color" type="color"
className="form-control form-control-color bg-dark border-secondary w-100" className="form-control form-control-color mx-auto border-0"
style={{ width: 50, height: 50, cursor: 'pointer', background: 'transparent' }}
name="color" name="color"
value={formData.color} value={formData.color}
onChange={handleChange} onChange={handleChange}
/> />
</div> </div>
</div>
{/* Ícone */} <div className="col-8">
<div className="col-md-5"> <div className="p-3 rounded h-100" style={{ background: '#0f172a' }}>
<label className="form-label text-slate-300">{t('common.icon')}</label> <label className="text-slate-400 small d-block mb-2">{t('common.icon')}</label>
<IconSelector <IconSelector
value={formData.icon} value={formData.icon}
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))} onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
type="category" type="category"
/> />
</div> </div>
</div>
</div>
</div>
{/* Categoria Pai (se não for edição de uma categoria raiz) */}
{!selectedItem?.children?.length && (
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-diagram-2 me-2 text-info"></i>
{t('categories.parentCategory')}
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
</label>
<div className="row g-2">
<div className="col-4 col-md-3">
<div
onClick={() => setFormData({...formData, parent_id: ''})}
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
style={{
background: !formData.parent_id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
cursor: 'pointer',
border: !formData.parent_id ? '2px solid #3b82f6' : '2px solid transparent',
minHeight: 60
}}
>
<i className="bi bi-app d-block mb-1 text-slate-400"></i>
<small className="text-white">{t('categories.noParent')}</small>
</div>
</div>
{flatCategories
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
.slice(0, 7)
.map(cat => (
<div key={cat.id} className="col-4 col-md-3">
<div
onClick={() => setFormData({...formData, parent_id: cat.id.toString()})}
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
style={{
background: formData.parent_id == cat.id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
cursor: 'pointer',
border: formData.parent_id == cat.id ? '2px solid #3b82f6' : '2px solid transparent',
minHeight: 60
}}
>
<i className={`bi ${cat.icon || 'bi-tag'} d-block mb-1`} style={{ color: cat.color || '#6b7280' }}></i>
<small className="text-white text-truncate" title={cat.name}>{cat.name}</small>
</div>
</div>
))}
</div>
{flatCategories.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both')).length > 7 && (
<select
className="form-select bg-dark text-white border-0 mt-2"
style={{ background: '#0f172a' }}
name="parent_id"
value={formData.parent_id}
onChange={handleChange}
>
<option value="">{t('categories.selectParent') || 'Mais categorias...'}</option>
{flatCategories
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
)}
</div>
)}
{/* Descrição */}
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-text-paragraph me-2 text-secondary"></i>
{t('common.description')}
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
</label>
<textarea
className="form-control bg-dark text-white border-0"
style={{ background: '#0f172a' }}
name="description"
value={formData.description}
onChange={handleChange}
rows="2"
placeholder={t('categories.descPlaceholder') || 'Descreva esta categoria...'}
></textarea>
</div>
{/* Palavras-chave - Seção destacada */}
<div className="mb-3">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-key me-2 text-warning"></i>
{t('categories.keywords')}
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: '0.65rem' }}>
{t('categories.autoCategorizationLabel') || 'Auto-categorização'}
</span>
</label>
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
<div className="input-group mb-2">
<input
type="text"
className="form-control bg-dark text-white border-0"
style={{ background: '#1e293b' }}
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
onKeyPress={handleKeywordKeyPress}
placeholder={t('categories.keywordPlaceholder') || 'Digite e pressione Enter...'}
/>
<button
type="button"
className="btn btn-info px-3"
onClick={handleAddKeyword}
>
<i className="bi bi-plus-lg"></i>
</button>
</div>
<div className="d-flex flex-wrap gap-2">
{formData.keywords.map((keyword, index) => (
<span
key={index}
className="badge d-flex align-items-center py-2 px-3"
style={{
backgroundColor: formData.color + '25',
color: formData.color,
fontSize: '0.85rem'
}}
>
{keyword}
<button
type="button"
className="btn-close ms-2"
style={{ fontSize: '8px', filter: 'brightness(1.5)' }}
onClick={() => handleRemoveKeyword(keyword)}
></button>
</span>
))}
{formData.keywords.length === 0 && (
<small className="text-slate-500">
<i className="bi bi-info-circle me-1"></i>
{t('categories.noKeywords') || 'Nenhuma palavra-chave. Transações serão categorizadas manualmente.'}
</small>
)}
</div>
</div>
<small className="text-slate-500 mt-2 d-block">
<i className="bi bi-lightbulb me-1"></i>
{t('categories.keywordHelp') || 'Ex: "RESTAURANTE", "PIZZA" - Transações com essas palavras serão categorizadas automaticamente'}
</small>
</div>
{/* Status */} {/* Status */}
<div className="col-md-4"> <div className="form-check form-switch">
<label className="form-label text-slate-300">&nbsp;</label>
<div className="form-check mt-2">
<input <input
type="checkbox" type="checkbox"
className="form-check-input" className="form-check-input"
@ -569,93 +749,30 @@ const Categories = () => {
name="is_active" name="is_active"
checked={formData.is_active} checked={formData.is_active}
onChange={handleChange} onChange={handleChange}
role="switch"
/> />
<label className="form-check-label text-slate-300" htmlFor="is_active"> <label className="form-check-label text-white" htmlFor="is_active">
{t('common.active')} <i className={`bi ${formData.is_active ? 'bi-check-circle text-success' : 'bi-x-circle text-secondary'} me-2`}></i>
{formData.is_active ? t('common.active') : t('common.inactive')}
</label> </label>
</div> </div>
</div> </div>
{/* Descrição */} {/* Footer elegante */}
<div className="col-12"> <div className="modal-footer border-0">
<label className="form-label text-slate-300">{t('common.description')}</label> <button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseModal}>
<textarea <i className="bi bi-x-lg me-2"></i>
className="form-control bg-dark text-white border-secondary"
name="description"
value={formData.description}
onChange={handleChange}
rows="2"
placeholder="Descreva esta categoria..."
></textarea>
</div>
{/* Palavras-chave */}
<div className="col-12">
<label className="form-label text-slate-300">
<i className="bi bi-key me-1"></i>
{t('categories.keywordHelp')}
</label>
<div className="input-group mb-2">
<input
type="text"
className="form-control bg-dark text-white border-secondary"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
onKeyPress={handleKeywordKeyPress}
placeholder="Digite uma palavra-chave e pressione Enter..."
/>
<button
type="button"
className="btn btn-outline-info"
onClick={handleAddKeyword}
>
<i className="bi bi-plus"></i>
</button>
</div>
<div className="d-flex flex-wrap gap-2">
{formData.keywords.map((keyword, index) => (
<span
key={index}
className="badge d-flex align-items-center"
style={{
backgroundColor: formData.color + '25',
color: formData.color,
}}
>
{keyword}
<button
type="button"
className="btn-close btn-close-white ms-2"
style={{ fontSize: '8px' }}
onClick={() => handleRemoveKeyword(keyword)}
></button>
</span>
))}
{formData.keywords.length === 0 && (
<small className="text-slate-500">
{t('common.noData')}
</small>
)}
</div>
<small className="text-slate-500 mt-2 d-block">
Ex: "RESTAURANTE", "PIZZA", "HAMBURGUER" - Para a categoria Alimentação
</small>
</div>
</div>
</div>
<div className="modal-footer border-top" style={{ borderColor: '#334155 !important' }}>
<button type="button" className="btn btn-outline-light" onClick={handleCloseModal}>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button type="submit" className="btn btn-info" disabled={saving}> <button type="submit" className="btn btn-info px-4" disabled={saving || !formData.name.trim()}>
{saving ? ( {saving ? (
<> <>
<span className="spinner-border spinner-border-sm me-2"></span> <span className="spinner-border spinner-border-sm me-2"></span>
{t('common.loading')} {t('common.saving')}
</> </>
) : ( ) : (
<> <>
<i className="bi bi-check-lg me-2"></i> <i className={`bi ${selectedItem ? 'bi-check-lg' : 'bi-plus-lg'} me-2`}></i>
{selectedItem ? t('common.save') : t('common.create')} {selectedItem ? t('common.save') : t('common.create')}
</> </>
)} )}
@ -678,116 +795,145 @@ const Categories = () => {
loading={saving} loading={saving}
/> />
{/* Modal de Categorização em Lote */} {/* Modal de Categorização em Lote - Design Elegante */}
{showBatchModal && ( {showBatchModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}> <div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"> <div className="modal-dialog modal-lg modal-dialog-centered" style={{ maxWidth: '700px' }}>
<div className="modal-content border-0" style={{ background: '#1e293b' }}> <div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
<div className="modal-header border-secondary"> {/* Header elegante */}
<h5 className="modal-title text-white"> <div className="modal-header border-0 pb-0">
<i className="bi bi-lightning-charge me-2 text-warning"></i> <div>
{t('categories.batchCategorize') || 'Categorização em Lote'} <h5 className="modal-title text-white mb-1">
<i className="bi bi-lightning-charge-fill me-2 text-warning"></i>
{t('categories.batchCategorize') || 'Categorização Automática'}
</h5> </h5>
<p className="text-slate-400 mb-0 small">
{t('categories.batchDescription') || 'Categorize transações automaticamente usando palavras-chave'}
</p>
</div>
<button type="button" className="btn-close btn-close-white" onClick={handleCloseBatchModal}></button> <button type="button" className="btn-close btn-close-white" onClick={handleCloseBatchModal}></button>
</div> </div>
<div className="modal-body">
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
{loadingBatch ? ( {loadingBatch ? (
<div className="text-center py-5"> <div className="text-center py-5">
<div className="spinner-border text-info" role="status"> <div className="spinner-border text-warning" role="status" style={{ width: '3rem', height: '3rem' }}>
<span className="visually-hidden">Loading...</span> <span className="visually-hidden">Loading...</span>
</div> </div>
<p className="text-slate-400 mt-3">{t('common.loading') || 'Carregando...'}</p> <p className="text-slate-400 mt-3 mb-0">{t('categories.analyzingTransactions') || 'Analisando transações...'}</p>
</div> </div>
) : batchPreview ? ( ) : batchPreview ? (
<> <>
{/* Resumo */} {/* Cards de Resumo */}
<div className="row mb-4"> <div className="row g-3 mb-4">
<div className="col-md-3"> <div className="col-6 col-md-3">
<div className="card border-0" style={{ background: '#0f172a' }}> <div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
<div className="card-body text-center"> <div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
<h3 className="text-warning mb-1">{batchPreview.total_uncategorized}</h3> style={{ width: 40, height: 40, background: 'rgba(234, 179, 8, 0.2)' }}>
<small className="text-slate-400">{t('categories.uncategorized') || 'Sem categoria'}</small> <i className="bi bi-question-circle text-warning"></i>
</div>
<h4 className="text-warning mb-0">{batchPreview.total_uncategorized}</h4>
<small className="text-slate-500">{t('categories.uncategorized') || 'Sem categoria'}</small>
</div> </div>
</div> </div>
<div className="col-6 col-md-3">
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
style={{ width: 40, height: 40, background: 'rgba(34, 197, 94, 0.2)' }}>
<i className="bi bi-check-circle text-success"></i>
</div> </div>
<div className="col-md-3"> <h4 className="text-success mb-0">{batchPreview.would_categorize}</h4>
<div className="card border-0" style={{ background: '#0f172a' }}> <small className="text-slate-500">{t('categories.willCategorize') || 'Serão categorizadas'}</small>
<div className="card-body text-center">
<h3 className="text-success mb-1">{batchPreview.would_categorize}</h3>
<small className="text-slate-400">{t('categories.willCategorize') || 'Serão categorizadas'}</small>
</div> </div>
</div> </div>
<div className="col-6 col-md-3">
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
style={{ width: 40, height: 40, background: 'rgba(148, 163, 184, 0.2)' }}>
<i className="bi bi-dash-circle text-slate-400"></i>
</div> </div>
<div className="col-md-3"> <h4 className="text-slate-400 mb-0">{batchPreview.would_skip}</h4>
<div className="card border-0" style={{ background: '#0f172a' }}> <small className="text-slate-500">{t('categories.willSkip') || 'Sem correspondência'}</small>
<div className="card-body text-center">
<h3 className="text-secondary mb-1">{batchPreview.would_skip}</h3>
<small className="text-slate-400">{t('categories.willSkip') || 'Sem correspondência'}</small>
</div> </div>
</div> </div>
<div className="col-6 col-md-3">
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
style={{ width: 40, height: 40, background: 'rgba(59, 130, 246, 0.2)' }}>
<i className="bi bi-key text-info"></i>
</div> </div>
<div className="col-md-3"> <h4 className="text-info mb-0">{batchPreview.total_keywords}</h4>
<div className="card border-0" style={{ background: '#0f172a' }}> <small className="text-slate-500">{t('categories.totalKeywords') || 'Palavras-chave'}</small>
<div className="card-body text-center">
<h3 className="text-info mb-1">{batchPreview.total_keywords}</h3>
<small className="text-slate-400">{t('categories.totalKeywords') || 'Palavras-chave'}</small>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Preview */} {/* Preview Table */}
{batchPreview.preview.length > 0 ? ( {batchPreview.preview.length > 0 ? (
<> <>
<h6 className="text-white mb-3"> <div className="d-flex align-items-center mb-3">
<i className="bi bi-eye me-2"></i> <i className="bi bi-eye me-2 text-info"></i>
<h6 className="text-white mb-0">
{t('categories.previewTitle') || 'Preview das categorizações'} {t('categories.previewTitle') || 'Preview das categorizações'}
</h6> </h6>
<div className="table-responsive" style={{ maxHeight: '300px' }}> <span className="badge bg-info bg-opacity-25 text-info ms-2">
<table className="table table-dark table-striped table-hover mb-0"> {batchPreview.preview.length} {t('common.items') || 'itens'}
<thead style={{ position: 'sticky', top: 0, background: '#1e293b' }}> </span>
</div>
<div className="rounded-3 overflow-hidden" style={{ background: '#0f172a' }}>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
<table className="table table-dark mb-0" style={{ background: 'transparent' }}>
<thead style={{ position: 'sticky', top: 0, background: '#0f172a', zIndex: 1 }}>
<tr> <tr>
<th>{t('transactions.description') || 'Descrição'}</th> <th className="border-0 text-slate-400 fw-normal small">{t('transactions.description') || 'Descrição'}</th>
<th>{t('categories.matchedKeyword') || 'Keyword'}</th> <th className="border-0 text-slate-400 fw-normal small text-center" style={{ width: '120px' }}>{t('categories.matchedKeyword') || 'Keyword'}</th>
<th>{t('categories.category') || 'Categoria'}</th> <th className="border-0 text-slate-400 fw-normal small" style={{ width: '140px' }}>{t('categories.category') || 'Categoria'}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{batchPreview.preview.map((item, index) => ( {batchPreview.preview.map((item, index) => (
<tr key={index}> <tr key={index} style={{ borderColor: '#334155' }}>
<td className="text-truncate" style={{ maxWidth: '300px' }}> <td className="text-white text-truncate border-secondary" style={{ maxWidth: '200px' }}>
{item.description} {item.description}
</td> </td>
<td> <td className="text-center border-secondary">
<span className="badge bg-warning">{item.matched_keyword}</span> <span className="badge bg-warning bg-opacity-25 text-warning">{item.matched_keyword}</span>
</td> </td>
<td className="text-info">{item.category_name}</td> <td className="text-info border-secondary">{item.category_name}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</> </>
) : ( ) : (
<div className="alert alert-warning"> <div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
<i className="bi bi-exclamation-triangle me-2"></i> <i className="bi bi-search display-4 text-slate-600 mb-3 d-block"></i>
{t('categories.noMatchesFound') || 'Nenhuma transação corresponde às palavras-chave configuradas'} <h6 className="text-white mb-2">{t('categories.noMatchesFoundTitle') || 'Nenhuma correspondência encontrada'}</h6>
<p className="text-slate-400 mb-0 small">
{t('categories.noMatchesFound') || 'Adicione palavras-chave às categorias para permitir categorização automática'}
</p>
</div> </div>
)} )}
</> </>
) : ( ) : (
<div className="alert alert-danger"> <div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
{t('categories.previewError') || 'Erro ao carregar preview'} <i className="bi bi-exclamation-triangle display-4 text-danger mb-3 d-block"></i>
<p className="text-slate-400 mb-0">{t('categories.previewError') || 'Erro ao carregar preview'}</p>
</div> </div>
)} )}
</div> </div>
<div className="modal-footer border-secondary">
<button type="button" className="btn btn-outline-secondary" onClick={handleCloseBatchModal}> {/* Footer elegante */}
<div className="modal-footer border-0">
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseBatchModal}>
<i className="bi bi-x-lg me-2"></i>
{t('common.cancel') || 'Cancelar'} {t('common.cancel') || 'Cancelar'}
</button> </button>
<button <button
type="button" type="button"
className="btn btn-warning" className="btn btn-warning px-4"
onClick={handleExecuteBatch} onClick={handleExecuteBatch}
disabled={executingBatch || !batchPreview || batchPreview.would_categorize === 0} disabled={executingBatch || !batchPreview || batchPreview.would_categorize === 0}
> >
@ -798,8 +944,8 @@ const Categories = () => {
</> </>
) : ( ) : (
<> <>
<i className="bi bi-lightning-charge me-2"></i> <i className="bi bi-lightning-charge-fill me-2"></i>
{t('categories.executeBatch') || 'Executar Categorização'} {t('categories.executeBatch') || 'Categorizar'} ({batchPreview?.would_categorize || 0})
</> </>
)} )}
</button> </button>

View File

@ -63,6 +63,59 @@
z-index: 1; z-index: 1;
} }
/* Hero Title Animation */
.hero-title-wrapper {
line-height: 1.1;
}
.hero-title-wrapper h1 {
line-height: 1.1;
}
.hero-title-wrapper h1:first-child {
padding-left: 0;
}
.hero-title-wrapper h1:last-child {
padding-left: 3.5rem;
}
@media (min-width: 768px) {
.hero-title-wrapper h1:last-child {
padding-left: 5rem;
}
}
.hero-title-highlight {
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: glow 3s ease-in-out infinite, colorShift 5s ease-in-out infinite;
}
@keyframes glow {
0%, 100% {
filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.3));
}
50% {
filter: drop-shadow(0 0 20px rgba(16, 185, 129, 0.5)) drop-shadow(0 0 40px rgba(59, 130, 246, 0.3));
}
}
@keyframes colorShift {
0%, 100% {
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
-webkit-background-clip: text;
background-clip: text;
}
50% {
background: linear-gradient(135deg, var(--landing-secondary), var(--landing-primary));
-webkit-background-clip: text;
background-clip: text;
}
}
/* Dashboard Preview */ /* Dashboard Preview */
.hero-image { .hero-image {
perspective: 1000px; perspective: 1000px;

View File

@ -149,9 +149,14 @@ export default function Landing() {
<div className="container"> <div className="container">
<div className="row align-items-center min-vh-100 py-5"> <div className="row align-items-center min-vh-100 py-5">
<div className="col-lg-6"> <div className="col-lg-6">
<h1 className="display-3 fw-bold text-white mb-4"> <div className="hero-title-wrapper mb-4">
{t('landing.hero.title')} <h1 className="display-3 fw-bold text-white mb-0">
{i18n.language === 'es' ? 'Toma el Control' : i18n.language === 'pt-BR' ? 'Assuma o Controle' : 'Take Control'}
</h1> </h1>
<h1 className="display-3 fw-bold mb-0 hero-title-highlight">
{i18n.language === 'es' ? 'de tus Finanzas' : i18n.language === 'pt-BR' ? 'das suas Finanças' : 'of Your Finances'}
</h1>
</div>
<p className="lead text-white-50 mb-4"> <p className="lead text-white-50 mb-4">
{t('landing.hero.subtitle')} {t('landing.hero.subtitle')}
</p> </p>

View File

@ -4,6 +4,7 @@ import { liabilityAccountService } from '../services/api';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { ConfirmModal } from '../components/Modal'; import { ConfirmModal } from '../components/Modal';
import { useFormatters } from '../hooks'; import { useFormatters } from '../hooks';
import LiabilityWizard from '../components/LiabilityWizard';
const LiabilityAccounts = () => { const LiabilityAccounts = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -24,6 +25,7 @@ const LiabilityAccounts = () => {
const [summary, setSummary] = useState(null); const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showWizardModal, setShowWizardModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false); const [showDetailModal, setShowDetailModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showReconcileModal, setShowReconcileModal] = useState(false); const [showReconcileModal, setShowReconcileModal] = useState(false);
@ -70,6 +72,31 @@ const LiabilityAccounts = () => {
} }
}; };
// Download template Excel
const handleDownloadTemplate = async () => {
try {
const blob = await liabilityAccountService.downloadTemplate();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'plantilla_importacion_pasivo.xlsx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Plantilla descargada');
} catch (error) {
toast.error('Error al descargar plantilla');
}
};
// Wizard success handler
const handleWizardSuccess = (newAccount) => {
loadAccounts();
setSelectedAccount(newAccount);
setShowDetailModal(true);
};
const handleOpenImportModal = () => { const handleOpenImportModal = () => {
setImportForm({ setImportForm({
file: null, file: null,
@ -366,13 +393,44 @@ const LiabilityAccounts = () => {
{t('liabilities.subtitle')} {t('liabilities.subtitle')}
</small> </small>
</div> </div>
<div className={`d-flex gap-2 ${isMobile ? 'flex-column' : ''}`}>
{/* Botão Criar com Wizard */}
<button <button
className={`btn btn-primary ${isMobile ? 'w-100' : ''}`} className={`btn btn-success ${isMobile ? 'w-100' : ''}`}
onClick={handleOpenImportModal} onClick={() => setShowWizardModal(true)}
>
<i className="bi bi-plus-circle me-2"></i>
{isMobile ? 'Crear' : 'Crear Pasivo'}
</button>
{/* Dropdown para importação */}
<div className="dropdown">
<button
className={`btn btn-primary dropdown-toggle ${isMobile ? 'w-100' : ''}`}
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
> >
<i className="bi bi-upload me-2"></i> <i className="bi bi-upload me-2"></i>
{isMobile ? t('common.import') : t('liabilities.importContract')} {isMobile ? t('common.import') : t('liabilities.importContract')}
</button> </button>
<ul className="dropdown-menu dropdown-menu-end">
<li>
<button className="dropdown-item" onClick={handleOpenImportModal}>
<i className="bi bi-file-earmark-excel me-2 text-success"></i>
Importar desde Excel
</button>
</li>
<li><hr className="dropdown-divider" /></li>
<li>
<button className="dropdown-item" onClick={handleDownloadTemplate}>
<i className="bi bi-download me-2 text-primary"></i>
Descargar Plantilla
</button>
</li>
</ul>
</div>
</div>
</div> </div>
{/* Summary Cards */} {/* Summary Cards */}
@ -488,10 +546,20 @@ const LiabilityAccounts = () => {
<i className="bi bi-inbox fs-1 text-muted mb-3 d-block"></i> <i className="bi bi-inbox fs-1 text-muted mb-3 d-block"></i>
<h5 className="text-muted">{t('liabilities.noContracts')}</h5> <h5 className="text-muted">{t('liabilities.noContracts')}</h5>
<p className="text-muted mb-3">{t('liabilities.importHint')}</p> <p className="text-muted mb-3">{t('liabilities.importHint')}</p>
<div className="d-flex gap-2 justify-content-center flex-wrap">
<button className="btn btn-success" onClick={() => setShowWizardModal(true)}>
<i className="bi bi-plus-circle me-2"></i>
Crear Pasivo
</button>
<button className="btn btn-primary" onClick={handleOpenImportModal}> <button className="btn btn-primary" onClick={handleOpenImportModal}>
<i className="bi bi-upload me-2"></i> <i className="bi bi-upload me-2"></i>
{t('liabilities.importContract')} {t('liabilities.importContract')}
</button> </button>
<button className="btn btn-outline-primary" onClick={handleDownloadTemplate}>
<i className="bi bi-download me-2"></i>
Descargar Plantilla
</button>
</div>
</div> </div>
</div> </div>
) : ( ) : (
@ -1465,6 +1533,13 @@ const LiabilityAccounts = () => {
</div> </div>
</div> </div>
)} )}
{/* Liability Wizard Modal */}
<LiabilityWizard
isOpen={showWizardModal}
onClose={() => setShowWizardModal(false)}
onSuccess={handleWizardSuccess}
/>
</div> </div>
); );
}; };

View File

@ -928,11 +928,15 @@ export default function Transactions() {
pending: 'warning', pending: 'warning',
completed: 'success', completed: 'success',
cancelled: 'secondary', cancelled: 'secondary',
effective: 'success',
scheduled: 'primary',
}; };
const labels = { const labels = {
pending: t('transactions.status.pending'), pending: t('transactions.status.pending'),
completed: t('transactions.status.completed'), completed: t('transactions.status.completed'),
cancelled: t('transactions.status.cancelled'), cancelled: t('transactions.status.cancelled'),
effective: t('transactions.status.effective'),
scheduled: t('transactions.status.scheduled'),
}; };
return <span className={`badge bg-${colors[status]}`}>{labels[status]}</span>; return <span className={`badge bg-${colors[status]}`}>{labels[status]}</span>;
}; };

View File

@ -417,20 +417,116 @@ export const liabilityAccountService = {
// Status disponíveis para contas // Status disponíveis para contas
statuses: { statuses: {
active: 'Ativo', active: 'Activo',
paid_off: 'Quitado', paid_off: 'Liquidado',
defaulted: 'Inadimplente', defaulted: 'En mora',
renegotiated: 'Renegociado', renegotiated: 'Renegociado',
}, },
// Status disponíveis para parcelas // Status disponíveis para parcelas
installmentStatuses: { installmentStatuses: {
pending: 'Pendente', pending: 'Pendiente',
paid: 'Pago', paid: 'Pagado',
partial: 'Parcial', partial: 'Parcial',
overdue: 'Atrasado', overdue: 'Vencido',
cancelled: 'Cancelado', cancelled: 'Cancelado',
}, },
// Download template Excel
downloadTemplate: async () => {
const response = await api.get('/liability-accounts/template', {
responseType: 'blob',
});
return response.data;
},
// Obter tipos de contrato
getContractTypes: async () => {
const response = await api.get('/liability-accounts/contract-types');
return response.data;
},
// Criar conta passivo via wizard
createWithWizard: async (data) => {
const response = await api.post('/liability-accounts/wizard', data);
return response.data;
},
};
// ============================================
// Asset Account Services (Contas Ativo)
// ============================================
export const assetAccountService = {
// Listar todos os ativos
getAll: async (params = {}) => {
const response = await api.get('/asset-accounts', { params });
return response.data;
},
// Obter um ativo específico
getById: async (id) => {
const response = await api.get(`/asset-accounts/${id}`);
return response.data;
},
// Criar novo ativo manualmente
create: async (data) => {
const response = await api.post('/asset-accounts', data);
return response.data;
},
// Atualizar ativo
update: async (id, data) => {
const response = await api.put(`/asset-accounts/${id}`, data);
return response.data;
},
// Excluir ativo
delete: async (id) => {
const response = await api.delete(`/asset-accounts/${id}`);
return response.data;
},
// Obter tipos de ativos e opções
getAssetTypes: async () => {
const response = await api.get('/asset-accounts/asset-types');
return response.data;
},
// Criar ativo via wizard
createWithWizard: async (data) => {
const response = await api.post('/asset-accounts/wizard', data);
return response.data;
},
// Obter resumo geral
getSummary: async () => {
const response = await api.get('/asset-summary');
return response.data;
},
// Atualizar valor de mercado
updateValue: async (id, currentValue, note = null) => {
const response = await api.put(`/asset-accounts/${id}/value`, {
current_value: currentValue,
note: note,
});
return response.data;
},
// Registrar venda/baixa
dispose: async (id, data) => {
const response = await api.post(`/asset-accounts/${id}/dispose`, data);
return response.data;
},
// Status disponíveis
statuses: {
active: 'Activo',
sold: 'Vendido',
depreciated: 'Depreciado',
written_off: 'Dado de baja',
},
}; };
// ============================================ // ============================================

View File

@ -130,6 +130,34 @@
background-clip: text; background-clip: text;
} }
/* Efeito de animação para o título */
.typing-effect {
position: relative;
animation: glow 3s ease-in-out infinite, colorShift 5s ease-in-out infinite;
}
@keyframes glow {
0%, 100% {
filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.3));
}
50% {
filter: drop-shadow(0 0 20px rgba(16, 185, 129, 0.5)) drop-shadow(0 0 40px rgba(59, 130, 246, 0.3));
}
}
@keyframes colorShift {
0%, 100% {
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
background-clip: text;
}
50% {
background: linear-gradient(135deg, var(--secondary), var(--primary));
-webkit-background-clip: text;
background-clip: text;
}
}
.hero-subtitle { .hero-subtitle {
font-size: 1.25rem; font-size: 1.25rem;
color: var(--text-muted); color: var(--text-muted);
@ -656,7 +684,7 @@
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-lg-6"> <div class="col-lg-6">
<h1 class="hero-title fade-in"> <h1 class="hero-title fade-in">
Soluciones Digitales para <span>impulsar tu negocio</span> Soluciones Digitales para <span class="typing-effect">impulsar tu negocio</span>
</h1> </h1>
<p class="hero-subtitle fade-in"> <p class="hero-subtitle fade-in">
Desarrollamos plataformas digitales innovadoras para simplificar la gestión de tu negocio. Finanzas, piscinas, turismo y más. Desarrollamos plataformas digitales innovadoras para simplificar la gestión de tu negocio. Finanzas, piscinas, turismo y más.
@ -741,7 +769,7 @@
</div> </div>
<p class="text-muted small mb-3">Plan Free disponible con funciones limitadas</p> <p class="text-muted small mb-3">Plan Free disponible con funciones limitadas</p>
<a href="https://webmoney.cnxifly.com/register" class="btn btn-webmoney btn-product text-white"> <a href="https://webmoney.cnxifly.com/register" class="btn btn-webmoney btn-product text-white">
<i class="bi bi-rocket-takeoff me-2"></i>Comenzar Gratis <i class="bi bi-rocket-takeoff me-2"></i>Comenzar Ahora
</a> </a>
</div> </div>
</div> </div>
@ -853,7 +881,7 @@
<p class="cta-text fade-in">Únete a miles de usuarios que ya confían en ConneXiFly</p> <p class="cta-text fade-in">Únete a miles de usuarios que ya confían en ConneXiFly</p>
<div class="d-flex justify-content-center gap-3 flex-wrap fade-in"> <div class="d-flex justify-content-center gap-3 flex-wrap fade-in">
<a href="https://webmoney.cnxifly.com/register" class="btn btn-light btn-lg px-4"> <a href="https://webmoney.cnxifly.com/register" class="btn btn-light btn-lg px-4">
<i class="bi bi-person-plus me-2"></i>Crear Cuenta Gratis <i class="bi bi-rocket-takeoff me-2"></i>Comenzar Ahora
</a> </a>
<a href="mailto:admin@cnxifly.com" class="btn btn-outline-light btn-lg px-4"> <a href="mailto:admin@cnxifly.com" class="btn btn-outline-light btn-lg px-4">
<i class="bi bi-envelope me-2"></i>Contactar <i class="bi bi-envelope me-2"></i>Contactar