diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5b85b..3427e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,140 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). +## [1.44.5] - 2025-12-17 + +### Added +- 📧 **Notificações de Pagamentos Vencidos** - Sistema completo de alertas diários por email + - **Email diário** no horário configurado pelo usuário (padrão: 20:00) + - **Conteúdo do email**: + - Resumo geral: total vencido, total amanhã, saldo disponível, déficit + - Lista de itens vencidos (ordenados pelo mais antigo primeiro) + - Lista de itens que vencem amanhã + - Saldos de todas as contas + - Lista de pagamentos que podem ser feitos com saldo atual + - Lista de pagamentos que não podem ser feitos (sem saldo) + - Sugestões de transferência entre contas para cobrir déficits + - **Fontes de dados**: transactions pendentes, liability_installments pendentes, recurring_instances pendentes + - **Template responsivo** em HTML com fallback para texto puro + +- ⚙️ **Página de Preferências do Usuário** - Nova seção de configurações + - Ativar/desativar notificações de pagamentos + - Configurar horário de envio (input tipo time) + - Email personalizado (opcional, padrão é email do usuário) + - Botão de teste para enviar notificação imediatamente + - Configurações de idioma, timezone e moeda + - Interface com traduções em PT-BR, EN e ES + +### Technical Details +- **Backend**: + - Nova tabela: `user_preferences` (notify_due_payments, notify_due_payments_time, notify_due_payments_email, language, timezone, currency) + - Novo Model: `UserPreference` com relação ao User + - Novo Artisan Command: `notify:due-payments` - executa a cada minuto via scheduler + - Nova Mailable: `DuePaymentsAlert` com templates HTML e texto + - Novo Controller: `UserPreferenceController` (GET/PUT preferences, POST test-notification) + - Scheduler configurado em `routes/console.php` + +- **Frontend**: + - Nova página: `Preferences.jsx` com formulário de configurações + - Novo serviço: `preferencesService` em api.js + - Nova rota: `/preferences` + - Link no menu de configurações (dropdown do usuário) + +- **Servidor**: + - Cron configurado: `* * * * * cd /var/www/webmoney/backend && php artisan schedule:run` + +## [1.44.4] - 2025-12-17 + +### Fixed +- **Reports - Vencidos** - Corrigido endpoint `/api/reports/overdue` para exibir todas as transações vencidas + - Adicionada busca direta em `recurring_instances` pendentes com `due_date < hoje` + - Antes: apenas 1 vencido era exibido (dependia de `getOverdueRecurrences` que só buscava templates ativos) + - Agora: todas as instâncias de recorrências pendentes vencidas são exibidas (igual a parcelas de passivos e transações pendentes) + - Identificação clara com sufixo "(Recorrente)" na descrição + - `source_type: 'recurring_instance'` para diferenciar de outros tipos + +## [1.44.3] - 2025-12-17 + +### Fixed +- 🐛 **CORREÇÃO DEFINITIVA**: Propagação Espaçada de Orçamentos por Período + - **Comportamento Desejado**: Orçamentos devem ser criados de forma **espaçada** ao longo do ano + - **Implementação Correta**: + * **monthly**: A cada 1 mês → Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec = **12 orçamentos** + * **bimestral**: A cada 2 meses → Jan, Mar, May, Jul, Sep, Nov = **6 orçamentos** + * **trimestral**: A cada 3 meses → Jan, Apr, Jul, Oct = **4 orçamentos** + * **semestral**: A cada 6 meses → Jan, Jul = **2 orçamentos** + * **yearly**: Apenas 1 vez → Jan = **1 orçamento** + - Exemplo prático: Criar orçamento **bimestral** em Janeiro → sistema cria automaticamente para Jan, Mar, Mai, Jul, Set, Nov + - v1.44.2 estava criando apenas 2 meses consecutivos (Jan, Fev) em vez de espaçados + +### Technical Details +- Arquivo: `backend/app/Http/Controllers/Api/BudgetController.php` +- Método: `store()` - linhas 110-130 +- Mudança: Implementado `$periodStepMap` com salto de meses baseado no período +- Lógica: `for ($monthsAhead = $step; $monthsAhead < 12; $monthsAhead += $step)` +- Resultado: Orçamentos distribuídos uniformemente ao longo de 12 meses + +## [1.44.2] - 2025-12-17 + +### Fixed +- 🐛 **CORREÇÃO CRÍTICA**: Propagação de Orçamentos por Tipo de Período + - **BUG**: Orçamentos bimestrais, trimestrais, semestrais e anuais estavam sendo propagados para TODOS os 12 meses seguintes, ignorando o tipo de período selecionado + - **CAUSA**: Loop fixo `for ($i = 1; $i <= 12; $i++)` no método `store()` do `BudgetController` + - **SOLUÇÃO**: Implementado mapa dinâmico de propagação por tipo de período: + * **monthly**: Propaga 12 meses adicionais (mês atual + 12 seguintes) ✓ + * **bimestral**: Propaga 1 mês adicional (2 meses totais) ✓ + * **trimestral**: Propaga 2 meses adicionais (3 meses totais) ✓ + * **semestral**: Propaga 5 meses adicionais (6 meses totais) ✓ + * **yearly**: SEM propagação (1 mês apenas) ✓ + - Exemplo: Orçamento bimestral criado em Janeiro → cria Janeiro + Fevereiro (2 meses) + - Antes: Orçamento bimestral criado em Janeiro → criava Janeiro até Dezembro (12 meses) ❌ + +### Technical Details +- Arquivo: `backend/app/Http/Controllers/Api/BudgetController.php` +- Método: `store()` - linhas 110-161 +- Mudança: Loop dinâmico baseado em `$monthsToPropagateMap` +- Compatibilidade: Mantida para orçamentos existentes (não afetados) + +## [1.44.1] - 2025-12-17 + +### Fixed +- 🐛 **HOTFIX**: Erro de sintaxe no BudgetController.php linha 158 + - Removida vírgula dupla e código solto que causava erro 500 em todas as rotas de budgets + - Corrigido: `'message' => 'Presupuesto creado y propagado',, 'costCenter'` + - Para: `'message' => 'Presupuesto creado y propagado',` + - Adicionado `costCenter` ao load() corretamente + +## [1.44.0] - 2025-12-17 + +### Added +- ✨ **NOVA FUNCIONALIDADE**: Centros de Custos em Orçamentos + - Orçamentos agora podem ser filtrados por Centro de Custos (opcional) + - Validação aceita: categoria OU subcategoria OU centro de custos + - Backend: Migration adiciona coluna `cost_center_id` na tabela `budgets` + - Frontend: Seletor de centro de custos no modal de criação de orçamento + - Frontend: Exibição do nome do centro de custos nos cards de orçamento + +- ✨ **NOVA FUNCIONALIDADE**: Tipos de Período Completos + - Migration atualiza enum `period_type` com todos os períodos: + * monthly (mensal) - 1 mês + * bimestral (bimestral) - 2 meses + * trimestral (trimestral) - 3 meses + * semestral (semestral) - 6 meses + * yearly (anual) - 12 meses + - Cálculo de períodos já estava implementado no modelo Budget.php + +### Changed +- 📝 BudgetController: Atualizado para incluir `costCenter` em relacionamentos eager loading +- 📝 BudgetController: Validação de duplicatas agora considera `cost_center_id` +- 📝 Budget Model: Cálculo de `spent_amount` agora filtra por `cost_center_id` +- 📝 Budget Model: Adicionado relacionamento `costCenter()` +- 🌐 Traduções: Adicionadas chaves para cost center e tipos de período (pt-BR, es, en) + +### Technical +- 🗄️ Migration: `2025_12_17_000001_add_cost_center_and_update_periods_to_budgets.php` + - Adiciona coluna `cost_center_id` (nullable, foreign key) + - Atualiza `period_type` ENUM para incluir bimestral, trimestral, semestral + ## [1.43.26] - 2025-12-16 ### Fixed diff --git a/IMPLEMENTACAO_ORCAMENTOS_SUBCATEGORIA.md b/IMPLEMENTACAO_ORCAMENTOS_SUBCATEGORIA.md new file mode 100644 index 0000000..97c8624 --- /dev/null +++ b/IMPLEMENTACAO_ORCAMENTOS_SUBCATEGORIA.md @@ -0,0 +1,90 @@ +# Implementação de Orçamentos por Subcategoria + +## ✅ O que foi feito: + +### 1. **Backend - Database** +- ✅ Migration criada: `2025_12_16_211102_add_subcategory_to_budgets_table.php` + - Adiciona coluna `subcategory_id` + - Atualiza unique constraint para incluir subcategoria + +### 2. **Backend - Model** +- ✅ `Budget.php` atualizado: + - Adicionado `subcategory_id` ao fillable + - Nova relação `subcategory()` + - Lógica de `getSpentAmountAttribute()` atualizada para considerar subcategorias + +### 3. **Backend - Controller** +- ✅ `BudgetController.php` atualizado: + - `index()`: carrega relação `subcategory` + - `store()`: valida e cria orçamentos com subcategoria + - `show()`: filtra transações por subcategoria quando aplicável + - `destroy()`: deleta considerando subcategoria + +### 4. **Frontend** +- ✅ `Budgets.jsx` atualizado: + - Formulário com seleção de subcategoria (opcional) + - Exibição de subcategoria nos cards + - Se categoria tem subcategorias, mostra opção "Toda a categoria" + lista de subcategorias + +### 5. **Traduções** +- ✅ Adicionado em PT-BR, ES, EN: + - `budgets.subcategory`: "Subcategoria" + - `budgets.allCategory`: "Toda a categoria" / "All category" / "Toda la categoría" + +## 🚀 Como fazer Deploy: + +### Backend: +```bash +cd /workspaces/webmoney + +# 1. Enviar arquivos +sshpass -p 'Master9354' scp -o StrictHostKeyChecking=no \ + backend/database/migrations/2025_12_16_211102_add_subcategory_to_budgets_table.php \ + root@213.165.93.60:/var/www/webmoney/backend/database/migrations/ + +sshpass -p 'Master9354' scp -o StrictHostKeyChecking=no \ + backend/app/Models/Budget.php \ + root@213.165.93.60:/var/www/webmoney/backend/app/Models/ + +sshpass -p 'Master9354' scp -o StrictHostKeyChecking=no \ + backend/app/Http/Controllers/Api/BudgetController.php \ + root@213.165.93.60:/var/www/webmoney/backend/app/Http/Controllers/Api/ + +# 2. Executar migration +sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 \ + "cd /var/www/webmoney/backend && php artisan migrate --force" + +# 3. Limpar cache +sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 \ + "cd /var/www/webmoney/backend && php artisan config:clear && php artisan cache:clear" +``` + +### Frontend: +```bash +cd /workspaces/webmoney/frontend && ./deploy.sh +``` + +## 📝 Como usar: + +1. **Criar orçamento para categoria inteira** (comportamento existente): + - Selecionar categoria + - NÃO selecionar subcategoria (ou selecionar "Toda a categoria") + - Valor do orçamento + +2. **Criar orçamento para subcategoria específica** (NOVO): + - Selecionar categoria + - Selecionar a subcategoria desejada + - Valor do orçamento + +### Exemplo: +- Categoria: "Alimentação" - Orçamento R$ 1000 (todas as subcategorias) +- Ou +- Categoria: "Alimentação" → Subcategoria: "Restaurante" - Orçamento R$ 300 (apenas restaurante) + +## 🔍 Características: + +- ✅ Orçamento por categoria inclui automaticamente todas as subcategorias +- ✅ Orçamento por subcategoria é específico e não inclui outras subcategorias +- ✅ Pode ter orçamento geral E orçamentos específicos por subcategoria +- ✅ Propagação automática para 12 meses futuros funciona com subcategorias +- ✅ Unique constraint garante 1 orçamento por categoria/subcategoria/mês diff --git a/VERSION b/VERSION index 587c490..5b739d7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.43.26 +1.44.5 diff --git a/backend/app/Console/Commands/SendDuePaymentsAlert.php b/backend/app/Console/Commands/SendDuePaymentsAlert.php new file mode 100644 index 0000000..641abf9 --- /dev/null +++ b/backend/app/Console/Commands/SendDuePaymentsAlert.php @@ -0,0 +1,444 @@ + 1.0, + 'USD' => 0.92, + 'BRL' => 0.17, + 'GBP' => 1.16, + ]; + + public function handle(): void + { + $currentTime = now()->format('H:i'); + $this->info("Running due payments notification at {$currentTime}"); + + // Get users with notification enabled + $query = UserPreference::where('notify_due_payments', true); + + if ($userId = $this->option('user')) { + $query->where('user_id', $userId); + } + + // Filter by time (within 5 minute window) unless forced + if (!$this->option('force')) { + $query->whereRaw("TIME_FORMAT(notify_due_payments_time, '%H:%i') = ?", [$currentTime]); + } + + $preferences = $query->with('user')->get(); + + if ($preferences->isEmpty()) { + $this->info('No users to notify at this time.'); + return; + } + + foreach ($preferences as $preference) { + $this->processUser($preference); + } + + $this->info('Done!'); + } + + private function processUser(UserPreference $preference): void + { + $user = $preference->user; + $this->info("Processing user: {$user->name} ({$user->email})"); + + $today = now()->format('Y-m-d'); + $tomorrow = now()->addDay()->format('Y-m-d'); + + // 1. Get all account balances + $accountBalances = $this->getAccountBalances($user->id); + $totalAvailable = array_sum(array_column($accountBalances, 'balance_converted')); + + // 2. Get overdue items (date < today) + $overdueItems = $this->getOverdueItems($user->id, $today); + + // 3. Get tomorrow items (date = tomorrow) + $tomorrowItems = $this->getTomorrowItems($user->id, $tomorrow); + + // If no items, skip + if (empty($overdueItems) && empty($tomorrowItems)) { + $this->info(" No overdue or tomorrow items for {$user->name}"); + return; + } + + // 4. Calculate totals + $allItems = array_merge($overdueItems, $tomorrowItems); + $totalDue = array_sum(array_column($allItems, 'amount_converted')); + $shortage = max(0, $totalDue - $totalAvailable); + + // 5. Determine which items can be paid (priority: most overdue first) + usort($allItems, fn($a, $b) => $b['days_overdue'] <=> $a['days_overdue']); + + $payableItems = []; + $unpayableItems = []; + $remainingBalance = $totalAvailable; + + foreach ($allItems as $item) { + if ($remainingBalance >= $item['amount_converted']) { + $item['can_pay'] = true; + $payableItems[] = $item; + $remainingBalance -= $item['amount_converted']; + } else { + $item['can_pay'] = false; + $item['partial_available'] = $remainingBalance > 0 ? $remainingBalance : 0; + $unpayableItems[] = $item; + } + } + + // 6. Suggest transfers if needed + $transferSuggestions = $this->suggestTransfers($accountBalances, $payableItems); + + // 7. Get primary currency + $primaryCurrency = $this->getPrimaryCurrency($user->id); + + // 8. Send email + $email = $preference->getNotificationEmail(); + + try { + Mail::to($email)->send(new DuePaymentsAlert( + userName: $user->name, + overdueItems: $overdueItems, + tomorrowItems: $tomorrowItems, + accountBalances: $accountBalances, + totalAvailable: round($totalAvailable, 2), + totalDue: round($totalDue, 2), + shortage: round($shortage, 2), + payableItems: $payableItems, + unpayableItems: $unpayableItems, + transferSuggestions: $transferSuggestions, + currency: $primaryCurrency + )); + + $this->info(" ✓ Email sent to {$email}"); + } catch (\Exception $e) { + $this->error(" ✗ Failed to send email: " . $e->getMessage()); + \Log::error("DuePaymentsAlert failed for user {$user->id}: " . $e->getMessage()); + } + } + + private function getAccountBalances(int $userId): array + { + $accounts = DB::select(" + SELECT + a.id, + a.name, + a.currency, + COALESCE( + (SELECT SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE -t.amount END) + FROM transactions t + WHERE t.account_id = a.id + AND t.status = 'completed' + AND t.deleted_at IS NULL), + 0 + ) + COALESCE(a.initial_balance, 0) as balance + FROM accounts a + WHERE a.user_id = ? + AND a.deleted_at IS NULL + ORDER BY a.name + ", [$userId]); + + $result = []; + foreach ($accounts as $account) { + $balanceConverted = $this->convertToEUR($account->balance, $account->currency); + $result[] = [ + 'id' => $account->id, + 'name' => $account->name, + 'currency' => $account->currency, + 'balance' => round($account->balance, 2), + 'balance_converted' => round($balanceConverted, 2), + ]; + } + + return $result; + } + + private function getOverdueItems(int $userId, string $today): array + { + $items = []; + + // 1. Transactions pending/scheduled with planned_date < today + $transactions = DB::select(" + SELECT + t.id, + t.description, + COALESCE(t.planned_amount, t.amount) as amount, + t.planned_date as due_date, + COALESCE(a.currency, 'EUR') as currency, + a.name as account_name, + a.id as account_id, + DATEDIFF(?, t.planned_date) as days_overdue, + 'transaction' as source_type + 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.type = 'debit' + AND t.planned_date < ? + AND t.deleted_at IS NULL + AND t.is_transfer = 0 + ORDER BY t.planned_date ASC + ", [$today, $userId, $today]); + + foreach ($transactions as $t) { + $items[] = $this->formatItem($t, 'overdue'); + } + + // 2. Liability installments with due_date < today + $installments = DB::select(" + SELECT + li.id, + CONCAT(la.name, ' (Parcela)') as description, + li.installment_amount as amount, + li.due_date, + la.currency, + a.name as account_name, + la.account_id, + DATEDIFF(?, li.due_date) as days_overdue, + 'liability_installment' as source_type + FROM liability_installments li + JOIN liability_accounts la ON li.liability_account_id = la.id + LEFT JOIN accounts a ON la.account_id = a.id + WHERE la.user_id = ? + AND li.status = 'pending' + AND li.due_date < ? + AND li.deleted_at IS NULL + ORDER BY li.due_date ASC + ", [$today, $userId, $today]); + + foreach ($installments as $i) { + $items[] = $this->formatItem($i, 'overdue'); + } + + // 3. Recurring instances pending with due_date < today + $recurring = DB::select(" + SELECT + ri.id, + CONCAT(rt.name, ' (Recorrente)') as description, + ri.planned_amount as amount, + ri.due_date, + COALESCE(a.currency, 'EUR') as currency, + a.name as account_name, + rt.account_id, + DATEDIFF(?, ri.due_date) as days_overdue, + 'recurring_instance' as source_type + FROM recurring_instances ri + JOIN recurring_templates rt ON ri.recurring_template_id = rt.id + LEFT JOIN accounts a ON rt.account_id = a.id + WHERE ri.user_id = ? + AND ri.status = 'pending' + AND rt.type = 'debit' + AND ri.due_date < ? + AND ri.deleted_at IS NULL + ORDER BY ri.due_date ASC + ", [$today, $userId, $today]); + + foreach ($recurring as $r) { + $items[] = $this->formatItem($r, 'overdue'); + } + + // Sort by days overdue (most overdue first) + usort($items, fn($a, $b) => $b['days_overdue'] <=> $a['days_overdue']); + + return $items; + } + + private function getTomorrowItems(int $userId, string $tomorrow): array + { + $items = []; + + // 1. Transactions pending/scheduled for tomorrow + $transactions = DB::select(" + SELECT + t.id, + t.description, + COALESCE(t.planned_amount, t.amount) as amount, + t.planned_date as due_date, + COALESCE(a.currency, 'EUR') as currency, + a.name as account_name, + a.id as account_id, + 0 as days_overdue, + 'transaction' as source_type + 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.type = 'debit' + AND t.planned_date = ? + AND t.deleted_at IS NULL + AND t.is_transfer = 0 + ", [$userId, $tomorrow]); + + foreach ($transactions as $t) { + $items[] = $this->formatItem($t, 'tomorrow'); + } + + // 2. Liability installments for tomorrow + $installments = DB::select(" + SELECT + li.id, + CONCAT(la.name, ' (Parcela)') as description, + li.installment_amount as amount, + li.due_date, + la.currency, + a.name as account_name, + la.account_id, + 0 as days_overdue, + 'liability_installment' as source_type + FROM liability_installments li + JOIN liability_accounts la ON li.liability_account_id = la.id + LEFT JOIN accounts a ON la.account_id = a.id + WHERE la.user_id = ? + AND li.status = 'pending' + AND li.due_date = ? + AND li.deleted_at IS NULL + ", [$userId, $tomorrow]); + + foreach ($installments as $i) { + $items[] = $this->formatItem($i, 'tomorrow'); + } + + // 3. Recurring instances for tomorrow + $recurring = DB::select(" + SELECT + ri.id, + CONCAT(rt.name, ' (Recorrente)') as description, + ri.planned_amount as amount, + ri.due_date, + COALESCE(a.currency, 'EUR') as currency, + a.name as account_name, + rt.account_id, + 0 as days_overdue, + 'recurring_instance' as source_type + FROM recurring_instances ri + JOIN recurring_templates rt ON ri.recurring_template_id = rt.id + LEFT JOIN accounts a ON rt.account_id = a.id + WHERE ri.user_id = ? + AND ri.status = 'pending' + AND rt.type = 'debit' + AND ri.due_date = ? + AND ri.deleted_at IS NULL + ", [$userId, $tomorrow]); + + foreach ($recurring as $r) { + $items[] = $this->formatItem($r, 'tomorrow'); + } + + return $items; + } + + private function formatItem($row, string $status): array + { + $amount = abs($row->amount); + $amountConverted = $this->convertToEUR($amount, $row->currency); + + return [ + 'id' => $row->id, + 'description' => $row->description, + 'amount' => round($amount, 2), + 'amount_converted' => round($amountConverted, 2), + 'currency' => $row->currency, + 'due_date' => $row->due_date, + 'days_overdue' => (int) $row->days_overdue, + 'account_name' => $row->account_name ?? 'Sem conta', + 'account_id' => $row->account_id ?? null, + 'source_type' => $row->source_type, + 'status' => $status, // 'overdue' or 'tomorrow' + ]; + } + + private function suggestTransfers(array $accountBalances, array $payableItems): array + { + $suggestions = []; + + // Group payable items by account + $itemsByAccount = []; + foreach ($payableItems as $item) { + $accountId = $item['account_id'] ?? 0; + if (!isset($itemsByAccount[$accountId])) { + $itemsByAccount[$accountId] = 0; + } + $itemsByAccount[$accountId] += $item['amount_converted']; + } + + // Check each account balance vs required + $accountBalanceMap = []; + foreach ($accountBalances as $account) { + $accountBalanceMap[$account['id']] = $account; + } + + foreach ($itemsByAccount as $accountId => $required) { + if ($accountId === 0) continue; + + $account = $accountBalanceMap[$accountId] ?? null; + if (!$account) continue; + + $balance = $account['balance_converted']; + + if ($balance < $required) { + $deficit = $required - $balance; + + // Find accounts with surplus to transfer from + foreach ($accountBalances as $sourceAccount) { + if ($sourceAccount['id'] === $accountId) continue; + if ($sourceAccount['balance_converted'] <= 0) continue; + + $availableToTransfer = $sourceAccount['balance_converted']; + $transferAmount = min($deficit, $availableToTransfer); + + if ($transferAmount > 10) { // Only suggest transfers > 10 EUR + $suggestions[] = [ + 'from_account' => $sourceAccount['name'], + 'from_account_id' => $sourceAccount['id'], + 'to_account' => $account['name'], + 'to_account_id' => $accountId, + 'amount' => round($transferAmount, 2), + 'reason' => "Para cobrir pagamentos pendentes", + ]; + $deficit -= $transferAmount; + } + + if ($deficit <= 0) break; + } + } + } + + return $suggestions; + } + + private function convertToEUR(float $amount, string $currency): float + { + $rate = $this->exchangeRates[$currency] ?? 1.0; + return $amount * $rate; + } + + private function getPrimaryCurrency(int $userId): string + { + $result = DB::selectOne(" + SELECT currency, COUNT(*) as cnt + FROM accounts + WHERE user_id = ? AND deleted_at IS NULL + GROUP BY currency + ORDER BY cnt DESC + LIMIT 1 + ", [$userId]); + + return $result->currency ?? 'EUR'; + } +} diff --git a/backend/app/Http/Controllers/Api/BudgetController.php b/backend/app/Http/Controllers/Api/BudgetController.php index 208b819..17f2325 100644 --- a/backend/app/Http/Controllers/Api/BudgetController.php +++ b/backend/app/Http/Controllers/Api/BudgetController.php @@ -23,7 +23,7 @@ public function index(Request $request) $budgets = Budget::forUser(Auth::id()) ->forPeriod($year, $month) ->active() - ->with('category') + ->with(['category', 'subcategory', 'costCenter']) ->orderBy('amount', 'desc') ->get(); @@ -53,39 +53,84 @@ public function store(Request $request) { $validated = $request->validate([ 'category_id' => 'nullable|exists:categories,id', + 'subcategory_id' => 'nullable|exists:categories,id', + 'cost_center_id' => 'nullable|exists:cost_centers,id', 'name' => 'nullable|string|max:255', 'amount' => 'required|numeric|min:0.01', 'currency' => 'nullable|string|size:3', 'year' => 'required|integer|min:2020|max:2100', 'month' => 'required|integer|min:1|max:12', - 'period_type' => 'nullable|in:monthly,yearly', + 'period_type' => 'nullable|in:monthly,bimestral,trimestral,semestral,yearly', + 'is_cumulative' => 'nullable|boolean', 'notes' => 'nullable|string', ]); + // Validar que ao menos uma (categoria, subcategoria ou centro de custos) esteja preenchida + if (empty($validated['category_id']) && empty($validated['subcategory_id']) && empty($validated['cost_center_id'])) { + return response()->json([ + 'message' => 'Debe especificar al menos una categoría, subcategoría o centro de custos', + ], 422); + } + + // Validar que subcategoria pertence à categoria (se ambas fornecidas) + if (!empty($validated['subcategory_id']) && !empty($validated['category_id'])) { + $subcat = Category::find($validated['subcategory_id']); + if ($subcat && $subcat->parent_id != $validated['category_id']) { + return response()->json([ + 'message' => 'A subcategoria não pertence à categoria selecionada', + ], 422); + } + } + + // Se tem subcategoria mas não tem categoria, buscar a categoria pai + if (!empty($validated['subcategory_id']) && empty($validated['category_id'])) { + $subcat = Category::find($validated['subcategory_id']); + if ($subcat && $subcat->parent_id) { + $validated['category_id'] = $subcat->parent_id; + } + } + // Verificar que no exista ya $exists = Budget::forUser(Auth::id()) ->where('category_id', $validated['category_id']) + ->where('subcategory_id', $validated['subcategory_id'] ?? null) + ->where('cost_center_id', $validated['cost_center_id'] ?? null) ->where('year', $validated['year']) ->where('month', $validated['month']) ->exists(); if ($exists) { return response()->json([ - 'message' => 'Ya existe un presupuesto para esta categoría en este período', + 'message' => 'Ya existe un presupuesto para esta categoría/subcategoría en este período', ], 422); } $validated['user_id'] = Auth::id(); + // Adicionar moeda primária do usuário se não fornecida + if (empty($validated['currency'])) { + $validated['currency'] = Auth::user()->primary_currency ?? 'EUR'; + } // Crear el presupuesto del mes actual $budget = Budget::create($validated); - // Propagar automáticamente a los 12 meses siguientes + // Determinar el salto de meses según period_type + $periodType = $validated['period_type'] ?? 'monthly'; + $periodStepMap = [ + 'monthly' => 1, // A cada 1 mes (Jan, Feb, Mar, ..., Dec) = 12 presupuestos + 'bimestral' => 2, // A cada 2 meses (Jan, Mar, May, Jul, Sep, Nov) = 6 presupuestos + 'trimestral' => 3, // A cada 3 meses (Jan, Apr, Jul, Oct) = 4 presupuestos + 'semestral' => 6, // A cada 6 meses (Jan, Jul) = 2 presupuestos + 'yearly' => 12, // A cada 12 meses (solo Jan) = 1 presupuesto + ]; + + $step = $periodStepMap[$periodType] ?? 1; $currentYear = $validated['year']; $currentMonth = $validated['month']; - for ($i = 1; $i <= 12; $i++) { - $nextMonth = $currentMonth + $i; + // Propagar hasta completar 12 meses (1 año) + for ($monthsAhead = $step; $monthsAhead < 12; $monthsAhead += $step) { + $nextMonth = $currentMonth + $monthsAhead; $nextYear = $currentYear; if ($nextMonth > 12) { @@ -96,6 +141,8 @@ public function store(Request $request) // Solo crear si no existe $existsNext = Budget::forUser(Auth::id()) ->where('category_id', $validated['category_id']) + ->where('subcategory_id', $validated['subcategory_id'] ?? null) + ->where('cost_center_id', $validated['cost_center_id'] ?? null) ->where('year', $nextYear) ->where('month', $nextMonth) ->exists(); @@ -104,12 +151,15 @@ public function store(Request $request) Budget::create([ 'user_id' => Auth::id(), 'category_id' => $validated['category_id'], + 'subcategory_id' => $validated['subcategory_id'] ?? null, + 'cost_center_id' => $validated['cost_center_id'] ?? null, 'name' => $validated['name'] ?? null, 'amount' => $validated['amount'], 'currency' => $validated['currency'] ?? null, 'year' => $nextYear, 'month' => $nextMonth, 'period_type' => $validated['period_type'] ?? 'monthly', + 'is_cumulative' => $validated['is_cumulative'] ?? false, 'notes' => $validated['notes'] ?? null, ]); } @@ -117,7 +167,7 @@ public function store(Request $request) return response()->json([ 'message' => 'Presupuesto creado y propagado', - 'data' => $budget->load('category'), + 'data' => $budget->load(['category', 'subcategory', 'costCenter']), ], 201); } @@ -127,7 +177,7 @@ public function store(Request $request) public function show($id) { $budget = Budget::forUser(Auth::id()) - ->with('category') + ->with(['category', 'subcategory', 'costCenter']) ->findOrFail($id); // Obtener transacciones del período @@ -136,7 +186,12 @@ public function show($id) ->whereYear('effective_date', $budget->year) ->whereMonth('effective_date', $budget->month); - if ($budget->category_id) { + // Se tem subcategoria específica, usa apenas ela + if ($budget->subcategory_id) { + $query->where('category_id', $budget->subcategory_id); + } + // Se tem apenas categoria, inclui subcategorias + elseif ($budget->category_id) { $categoryIds = [$budget->category_id]; $subcategories = Category::where('parent_id', $budget->category_id)->pluck('id')->toArray(); $categoryIds = array_merge($categoryIds, $subcategories); @@ -183,12 +238,17 @@ public function destroy($id) $budget = Budget::forUser(Auth::id())->findOrFail($id); $categoryId = $budget->category_id; + $subcategoryId = $budget->subcategory_id; $year = $budget->year; $month = $budget->month; - // Eliminar este y todos los futuros de la misma categoría + $costCenterId = $budget->cost_center_id; + + // Eliminar este e todos os futuros da mesma categoria/subcategoria/centro de custos Budget::forUser(Auth::id()) ->where('category_id', $categoryId) + ->where('subcategory_id', $subcategoryId) + ->where('cost_center_id', $costCenterId) ->where(function($q) use ($year, $month) { $q->where('year', '>', $year) ->orWhere(function($q2) use ($year, $month) { @@ -235,21 +295,48 @@ public function availableCategories(Request $request) $year = $request->get('year', now()->year); $month = $request->get('month', now()->month); - // Obtener IDs de categorías ya usadas en el período - $usedCategoryIds = Budget::forUser(Auth::id()) + // Obtener pares (category_id, subcategory_id) já usados no período + $usedPairs = Budget::forUser(Auth::id()) ->forPeriod($year, $month) - ->pluck('category_id') + ->get() + ->map(fn($b) => $b->category_id . '_' . ($b->subcategory_id ?? 'null')) ->toArray(); // Categorías padre con tipo expense o both (gastos) $categories = Category::where('user_id', Auth::id()) ->whereNull('parent_id') ->whereIn('type', ['expense', 'both']) - ->whereNotIn('id', $usedCategoryIds) + ->with(['subcategories' => function($q) { + $q->orderBy('name'); + }]) ->orderBy('name') ->get(); - return response()->json($categories); + // Filtrar categorias/subcategorias já usadas + $available = $categories->map(function($category) use ($usedPairs) { + $categoryKey = $category->id . '_null'; + + // Filtrar subcategorias não usadas + $availableSubcategories = $category->subcategories->filter(function($sub) use ($usedPairs, $category) { + $subKey = $category->id . '_' . $sub->id; + return !in_array($subKey, $usedPairs); + })->values(); + + return [ + 'id' => $category->id, + 'name' => $category->name, + 'type' => $category->type, + 'color' => $category->color, + 'icon' => $category->icon, + 'is_available' => !in_array($categoryKey, $usedPairs), + 'subcategories' => $availableSubcategories, + ]; + })->filter(function($cat) { + // Manter categoria se ela mesma está disponível OU se tem subcategorias disponíveis + return $cat['is_available'] || $cat['subcategories']->count() > 0; + })->values(); + + return response()->json($available); } /** diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php index 55dac6d..a851591 100644 --- a/backend/app/Http/Controllers/Api/ReportController.php +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -1760,13 +1760,48 @@ 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']); + // 3. Instancias de recorrências pendentes e vencidas + $overdueRecurringInstances = DB::select(" + SELECT + ri.id, + rt.name as description, + ri.planned_amount as amount, + ri.due_date, + COALESCE(a.currency, 'EUR') as currency, + DATEDIFF(?, ri.due_date) as days_overdue, + a.name as account_name, + c.name as category_name, + rt.type + FROM recurring_instances ri + JOIN recurring_templates rt ON ri.recurring_template_id = rt.id + LEFT JOIN accounts a ON rt.account_id = a.id + LEFT JOIN categories c ON rt.category_id = c.id + WHERE ri.user_id = ? + AND ri.status = 'pending' + AND ri.due_date < ? + AND ri.deleted_at IS NULL + ORDER BY ri.due_date ASC + ", [$today, $this->userId, $today]); + + foreach ($overdueRecurringInstances as $row) { + $amount = abs($row->amount); + $converted = $this->convertToPrimaryCurrency($amount, $row->currency); $totalOverdueConverted += $converted; - $rec['amount_converted'] = round($converted, 2); - $result[] = $rec; + + $result[] = [ + 'id' => $row->id, + 'description' => $row->description . ' (Recorrente)', + 'amount' => round($amount, 2), + 'amount_converted' => round($converted, 2), + 'currency' => $row->currency, + 'due_date' => $row->due_date, + 'days_overdue' => (int) $row->days_overdue, + 'source_type' => 'recurring_instance', + 'type' => $row->type, + 'status' => 'pending', + 'account' => $row->account_name, + 'category' => $row->category_name, + ]; } // Ordenar por días de atraso (más atrasado primero) diff --git a/backend/app/Http/Controllers/Api/UserPreferenceController.php b/backend/app/Http/Controllers/Api/UserPreferenceController.php new file mode 100644 index 0000000..2fbe51d --- /dev/null +++ b/backend/app/Http/Controllers/Api/UserPreferenceController.php @@ -0,0 +1,124 @@ +user()->id; + + $preferences = UserPreference::firstOrCreate( + ['user_id' => $userId], + [ + 'notify_due_payments' => false, + 'notify_due_payments_time' => '20:00:00', + 'notify_due_payments_email' => null, + 'language' => 'pt-BR', + 'timezone' => 'Europe/Madrid', + 'currency' => 'EUR', + ] + ); + + // Get raw time from database attribute (without datetime cast) + $rawTime = $preferences->getRawOriginal('notify_due_payments_time'); + $timeFormatted = $rawTime ? substr($rawTime, 0, 5) : '20:00'; + + return response()->json([ + 'success' => true, + 'data' => [ + 'notify_due_payments' => $preferences->notify_due_payments, + 'notify_due_payments_time' => $timeFormatted, + 'notify_due_payments_email' => $preferences->notify_due_payments_email, + 'language' => $preferences->language, + 'timezone' => $preferences->timezone, + 'currency' => $preferences->currency, + 'user_email' => $request->user()->email, // For display when custom email is null + ], + ]); + } + + /** + * Update user preferences + */ + public function update(Request $request): JsonResponse + { + $validated = $request->validate([ + 'notify_due_payments' => 'sometimes|boolean', + 'notify_due_payments_time' => 'sometimes|date_format:H:i', + 'notify_due_payments_email' => 'sometimes|nullable|email', + 'language' => 'sometimes|string|in:pt-BR,en,es', + 'timezone' => 'sometimes|string|timezone', + 'currency' => 'sometimes|string|in:EUR,USD,BRL,GBP', + ]); + + $userId = $request->user()->id; + + $preferences = UserPreference::firstOrCreate( + ['user_id' => $userId], + [ + 'notify_due_payments' => false, + 'notify_due_payments_time' => '20:00:00', + 'language' => 'pt-BR', + 'timezone' => 'Europe/Madrid', + 'currency' => 'EUR', + ] + ); + + // Convert time to proper format if provided + if (isset($validated['notify_due_payments_time'])) { + $validated['notify_due_payments_time'] = $validated['notify_due_payments_time'] . ':00'; + } + + $preferences->update($validated); + + return response()->json([ + 'success' => true, + 'message' => 'Preferências atualizadas com sucesso', + 'data' => [ + 'notify_due_payments' => $preferences->notify_due_payments, + 'notify_due_payments_time' => substr($preferences->notify_due_payments_time, 0, 5), + 'notify_due_payments_email' => $preferences->notify_due_payments_email, + 'language' => $preferences->language, + 'timezone' => $preferences->timezone, + 'currency' => $preferences->currency, + ], + ]); + } + + /** + * Test due payments notification (send immediately) + */ + public function testNotification(Request $request): JsonResponse + { + $userId = $request->user()->id; + + try { + \Artisan::call('notify:due-payments', [ + '--user' => $userId, + '--force' => true, + ]); + + $output = \Artisan::output(); + + return response()->json([ + 'success' => true, + 'message' => 'Notificação de teste enviada! Verifique seu email.', + 'output' => $output, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Erro ao enviar notificação: ' . $e->getMessage(), + ], 500); + } + } +} diff --git a/backend/app/Mail/DuePaymentsAlert.php b/backend/app/Mail/DuePaymentsAlert.php new file mode 100644 index 0000000..7d26a75 --- /dev/null +++ b/backend/app/Mail/DuePaymentsAlert.php @@ -0,0 +1,125 @@ +userName = $userName; + $this->overdueItems = $overdueItems; + $this->tomorrowItems = $tomorrowItems; + $this->accountBalances = $accountBalances; + $this->totalAvailable = $totalAvailable; + $this->totalDue = $totalDue; + $this->shortage = $shortage; + $this->payableItems = $payableItems; + $this->unpayableItems = $unpayableItems; + $this->transferSuggestions = $transferSuggestions; + $this->currency = $currency; + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + $subject = $this->shortage > 0 + ? '⚠️ Alerta: Pagamentos Vencidos - Saldo Insuficiente' + : '📋 Lembrete: Pagamentos Pendentes'; + + return new Envelope( + from: new Address('no-reply@cnxifly.com', 'WEBMoney - ConneXiFly'), + replyTo: [ + new Address('support@cnxifly.com', 'Soporte WEBMoney'), + ], + subject: $subject, + tags: ['due-payments', 'alert'], + metadata: [ + 'total_due' => $this->totalDue, + 'shortage' => $this->shortage, + ], + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + view: 'emails.due-payments-alert', + text: 'emails.due-payments-alert-text', + with: [ + 'userName' => $this->userName, + 'overdueItems' => $this->overdueItems, + 'tomorrowItems' => $this->tomorrowItems, + 'accountBalances' => $this->accountBalances, + 'totalAvailable' => $this->totalAvailable, + 'totalDue' => $this->totalDue, + 'shortage' => $this->shortage, + 'payableItems' => $this->payableItems, + 'unpayableItems' => $this->unpayableItems, + 'transferSuggestions' => $this->transferSuggestions, + 'currency' => $this->currency, + ], + ); + } + + /** + * Extra headers to help deliverability. + */ + public function headers(): Headers + { + return new Headers( + text: [ + 'List-Unsubscribe' => '', + 'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click', + ], + ); + } + + /** + * Get the attachments for the message. + */ + public function attachments(): array + { + return []; + } +} diff --git a/backend/app/Models/Budget.php b/backend/app/Models/Budget.php index cebc75a..5de9827 100644 --- a/backend/app/Models/Budget.php +++ b/backend/app/Models/Budget.php @@ -13,12 +13,15 @@ class Budget extends Model protected $fillable = [ 'user_id', 'category_id', + 'subcategory_id', + 'cost_center_id', 'name', 'amount', 'currency', 'year', 'month', 'period_type', + 'is_cumulative', 'is_active', 'notes', ]; @@ -26,6 +29,7 @@ class Budget extends Model protected $casts = [ 'amount' => 'decimal:2', 'is_active' => 'boolean', + 'is_cumulative' => 'boolean', ]; protected $appends = [ @@ -50,6 +54,16 @@ public function category() return $this->belongsTo(Category::class); } + public function subcategory() + { + return $this->belongsTo(Category::class, 'subcategory_id'); + } + + public function costCenter() + { + return $this->belongsTo(CostCenter::class); + } + // ============================================ // Accessors // ============================================ @@ -58,15 +72,54 @@ public function getSpentAmountAttribute() { // Calcular el gasto real de las transacciones $query = Transaction::where('user_id', $this->user_id) - ->where('type', 'debit') - ->whereYear('effective_date', $this->year); + ->where('type', 'debit'); - if ($this->period_type === 'monthly' && $this->month) { - $query->whereMonth('effective_date', $this->month); + // Definir el rango de fechas según el tipo de período + $startDate = Carbon::create($this->year, $this->month, 1); + + if ($this->is_cumulative) { + // Cumulativo: desde el inicio del año hasta el final del período actual + $endDate = $this->getPeriodEndDate(); + $query->whereYear('effective_date', $this->year) + ->whereDate('effective_date', '<=', $endDate); + } else { + // No cumulativo: solo el período específico + switch ($this->period_type) { + case 'monthly': + $query->whereYear('effective_date', $this->year) + ->whereMonth('effective_date', $this->month); + break; + + case 'bimestral': + // 2 meses: mes actual + anterior (o siguiente según configuración) + $endDate = $startDate->copy()->addMonths(2)->subDay(); + $query->whereBetween('effective_date', [$startDate, $endDate]); + break; + + case 'trimestral': + // 3 meses + $endDate = $startDate->copy()->addMonths(3)->subDay(); + $query->whereBetween('effective_date', [$startDate, $endDate]); + break; + + case 'semestral': + // 6 meses + $endDate = $startDate->copy()->addMonths(6)->subDay(); + $query->whereBetween('effective_date', [$startDate, $endDate]); + break; + + case 'yearly': + $query->whereYear('effective_date', $this->year); + break; + } } - if ($this->category_id) { - // Incluir subcategorías + // Se tem subcategoria específica, usa apenas ela + if ($this->subcategory_id) { + $query->where('category_id', $this->subcategory_id); + } + // Se tem apenas categoria, inclui todas as subcategorias + elseif ($this->category_id) { $categoryIds = [$this->category_id]; $subcategories = Category::where('parent_id', $this->category_id)->pluck('id')->toArray(); $categoryIds = array_merge($categoryIds, $subcategories); @@ -74,8 +127,36 @@ public function getSpentAmountAttribute() $query->whereIn('category_id', $categoryIds); } + // Filtrar por centro de custos se especificado + if ($this->cost_center_id) { + $query->where('cost_center_id', $this->cost_center_id); + } + return abs($query->sum('amount')); } + + /** + * Obtém a data final do período + */ + private function getPeriodEndDate() + { + $startDate = Carbon::create($this->year, $this->month, 1); + + switch ($this->period_type) { + case 'monthly': + return $startDate->endOfMonth(); + case 'bimestral': + return $startDate->copy()->addMonths(2)->subDay(); + case 'trimestral': + return $startDate->copy()->addMonths(3)->subDay(); + case 'semestral': + return $startDate->copy()->addMonths(6)->subDay(); + case 'yearly': + return $startDate->endOfYear(); + default: + return $startDate->endOfMonth(); + } + } public function getRemainingAmountAttribute() { @@ -95,17 +176,26 @@ public function getIsExceededAttribute() public function getPeriodLabelAttribute() { - if ($this->period_type === 'yearly') { - return $this->year; - } - $months = [ 1 => 'Enero', 2 => 'Febrero', 3 => 'Marzo', 4 => 'Abril', 5 => 'Mayo', 6 => 'Junio', 7 => 'Julio', 8 => 'Agosto', 9 => 'Septiembre', 10 => 'Octubre', 11 => 'Noviembre', 12 => 'Diciembre' ]; - return ($months[$this->month] ?? '') . ' ' . $this->year; + $monthName = $months[$this->month] ?? ''; + + switch ($this->period_type) { + case 'yearly': + return $this->year; + case 'bimestral': + return $monthName . ' ' . $this->year . ' (Bimestral)'; + case 'trimestral': + return $monthName . ' ' . $this->year . ' (Trimestral)'; + case 'semestral': + return $monthName . ' ' . $this->year . ' (Semestral)'; + default: + return $monthName . ' ' . $this->year; + } } // ============================================ diff --git a/backend/app/Models/Category.php b/backend/app/Models/Category.php index 183a580..26f21aa 100644 --- a/backend/app/Models/Category.php +++ b/backend/app/Models/Category.php @@ -68,6 +68,14 @@ public function children(): HasMany return $this->hasMany(Category::class, 'parent_id'); } + /** + * Alias para children (usado em budgets) + */ + public function subcategories(): HasMany + { + return $this->children(); + } + /** * Relação com as sub-categorias ativas */ diff --git a/backend/app/Models/UserPreference.php b/backend/app/Models/UserPreference.php new file mode 100644 index 0000000..67875b8 --- /dev/null +++ b/backend/app/Models/UserPreference.php @@ -0,0 +1,41 @@ + 'boolean', + // notify_due_payments_time is stored as TIME in DB, no cast needed + ]; + + /** + * Get the user that owns the preferences. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the email to send notifications to. + * Falls back to user's email if not set. + */ + public function getNotificationEmail(): string + { + return $this->notify_due_payments_email ?? $this->user->email; + } +} diff --git a/backend/database/migrations/2025_12_16_211102_add_subcategory_to_budgets_table.php b/backend/database/migrations/2025_12_16_211102_add_subcategory_to_budgets_table.php new file mode 100644 index 0000000..e17998c --- /dev/null +++ b/backend/database/migrations/2025_12_16_211102_add_subcategory_to_budgets_table.php @@ -0,0 +1,43 @@ +foreignId('subcategory_id')->nullable()->after('category_id')->constrained('categories')->onDelete('cascade'); + + // Remover unique constraint antiga + $table->dropUnique('unique_budget_category_period'); + + // Nova unique constraint incluindo subcategoria + $table->unique(['user_id', 'category_id', 'subcategory_id', 'year', 'month'], 'unique_budget_period'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('budgets', function (Blueprint $table) { + // Remover nova constraint + $table->dropUnique('unique_budget_period'); + + // Restaurar constraint antiga + $table->unique(['user_id', 'category_id', 'year', 'month'], 'unique_budget_category_period'); + + // Remover coluna + $table->dropForeign(['subcategory_id']); + $table->dropColumn('subcategory_id'); + }); + } +}; diff --git a/backend/database/migrations/2025_12_16_224751_add_is_cumulative_to_budgets_table.php b/backend/database/migrations/2025_12_16_224751_add_is_cumulative_to_budgets_table.php new file mode 100644 index 0000000..6d817fe --- /dev/null +++ b/backend/database/migrations/2025_12_16_224751_add_is_cumulative_to_budgets_table.php @@ -0,0 +1,28 @@ +boolean('is_cumulative')->default(false)->after('period_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('budgets', function (Blueprint $table) { + $table->dropColumn('is_cumulative'); + }); + } +}; diff --git a/backend/database/migrations/2025_12_17_000001_add_cost_center_and_update_periods_to_budgets.php b/backend/database/migrations/2025_12_17_000001_add_cost_center_and_update_periods_to_budgets.php new file mode 100644 index 0000000..1dd7bb8 --- /dev/null +++ b/backend/database/migrations/2025_12_17_000001_add_cost_center_and_update_periods_to_budgets.php @@ -0,0 +1,38 @@ +unsignedBigInteger('cost_center_id')->nullable()->after('subcategory_id'); + $table->foreign('cost_center_id')->references('id')->on('cost_centers')->onDelete('cascade'); + }); + + // Atualizar o enum de period_type para incluir bimestral, trimestral, semestral + DB::statement("ALTER TABLE budgets MODIFY COLUMN period_type ENUM('monthly', 'bimestral', 'trimestral', 'semestral', 'yearly') NOT NULL DEFAULT 'monthly'"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('budgets', function (Blueprint $table) { + $table->dropForeign(['cost_center_id']); + $table->dropColumn('cost_center_id'); + }); + + // Voltar ao enum original + DB::statement("ALTER TABLE budgets MODIFY COLUMN period_type ENUM('monthly', 'yearly') NOT NULL DEFAULT 'monthly'"); + } +}; diff --git a/backend/database/migrations/2025_12_17_200001_create_user_preferences_table.php b/backend/database/migrations/2025_12_17_200001_create_user_preferences_table.php new file mode 100644 index 0000000..6ca723f --- /dev/null +++ b/backend/database/migrations/2025_12_17_200001_create_user_preferences_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + + // Notificação de vencimentos + $table->boolean('notify_due_payments')->default(false); + $table->time('notify_due_payments_time')->default('20:00:00'); + $table->string('notify_due_payments_email')->nullable(); // Se null, usa email do usuário + + // Outras preferências futuras podem ser adicionadas aqui + $table->string('language')->default('pt-BR'); + $table->string('timezone')->default('Europe/Madrid'); + $table->string('currency')->default('EUR'); + + $table->timestamps(); + + $table->unique('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_preferences'); + } +}; diff --git a/backend/resources/views/emails/due-payments-alert-text.blade.php b/backend/resources/views/emails/due-payments-alert-text.blade.php new file mode 100644 index 0000000..fea97a6 --- /dev/null +++ b/backend/resources/views/emails/due-payments-alert-text.blade.php @@ -0,0 +1,85 @@ +WEBMoney - Alerta de Pagamentos +================================ + +Olá, {{ $userName }}! + +RESUMO FINANCEIRO +----------------- +💳 Saldo Total Disponível: {{ number_format($totalAvailable, 2, ',', '.') }} {{ $currency }} +📋 Total a Pagar: {{ number_format($totalDue, 2, ',', '.') }} {{ $currency }} +@if($shortage > 0) +⚠️ FALTA: {{ number_format($shortage, 2, ',', '.') }} {{ $currency }} + +ATENÇÃO: Você não tem saldo suficiente para cobrir todos os pagamentos! +@else +✅ Situação: Saldo suficiente para cobrir os pagamentos! +@endif + +SALDO DAS CONTAS +---------------- +@foreach($accountBalances as $account) +• {{ $account['name'] }}: {{ number_format($account['balance'], 2, ',', '.') }} {{ $account['currency'] }} +@endforeach + +@if(count($overdueItems) > 0) +🔴 PAGAMENTOS VENCIDOS ({{ count($overdueItems) }}) +--------------------------------------------------- +@foreach($overdueItems as $item) +• {{ $item['description'] }} + Valor: {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} + Venceu em: {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }} ({{ $item['days_overdue'] }} dias de atraso) + @if($item['account_name'])Conta: {{ $item['account_name'] }}@endif + +@endforeach +@endif + +@if(count($tomorrowItems) > 0) +🟡 VENCEM AMANHÃ ({{ count($tomorrowItems) }}) +---------------------------------------------- +@foreach($tomorrowItems as $item) +• {{ $item['description'] }} + Valor: {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} + Vence em: {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }} + @if($item['account_name'])Conta: {{ $item['account_name'] }}@endif + +@endforeach +@endif + +@if(count($payableItems) > 0) +✅ PAGAMENTOS POSSÍVEIS ({{ count($payableItems) }}) +---------------------------------------------------- +Com base no saldo atual, você consegue pagar: +@foreach($payableItems as $item) +• {{ $item['description'] }} - {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} ✓ +@endforeach + +@endif + +@if(count($unpayableItems) > 0) +❌ SEM SALDO SUFICIENTE ({{ count($unpayableItems) }}) +------------------------------------------------------ +Não há saldo disponível para estes pagamentos: +@foreach($unpayableItems as $item) +• {{ $item['description'] }} - {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} ✗ +@endforeach + +@endif + +@if(count($transferSuggestions) > 0) +💱 SUGESTÕES DE TRANSFERÊNCIA +----------------------------- +Para cobrir os pagamentos, considere transferir entre suas contas: +@foreach($transferSuggestions as $transfer) +• De "{{ $transfer['from_account'] }}" para "{{ $transfer['to_account'] }}": {{ number_format($transfer['amount'], 2, ',', '.') }} {{ $currency }} + Motivo: {{ $transfer['reason'] }} +@endforeach + +@endif + +-------------------------------------------------- +Acesse o WEBMoney: https://webmoney.cnxifly.com + +Para desativar estas notificações, acesse: +https://webmoney.cnxifly.com/preferences + +© {{ date('Y') }} WEBMoney - ConneXiFly diff --git a/backend/resources/views/emails/due-payments-alert.blade.php b/backend/resources/views/emails/due-payments-alert.blade.php new file mode 100644 index 0000000..a173e6a --- /dev/null +++ b/backend/resources/views/emails/due-payments-alert.blade.php @@ -0,0 +1,375 @@ + + + + + + Alerta de Pagamentos + + + +
+
+

