v1.33.0: Gráfico de projeção de saldo + reset completo do banco de dados
Added: - Gráfico de projeção de saldo com período ajustável (1-12 meses) - Endpoint GET /api/reports/projection-chart - Componente BalanceProjectionChart com Chart.js - Projeções baseadas em recorrências, passivos e transações agendadas - Tradução completa (pt-BR, en, es) Fixed: - Type casting para parâmetro months no endpoint - Query SQL simplificada sem exchange_rates - Ordem de execução das migrações - Permissões do bootstrap/cache (www-data) Changed: - Database reset completo (migrate:fresh) - Usuário recriado com novo token API - Deploy completo via scripts oficiais
This commit is contained in:
parent
1feb3354ea
commit
8d9e022f9f
245
.DIRETRIZES_DESENVOLVIMENTO_v5
Normal file
245
.DIRETRIZES_DESENVOLVIMENTO_v5
Normal file
@ -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
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
86
.github/copilot-instructions.md
vendored
Normal file
86
.github/copilot-instructions.md
vendored
Normal file
@ -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.
|
||||
41
CHANGELOG.md
41
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
|
||||
|
||||
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']);
|
||||
|
||||
302
frontend/src/components/dashboard/BalanceProjectionChart.jsx
Normal file
302
frontend/src/components/dashboard/BalanceProjectionChart.jsx
Normal file
@ -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 (
|
||||
<div className="card border-0 shadow-sm bg-slate-800">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-center align-items-center" style={{ height: '350px' }}>
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card border-0 shadow-sm bg-slate-800">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-center align-items-center text-danger" style={{ height: '350px' }}>
|
||||
<div className="text-center">
|
||||
<i className="bi bi-exclamation-triangle fs-1 mb-2"></i>
|
||||
<p>{error}</p>
|
||||
<button className="btn btn-sm btn-outline-primary" onClick={loadData}>
|
||||
{t('common.retry') || 'Tentar novamente'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="card border-0 shadow-sm bg-slate-800">
|
||||
<div className="card-header bg-transparent border-bottom border-slate-700 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 className="card-title text-light mb-1">
|
||||
<i className="bi bi-graph-up-arrow me-2 text-primary"></i>
|
||||
{t('reports.projectionChart.title') || 'Projeção de Saldo'}
|
||||
</h5>
|
||||
<small className="text-slate-400">
|
||||
{t('reports.projectionChart.subtitle') || 'Evolução prevista do seu saldo'}
|
||||
</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
{periodOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`btn btn-sm ${months === opt.value ? 'btn-primary' : 'btn-outline-secondary'}`}
|
||||
onClick={() => setMonths(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{/* Summary Stats */}
|
||||
{summary && (
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="p-3 rounded bg-slate-700">
|
||||
<small className="text-slate-400 d-block">{t('reports.projectionChart.currentBalance') || 'Saldo Atual'}</small>
|
||||
<span className="fs-5 fw-bold text-light">{currency(summary.current_balance, data?.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="p-3 rounded bg-slate-700">
|
||||
<small className="text-slate-400 d-block">{t('reports.projectionChart.finalBalance') || 'Saldo Final'}</small>
|
||||
<span className={`fs-5 fw-bold ${summary.final_balance >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||
{currency(summary.final_balance, data?.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="p-3 rounded bg-slate-700">
|
||||
<small className="text-slate-400 d-block">{t('reports.projectionChart.minBalance') || 'Saldo Mínimo'}</small>
|
||||
<span className={`fs-5 fw-bold ${summary.min_balance >= 0 ? 'text-warning' : 'text-danger'}`}>
|
||||
{currency(summary.min_balance, data?.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="p-3 rounded bg-slate-700">
|
||||
<small className="text-slate-400 d-block">{t('reports.projectionChart.change') || 'Variação'}</small>
|
||||
<span className={`fs-5 fw-bold ${changeClass}`}>
|
||||
<i className={`bi ${changeIcon} me-1`}></i>
|
||||
{currency(Math.abs(summary.change), data?.currency)}
|
||||
<small className="ms-1">({summary.change_percent}%)</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert if negative balance predicted */}
|
||||
{summary?.negative_month && (
|
||||
<div className="alert alert-danger d-flex align-items-center mb-4" role="alert">
|
||||
<i className="bi bi-exclamation-triangle-fill me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>{t('reports.projectionChart.warning') || 'Atenção!'}</strong>{' '}
|
||||
{t('reports.projectionChart.negativeAlert') || 'Previsão de saldo negativo em'}{' '}
|
||||
<strong>{summary.negative_month}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
<div style={{ height: '350px' }}>
|
||||
<Line data={chartData} options={options} />
|
||||
</div>
|
||||
|
||||
{/* Period Info */}
|
||||
{data?.period && (
|
||||
<div className="text-center mt-3">
|
||||
<small className="text-slate-400">
|
||||
{t('reports.projectionChart.period') || 'Período'}:{' '}
|
||||
<span className="text-light">{data.period.start}</span>
|
||||
{' → '}
|
||||
<span className="text-light">{data.period.end}</span>
|
||||
{' | '}
|
||||
{data.period.total_points} {t('reports.projectionChart.dataPoints') || 'pontos de dados'}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BalanceProjectionChart;
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 (
|
||||
<div className="row g-4">
|
||||
<div className="col-md-6">
|
||||
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<i className="bi bi-calendar3 me-2"></i>
|
||||
{t('reports.currentMonth')}
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<span className="text-slate-400">{t('reports.income')}</span>
|
||||
<span className="text-success">{currency(projection.current_month.income, projection.currency)}</span>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<span className="text-slate-400">{t('reports.expenses')}</span>
|
||||
<span className="text-danger">{currency(projection.current_month.expense, projection.currency)}</span>
|
||||
</div>
|
||||
<hr className="border-secondary" />
|
||||
<div className="d-flex justify-content-between">
|
||||
<span className="text-slate-400">{t('reports.daysRemaining')}</span>
|
||||
<span className="text-white">{projection.current_month.days_remaining} {t('common.days')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #1e40af 0%, #3b82f6 100%)' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<i className="bi bi-lightning me-2"></i>
|
||||
{t('reports.projectionTitle')}
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body text-white">
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<span className="opacity-75">{t('reports.projectedIncome')}</span>
|
||||
<span className="fw-bold">{currency(projection.projection.income, projection.currency)}</span>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<span className="opacity-75">{t('reports.projectedExpense')}</span>
|
||||
<span className="fw-bold">{currency(projection.projection.expense, projection.currency)}</span>
|
||||
</div>
|
||||
<hr className="border-white opacity-25" />
|
||||
<div className="d-flex justify-content-between">
|
||||
<span className="opacity-75">{t('reports.balance')}</span>
|
||||
<span className={`fw-bold ${projection.projection.balance >= 0 ? '' : 'text-warning'}`}>
|
||||
{currency(projection.projection.balance, projection.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* vs Average */}
|
||||
{/* Balance Projection Chart - Full Width First */}
|
||||
<div className="col-12">
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<i className="bi bi-bar-chart me-2"></i>
|
||||
{t('reports.vsAverage')} ({t('reports.last3Months')})
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body" style={{ height: '250px' }}>
|
||||
<Bar
|
||||
data={{
|
||||
labels: [t('reports.income'), t('reports.expenses')],
|
||||
datasets: [
|
||||
{
|
||||
label: t('reports.historicalAverage'),
|
||||
data: [projection.historical_average.income, projection.historical_average.expense],
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.5)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
{
|
||||
label: t('reports.monthProjection'),
|
||||
data: [projection.projection.income, projection.projection.expense],
|
||||
backgroundColor: ['rgba(16, 185, 129, 0.7)', 'rgba(239, 68, 68, 0.7)'],
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={chartOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BalanceProjectionChart />
|
||||
</div>
|
||||
|
||||
{projection && (
|
||||
<>
|
||||
<div className="col-md-6">
|
||||
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<i className="bi bi-calendar3 me-2"></i>
|
||||
{t('reports.currentMonth')}
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<span className="text-slate-400">{t('reports.income')}</span>
|
||||
<span className="text-success">{currency(projection.current_month.income, projection.currency)}</span>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<span className="text-slate-400">{t('reports.expenses')}</span>
|
||||
<span className="text-danger">{currency(projection.current_month.expense, projection.currency)}</span>
|
||||
</div>
|
||||
<hr className="border-secondary" />
|
||||
<div className="d-flex justify-content-between">
|
||||
<span className="text-slate-400">{t('reports.daysRemaining')}</span>
|
||||
<span className="text-white">{projection.current_month.days_remaining} {t('common.days')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #1e40af 0%, #3b82f6 100%)' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<i className="bi bi-lightning me-2"></i>
|
||||
{t('reports.projectionTitle')}
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body text-white">
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<span className="opacity-75">{t('reports.projectedIncome')}</span>
|
||||
<span className="fw-bold">{currency(projection.projection.income, projection.currency)}</span>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<span className="opacity-75">{t('reports.projectedExpense')}</span>
|
||||
<span className="fw-bold">{currency(projection.projection.expense, projection.currency)}</span>
|
||||
</div>
|
||||
<hr className="border-white opacity-25" />
|
||||
<div className="d-flex justify-content-between">
|
||||
<span className="opacity-75">{t('reports.balance')}</span>
|
||||
<span className={`fw-bold ${projection.projection.balance >= 0 ? '' : 'text-warning'}`}>
|
||||
{currency(projection.projection.balance, projection.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* vs Average */}
|
||||
<div className="col-12">
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<i className="bi bi-bar-chart me-2"></i>
|
||||
{t('reports.vsAverage')} ({t('reports.last3Months')})
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body" style={{ height: '250px' }}>
|
||||
<Bar
|
||||
data={{
|
||||
labels: [t('reports.income'), t('reports.expenses')],
|
||||
datasets: [
|
||||
{
|
||||
label: t('reports.historicalAverage'),
|
||||
data: [projection.historical_average.income, projection.historical_average.expense],
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.5)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
{
|
||||
label: t('reports.monthProjection'),
|
||||
data: [projection.projection.income, projection.projection.expense],
|
||||
backgroundColor: ['rgba(16, 185, 129, 0.7)', 'rgba(239, 68, 68, 0.7)'],
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={chartOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user