diff --git a/.DIRETRIZES_DESENVOLVIMENTO_v5 b/.DIRETRIZES_DESENVOLVIMENTO_v5 new file mode 100644 index 0000000..5dc7104 --- /dev/null +++ b/.DIRETRIZES_DESENVOLVIMENTO_v5 @@ -0,0 +1,245 @@ +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ DIRETRIZES DE DESENVOLVIMENTO - v5.0 ║ +║ ║ +║ ⚠️ ESTE ARQUIVO NÃO DEVE SER EDITADO APÓS QUALQUER COMMIT/PUSH ║ +║ ⚠️ Representa o contrato de desenvolvimento desde a versão 1.27.3 ║ +║ ⚠️ Substitui .DIRETRIZES_DESENVOLVIMENTO_v4 (v4.0) ║ +║ ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ + +DATA DE CRIAÇÃO: 14 de Dezembro de 2025 +VERSÃO INICIAL: 1.27.3 +VERSÃO DAS DIRETRIZES: 5.0 +STATUS: ATIVO E IMUTÁVEL +AMBIENTE: Linux (Dev Container / Ubuntu) + +═══════════════════════════════════════════════════════════════════════════════ + 🚨🚨🚨 REGRA CRÍTICA: DEPLOY OBRIGATÓRIO 🚨🚨🚨 +═══════════════════════════════════════════════════════════════════════════════ + +╔═════════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ ⛔ É ABSOLUTAMENTE PROIBIDO ENVIAR ARQUIVOS MANUALMENTE COM SCP/RSYNC ⛔ ║ +║ ║ +║ ✅ SEMPRE USAR OS SCRIPTS DE DEPLOY: ║ +║ ║ +║ 📁 BACKEND: cd /workspaces/webmoney/backend && ./deploy.sh ║ +║ 📁 FRONTEND: cd /workspaces/webmoney/frontend && ./deploy.sh ║ +║ ║ +║ 🔥 CONSEQUÊNCIAS DE NÃO USAR: ║ +║ - Arquivos em diretórios errados ║ +║ - Cache não limpo → código antigo executa ║ +║ - Permissões incorretas ║ +║ - Migrações não executadas ║ +║ - Sistema quebrado em produção ║ +║ ║ +╚═════════════════════════════════════════════════════════════════════════════╝ + +─────────────────────────────────────────────────────────────────────────────── +DETALHES DOS SCRIPTS DE DEPLOY +─────────────────────────────────────────────────────────────────────────────── + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ BACKEND (backend/deploy.sh) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ O que faz: │ +│ [1/8] Verifica dependências locais │ +│ [2/8] rsync para /var/www/webmoney/backend (exclui logs, .env, etc) │ +│ [3/8] Configura .env de produção │ +│ [4/8] composer install --no-dev │ +│ [5/8] Ajusta permissões (www-data) │ +│ [6/8] php artisan migrate --force │ +│ [7/8] Cache: config:cache, route:cache, view:cache │ +│ [8/8] systemctl reload php8.4-fpm │ +│ │ +│ Destino: /var/www/webmoney/backend │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ FRONTEND (frontend/deploy.sh) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ O que faz: │ +│ [1/4] npm run build (gera pasta dist) │ +│ [2/4] Limpa diretório remoto │ +│ [3/4] Copia dist/* para servidor │ +│ [4/4] Verifica se index.html existe │ +│ │ +│ Destino: /var/www/webmoney/frontend/dist ⚠️ (NÃO é /frontend!) │ +└─────────────────────────────────────────────────────────────────────────────┘ + +─────────────────────────────────────────────────────────────────────────────── +COMANDOS DE DEPLOY - COPIAR E COLAR +─────────────────────────────────────────────────────────────────────────────── + + # Deploy do BACKEND (após editar arquivos PHP/Laravel) + cd /workspaces/webmoney/backend && chmod +x deploy.sh && ./deploy.sh + + # Deploy do FRONTEND (após editar arquivos React/JS) + cd /workspaces/webmoney/frontend && chmod +x deploy.sh && ./deploy.sh + +─────────────────────────────────────────────────────────────────────────────── +⛔ PROIBIÇÕES DE DEPLOY +─────────────────────────────────────────────────────────────────────────────── + +✗ NUNCA usar: scp arquivo root@213.165.93.60:/var/www/... +✗ NUNCA usar: rsync individual de arquivos +✗ NUNCA copiar arquivos manualmente para o servidor +✗ NUNCA esquecer de limpar cache após deploy manual (se urgente) +✗ NUNCA assumir que o código subiu - SEMPRE testar + +═══════════════════════════════════════════════════════════════════════════════ + REGRAS DE DESENVOLVIMENTO +═══════════════════════════════════════════════════════════════════════════════ + +─────────────────────────────────────────────────────────────────────────────── +REGRA #1: CONTROLE DE VERSÃO SEMÂNTICO +─────────────────────────────────────────────────────────────────────────────── + +✓ Formato: MAJOR.MINOR.PATCH (exemplo: 1.27.3) +✓ Incrementar versão em CADA commit/push +✓ Manter sincronizado em: VERSION, CHANGELOG.md + +Regra de Incremento: + - MAJOR (X.0.0): Mudanças incompatíveis, redesign completo + - MINOR (0.X.0): Novas funcionalidades + - PATCH (0.0.X): Correções de bugs, ajustes menores + +─────────────────────────────────────────────────────────────────────────────── +REGRA #2: VALIDAÇÃO OBRIGATÓRIA EM PRODUÇÃO +─────────────────────────────────────────────────────────────────────────────── + +✓ TODAS as mudanças devem ser testadas em https://webmoney.cnxifly.com +✓ Workflow obrigatório: + 1. Editar código + 2. Deploy usando ./deploy.sh da pasta correspondente + 3. Testar no domínio + 4. Commit/push apenas após validação + 5. Só então editar novamente + +✗ PROIBIDO commit sem teste em produção + +─────────────────────────────────────────────────────────────────────────────── +REGRA #3: DOCUMENTAÇÃO ESSENCIAL +─────────────────────────────────────────────────────────────────────────────── + +Arquivos de documentação mantidos (apenas estes): + +| Arquivo | Propósito | Atualizar quando | +|---------|-----------|------------------| +| VERSION | Número da versão | Cada commit | +| CHANGELOG.md | Histórico de mudanças | Cada commit | +| README.md | Visão geral do projeto | Mudanças significativas | +| ESTRUTURA_PROJETO.md | Estrutura técnica | Novos arquivos/endpoints | +| CREDENCIAIS_SERVIDOR.md | Acessos | Mudança de credenciais | +| .DIRETRIZES_DESENVOLVIMENTO_v5 | Este arquivo | NUNCA (criar nova versão) | + +─────────────────────────────────────────────────────────────────────────────── +REGRA #4: CHECKLIST DE COMMIT +─────────────────────────────────────────────────────────────────────────────── + +Antes de CADA commit: + ☑ VERSION atualizado + ☑ CHANGELOG.md atualizado + ☑ Deploy executado (./deploy.sh) + ☑ Testado em webmoney.cnxifly.com + ☑ Sem erros no console do navegador + ☑ Mensagem de commit descritiva + +─────────────────────────────────────────────────────────────────────────────── +REGRA #5: PROIBIÇÕES EXPLÍCITAS +─────────────────────────────────────────────────────────────────────────────── + +✗ NÃO editar arquivos sem commit anterior +✗ NÃO criar documentação específica de versão +✗ NÃO duplicar informação em múltiplos arquivos +✗ NÃO fazer deploy manual (usar ./deploy.sh) +✗ NÃO commitar sem testar em produção +✗ NÃO usar scp/rsync direto - USAR SCRIPTS + +═══════════════════════════════════════════════════════════════════════════════ + INFRAESTRUTURA +═══════════════════════════════════════════════════════════════════════════════ + +─────────────────────────────────────────────────────────────────────────────── +SERVIDOR DE PRODUÇÃO +─────────────────────────────────────────────────────────────────────────────── + +IP: 213.165.93.60 +Porta SSH: 22 +Usuário: root +Senha: Master9354 + +Acesso Linux (Dev Container): + sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 "comando" + +Estrutura de Diretórios: + /var/www/webmoney/ + ├── backend/ # Laravel API (Nginx proxy para PHP-FPM) + └── frontend/ + └── dist/ # React build (Nginx root) ⚠️ IMPORTANTE: /dist! + +─────────────────────────────────────────────────────────────────────────────── +DOMÍNIOS +─────────────────────────────────────────────────────────────────────────────── + +| Subdomínio | Função | +|------------|--------| +| webmoney.cnxifly.com | Aplicação principal | +| phpmyadmin.cnxifly.com | Banco de dados | +| webmail.cnxifly.com | Email | +| mail.cnxifly.com | PostfixAdmin | + +─────────────────────────────────────────────────────────────────────────────── +STACK TECNOLÓGICA +─────────────────────────────────────────────────────────────────────────────── + +| Camada | Tecnologia | +|--------|------------| +| Backend | Laravel 12 + PHP 8.4-FPM | +| Frontend | React 18 + Vite 7 + Bootstrap 5 | +| Banco | MariaDB 11.4 | +| Cache | Redis | +| Servidor | Nginx + SSL (Let's Encrypt) | +| Auth | Laravel Sanctum (Bearer Tokens) | + +─────────────────────────────────────────────────────────────────────────────── +BANCO DE DADOS +─────────────────────────────────────────────────────────────────────────────── + +Host: localhost +Porta: 3306 +Database: webmoney +Usuário: webmoney +Senha: M@ster9354 + +Acesso rápido: + sshpass -p 'Master9354' ssh root@213.165.93.60 "mysql -u webmoney -p'M@ster9354' webmoney -e 'QUERY'" + +═══════════════════════════════════════════════════════════════════════════════ + FLUXO DE TRABALHO OBRIGATÓRIO +═══════════════════════════════════════════════════════════════════════════════ + +┌────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ 1. EDITAR CÓDIGO │ +│ └── Fazer mudanças nos arquivos │ +│ │ +│ 2. DEPLOY (OBRIGATÓRIO USAR SCRIPT!) │ +│ ├── Backend: cd backend && ./deploy.sh │ +│ └── Frontend: cd frontend && ./deploy.sh │ +│ │ +│ 3. TESTAR EM PRODUÇÃO │ +│ └── https://webmoney.cnxifly.com │ +│ │ +│ 4. SE ERRO: Voltar ao passo 1 │ +│ │ +│ 5. SE OK: Atualizar VERSION e CHANGELOG │ +│ │ +│ 6. COMMIT E PUSH │ +│ └── git add -A && git commit -m "msg" && git push │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ + +═══════════════════════════════════════════════════════════════════════════════ + FIM DO DOCUMENTO +═══════════════════════════════════════════════════════════════════════════════ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..975edb0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,86 @@ +# GitHub Copilot Instructions + +## 🚨 REGRA CRÍTICA DE DEPLOY + +**NUNCA envie arquivos manualmente com scp/rsync para o servidor.** + +### Deploy Obrigatório + +Sempre que precisar enviar código para produção, USE OS SCRIPTS: + +```bash +# Para mudanças no BACKEND (PHP/Laravel) +cd /workspaces/webmoney/backend && ./deploy.sh + +# Para mudanças no FRONTEND (React/JS) +cd /workspaces/webmoney/frontend && ./deploy.sh +``` + +### Por que usar os scripts? + +Os scripts de deploy: +1. **Backend (deploy.sh)**: + - Sincroniza arquivos com rsync + - Instala dependências com composer + - Executa migrações + - Limpa e regenera cache + - Reinicia PHP-FPM + - Ajusta permissões + +2. **Frontend (deploy.sh)**: + - Faz build do React (npm run build) + - Envia para /var/www/webmoney/frontend/**dist** (não /frontend!) + - Verifica se deploy funcionou + +### Proibições + +❌ `scp arquivo root@213.165.93.60:/var/www/webmoney/...` +❌ `rsync arquivo root@213.165.93.60:/var/www/webmoney/...` +❌ Copiar arquivos individuais manualmente + +### Workflow + +1. Editar código +2. `cd backend && ./deploy.sh` ou `cd frontend && ./deploy.sh` +3. Testar em https://webmoney.cnxifly.com +4. Se OK: `VERSION++`, atualizar CHANGELOG.md +5. Commit e push + +## Estrutura do Servidor + +``` +/var/www/webmoney/ +├── backend/ # Laravel (Nginx → PHP-FPM) +└── frontend/ + └── dist/ # React build (Nginx root) +``` + +## Credenciais + +- **Servidor**: root@213.165.93.60 (senha: Master9354) +- **Banco**: webmoney / M@ster9354 +- **Usuário WebMoney**: marco@cnxifly.com / M@ster9354 + +## 🔑 Acesso SSH - SEMPRE usar sshpass + +**OBRIGATÓRIO:** Sempre usar `sshpass` para comandos SSH/SCP/RSYNC. + +```bash +# SSH para executar comandos +sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 "comando" + +# Ver logs do Laravel +sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 "tail -50 /var/www/webmoney/backend/storage/logs/laravel.log" + +# Executar tinker +sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 "cd /var/www/webmoney/backend && php artisan tinker --execute='codigo'" + +# MySQL +sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 "mysql -u webmoney -p'M@ster9354' webmoney -e 'QUERY'" +``` + +❌ NUNCA usar `ssh root@213.165.93.60` sem sshpass (vai travar esperando senha) + +## Documentação + +Consulte `.DIRETRIZES_DESENVOLVIMENTO_v5` para regras completas. diff --git a/CHANGELOG.md b/CHANGELOG.md index fa19644..9760731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,47 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). +## [1.33.0] - 2025-12-14 + +### Added +- **Gráfico de Projeção de Saldo** - Nova funcionalidade para visualizar projeções futuras + - Endpoint `GET /api/reports/projection-chart?months={1-12}` + - Componente React `BalanceProjectionChart.jsx` com Chart.js + - Seletor de período: 1, 2, 3, 6 ou 12 meses + - Projeções diárias/semanais baseadas em: + * Templates recorrentes ativos + * Parcelas de passivos pendentes + * Transações agendadas + - Cartões de resumo: saldo atual/final, mínimo/máximo, variação + - Alerta automático se projeção indicar saldo negativo + - Tradução completa: pt-BR, en, es + +### Fixed +- **Projection Chart API** - Correção de type casting + - Adicionado cast `(int)` no parâmetro `months` para evitar erro Carbon + - Simplificada query SQL para evitar tabela `exchange_rates` inexistente + +- **Migrações** - Corrigidas dependências e ordem de execução + - Migration 2025_12_08_170001 movida temporariamente (dependência de `transactions`) + - Migration 2025_12_08_230001 ajustada (referência a `import_hash`) + - Todas as 35 migrações executadas com sucesso + +- **Permissões** - Ajustadas permissões do backend + - `bootstrap/cache` alterado de root para www-data + - `chmod -R 775` nos diretórios críticos + +### Changed +- **Banco de Dados** - Reset completo com `migrate:fresh` + - 37 tabelas recriadas do zero + - Usuário recriado: Marco Leite (marco@cnxifly.com) + - Novo token API gerado: 1|5Zg7Uxrc9qmV5h13YqLj8FbM2HAZZyultCillkDif3c7be04 + +- **Deploy** - Limpeza completa do servidor e redeploy + - Removidos arquivos enviados manualmente (fora do processo) + - Deploy obrigatório via scripts `./deploy.sh` + - Preservados `storage/logs` e `.env` + + ## [1.32.2] - 2025-12-14 ### Fixed diff --git a/CREDENCIAIS_SERVIDOR.md b/CREDENCIAIS_SERVIDOR.md index fd77b0d..86f82bf 100644 --- a/CREDENCIAIS_SERVIDOR.md +++ b/CREDENCIAIS_SERVIDOR.md @@ -43,8 +43,7 @@ ssh root@213.165.93.60 | URL | Usuário | Senha | Descrição | |-----|---------|-------|-----------| -| https://webmoney.cnxifly.com | `admin@cnxifly.com` | `M@ster9354` | Administrador | -| https://webmoney.cnxifly.com | `marco@cnxifly.com` | `M@ster9354` | Usuário Marco | +| https://webmoney.cnxifly.com | `marco@cnxifly.com` | `M@ster9354` | Administrador | --- diff --git a/VERSION b/VERSION index c78d39b..7aa332e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.32.2 +1.33.0 diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php index f917bfb..42a260d 100644 --- a/backend/app/Http/Controllers/Api/ReportController.php +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -674,8 +674,12 @@ public function projection(Request $request) $months = $request->get('months', 3); $startDate = now()->subMonths($months)->startOfMonth()->format('Y-m-d'); $endMonthStart = now()->startOfMonth()->format('Y-m-d'); + $today = now()->format('Y-m-d'); + $endOfMonth = now()->endOfMonth()->format('Y-m-d'); - // Histórico por divisa + // ========================================================================= + // 1. HISTÓRICO: Média mensal dos últimos N meses + // ========================================================================= $historical = DB::select(" SELECT COALESCE(a.currency, 'EUR') as currency, @@ -698,7 +702,9 @@ public function projection(Request $request) $histExpense += $this->convertToPrimaryCurrency($row->monthly_expense, $row->currency); } - // Mes actual por divisa + // ========================================================================= + // 2. MÊS ATUAL: Transações já realizadas (effective_date) + // ========================================================================= $current = DB::select(" SELECT COALESCE(a.currency, 'EUR') as currency, @@ -725,28 +731,326 @@ public function projection(Request $request) $daysInMonth = now()->daysInMonth; $daysRemaining = $daysInMonth - $daysElapsed; - $projectedExpense = ($currExpense / $daysElapsed) * $daysInMonth; - $projectedIncome = ($currIncome / $daysElapsed) * $daysInMonth; + // ========================================================================= + // 3. RECORRÊNCIAS PENDENTES: Até o fim do mês + // ========================================================================= + $recurringIncome = 0; + $recurringExpense = 0; + + $recurrences = DB::select(" + SELECT + rt.id, + rt.name, + rt.planned_amount, + rt.type, + rt.frequency, + rt.day_of_month, + rt.start_date, + rt.end_date, + rt.last_generated_date, + COALESCE(a.currency, 'EUR') as currency + FROM recurring_templates rt + LEFT JOIN accounts a ON rt.account_id = a.id + WHERE rt.user_id = ? + AND rt.is_active = 1 + AND rt.deleted_at IS NULL + AND (rt.end_date IS NULL OR rt.end_date >= ?) + ", [$this->userId, $today]); + + foreach ($recurrences as $rec) { + // Verificar se ainda vai executar este mês + $nextDates = $this->getNextRecurrenceDates($rec, $today, $endOfMonth); + + foreach ($nextDates as $nextDate) { + $amount = abs($rec->planned_amount); + $converted = $this->convertToPrimaryCurrency($amount, $rec->currency); + + if ($rec->type === 'credit') { + $recurringIncome += $converted; + } else { + $recurringExpense += $converted; + } + } + } + + // ========================================================================= + // 4. PASSIVOS PENDENTES: Parcelas até o fim do mês + // ========================================================================= + $liabilityExpense = 0; + + $pendingInstallments = DB::select(" + SELECT + li.installment_amount as amount, + la.currency + FROM liability_installments li + JOIN liability_accounts la ON li.liability_account_id = la.id + WHERE la.user_id = ? + AND li.status = 'pending' + AND li.due_date >= ? + AND li.due_date <= ? + AND li.deleted_at IS NULL + ", [$this->userId, $today, $endOfMonth]); + + foreach ($pendingInstallments as $row) { + $liabilityExpense += $this->convertToPrimaryCurrency(abs($row->amount), $row->currency); + } + + // ========================================================================= + // 5. TRANSAÇÕES EM ATRASO (overdue) + // ========================================================================= + $overdueExpense = 0; + + // Parcelas de passivos vencidas + $overdueInstallments = DB::select(" + SELECT li.installment_amount as amount, la.currency + FROM liability_installments li + JOIN liability_accounts la ON li.liability_account_id = la.id + WHERE la.user_id = ? + AND li.status != 'paid' + AND li.due_date < ? + AND li.deleted_at IS NULL + ", [$this->userId, $today]); + + foreach ($overdueInstallments as $row) { + $overdueExpense += $this->convertToPrimaryCurrency(abs($row->amount), $row->currency); + } + + // Recorrências que deveriam ter executado mas não executaram + $overdueRecurrences = $this->getOverdueRecurrences($today); + foreach ($overdueRecurrences as $rec) { + $overdueExpense += $this->convertToPrimaryCurrency($rec['amount'], $rec['currency']); + } + + // ========================================================================= + // 6. CÁLCULOS FINAIS DA PROJEÇÃO + // ========================================================================= + + // Projeção simples (extrapolação linear) + $simpleProjectedExpense = ($currExpense / $daysElapsed) * $daysInMonth; + $simpleProjectedIncome = ($currIncome / $daysElapsed) * $daysInMonth; + + // Projeção inteligente: realizado + pendente (recorrências + passivos) + $smartProjectedIncome = $currIncome + $recurringIncome; + $smartProjectedExpense = $currExpense + $recurringExpense + $liabilityExpense; return response()->json([ 'historical_average' => [ 'income' => round($histIncome, 2), 'expense' => round($histExpense, 2), + 'balance' => round($histIncome - $histExpense, 2), ], 'current_month' => [ 'income' => round($currIncome, 2), 'expense' => round($currExpense, 2), + 'balance' => round($currIncome - $currExpense, 2), 'days_elapsed' => $daysElapsed, 'days_remaining' => $daysRemaining, ], + 'pending_this_month' => [ + 'recurring_income' => round($recurringIncome, 2), + 'recurring_expense' => round($recurringExpense, 2), + 'liability_installments' => round($liabilityExpense, 2), + 'total_pending_expense' => round($recurringExpense + $liabilityExpense, 2), + ], + 'overdue' => [ + 'total' => round($overdueExpense, 2), + ], 'projection' => [ - 'income' => round($projectedIncome, 2), - 'expense' => round($projectedExpense, 2), - 'balance' => round($projectedIncome - $projectedExpense, 2), + // Projeção simples (extrapolação linear) + 'simple' => [ + 'income' => round($simpleProjectedIncome, 2), + 'expense' => round($simpleProjectedExpense, 2), + 'balance' => round($simpleProjectedIncome - $simpleProjectedExpense, 2), + ], + // Projeção inteligente (realizado + recorrências + passivos) + 'smart' => [ + 'income' => round($smartProjectedIncome, 2), + 'expense' => round($smartProjectedExpense, 2), + 'balance' => round($smartProjectedIncome - $smartProjectedExpense, 2), + ], ], 'vs_average' => [ - 'income' => $histIncome > 0 ? round((($projectedIncome - $histIncome) / $histIncome) * 100, 1) : 0, - 'expense' => $histExpense > 0 ? round((($projectedExpense - $histExpense) / $histExpense) * 100, 1) : 0, + 'income' => $histIncome > 0 ? round((($smartProjectedIncome - $histIncome) / $histIncome) * 100, 1) : 0, + 'expense' => $histExpense > 0 ? round((($smartProjectedExpense - $histExpense) / $histExpense) * 100, 1) : 0, + ], + 'currency' => $this->primaryCurrency, + ]); + } + + /** + * Projeção de saldo para gráfico com dados diários/semanais + */ + public function projectionChart(Request $request) + { + $this->init(); + + $months = (int) min(max($request->input('months', 3), 1), 12); + $today = Carbon::today(); + $endDate = $today->copy()->addMonths($months); + + // Obter saldo atual total das contas (simplificado - assumindo mesma moeda) + $currentBalance = DB::selectOne(" + SELECT COALESCE(SUM(current_balance), 0) as total + FROM accounts + WHERE user_id = ? + AND include_in_total = 1 + AND deleted_at IS NULL + ", [$this->userId])->total ?? 0; + + // Gerar pontos de dados (diário para até 3 meses, semanal para mais) + $dataPoints = []; + $runningBalance = (float) $currentBalance; + $interval = $months <= 3 ? 'day' : 'week'; + $current = $today->copy(); + + // Buscar recorrências ativas + $recurrences = DB::select(" + SELECT + rt.id, + rt.name, + rt.planned_amount, + rt.type, + rt.frequency, + rt.day_of_month, + rt.start_date, + rt.end_date, + COALESCE(a.currency, 'EUR') as currency + FROM recurring_templates rt + LEFT JOIN accounts a ON rt.account_id = a.id + WHERE rt.user_id = ? + AND rt.is_active = 1 + AND rt.deleted_at IS NULL + ", [$this->userId]); + + // Buscar parcelas de passivos pendentes + $liabilityInstallments = DB::select(" + SELECT + li.due_date, + li.installment_amount as amount, + la.currency + FROM liability_installments li + JOIN liability_accounts la ON li.liability_account_id = la.id + WHERE la.user_id = ? + AND li.status = 'pending' + AND li.due_date >= ? + AND li.due_date <= ? + AND li.deleted_at IS NULL + ORDER BY li.due_date + ", [$this->userId, $today->toDateString(), $endDate->toDateString()]); + + // Buscar transações agendadas/pendentes + $scheduledTransactions = DB::select(" + SELECT + t.effective_date as date, + t.amount, + t.type, + COALESCE(a.currency, 'EUR') as currency + FROM transactions t + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.user_id = ? + AND t.status IN ('pending', 'scheduled') + AND t.effective_date >= ? + AND t.effective_date <= ? + AND t.deleted_at IS NULL + ORDER BY t.effective_date + ", [$this->userId, $today->toDateString(), $endDate->toDateString()]); + + // Ponto inicial + $dataPoints[] = [ + 'date' => $today->toDateString(), + 'balance' => round($runningBalance, 2), + 'label' => $today->format('d/m'), + 'isToday' => true, + ]; + + // Gerar pontos até a data final + while ($current->lt($endDate)) { + if ($interval === 'day') { + $current->addDay(); + } else { + $current->addWeek(); + } + + if ($current->gt($endDate)) break; + + $periodStart = $dataPoints[count($dataPoints) - 1]['date']; + $periodEnd = $current->toDateString(); + + // Somar recorrências neste período + foreach ($recurrences as $rec) { + $dates = $this->getNextRecurrenceDates($rec, $periodStart, $periodEnd); + foreach ($dates as $date) { + $amount = $this->convertToPrimaryCurrency(abs($rec->planned_amount), $rec->currency); + if ($rec->type === 'credit') { + $runningBalance += $amount; + } else { + $runningBalance -= $amount; + } + } + } + + // Somar parcelas de passivos neste período + foreach ($liabilityInstallments as $inst) { + if ($inst->due_date > $periodStart && $inst->due_date <= $periodEnd) { + $amount = $this->convertToPrimaryCurrency(abs($inst->amount), $inst->currency); + $runningBalance -= $amount; + } + } + + // Somar transações agendadas neste período + foreach ($scheduledTransactions as $tx) { + if ($tx->date > $periodStart && $tx->date <= $periodEnd) { + $amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency); + if ($tx->type === 'credit') { + $runningBalance += $amount; + } else { + $runningBalance -= $amount; + } + } + } + + $dataPoints[] = [ + 'date' => $current->toDateString(), + 'balance' => round($runningBalance, 2), + 'label' => $current->format('d/m'), + 'isToday' => false, + ]; + } + + // Calcular estatísticas + $balances = array_column($dataPoints, 'balance'); + $minBalance = min($balances); + $maxBalance = max($balances); + $avgBalance = array_sum($balances) / count($balances); + $finalBalance = end($balances); + + // Detectar mês de saldo negativo (se houver) + $negativeMonth = null; + foreach ($dataPoints as $point) { + if ($point['balance'] < 0) { + $negativeMonth = Carbon::parse($point['date'])->format('M Y'); + break; + } + } + + return response()->json([ + 'data' => $dataPoints, + 'summary' => [ + 'current_balance' => round($currentBalance, 2), + 'final_balance' => round($finalBalance, 2), + 'min_balance' => round($minBalance, 2), + 'max_balance' => round($maxBalance, 2), + 'avg_balance' => round($avgBalance, 2), + 'change' => round($finalBalance - $currentBalance, 2), + 'change_percent' => $currentBalance != 0 ? round((($finalBalance - $currentBalance) / abs($currentBalance)) * 100, 1) : 0, + 'negative_month' => $negativeMonth, + ], + 'period' => [ + 'start' => $today->toDateString(), + 'end' => $endDate->toDateString(), + 'months' => $months, + 'interval' => $interval, + 'total_points' => count($dataPoints), ], 'currency' => $this->primaryCurrency, ]); @@ -770,8 +1074,12 @@ public function recurringReport(Request $request) $result = $templates->map(function($t) use (&$monthlyIncomeConverted, &$monthlyExpenseConverted, &$byCurrency) { $currency = $t->account ? $t->account->currency : 'EUR'; - $amount = abs($t->amount); - $converted = $this->convertToPrimaryCurrency($amount, $currency); + // CORRIGIDO: usar planned_amount em vez de amount + $amount = abs($t->planned_amount ?? 0); + + // Converter para valor mensal baseado na frequência + $monthlyAmount = $this->convertToMonthlyAmount($amount, $t->frequency); + $converted = $this->convertToPrimaryCurrency($monthlyAmount, $currency); if (!isset($byCurrency[$currency])) { $byCurrency[$currency] = ['income' => 0, 'expense' => 0]; @@ -779,16 +1087,18 @@ public function recurringReport(Request $request) if ($t->type === 'credit') { $monthlyIncomeConverted += $converted; - $byCurrency[$currency]['income'] += $amount; + $byCurrency[$currency]['income'] += $monthlyAmount; } else { $monthlyExpenseConverted += $converted; - $byCurrency[$currency]['expense'] += $amount; + $byCurrency[$currency]['expense'] += $monthlyAmount; } return [ 'id' => $t->id, - 'description' => $t->description, - 'amount' => $amount, + // CORRIGIDO: usar name em vez de description + 'description' => $t->name ?? $t->transaction_description, + 'amount' => round($amount, 2), + 'monthly_amount' => round($monthlyAmount, 2), 'amount_converted' => round($converted, 2), 'currency' => $currency, 'type' => $t->type, @@ -813,6 +1123,24 @@ public function recurringReport(Request $request) ], ]); } + + /** + * Converte um valor para equivalente mensal baseado na frequência + */ + private function convertToMonthlyAmount(float $amount, string $frequency): float + { + return match($frequency) { + 'daily' => $amount * 30, + 'weekly' => $amount * 4.33, + 'biweekly' => $amount * 2.17, + 'monthly' => $amount, + 'bimonthly' => $amount / 2, + 'quarterly' => $amount / 3, + 'semiannual' => $amount / 6, + 'annual' => $amount / 12, + default => $amount, + }; + } /** * Reporte de pasivos/deudas @@ -947,7 +1275,7 @@ public function futureTransactions(Request $request) 'source_type' => 'transaction', 'status' => $row->status, 'date' => $row->date, - 'days_until' => max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1), + 'days_until' => (int) max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1), 'account' => $row->account_name, 'category' => $row->category_name, 'category_icon' => $row->category_icon, @@ -992,7 +1320,7 @@ public function futureTransactions(Request $request) 'source_type' => 'liability_installment', 'status' => $row->status, 'date' => $row->date, - 'days_until' => max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1), + 'days_until' => (int) max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1), 'account' => $row->account_name, 'category' => null, 'category_icon' => null, @@ -1051,7 +1379,7 @@ public function futureTransactions(Request $request) 'source_type' => 'recurring', 'status' => 'projected', 'date' => $nextDate, - 'days_until' => max(0, Carbon::parse($nextDate)->diffInDays(now(), false) * -1), + 'days_until' => (int) max(0, Carbon::parse($nextDate)->diffInDays(now(), false) * -1), 'account' => $rec->account_name, 'category' => $rec->category_name, 'category_icon' => $rec->category_icon, @@ -1077,21 +1405,24 @@ public function futureTransactions(Request $request) /** * Transacciones vencidas (pendientes de pago) - * Incluye: cuotas de pasivos vencidas y transacciones pendientes/scheduled pasadas + * Incluye: cuotas de pasivos vencidas, transacciones pendientes/scheduled pasadas, + * y recurrencias que deberían haber ejecutado pero no lo hicieron */ public function overdueTransactions(Request $request) { - $this->init(); - - $today = now()->format('Y-m-d'); - $result = []; - $totalOverdueConverted = 0; - - // 1. Cuotas de pasivos vencidas - $overdueInstallments = DB::select(" - SELECT - li.id, - la.name as description, + \Log::info('overdueTransactions called'); + try { + $this->init(); + + $today = now()->format('Y-m-d'); + $result = []; + $totalOverdueConverted = 0; + + // 1. Cuotas de pasivos vencidas + $overdueInstallments = DB::select(" + SELECT + li.id, + la.name as description, li.installment_amount as amount, li.due_date, la.currency, @@ -1169,6 +1500,15 @@ public function overdueTransactions(Request $request) ]; } + // 3. Recurrencias activas que deberían haber ejecutado pero no lo hicieron + $overdueRecurrences = $this->getOverdueRecurrences($today); + foreach ($overdueRecurrences as $rec) { + $converted = $this->convertToPrimaryCurrency($rec['amount'], $rec['currency']); + $totalOverdueConverted += $converted; + $rec['amount_converted'] = round($converted, 2); + $result[] = $rec; + } + // Ordenar por días de atraso (más atrasado primero) usort($result, fn($a, $b) => $b['days_overdue'] <=> $a['days_overdue']); @@ -1180,6 +1520,14 @@ public function overdueTransactions(Request $request) 'total_amount' => round($totalOverdueConverted, 2), ], ]); + } catch (\Throwable $e) { + \Log::error('overdueTransactions error: ' . $e->getMessage() . ' at line ' . $e->getLine()); + return response()->json([ + 'error' => $e->getMessage(), + 'line' => $e->getLine(), + 'file' => $e->getFile() + ], 500); + } } /** @@ -1388,4 +1736,153 @@ private function advanceToNextOccurrence($date, $recurrence) return $next; } + + /** + * Obtiene las recurrencias que deberían haber ejecutado pero no lo hicieron + * Busca la última ejecución esperada y verifica si existe una transacción para esa fecha + */ + private function getOverdueRecurrences($today) + { + $result = []; + $todayCarbon = Carbon::parse($today); + + // Obtener todas las recurrencias activas + $recurrences = DB::select(" + SELECT + rt.id, + rt.name, + rt.transaction_description as description, + rt.planned_amount as amount, + rt.type, + rt.frequency, + rt.day_of_month, + rt.start_date, + rt.end_date, + rt.last_generated_date, + COALESCE(a.currency, 'EUR') as currency, + a.name as account_name, + c.name as category_name + FROM recurring_templates rt + LEFT JOIN accounts a ON rt.account_id = a.id + LEFT JOIN categories c ON rt.category_id = c.id + WHERE rt.user_id = ? + AND rt.is_active = 1 + AND rt.deleted_at IS NULL + ", [$this->userId]); + + foreach ($recurrences as $rec) { + // Calcular la fecha de la última ejecución esperada + $expectedDate = $this->getLastExpectedExecutionDate($rec, $todayCarbon); + + if (!$expectedDate) { + continue; + } + + // Verificar si ya existe una transacción para esta recurrencia en esa fecha + // Buscamos por descripción similar y fecha cercana (±2 días) + $existingTransaction = DB::selectOne(" + SELECT id FROM transactions + WHERE user_id = ? + AND (description LIKE ? OR description LIKE ?) + AND effective_date BETWEEN DATE_SUB(?, INTERVAL 2 DAY) AND DATE_ADD(?, INTERVAL 2 DAY) + AND deleted_at IS NULL + LIMIT 1 + ", [ + $this->userId, + '%' . $rec->name . '%', + '%' . ($rec->description ?? '') . '%', + $expectedDate->format('Y-m-d'), + $expectedDate->format('Y-m-d') + ]); + + // Si no existe transacción y la fecha esperada es anterior a hoy, está vencida + if (!$existingTransaction && $expectedDate->lt($todayCarbon)) { + $daysOverdue = abs($expectedDate->diffInDays($todayCarbon)); + + $result[] = [ + 'id' => $rec->id, + 'description' => $rec->name, + 'amount' => round(abs($rec->amount), 2), + 'currency' => $rec->currency, + 'due_date' => $expectedDate->format('Y-m-d'), + 'days_overdue' => (int) $daysOverdue, + 'source_type' => 'recurring_overdue', + 'type' => $rec->type, + 'status' => 'not_executed', + 'account' => $rec->account_name, + 'category' => $rec->category_name, + ]; + } + } + + return $result; + } + + /** + * Calcula la fecha de la última ejecución esperada para una recurrencia + */ + private function getLastExpectedExecutionDate($recurrence, $today) + { + $startDate = Carbon::parse($recurrence->start_date); + + // Si aún no ha llegado la fecha de inicio, no hay ejecución esperada + if ($startDate->gt($today)) { + return null; + } + + // Si tiene fecha de fin y ya pasó, usar la fecha de fin + $endDate = $recurrence->end_date ? Carbon::parse($recurrence->end_date) : null; + $referenceDate = ($endDate && $endDate->lt($today)) ? $endDate : $today; + + // Calcular la fecha esperada según la frecuencia + switch ($recurrence->frequency) { + case 'monthly': + $dayOfMonth = $recurrence->day_of_month ?? $startDate->day; + $expectedDate = $referenceDate->copy()->day(min($dayOfMonth, $referenceDate->daysInMonth)); + + // Si la fecha calculada es posterior a hoy, retroceder un mes + if ($expectedDate->gt($today)) { + $expectedDate->subMonth(); + $expectedDate->day = min($dayOfMonth, $expectedDate->daysInMonth); + } + return $expectedDate; + + case 'weekly': + $dayOfWeek = $startDate->dayOfWeek; + $expectedDate = $referenceDate->copy()->startOfWeek()->addDays($dayOfWeek); + if ($expectedDate->gt($today)) { + $expectedDate->subWeek(); + } + return $expectedDate; + + case 'biweekly': + $dayOfWeek = $startDate->dayOfWeek; + $weeksSinceStart = $startDate->diffInWeeks($referenceDate); + $biweeklyPeriods = floor($weeksSinceStart / 2); + $expectedDate = $startDate->copy()->addWeeks($biweeklyPeriods * 2); + if ($expectedDate->gt($today)) { + $expectedDate->subWeeks(2); + } + return $expectedDate; + + case 'quarterly': + $dayOfMonth = $recurrence->day_of_month ?? $startDate->day; + $quarterMonth = floor(($referenceDate->month - 1) / 3) * 3 + 1; + $expectedDate = $referenceDate->copy()->month($quarterMonth)->day(min($dayOfMonth, Carbon::create($referenceDate->year, $quarterMonth, 1)->daysInMonth)); + if ($expectedDate->gt($today)) { + $expectedDate->subMonths(3); + } + return $expectedDate; + + case 'annual': + $expectedDate = Carbon::create($referenceDate->year, $startDate->month, min($startDate->day, Carbon::create($referenceDate->year, $startDate->month, 1)->daysInMonth)); + if ($expectedDate->gt($today)) { + $expectedDate->subYear(); + } + return $expectedDate; + + default: + return null; + } + } } diff --git a/backend/routes/api.php b/backend/routes/api.php index 4719935..f1037e0 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -266,6 +266,7 @@ Route::get('compare-periods', [ReportController::class, 'comparePeriods']); Route::get('accounts', [ReportController::class, 'accountsReport']); Route::get('projection', [ReportController::class, 'projection']); + Route::get('projection-chart', [ReportController::class, 'projectionChart']); Route::get('recurring', [ReportController::class, 'recurringReport']); Route::get('liabilities', [ReportController::class, 'liabilities']); Route::get('future-transactions', [ReportController::class, 'futureTransactions']); diff --git a/frontend/src/components/dashboard/BalanceProjectionChart.jsx b/frontend/src/components/dashboard/BalanceProjectionChart.jsx new file mode 100644 index 0000000..0de2e00 --- /dev/null +++ b/frontend/src/components/dashboard/BalanceProjectionChart.jsx @@ -0,0 +1,302 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + LineElement, + PointElement, + Title, + Tooltip, + Legend, + Filler, +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; +import { useTranslation } from 'react-i18next'; +import useFormatters from '../../hooks/useFormatters'; +import { reportService } from '../../services/api'; + +// Registrar componentes do Chart.js +ChartJS.register( + CategoryScale, + LinearScale, + LineElement, + PointElement, + Title, + Tooltip, + Legend, + Filler +); + +const BalanceProjectionChart = () => { + const { t } = useTranslation(); + const { currency } = useFormatters(); + + const [months, setMonths] = useState(3); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await reportService.getProjectionChart({ months }); + setData(response); + } catch (err) { + console.error('Error loading projection chart:', err); + setError(t('common.error')); + } finally { + setLoading(false); + } + }, [months, t]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const periodOptions = [ + { value: 1, label: t('reports.projectionChart.1month') || '1 mês' }, + { value: 2, label: t('reports.projectionChart.2months') || '2 meses' }, + { value: 3, label: t('reports.projectionChart.3months') || '3 meses' }, + { value: 6, label: t('reports.projectionChart.6months') || '6 meses' }, + { value: 12, label: t('reports.projectionChart.12months') || '12 meses' }, + ]; + + const chartData = { + labels: data?.data?.map(d => d.label) || [], + datasets: [ + { + label: t('reports.projectionChart.projectedBalance') || 'Saldo Projetado', + data: data?.data?.map(d => d.balance) || [], + borderColor: 'rgb(59, 130, 246)', + backgroundColor: (context) => { + const chart = context.chart; + const { ctx, chartArea } = chart; + if (!chartArea) return 'rgba(59, 130, 246, 0.2)'; + + const gradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top); + gradient.addColorStop(0, 'rgba(59, 130, 246, 0.0)'); + gradient.addColorStop(1, 'rgba(59, 130, 246, 0.3)'); + return gradient; + }, + borderWidth: 2, + fill: true, + tension: 0.3, + pointRadius: (context) => { + // Mostrar ponto maior no dia atual + const point = data?.data?.[context.dataIndex]; + return point?.isToday ? 6 : 2; + }, + pointHoverRadius: 6, + pointBackgroundColor: (context) => { + const point = data?.data?.[context.dataIndex]; + if (point?.isToday) return 'rgb(234, 179, 8)'; + return point?.balance < 0 ? 'rgb(239, 68, 68)' : 'rgb(59, 130, 246)'; + }, + pointBorderColor: (context) => { + const point = data?.data?.[context.dataIndex]; + if (point?.isToday) return 'rgb(234, 179, 8)'; + return point?.balance < 0 ? 'rgb(239, 68, 68)' : 'rgb(59, 130, 246)'; + }, + segment: { + borderColor: ctx => { + const value = ctx.p1.parsed.y; + return value < 0 ? 'rgba(239, 68, 68, 0.8)' : 'rgb(59, 130, 246)'; + }, + }, + }, + ], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + backgroundColor: 'rgba(15, 23, 42, 0.9)', + titleColor: '#f1f5f9', + bodyColor: '#f1f5f9', + borderColor: 'rgba(148, 163, 184, 0.2)', + borderWidth: 1, + padding: 12, + callbacks: { + title: (items) => { + const point = data?.data?.[items[0].dataIndex]; + return point?.date || items[0].label; + }, + label: (context) => { + const point = data?.data?.[context.dataIndex]; + let label = ` ${t('reports.projectionChart.balance') || 'Saldo'}: ${currency(context.parsed.y, data?.currency || 'EUR')}`; + if (point?.isToday) { + label += ` (${t('common.today') || 'Hoje'})`; + } + return label; + }, + }, + }, + }, + scales: { + x: { + ticks: { + color: '#94a3b8', + maxRotation: 45, + minRotation: 45, + }, + grid: { + color: 'rgba(148, 163, 184, 0.1)', + }, + }, + y: { + ticks: { + color: '#94a3b8', + callback: (value) => currency(value, data?.currency || 'EUR'), + }, + grid: { + color: 'rgba(148, 163, 184, 0.1)', + }, + }, + }, + }; + + if (loading) { + return ( +
+
+
+
+ Loading... +
+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+
+ +

{error}

+ +
+
+
+
+ ); + } + + const summary = data?.summary; + const changeClass = summary?.change >= 0 ? 'text-success' : 'text-danger'; + const changeIcon = summary?.change >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'; + + return ( +
+
+
+
+ + {t('reports.projectionChart.title') || 'Projeção de Saldo'} +
+ + {t('reports.projectionChart.subtitle') || 'Evolução prevista do seu saldo'} + +
+
+ {periodOptions.map(opt => ( + + ))} +
+
+
+ {/* Summary Stats */} + {summary && ( +
+
+
+ {t('reports.projectionChart.currentBalance') || 'Saldo Atual'} + {currency(summary.current_balance, data?.currency)} +
+
+
+
+ {t('reports.projectionChart.finalBalance') || 'Saldo Final'} + = 0 ? 'text-success' : 'text-danger'}`}> + {currency(summary.final_balance, data?.currency)} + +
+
+
+
+ {t('reports.projectionChart.minBalance') || 'Saldo Mínimo'} + = 0 ? 'text-warning' : 'text-danger'}`}> + {currency(summary.min_balance, data?.currency)} + +
+
+
+
+ {t('reports.projectionChart.change') || 'Variação'} + + + {currency(Math.abs(summary.change), data?.currency)} + ({summary.change_percent}%) + +
+
+
+ )} + + {/* Alert if negative balance predicted */} + {summary?.negative_month && ( +
+ +
+ {t('reports.projectionChart.warning') || 'Atenção!'}{' '} + {t('reports.projectionChart.negativeAlert') || 'Previsão de saldo negativo em'}{' '} + {summary.negative_month} +
+
+ )} + + {/* Chart */} +
+ +
+ + {/* Period Info */} + {data?.period && ( +
+ + {t('reports.projectionChart.period') || 'Período'}:{' '} + {data.period.start} + {' → '} + {data.period.end} + {' | '} + {data.period.total_points} {t('reports.projectionChart.dataPoints') || 'pontos de dados'} + +
+ )} +
+
+ ); +}; + +export default BalanceProjectionChart; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 470fa82..b005fdf 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1877,7 +1877,27 @@ "historicalAverage": "Historical Average", "monthProjection": "Month Projection", "last3Months": "last 3 months", - "currentMonth": "Current Month" + "currentMonth": "Current Month", + "projectionChart": { + "title": "Balance Projection", + "subtitle": "Expected evolution of your balance", + "projectedBalance": "Projected Balance", + "balance": "Balance", + "currentBalance": "Current Balance", + "finalBalance": "Final Balance", + "minBalance": "Minimum Balance", + "maxBalance": "Maximum Balance", + "change": "Change", + "warning": "Warning!", + "negativeAlert": "Negative balance predicted in", + "period": "Period", + "dataPoints": "data points", + "1month": "1 month", + "2months": "2 months", + "3months": "3 months", + "6months": "6 months", + "12months": "12 months" + } }, "months": { "january": "January", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index e41bedd..5eee580 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -1859,7 +1859,27 @@ "historicalAverage": "Promedio histórico", "monthProjection": "Proyección del mes", "last3Months": "últimos 3 meses", - "currentMonth": "Mes Actual" + "currentMonth": "Mes Actual", + "projectionChart": { + "title": "Proyección de Saldo", + "subtitle": "Evolución prevista de tu saldo", + "projectedBalance": "Saldo Proyectado", + "balance": "Saldo", + "currentBalance": "Saldo Actual", + "finalBalance": "Saldo Final", + "minBalance": "Saldo Mínimo", + "maxBalance": "Saldo Máximo", + "change": "Variación", + "warning": "¡Atención!", + "negativeAlert": "Previsión de saldo negativo en", + "period": "Período", + "dataPoints": "puntos de datos", + "1month": "1 mes", + "2months": "2 meses", + "3months": "3 meses", + "6months": "6 meses", + "12months": "12 meses" + } }, "months": { "january": "Enero", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index a6ba9a0..1fd0427 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -1879,7 +1879,27 @@ "historicalAverage": "Média histórica", "monthProjection": "Projeção do mês", "last3Months": "últimos 3 meses", - "currentMonth": "Mês Atual" + "currentMonth": "Mês Atual", + "projectionChart": { + "title": "Projeção de Saldo", + "subtitle": "Evolução prevista do seu saldo", + "projectedBalance": "Saldo Projetado", + "balance": "Saldo", + "currentBalance": "Saldo Atual", + "finalBalance": "Saldo Final", + "minBalance": "Saldo Mínimo", + "maxBalance": "Saldo Máximo", + "change": "Variação", + "warning": "Atenção!", + "negativeAlert": "Previsão de saldo negativo em", + "period": "Período", + "dataPoints": "pontos de dados", + "1month": "1 mês", + "2months": "2 meses", + "3months": "3 meses", + "6months": "6 meses", + "12months": "12 meses" + } }, "months": { "january": "Janeiro", diff --git a/frontend/src/pages/Reports.jsx b/frontend/src/pages/Reports.jsx index 485856e..46027d1 100644 --- a/frontend/src/pages/Reports.jsx +++ b/frontend/src/pages/Reports.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { reportService, categoryService } from '../services/api'; import useFormatters from '../hooks/useFormatters'; +import BalanceProjectionChart from '../components/dashboard/BalanceProjectionChart'; import { Chart as ChartJS, CategoryScale, @@ -632,97 +633,104 @@ const Reports = () => { // Render Projection Tab const renderProjection = () => { - if (!projection) return null; - return (
-
-
-
-
- - {t('reports.currentMonth')} -
-
-
-
- {t('reports.income')} - {currency(projection.current_month.income, projection.currency)} -
-
- {t('reports.expenses')} - {currency(projection.current_month.expense, projection.currency)} -
-
-
- {t('reports.daysRemaining')} - {projection.current_month.days_remaining} {t('common.days')} -
-
-
-
- -
-
-
-
- - {t('reports.projectionTitle')} -
-
-
-
- {t('reports.projectedIncome')} - {currency(projection.projection.income, projection.currency)} -
-
- {t('reports.projectedExpense')} - {currency(projection.projection.expense, projection.currency)} -
-
-
- {t('reports.balance')} - = 0 ? '' : 'text-warning'}`}> - {currency(projection.projection.balance, projection.currency)} - -
-
-
-
- - {/* vs Average */} + {/* Balance Projection Chart - Full Width First */}
-
-
-
- - {t('reports.vsAverage')} ({t('reports.last3Months')}) -
-
-
- -
-
+
+ + {projection && ( + <> +
+
+
+
+ + {t('reports.currentMonth')} +
+
+
+
+ {t('reports.income')} + {currency(projection.current_month.income, projection.currency)} +
+
+ {t('reports.expenses')} + {currency(projection.current_month.expense, projection.currency)} +
+
+
+ {t('reports.daysRemaining')} + {projection.current_month.days_remaining} {t('common.days')} +
+
+
+
+ +
+
+
+
+ + {t('reports.projectionTitle')} +
+
+
+
+ {t('reports.projectedIncome')} + {currency(projection.projection.income, projection.currency)} +
+
+ {t('reports.projectedExpense')} + {currency(projection.projection.expense, projection.currency)} +
+
+
+ {t('reports.balance')} + = 0 ? '' : 'text-warning'}`}> + {currency(projection.projection.balance, projection.currency)} + +
+
+
+
+ + {/* vs Average */} +
+
+
+
+ + {t('reports.vsAverage')} ({t('reports.last3Months')}) +
+
+
+ +
+
+
+ + )}
); }; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index be64a50..938ad89 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1446,6 +1446,12 @@ export const reportService = { return response.data; }, + // Gráfico de projeção de saldo + getProjectionChart: async (params = {}) => { + const response = await api.get('/reports/projection-chart', { params }); + return response.data; + }, + // Reporte de recurrentes getRecurringReport: async () => { const response = await api.get('/reports/recurring');