💰 WEBMoney - Alerta de Pagamentos

+

Olá, {{ $userName }}!

+
+ + +
+
+ 💳 Saldo Total Disponível + {{ number_format($totalAvailable, 2, ',', '.') }} {{ $currency }} +
+
+ 📋 Total a Pagar + {{ number_format($totalDue, 2, ',', '.') }} {{ $currency }} +
+ @if($shortage > 0) +
+ ⚠️ Falta + {{ number_format($shortage, 2, ',', '.') }} {{ $currency }} +
+ @else +
+ ✅ Situação + Saldo suficiente! +
+ @endif +
+ + @if($shortage > 0) +
+

⚠️ SALDO INSUFICIENTE

+
-{{ number_format($shortage, 2, ',', '.') }} {{ $currency }}
+

Você não tem saldo suficiente para cobrir todos os pagamentos.

+
+ @endif + + +
+

💳 Saldo das Contas

+ @foreach($accountBalances as $account) + + @endforeach +
+ + + @if(count($overdueItems) > 0) +
+

🔴 Pagamentos Vencidos ({{ count($overdueItems) }})

+ @foreach($overdueItems as $item) +
+
+ {{ $item['description'] }} + {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} +
+
+ {{ $item['days_overdue'] }} dias de atraso + • Venceu em {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }} + @if($item['account_name']) + • Conta: {{ $item['account_name'] }} + @endif +
+
+ @endforeach +
+ @endif + + + @if(count($tomorrowItems) > 0) +
+

🟡 Vencem Amanhã ({{ count($tomorrowItems) }})

+ @foreach($tomorrowItems as $item) +
+
+ {{ $item['description'] }} + {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} +
+
+ Amanhã + • {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }} + @if($item['account_name']) + • Conta: {{ $item['account_name'] }} + @endif +
+
+ @endforeach +
+ @endif + + + @if(count($payableItems) > 0) +
+

✅ Pagamentos Possíveis ({{ count($payableItems) }})

+

Com base no saldo atual, você consegue pagar:

+ @foreach($payableItems as $item) +
+
+ {{ $item['description'] }} + {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} +
+
+ ✓ Pode pagar + @if($item['account_name']) + • Conta: {{ $item['account_name'] }} + @endif +
+
+ @endforeach +
+ @endif + + + @if(count($unpayableItems) > 0) +
+

❌ Sem Saldo Suficiente ({{ count($unpayableItems) }})

+

Não há saldo disponível para estes pagamentos:

+ @foreach($unpayableItems as $item) +
+
+ {{ $item['description'] }} + {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} +
+
+ ✗ Sem saldo + @if($item['account_name']) + • Conta: {{ $item['account_name'] }} + @endif +
+
+ @endforeach +
+ @endif + + + @if(count($transferSuggestions) > 0) +
+

💱 Sugestões de Transferência

+

Para cobrir os pagamentos, considere transferir entre suas contas:

+ @foreach($transferSuggestions as $transfer) +
+
+
+ {{ $transfer['from_account'] }} +
Origem
+
+
+
+ {{ $transfer['to_account'] }} +
Destino
+
+
+
+ {{ number_format($transfer['amount'], 2, ',', '.') }} {{ $currency }} +
+
{{ $transfer['reason'] }}
+
+ @endforeach +
+ @endif + +
+ + Acessar WEBMoney + +
+ + +
+ + diff --git a/backend/routes/api.php b/backend/routes/api.php index f1037e0..d5395f1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -21,6 +21,7 @@ use App\Http\Controllers\Api\BudgetController; use App\Http\Controllers\Api\ReportController; use App\Http\Controllers\Api\FinancialHealthController; +use App\Http\Controllers\Api\UserPreferenceController; // Public routes with rate limiting Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register'); @@ -278,5 +279,12 @@ // ============================================ Route::get('financial-health', [FinancialHealthController::class, 'index']); Route::get('financial-health/history', [FinancialHealthController::class, 'history']); + + // ============================================ + // Preferências do Usuário (User Preferences) + // ============================================ + Route::get('preferences', [UserPreferenceController::class, 'index']); + Route::put('preferences', [UserPreferenceController::class, 'update']); + Route::post('preferences/test-notification', [UserPreferenceController::class, 'testNotification']); }); diff --git a/backend/routes/console.php b/backend/routes/console.php index 3c9adf1..44f1161 100644 --- a/backend/routes/console.php +++ b/backend/routes/console.php @@ -2,7 +2,16 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Schedule due payments notification every minute +// The command itself filters users by their preferred time +Schedule::command('notify:due-payments') + ->everyMinute() + ->withoutOverlapping() + ->runInBackground() + ->appendOutputTo(storage_path('logs/due-payments.log')); diff --git a/deploy_budgets_subcategory.sh b/deploy_budgets_subcategory.sh new file mode 100644 index 0000000..5747a62 --- /dev/null +++ b/deploy_budgets_subcategory.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# Deploy de mudanças de orçamentos com subcategorias + +SERVER="root@213.165.93.60" +PASS="Master9354" + +echo "📦 Enviando arquivos..." + +# Enviar migration +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + /workspaces/webmoney/backend/database/migrations/2025_12_16_211102_add_subcategory_to_budgets_table.php \ + $SERVER:/var/www/webmoney/backend/database/migrations/ + +# Enviar Model +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + /workspaces/webmoney/backend/app/Models/Budget.php \ + $SERVER:/var/www/webmoney/backend/app/Models/ + +# Enviar Controller +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + /workspaces/webmoney/backend/app/Http/Controllers/Api/BudgetController.php \ + $SERVER:/var/www/webmoney/backend/app/Http/Controllers/Api/ + +echo "✅ Arquivos enviados" +echo "" + +echo "🔄 Executando migration..." +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no $SERVER "cd /var/www/webmoney/backend && php artisan migrate --force" + +echo "" +echo "🎯 Limpando cache..." +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no $SERVER "cd /var/www/webmoney/backend && php artisan config:clear && php artisan cache:clear && php artisan route:clear" + +echo "" +echo "✅ Deploy do backend concluído!" diff --git a/deploy_correto.sh b/deploy_correto.sh new file mode 100644 index 0000000..c118847 --- /dev/null +++ b/deploy_correto.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Deploy correto usando os scripts oficiais + +echo "🚀 Deploy de Orçamentos com Subcategorias" +echo "" + +# Backend +echo "📦 Deploy Backend..." +cd /workspaces/webmoney/backend && ./deploy.sh + +echo "" +echo "📦 Deploy Frontend..." +cd /workspaces/webmoney/frontend && ./deploy.sh + +echo "" +echo "✅ Deploy completo!" +echo "🌐 Teste em: https://webmoney.cnxifly.com" diff --git a/deploy_subcategory_budgets.sh b/deploy_subcategory_budgets.sh new file mode 100644 index 0000000..062d891 --- /dev/null +++ b/deploy_subcategory_budgets.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +echo "🚀 Deploy completo: Orçamentos com Subcategorias" +echo "" + +# === BACKEND === +echo "📦 Enviando backend..." + +sshpass -p 'Master9354' scp -o StrictHostKeyChecking=no \ + backend/database/migrations/2025_12_16_211102_add_subcategory_to_budgets_table.php \ + root@213.165.93.60:/var/www/webmoney/backend/database/migrations/ + +sshpass -p 'Master9354' scp -o StrictHostKeyChecking=no \ + backend/app/Models/Budget.php \ + root@213.165.93.60:/var/www/webmoney/backend/app/Models/ + +sshpass -p 'Master9354' scp -o StrictHostKeyChecking=no \ + backend/app/Http/Controllers/Api/BudgetController.php \ + root@213.165.93.60:/var/www/webmoney/backend/app/Http/Controllers/Api/ + +echo "✅ Arquivos backend enviados" +echo "" + +echo "🔄 Executando migration..." +sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 \ + "cd /var/www/webmoney/backend && php artisan migrate --force" +echo "" + +echo "🧹 Limpando cache..." +sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 \ + "cd /var/www/webmoney/backend && php artisan config:clear && php artisan cache:clear && php artisan route:clear" +echo "" + +# === FRONTEND === +echo "📦 Deploy frontend..." +cd frontend && ./deploy.sh + +echo "" +echo "✅ Deploy completo!" +echo "🌐 Acesse: https://webmoney.cnxifly.com" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c9b7fe4..2b494c6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -21,6 +21,7 @@ import FinancialHealth from './pages/FinancialHealth'; import Goals from './pages/Goals'; import Budgets from './pages/Budgets'; import Reports from './pages/Reports'; +import Preferences from './pages/Preferences'; function App() { return ( @@ -179,6 +180,16 @@ function App() { } /> + + + + + + } + /> } /> diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index a6ab2f9..913b484 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -103,6 +103,7 @@ const Layout = ({ children }) => { items: [ { path: '/categories', icon: 'bi-tags', label: t('nav.categories') }, { path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') }, + { path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') }, ] }, ]; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0d57f42..08f7d36 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -91,6 +91,7 @@ "refunds": "Refunds", "categories": "Categories", "costCenters": "Cost Centers", + "preferences": "Preferences", "reports": "Reports", "settings": "Settings", "business": "Business", @@ -1698,6 +1699,8 @@ "noBudgetsDescription": "Start by creating your first monthly budget", "createFirst": "Create First Budget", "category": "Category", + "subcategory": "Subcategory", + "allCategory": "All category", "selectCategory": "Select a category", "amount": "Amount", "month": "Month", @@ -1712,6 +1715,16 @@ "totalSpent": "Total Spent", "allCategoriesUsed": "All categories already have budgets this month", "autoPropagateInfo": "This budget will automatically propagate to future months", + "costCenter": "Cost Center", + "noCostCenter": "No cost center", + "periodType": "Period Type", + "monthly": "Monthly", + "bimestral": "Bimonthly", + "trimestral": "Quarterly", + "semestral": "Semiannual", + "yearly": "Yearly", + "isCumulative": "Cumulative Budget", + "isCumulativeHelp": "Accumulates expenses from the beginning of the year to the current period", "alert": { "exceeded": "Budget exceeded!", "warning": "Warning: near limit", @@ -1959,5 +1972,38 @@ "oct": "Oct", "nov": "Nov", "dec": "Dec" + }, + "preferences": { + "title": "Preferences", + "subtitle": "Configure your preferences and notifications", + "loadError": "Error loading preferences", + "saveSuccess": "Preferences saved successfully!", + "saveError": "Error saving preferences", + "testSent": "Test email sent! Check your inbox.", + "testError": "Error sending test email", + "duePaymentsNotification": { + "title": "Due Payments Alert", + "description": "Receive a daily email with overdue transactions and those due the next day, along with available balance and payment suggestions.", + "enabled": "Notifications enabled", + "disabled": "Notifications disabled", + "time": "Send time", + "timeHelp": "The email will be sent daily at this time", + "email": "Email for notifications", + "emailHelp": "Leave blank to use account email ({{email}})", + "testButton": "Send Test Email", + "sending": "Sending...", + "infoTitle": "What's included in the email?", + "info1": "Overdue payments (past due)", + "info2": "Payments due tomorrow", + "info3": "Current account balances and shortfall", + "info4": "Suggestions for transfers between accounts" + }, + "general": { + "title": "General Settings", + "language": "Language", + "currency": "Primary currency", + "timezone": "Timezone", + "note": "Some changes may require page refresh." + } } } \ No newline at end of file diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 8f01a3c..8849c97 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -92,6 +92,7 @@ "refunds": "Reembolsos", "categories": "Categorías", "costCenters": "Centros de Costo", + "preferences": "Preferencias", "reports": "Reportes", "settings": "Configuración", "business": "Negocio", @@ -1750,6 +1751,8 @@ "noBudgetsDescription": "Crea presupuestos mensuales para controlar y limitar tus gastos por categoría.", "createFirst": "Crear Primer Presupuesto", "category": "Categoría", + "subcategory": "Subcategoría", + "allCategory": "Toda la categoría", "selectCategory": "Seleccionar categoría", "amount": "Monto", "spent": "Gastado", @@ -1765,6 +1768,16 @@ "almostExceeded": "Cerca del límite (80%+)", "allCategoriesUsed": "Ya tienes presupuesto para todas las categorías este mes", "autoPropagateInfo": "Este presupuesto se propagará automáticamente a los meses siguientes", + "costCenter": "Centro de Costos", + "noCostCenter": "Sin centro de costos", + "periodType": "Tipo de Período", + "monthly": "Mensual", + "bimestral": "Bimestral", + "trimestral": "Trimestral", + "semestral": "Semestral", + "yearly": "Anual", + "isCumulative": "Presupuesto Acumulativo", + "isCumulativeHelp": "Acumula gastos desde inicio de año hasta el período actual", "summary": { "totalBudget": "Presupuesto Total", "totalSpent": "Gastado", @@ -1947,5 +1960,38 @@ "oct": "Oct", "nov": "Nov", "dec": "Dic" + }, + "preferences": { + "title": "Preferencias", + "subtitle": "Configura tus preferencias y notificaciones", + "loadError": "Error al cargar preferencias", + "saveSuccess": "¡Preferencias guardadas con éxito!", + "saveError": "Error al guardar preferencias", + "testSent": "¡Email de prueba enviado! Revisa tu bandeja de entrada.", + "testError": "Error al enviar email de prueba", + "duePaymentsNotification": { + "title": "Alerta de Pagos Vencidos", + "description": "Recibe un email diario con las transacciones vencidas y que vencen al día siguiente, junto con el saldo disponible y sugerencias de pago.", + "enabled": "Notificaciones activadas", + "disabled": "Notificaciones desactivadas", + "time": "Hora de envío", + "timeHelp": "El email se enviará diariamente a esta hora", + "email": "Email para notificaciones", + "emailHelp": "Deja en blanco para usar el email de la cuenta ({{email}})", + "testButton": "Enviar Email de Prueba", + "sending": "Enviando...", + "infoTitle": "¿Qué incluye el email?", + "info1": "Pagos vencidos (atrasados)", + "info2": "Pagos que vencen mañana", + "info3": "Saldo actual de las cuentas y cuánto falta", + "info4": "Sugerencias de transferencias entre cuentas" + }, + "general": { + "title": "Configuración General", + "language": "Idioma", + "currency": "Moneda principal", + "timezone": "Zona horaria", + "note": "Algunos cambios pueden requerir actualizar la página." + } } } \ No newline at end of file diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 566dfca..994da65 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -93,6 +93,7 @@ "refunds": "Reembolsos", "categories": "Categorias", "costCenters": "Centros de Custo", + "preferences": "Preferências", "reports": "Relatórios", "settings": "Configurações", "business": "Negócios", @@ -1704,6 +1705,8 @@ "noBudgetsDescription": "Comece criando seu primeiro orçamento mensal", "createFirst": "Criar Primeiro Orçamento", "category": "Categoria", + "subcategory": "Subcategoria", + "allCategory": "Toda a categoria", "selectCategory": "Selecione uma categoria", "amount": "Valor", "month": "Mês", @@ -1718,6 +1721,14 @@ "totalSpent": "Total Gasto", "allCategoriesUsed": "Todas as categorias já possuem orçamento este mês", "autoPropagateInfo": "Este orçamento será propagado automaticamente para os meses seguintes", + "periodType": "Tipo de Período", + "monthly": "Mensal", + "bimestral": "Bimestral", + "trimestral": "Trimestral", + "semestral": "Semestral", + "yearly": "Anual", + "isCumulative": "Orçamento Cumulativo", + "isCumulativeHelp": "Acumula gastos desde o início do ano até o período atual", "alert": { "exceeded": "Orçamento excedido!", "warning": "Atenção: próximo do limite", @@ -1734,7 +1745,9 @@ "noCategory": "Sem categoria", "exceededBy": "Excedido em", "copySuccess": "Orçamentos copiados para o próximo mês", - "copyTitle": "Copiar para próximo mês" + "copyTitle": "Copiar para próximo mês", + "costCenter": "Centro de Custos", + "noCostCenter": "Sem centro de custos" }, "goals": { "title": "Metas Financeiras", @@ -1965,5 +1978,38 @@ "oct": "Out", "nov": "Nov", "dec": "Dez" + }, + "preferences": { + "title": "Preferências", + "subtitle": "Configure suas preferências e notificações", + "loadError": "Erro ao carregar preferências", + "saveSuccess": "Preferências salvas com sucesso!", + "saveError": "Erro ao salvar preferências", + "testSent": "Email de teste enviado! Verifique sua caixa de entrada.", + "testError": "Erro ao enviar email de teste", + "duePaymentsNotification": { + "title": "Alerta de Pagamentos Vencidos", + "description": "Receba um email diário com as transações vencidas e que vencem no dia seguinte, junto com o saldo disponível e sugestões de pagamento.", + "enabled": "Notificações ativadas", + "disabled": "Notificações desativadas", + "time": "Horário do envio", + "timeHelp": "O email será enviado diariamente neste horário", + "email": "Email para notificações", + "emailHelp": "Deixe em branco para usar o email da conta ({{email}})", + "testButton": "Enviar Email de Teste", + "sending": "Enviando...", + "infoTitle": "O que está incluído no email?", + "info1": "Pagamentos vencidos (atrasados)", + "info2": "Pagamentos que vencem amanhã", + "info3": "Saldo atual das contas e quanto falta", + "info4": "Sugestões de transferências entre contas" + }, + "general": { + "title": "Configurações Gerais", + "language": "Idioma", + "currency": "Moeda principal", + "timezone": "Fuso horário", + "note": "Algumas alterações podem exigir atualização da página." + } } } \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 9cbddb8..a88c1ab 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -654,6 +654,95 @@ a { color: #3b82f6; } +/* Collapsed sidebar state */ +.app-sidebar.collapsed .logo-text, +.app-sidebar.collapsed .sidebar-link-text, +.app-sidebar.collapsed .user-details { + opacity: 0; + visibility: hidden; + width: 0; + overflow: hidden; +} + +.app-sidebar.collapsed .logo-brand, +.app-sidebar.collapsed .sidebar-link { + justify-content: center; +} + +.app-sidebar.collapsed .sidebar-link-icon { + margin: 0; +} + +/* ===== TABLET OPTIMIZATIONS (834px - 1366px) ===== */ +/* Optimizaciones específicas para tablets en landscape */ +@media (min-width: 834px) and (max-width: 1366px) and (orientation: landscape) { + /* Sidebar más compacto en tablets */ + .app-sidebar { + width: 190px; + } + + .app-main { + margin-left: 190px; + } + + /* Logo e iconos ligeramente más pequeños */ + .logo-icon { + width: 32px; + height: 32px; + font-size: 1rem; + } + + .logo-text { + font-size: 1rem; + } + + .sidebar-link { + padding: 0.5rem 0.625rem; + font-size: 0.8rem; + } + + .sidebar-link-icon { + width: 28px; + height: 28px; + font-size: 0.9rem; + } + + /* Header más compacto */ + .app-header { + padding: 0.75rem 1.25rem; + } + + .welcome-label, + .welcome-name { + font-size: 0.75rem; + } + + .header-date { + font-size: 0.7rem; + padding: 0.3rem 0.625rem; + } + + /* User section compacto */ + .user-avatar { + width: 32px; + height: 32px; + font-size: 0.9rem; + } + + .user-name { + font-size: 0.7rem; + } + + .user-email { + font-size: 0.6rem; + } + + .user-logout { + width: 28px; + height: 28px; + } +} + /* Content Area */ .app-content { flex: 1; @@ -2213,111 +2302,304 @@ input[type="color"]::-webkit-color-swatch { /* Responsive adjustments */ -/* iPad Pro 12.9" landscape and large tablets (1024px - 1366px) */ -@media (min-width: 1024px) and (max-width: 1366px) { +/* ================================================================ + iPad Pro 12.9" y 11" en LANDSCAPE (1024px - 1366px) + Optimización para pantalla horizontal - máximo aprovechamiento + ================================================================ */ +@media (min-width: 1024px) and (max-width: 1366px) and (orientation: landscape) { + /* === LAYOUT GENERAL === */ + .app-sidebar { + width: 200px; /* Sidebar más compacto en tablet */ + } + + .app-main { + margin-left: 200px; + } + + .app-content { + padding: 1.25rem; + } + + /* === DASHBOARD === */ + .dashboard-stats { + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + } + + /* === TRANSACTIONS PAGE === */ .txn-page { padding: 1rem; } .txn-stats { grid-template-columns: repeat(4, 1fr); - gap: 0.5rem; + gap: 0.75rem; } .txn-stat-card { - padding: 0.75rem; + padding: 0.875rem; } .txn-stat-icon { - width: 36px; - height: 36px; - font-size: 1rem; + width: 38px; + height: 38px; + font-size: 1.1rem; } .txn-stat-value { - font-size: 0.9rem; + font-size: 1rem; } .txn-filters-grid { - grid-template-columns: repeat(4, 1fr) auto; - gap: 0.5rem; + grid-template-columns: repeat(5, 1fr) auto; /* 5 columnas para aprovechar ancho */ + gap: 0.75rem; + } + + .txn-week-summary { + gap: 1rem; + flex-wrap: nowrap; /* No wrap en landscape */ + } + + .txn-week-stat { + min-width: 80px; + } + + .txn-week-stat-value { + font-size: 0.9rem; + } + + /* === TABLES - Mostrar más columnas === */ + .txn-table th, + .txn-table td { + padding: 0.625rem 0.75rem; + font-size: 0.9rem; + } + + /* NO ocultar columnas - tenemos espacio */ + .txn-table .col-account, + .txn-table .col-status, + .txn-table .col-category { + display: table-cell !important; + } + + .table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + /* === HEADER === */ + .txn-header { + flex-direction: row; + justify-content: space-between; + } + + .txn-header-title h1 { + font-size: 1.25rem; + } + + .txn-header-actions { + gap: 0.75rem; + } + + .txn-header-actions .btn { + padding: 0.4rem 0.75rem; + font-size: 0.875rem; + } + + /* === MODALS - Aprovechar pantalla horizontal === */ + .modal-dialog { + max-width: 90%; + margin: 1rem auto; + } + + .modal-dialog.modal-lg { + max-width: 95%; + } + + .modal-body { + max-height: 75vh; + overflow-y: auto; + } + + /* === FORMS - Layout de 2 columnas === */ + .form-row-tablet { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + + /* Formularios - inputs más grandes para touch */ + .form-control, + .form-select, + input, + select, + textarea { + padding: 0.625rem 0.875rem !important; + font-size: 0.9rem !important; + min-height: 42px; + } + + /* Botones touch-friendly */ + .btn { + padding: 0.625rem 1rem !important; + min-height: 42px; + font-size: 0.9rem !important; + } + + .btn-sm { + padding: 0.4rem 0.75rem !important; + min-height: 36px; + } + + /* Input groups */ + .input-group > .form-control, + .input-group > .form-select, + .input-group > .btn { + min-height: 42px; + } + + /* === CARDS === */ + .card { + margin-bottom: 1rem; + } + + .card-body { + padding: 1.25rem; + } + + /* === CHARTS - Altura optimizada === */ + canvas { + max-height: 350px !important; + } + + /* === GRID LAYOUTS === */ + .row.g-3 { + --bs-gutter-x: 1rem; + --bs-gutter-y: 1rem; + } + + .row.g-4 { + --bs-gutter-x: 1.25rem; + --bs-gutter-y: 1.25rem; + } + + /* === WIDGETS Dashboard === */ + .dashboard-widget { + height: 100%; + min-height: 300px; + } + + .widget-card { + height: 100%; + } + + /* === BUSINESS CARDS === */ + .business-card { + margin-bottom: 1rem; + } + + .business-stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + } + + /* === DROPDOWNS y SELECT === */ + .form-select { + cursor: pointer; + } + + .dropdown-menu { + max-height: 400px; + overflow-y: auto; + } + + /* === PAGINATION === */ + .pagination { + margin-bottom: 0; + } + + .page-link { + min-width: 42px; + min-height: 42px; + display: flex; + align-items: center; + justify-content: center; + } +} + +/* iPad Pro 11" and smaller tablets (834px - 1024px) */ +/* Optimizado para iPad Pro 11" en landscape */ +@media (min-width: 834px) and (max-width: 1023px) and (orientation: landscape) { + /* Sidebar compacto */ + .app-sidebar { + width: 180px; + } + + .app-main { + margin-left: 180px; + } + + .app-content { + padding: 1rem; + } + + /* Stats en 3 columnas */ + .txn-stats { + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + } + + .dashboard-stats { + grid-template-columns: repeat(3, 1fr); + } + + /* Filtros en 4 columnas */ + .txn-filters-grid { + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; + } + + .txn-week-header { + padding: 0.75rem; } .txn-week-summary { gap: 0.75rem; + flex-wrap: nowrap; } .txn-week-stat { min-width: 70px; } - .txn-week-stat-value { - font-size: 0.8rem; + /* Mantener columnas importantes */ + .txn-table .col-category, + .txn-table .col-date { + display: table-cell !important; } - .txn-table th, - .txn-table td { - padding: 0.5rem 0.625rem; - } - - /* Ocultar algunas columnas menos importantes en tablets */ + /* Ocultar solo las menos críticas */ .txn-table .col-account { display: none; } - .txn-header-title h1 { - font-size: 1.1rem; + .txn-amount { + font-size: 0.875rem; } - .txn-header-actions .btn { - padding: 0.3rem 0.6rem; - font-size: 0.75rem; - } - - /* Modals optimization for iPad */ - .modal-dialog.modal-lg { + /* Modals */ + .modal-dialog { max-width: 85%; - margin: 1rem auto; } .modal-body { max-height: 70vh; - overflow-y: auto; - } -} - -/* iPad Pro 11" and smaller tablets (834px - 1024px) */ -@media (min-width: 834px) and (max-width: 1024px) { - .txn-stats { - grid-template-columns: repeat(2, 1fr); - gap: 0.5rem; } - .txn-filters-grid { - grid-template-columns: repeat(3, 1fr); - gap: 0.5rem; - } - - .txn-week-header { - padding: 0.625rem 0.75rem; - } - - .txn-week-summary { - gap: 0.5rem; - flex-wrap: wrap; - } - - .txn-week-stat { - min-width: 60px; - } - - .txn-table .col-account, - .txn-table .col-status { - display: none; - } - - .txn-amount { - font-size: 0.8rem; + /* Charts */ + canvas { + max-height: 300px !important; } } @@ -2697,6 +2979,7 @@ input[type="color"]::-webkit-color-swatch { } /* iPhone em landscape (horizontal) */ +/* También aplicable a tablets pequeños en landscape */ @media (max-height: 430px) and (orientation: landscape) { /* Reduz altura de elementos */ .card-body { @@ -2705,6 +2988,7 @@ input[type="color"]::-webkit-color-swatch { .modal-dialog { max-height: 90vh !important; + margin: 0.5rem auto !important; } .modal-body { @@ -2720,6 +3004,76 @@ input[type="color"]::-webkit-color-swatch { .app-header { padding: 0.5rem 1rem !important; } + + /* Sidebar oculto ou compacto em landscape baixo */ + .app-sidebar { + width: 60px !important; + } + + .app-main { + margin-left: 60px !important; + } +} + +/* ================================================================ + iPad/Tablet en LANDSCAPE - Optimización de altura (768px-900px) + Para pantallas horizontales con altura limitada + ================================================================ */ +@media (min-width: 834px) and (max-height: 900px) and (orientation: landscape) { + /* Aprovechar máximo la altura disponible */ + .app-header { + padding: 0.625rem 1.25rem; + } + + .app-content { + padding: 1rem 1.25rem; + } + + /* Cards más compactos verticalmente */ + .card { + margin-bottom: 0.875rem; + } + + .card-body { + padding: 1rem; + } + + /* Charts con altura reducida */ + canvas { + max-height: 280px !important; + } + + /* Dashboard widgets */ + .dashboard-widget { + min-height: 250px; + } + + /* Tables con menos padding vertical */ + .table th, + .table td { + padding: 0.5rem 0.75rem; + } + + /* Stats cards más compactos */ + .txn-stat-card, + .stat-card { + padding: 0.75rem; + } + + /* Modales optimizados */ + .modal-dialog { + margin: 0.75rem auto; + } + + .modal-header, + .modal-footer { + padding: 0.75rem 1rem; + } + + .modal-body { + padding: 1rem; + max-height: 65vh; + } } /* Previne problemas de input no iOS */ diff --git a/frontend/src/pages/Budgets.jsx b/frontend/src/pages/Budgets.jsx index 13c8b44..4c9def6 100644 --- a/frontend/src/pages/Budgets.jsx +++ b/frontend/src/pages/Budgets.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { budgetService, categoryService } from '../services/api'; +import { budgetService, categoryService, costCenterService } from '../services/api'; import useFormatters from '../hooks/useFormatters'; import { getCurrencyByCode } from '../config/currencies'; import ConfirmModal from '../components/ConfirmModal'; @@ -13,6 +13,7 @@ const Budgets = () => { const [budgets, setBudgets] = useState([]); const [categories, setCategories] = useState([]); const [availableCategories, setAvailableCategories] = useState([]); + const [costCenters, setCostCenters] = useState([]); const [year, setYear] = useState(new Date().getFullYear()); const [month, setMonth] = useState(new Date().getMonth() + 1); const [showModal, setShowModal] = useState(false); @@ -22,7 +23,11 @@ const Budgets = () => { const [primaryCurrency, setPrimaryCurrency] = useState('EUR'); const [formData, setFormData] = useState({ category_id: '', + subcategory_id: '', + cost_center_id: '', amount: '', + period_type: 'monthly', + is_cumulative: false, }); // Meses con i18n @@ -50,11 +55,12 @@ const Budgets = () => { const loadData = async () => { setLoading(true); try { - const [budgetsData, categoriesData, availableData, summaryData] = await Promise.all([ + const [budgetsData, categoriesData, availableData, summaryData, costCentersData] = await Promise.all([ budgetService.getAll({ year, month }), categoryService.getAll(), budgetService.getAvailableCategories({ year, month }), budgetService.getYearSummary({ year }), + costCenterService.getAll(), ]); // Extraer datos del response si viene en formato { data, ... } @@ -75,6 +81,10 @@ const Budgets = () => { setAvailableCategories(available); setYearSummary(Array.isArray(summaryData) ? summaryData : []); + + // Cost Centers + const centers = costCentersData?.data || costCentersData; + setCostCenters(Array.isArray(centers) ? centers : []); } catch (error) { console.error('Error loading budgets:', error); } finally { @@ -120,6 +130,7 @@ const Budgets = () => { setEditingBudget(budget); setFormData({ category_id: budget.category_id, + subcategory_id: budget.subcategory_id || '', amount: budget.amount, }); setShowModal(true); @@ -143,7 +154,11 @@ const Budgets = () => { const resetForm = () => { setFormData({ category_id: '', + subcategory_id: '', + cost_center_id: '', amount: '', + period_type: 'monthly', + is_cumulative: false, }); }; @@ -381,15 +396,32 @@ const Budgets = () => {
-
{budget.category?.name || t('budgets.noCategory')}
+
+ {budget.subcategory ? budget.subcategory.name : budget.category?.name || t('budgets.general')} +
+ {budget.subcategory && budget.category && ( + + + {budget.category.name} + + )} + {budget.cost_center && ( + + + {budget.cost_center.name} + + )}
@@ -571,7 +603,7 @@ const Budgets = () => { >
-
+

{months.find(m => m.value === month)?.label} {year} @@ -599,14 +631,14 @@ const Budgets = () => { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: '8px', - maxHeight: '300px', + maxHeight: '200px', overflowY: 'auto', padding: '4px' }}> {availableCategories.map(cat => (

setFormData({...formData, category_id: cat.id})} + onClick={() => setFormData({...formData, category_id: cat.id, subcategory_id: ''})} className={`p-2 rounded text-center cursor-pointer ${ formData.category_id == cat.id ? 'border border-primary' @@ -636,6 +668,109 @@ const Budgets = () => { )}
+ {/* Subcategory Selection - Only if category selected and has subcategories */} + {formData.category_id && !editingBudget && (() => { + const selectedCategory = availableCategories.find(c => c.id == formData.category_id); + const subcategories = selectedCategory?.subcategories || []; + + if (subcategories.length > 0) { + return ( +
+ +
+ {/* Option: All category (no subcategory) */} +
setFormData({...formData, subcategory_id: ''})} + className={`p-2 rounded text-center cursor-pointer ${ + !formData.subcategory_id + ? 'border border-primary' + : 'border border-secondary' + }`} + style={{ + background: !formData.subcategory_id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a', + cursor: 'pointer', + transition: 'all 0.2s' + }} + > + + + {t('budgets.allCategory')} + +
+ + {/* Subcategories */} + {subcategories.map(sub => ( +
setFormData({...formData, subcategory_id: sub.id})} + className={`p-2 rounded text-center cursor-pointer ${ + formData.subcategory_id == sub.id + ? 'border border-primary' + : 'border border-secondary' + }`} + style={{ + background: formData.subcategory_id == sub.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a', + cursor: 'pointer', + transition: 'all 0.2s' + }} + > + + + {sub.name} + +
+ ))} +
+
+ ); + } + return null; + })()} + + {/* Cost Center Selection - Only if editing or creating new */} + {!editingBudget && costCenters.length > 0 && ( +
+ + +
+ )} + {/* Amount */}
@@ -656,6 +791,43 @@ const Budgets = () => {
+ {/* Period Type */} + {!editingBudget && ( +
+ + +
+ )} + + {/* Cumulative Option */} + {!editingBudget && ( +
+
+ setFormData({...formData, is_cumulative: e.target.checked})} + /> + +
+
+ )} + {/* Info about auto-propagation */} {!editingBudget && (
diff --git a/frontend/src/pages/Preferences.jsx b/frontend/src/pages/Preferences.jsx new file mode 100644 index 0000000..3481cef --- /dev/null +++ b/frontend/src/pages/Preferences.jsx @@ -0,0 +1,317 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useToast } from '../components/Toast'; +import { preferencesService } from '../services/api'; + +export default function Preferences() { + const { t, i18n } = useTranslation(); + const { showToast } = useToast(); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testingSending, setTestingSending] = useState(false); + const [preferences, setPreferences] = useState({ + notify_due_payments: false, + notify_due_payments_time: '20:00', + notify_due_payments_email: '', + language: 'pt-BR', + timezone: 'Europe/Madrid', + currency: 'EUR', + }); + const [userEmail, setUserEmail] = useState(''); + + useEffect(() => { + loadPreferences(); + }, []); + + const loadPreferences = async () => { + try { + setLoading(true); + const response = await preferencesService.get(); + if (response.success) { + setPreferences({ + notify_due_payments: response.data.notify_due_payments, + notify_due_payments_time: response.data.notify_due_payments_time || '20:00', + notify_due_payments_email: response.data.notify_due_payments_email || '', + language: response.data.language || 'pt-BR', + timezone: response.data.timezone || 'Europe/Madrid', + currency: response.data.currency || 'EUR', + }); + setUserEmail(response.data.user_email); + } + } catch (error) { + console.error('Error loading preferences:', error); + showToast(t('preferences.loadError'), 'error'); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + try { + setSaving(true); + const response = await preferencesService.update(preferences); + if (response.success) { + showToast(t('preferences.saveSuccess'), 'success'); + } + } catch (error) { + console.error('Error saving preferences:', error); + showToast(t('preferences.saveError'), 'error'); + } finally { + setSaving(false); + } + }; + + const handleTestNotification = async () => { + try { + setTestingSending(true); + const response = await preferencesService.testNotification(); + if (response.success) { + showToast(t('preferences.testSent'), 'success'); + } + } catch (error) { + console.error('Error sending test notification:', error); + showToast(t('preferences.testError'), 'error'); + } finally { + setTestingSending(false); + } + }; + + const handleChange = (field, value) => { + setPreferences(prev => ({ + ...prev, + [field]: value, + })); + }; + + if (loading) { + return ( +
+
+
+ {t('common.loading')} +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + {t('preferences.title')} +

+

{t('preferences.subtitle')}

+
+ +
+ +
+ {/* Notificações de Vencimentos */} +
+
+
+
+ + {t('preferences.duePaymentsNotification.title')} +
+
+
+

+ {t('preferences.duePaymentsNotification.description')} +

+ + {/* Toggle Ativar/Desativar */} +
+ handleChange('notify_due_payments', e.target.checked)} + style={{ width: '3rem', height: '1.5rem' }} + /> + +
+ + {/* Configurações quando ativado */} + {preferences.notify_due_payments && ( +
+ {/* Horário */} +
+ + handleChange('notify_due_payments_time', e.target.value)} + /> + + {t('preferences.duePaymentsNotification.timeHelp')} + +
+ + {/* Email */} +
+ + handleChange('notify_due_payments_email', e.target.value)} + /> + + {t('preferences.duePaymentsNotification.emailHelp', { email: userEmail })} + +
+ + {/* Botão de Teste */} + +
+ )} + + {/* Info Box */} +
+
+ + {t('preferences.duePaymentsNotification.infoTitle')} +
+
    +
  • {t('preferences.duePaymentsNotification.info1')}
  • +
  • {t('preferences.duePaymentsNotification.info2')}
  • +
  • {t('preferences.duePaymentsNotification.info3')}
  • +
  • {t('preferences.duePaymentsNotification.info4')}
  • +
+
+
+
+
+ + {/* Outras Preferências */} +
+
+
+
+ + {t('preferences.general.title')} +
+
+
+ {/* Idioma */} +
+ + +
+ + {/* Moeda */} +
+ + +
+ + {/* Fuso Horário */} +
+ + +
+ + {/* Info */} +
+ + {t('preferences.general.note')} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 938ad89..f7ad7b0 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1494,4 +1494,27 @@ export const financialHealthService = { }, }; +// ============================================ +// User Preferences (Preferências do Usuário) +// ============================================ +export const preferencesService = { + // Obter preferências + get: async () => { + const response = await api.get('/preferences'); + return response.data; + }, + + // Atualizar preferências + update: async (data) => { + const response = await api.put('/preferences', data); + return response.data; + }, + + // Enviar notificação de teste + testNotification: async () => { + const response = await api.post('/preferences/test-notification'); + return response.data; + }, +}; + export default api;