feat: Add daily due payments notification system with user preferences

## New Features
- Email notifications for overdue and upcoming payments
- User preferences page for notification settings
- Daily scheduler to send alerts at user-configured time
- Smart analysis: payable items, transfer suggestions between accounts

## Backend
- Migration for user_preferences table
- SendDuePaymentsAlert Artisan command
- DuePaymentsAlert Mailable with HTML/text templates
- UserPreferenceController with test-notification endpoint
- Scheduler config for notify:due-payments command

## Frontend
- Preferences.jsx page with notification toggle
- API service for preferences
- Route and menu link for settings
- Translations (PT-BR, EN, ES)

## Server
- Cron configured for Laravel scheduler

Version: 1.44.5
This commit is contained in:
marcoitaloesp-ai 2025-12-17 09:57:40 +00:00 committed by GitHub
parent 6149aee7ac
commit 19dcdce262
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 3019 additions and 105 deletions

View File

@ -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

View File

@ -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

View File

@ -1 +1 @@
1.43.26
1.44.5

View File

@ -0,0 +1,444 @@
<?php
namespace App\Console\Commands;
use App\Mail\DuePaymentsAlert;
use App\Models\User;
use App\Models\UserPreference;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Carbon\Carbon;
class SendDuePaymentsAlert extends Command
{
protected $signature = 'notify:due-payments {--user= : Send to specific user ID} {--force : Send even if disabled}';
protected $description = 'Send email alerts about overdue and upcoming payments';
// Exchange rates cache (same as ReportController)
private array $exchangeRates = [
'EUR' => 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';
}
}

View File

@ -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);
}
/**

View File

@ -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)

View File

@ -0,0 +1,124 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\UserPreference;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class UserPreferenceController extends Controller
{
/**
* Get user preferences
*/
public function index(Request $request): JsonResponse
{
$userId = $request->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);
}
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class DuePaymentsAlert extends Mailable
{
use Queueable, SerializesModels;
public string $userName;
public array $overdueItems;
public array $tomorrowItems;
public array $accountBalances;
public float $totalAvailable;
public float $totalDue;
public float $shortage;
public array $payableItems;
public array $unpayableItems;
public array $transferSuggestions;
public string $currency;
/**
* Create a new message instance.
*/
public function __construct(
string $userName,
array $overdueItems,
array $tomorrowItems,
array $accountBalances,
float $totalAvailable,
float $totalDue,
float $shortage,
array $payableItems,
array $unpayableItems,
array $transferSuggestions,
string $currency = 'EUR'
) {
$this->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' => '<mailto:support@cnxifly.com?subject=unsubscribe>',
'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
],
);
}
/**
* Get the attachments for the message.
*/
public function attachments(): array
{
return [];
}
}

View File

@ -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;
}
}
// ============================================

View File

@ -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
*/

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserPreference extends Model
{
protected $fillable = [
'user_id',
'notify_due_payments',
'notify_due_payments_time',
'notify_due_payments_email',
'language',
'timezone',
'currency',
];
protected $casts = [
'notify_due_payments' => '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;
}
}

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('budgets', function (Blueprint $table) {
// Adicionar subcategory_id
$table->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');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('budgets', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('budgets', function (Blueprint $table) {
// Adicionar centro de custos (opcional)
$table->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'");
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_preferences', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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 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

View File

@ -0,0 +1,375 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alerta de Pagamentos</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 2px solid #0f172a;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #0f172a;
margin: 0;
font-size: 24px;
}
.summary-box {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
color: white;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.summary-row:last-child {
border-bottom: none;
}
.summary-label {
color: #94a3b8;
}
.summary-value {
font-weight: bold;
}
.shortage {
background-color: #dc2626;
color: white;
padding: 15px;
border-radius: 8px;
text-align: center;
margin: 20px 0;
}
.shortage h3 {
margin: 0 0 5px 0;
}
.shortage .amount {
font-size: 28px;
font-weight: bold;
}
.section {
margin: 25px 0;
}
.section-title {
font-size: 18px;
color: #0f172a;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 10px;
margin-bottom: 15px;
}
.item {
background-color: #f8fafc;
border-left: 4px solid #64748b;
padding: 12px 15px;
margin: 10px 0;
border-radius: 0 4px 4px 0;
}
.item.overdue {
border-left-color: #dc2626;
background-color: #fef2f2;
}
.item.tomorrow {
border-left-color: #f59e0b;
background-color: #fffbeb;
}
.item.payable {
border-left-color: #22c55e;
background-color: #f0fdf4;
}
.item.unpayable {
border-left-color: #dc2626;
background-color: #fef2f2;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-description {
font-weight: 600;
color: #1e293b;
}
.item-amount {
font-weight: bold;
color: #dc2626;
}
.item-details {
font-size: 13px;
color: #64748b;
margin-top: 5px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
}
.badge-overdue {
background-color: #dc2626;
color: white;
}
.badge-tomorrow {
background-color: #f59e0b;
color: white;
}
.badge-ok {
background-color: #22c55e;
color: white;
}
.account-balance {
display: flex;
justify-content: space-between;
padding: 10px 15px;
background-color: #f8fafc;
margin: 5px 0;
border-radius: 4px;
}
.account-name {
font-weight: 500;
}
.balance-positive {
color: #22c55e;
font-weight: bold;
}
.balance-negative {
color: #dc2626;
font-weight: bold;
}
.transfer-suggestion {
background-color: #eff6ff;
border: 1px solid #3b82f6;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
}
.transfer-arrow {
text-align: center;
font-size: 20px;
color: #3b82f6;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
color: #64748b;
font-size: 13px;
}
.cta-button {
display: inline-block;
background-color: #3b82f6;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
margin: 20px 0;
}
.cta-button:hover {
background-color: #2563eb;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>💰 WEBMoney - Alerta de Pagamentos</h1>
<p>Olá, {{ $userName }}!</p>
</div>
<!-- Summary Box -->
<div class="summary-box">
<div class="summary-row">
<span class="summary-label">💳 Saldo Total Disponível</span>
<span class="summary-value">{{ number_format($totalAvailable, 2, ',', '.') }} {{ $currency }}</span>
</div>
<div class="summary-row">
<span class="summary-label">📋 Total a Pagar</span>
<span class="summary-value">{{ number_format($totalDue, 2, ',', '.') }} {{ $currency }}</span>
</div>
@if($shortage > 0)
<div class="summary-row" style="color: #fca5a5;">
<span class="summary-label">⚠️ Falta</span>
<span class="summary-value">{{ number_format($shortage, 2, ',', '.') }} {{ $currency }}</span>
</div>
@else
<div class="summary-row" style="color: #86efac;">
<span class="summary-label"> Situação</span>
<span class="summary-value">Saldo suficiente!</span>
</div>
@endif
</div>
@if($shortage > 0)
<div class="shortage">
<h3>⚠️ SALDO INSUFICIENTE</h3>
<div class="amount">-{{ number_format($shortage, 2, ',', '.') }} {{ $currency }}</div>
<p style="margin: 10px 0 0 0; font-size: 14px;">Você não tem saldo suficiente para cobrir todos os pagamentos.</p>
</div>
@endif
<!-- Account Balances -->
<div class="section">
<h2 class="section-title">💳 Saldo das Contas</h2>
@foreach($accountBalances as $account)
<div class="account-balance">
<span class="account-name">{{ $account['name'] }}</span>
<span class="{{ $account['balance'] >= 0 ? 'balance-positive' : 'balance-negative' }}">
{{ number_format($account['balance'], 2, ',', '.') }} {{ $account['currency'] }}
</span>
</div>
@endforeach
</div>
<!-- Overdue Items -->
@if(count($overdueItems) > 0)
<div class="section">
<h2 class="section-title">🔴 Pagamentos Vencidos ({{ count($overdueItems) }})</h2>
@foreach($overdueItems as $item)
<div class="item overdue">
<div class="item-header">
<span class="item-description">{{ $item['description'] }}</span>
<span class="item-amount">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
</div>
<div class="item-details">
<span class="badge badge-overdue">{{ $item['days_overdue'] }} dias de atraso</span>
Venceu em {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }}
@if($item['account_name'])
Conta: {{ $item['account_name'] }}
@endif
</div>
</div>
@endforeach
</div>
@endif
<!-- Tomorrow Items -->
@if(count($tomorrowItems) > 0)
<div class="section">
<h2 class="section-title">🟡 Vencem Amanhã ({{ count($tomorrowItems) }})</h2>
@foreach($tomorrowItems as $item)
<div class="item tomorrow">
<div class="item-header">
<span class="item-description">{{ $item['description'] }}</span>
<span class="item-amount">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
</div>
<div class="item-details">
<span class="badge badge-tomorrow">Amanhã</span>
{{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }}
@if($item['account_name'])
Conta: {{ $item['account_name'] }}
@endif
</div>
</div>
@endforeach
</div>
@endif
<!-- Payable Items -->
@if(count($payableItems) > 0)
<div class="section">
<h2 class="section-title"> Pagamentos Possíveis ({{ count($payableItems) }})</h2>
<p style="color: #64748b; font-size: 14px;">Com base no saldo atual, você consegue pagar:</p>
@foreach($payableItems as $item)
<div class="item payable">
<div class="item-header">
<span class="item-description">{{ $item['description'] }}</span>
<span class="item-amount" style="color: #22c55e;">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
</div>
<div class="item-details">
<span class="badge badge-ok"> Pode pagar</span>
@if($item['account_name'])
Conta: {{ $item['account_name'] }}
@endif
</div>
</div>
@endforeach
</div>
@endif
<!-- Unpayable Items -->
@if(count($unpayableItems) > 0)
<div class="section">
<h2 class="section-title"> Sem Saldo Suficiente ({{ count($unpayableItems) }})</h2>
<p style="color: #64748b; font-size: 14px;">Não saldo disponível para estes pagamentos:</p>
@foreach($unpayableItems as $item)
<div class="item unpayable">
<div class="item-header">
<span class="item-description">{{ $item['description'] }}</span>
<span class="item-amount">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
</div>
<div class="item-details">
<span class="badge badge-overdue"> Sem saldo</span>
@if($item['account_name'])
Conta: {{ $item['account_name'] }}
@endif
</div>
</div>
@endforeach
</div>
@endif
<!-- Transfer Suggestions -->
@if(count($transferSuggestions) > 0)
<div class="section">
<h2 class="section-title">💱 Sugestões de Transferência</h2>
<p style="color: #64748b; font-size: 14px;">Para cobrir os pagamentos, considere transferir entre suas contas:</p>
@foreach($transferSuggestions as $transfer)
<div class="transfer-suggestion">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<strong>{{ $transfer['from_account'] }}</strong>
<div style="font-size: 12px; color: #64748b;">Origem</div>
</div>
<div class="transfer-arrow"></div>
<div style="text-align: right;">
<strong>{{ $transfer['to_account'] }}</strong>
<div style="font-size: 12px; color: #64748b;">Destino</div>
</div>
</div>
<div style="text-align: center; margin-top: 10px; font-size: 20px; font-weight: bold; color: #3b82f6;">
{{ number_format($transfer['amount'], 2, ',', '.') }} {{ $currency }}
</div>
<div style="text-align: center; font-size: 12px; color: #64748b;">{{ $transfer['reason'] }}</div>
</div>
@endforeach
</div>
@endif
<div style="text-align: center;">
<a href="https://webmoney.cnxifly.com/transactions" class="cta-button">
Acessar WEBMoney
</a>
</div>
<div class="footer">
<p>Este email foi enviado automaticamente pelo sistema WEBMoney.</p>
<p>Para desativar estas notificações, acesse <a href="https://webmoney.cnxifly.com/preferences">Preferências</a>.</p>
<p style="margin-top: 15px;">© {{ date('Y') }} WEBMoney - ConneXiFly</p>
</div>
</div>
</body>
</html>

View File

@ -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']);
});

View File

@ -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'));

View File

@ -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!"

17
deploy_correto.sh Normal file
View File

@ -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"

View File

@ -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"

View File

@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/preferences"
element={
<ProtectedRoute>
<Layout>
<Preferences />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
<CookieConsent />

View File

@ -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') },
]
},
];

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}

View File

@ -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 */

View File

@ -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 = () => {
<div className="d-flex align-items-center">
<div
className="rounded-circle p-2 me-2"
style={{ background: `${budget.category?.color || '#3b82f6'}20` }}
style={{ background: `${(budget.subcategory || budget.category)?.color || '#3b82f6'}20` }}
>
<i
className={`bi ${budget.category?.icon || 'bi-tag'}`}
style={{ color: budget.category?.color || '#3b82f6' }}
className={`bi ${(budget.subcategory || budget.category)?.icon || 'bi-tag'}`}
style={{
color: (budget.subcategory || budget.category)?.color || '#3b82f6',
fontSize: '1.25rem'
}}
></i>
</div>
<div>
<h6 className="text-white mb-0">{budget.category?.name || t('budgets.noCategory')}</h6>
<h6 className="text-white mb-0">
{budget.subcategory ? budget.subcategory.name : budget.category?.name || t('budgets.general')}
</h6>
{budget.subcategory && budget.category && (
<small className="text-slate-400">
<i className="bi bi-arrow-return-right me-1"></i>
{budget.category.name}
</small>
)}
{budget.cost_center && (
<small className="text-info">
<i className="bi bi-building me-1"></i>
{budget.cost_center.name}
</small>
)}
</div>
</div>
<div className="dropdown">
@ -571,7 +603,7 @@ const Budgets = () => {
></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<p className="text-slate-400 small mb-3">
<i className="bi bi-calendar3 me-1"></i>
{months.find(m => m.value === month)?.label} {year}
@ -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 => (
<div
key={cat.id}
onClick={() => 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 = () => {
)}
</div>
{/* Subcategory Selection - Only if category selected and has subcategories */}
{formData.category_id && !editingBudget && (() => {
const selectedCategory = availableCategories.find(c => c.id == formData.category_id);
const subcategories = selectedCategory?.subcategories || [];
if (subcategories.length > 0) {
return (
<div className="mb-3">
<label className="form-label text-slate-400">
{t('budgets.subcategory')}
<small className="text-muted ms-2">({t('common.optional')})</small>
</label>
<div className="category-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: '8px',
maxHeight: '200px',
overflowY: 'auto',
padding: '4px'
}}>
{/* Option: All category (no subcategory) */}
<div
onClick={() => setFormData({...formData, subcategory_id: ''})}
className={`p-2 rounded text-center cursor-pointer ${
!formData.subcategory_id
? 'border border-primary'
: 'border border-secondary'
}`}
style={{
background: !formData.subcategory_id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<i
className={`bi ${selectedCategory?.icon || 'bi-tag'} d-block mb-1`}
style={{
fontSize: '1.5rem',
color: selectedCategory?.color || '#6b7280'
}}
></i>
<small className="text-white d-block text-truncate">
{t('budgets.allCategory')}
</small>
</div>
{/* Subcategories */}
{subcategories.map(sub => (
<div
key={sub.id}
onClick={() => setFormData({...formData, subcategory_id: sub.id})}
className={`p-2 rounded text-center cursor-pointer ${
formData.subcategory_id == sub.id
? 'border border-primary'
: 'border border-secondary'
}`}
style={{
background: formData.subcategory_id == sub.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<i
className={`bi ${sub.icon || 'bi-tag'} d-block mb-1`}
style={{
fontSize: '1.5rem',
color: sub.color || '#6b7280'
}}
></i>
<small className="text-white d-block text-truncate" title={sub.name}>
{sub.name}
</small>
</div>
))}
</div>
</div>
);
}
return null;
})()}
{/* Cost Center Selection - Only if editing or creating new */}
{!editingBudget && costCenters.length > 0 && (
<div className="mb-3">
<label className="form-label text-slate-400">
{t('budgets.costCenter')}
<small className="text-muted ms-2">({t('common.optional')})</small>
</label>
<select
className="form-select bg-dark border-secondary text-white"
value={formData.cost_center_id || ''}
onChange={(e) => setFormData({...formData, cost_center_id: e.target.value})}
>
<option value="">{t('budgets.noCostCenter')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>
{cc.name}
</option>
))}
</select>
</div>
)}
{/* Amount */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.amount')} *</label>
@ -656,6 +791,43 @@ const Budgets = () => {
</div>
</div>
{/* Period Type */}
{!editingBudget && (
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.periodType')}</label>
<select
className="form-select bg-dark border-secondary text-white"
value={formData.period_type || 'monthly'}
onChange={(e) => setFormData({...formData, period_type: e.target.value})}
>
<option value="monthly">{t('budgets.monthly')}</option>
<option value="bimestral">{t('budgets.bimestral')}</option>
<option value="trimestral">{t('budgets.trimestral')}</option>
<option value="semestral">{t('budgets.semestral')}</option>
<option value="yearly">{t('budgets.yearly')}</option>
</select>
</div>
)}
{/* Cumulative Option */}
{!editingBudget && (
<div className="mb-3">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
id="isCumulativeCheck"
checked={formData.is_cumulative || false}
onChange={(e) => setFormData({...formData, is_cumulative: e.target.checked})}
/>
<label className="form-check-label text-slate-400" htmlFor="isCumulativeCheck">
{t('budgets.isCumulative')}
<small className="d-block text-muted">{t('budgets.isCumulativeHelp')}</small>
</label>
</div>
</div>
)}
{/* Info about auto-propagation */}
{!editingBudget && (
<div className="alert alert-info py-2 mb-0">

View File

@ -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 (
<div className="container-fluid py-4">
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
</div>
</div>
);
}
return (
<div className="container-fluid py-4">
{/* Header */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 className="h3 text-white mb-1">
<i className="bi bi-gear me-2"></i>
{t('preferences.title')}
</h1>
<p className="text-slate-400 mb-0">{t('preferences.subtitle')}</p>
</div>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-check-lg me-2"></i>
{t('common.save')}
</>
)}
</button>
</div>
<div className="row g-4">
{/* Notificações de Vencimentos */}
<div className="col-lg-6">
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
<div className="card-header border-0 bg-transparent">
<h5 className="text-white mb-0">
<i className="bi bi-bell text-warning me-2"></i>
{t('preferences.duePaymentsNotification.title')}
</h5>
</div>
<div className="card-body">
<p className="text-slate-400 small mb-4">
{t('preferences.duePaymentsNotification.description')}
</p>
{/* Toggle Ativar/Desativar */}
<div className="form-check form-switch mb-4">
<input
className="form-check-input"
type="checkbox"
id="notifyDuePayments"
checked={preferences.notify_due_payments}
onChange={(e) => handleChange('notify_due_payments', e.target.checked)}
style={{ width: '3rem', height: '1.5rem' }}
/>
<label className="form-check-label text-white ms-2" htmlFor="notifyDuePayments">
{preferences.notify_due_payments
? t('preferences.duePaymentsNotification.enabled')
: t('preferences.duePaymentsNotification.disabled')
}
</label>
</div>
{/* Configurações quando ativado */}
{preferences.notify_due_payments && (
<div className="p-3 rounded" style={{ background: '#1e293b' }}>
{/* Horário */}
<div className="mb-3">
<label className="form-label text-slate-400 small">
<i className="bi bi-clock me-1"></i>
{t('preferences.duePaymentsNotification.time')}
</label>
<input
type="time"
className="form-control bg-dark text-white border-secondary"
value={preferences.notify_due_payments_time}
onChange={(e) => handleChange('notify_due_payments_time', e.target.value)}
/>
<small className="text-slate-500">
{t('preferences.duePaymentsNotification.timeHelp')}
</small>
</div>
{/* Email */}
<div className="mb-3">
<label className="form-label text-slate-400 small">
<i className="bi bi-envelope me-1"></i>
{t('preferences.duePaymentsNotification.email')}
</label>
<input
type="email"
className="form-control bg-dark text-white border-secondary"
placeholder={userEmail}
value={preferences.notify_due_payments_email}
onChange={(e) => handleChange('notify_due_payments_email', e.target.value)}
/>
<small className="text-slate-500">
{t('preferences.duePaymentsNotification.emailHelp', { email: userEmail })}
</small>
</div>
{/* Botão de Teste */}
<button
className="btn btn-outline-warning btn-sm w-100"
onClick={handleTestNotification}
disabled={testingSending}
>
{testingSending ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
{t('preferences.duePaymentsNotification.sending')}
</>
) : (
<>
<i className="bi bi-send me-2"></i>
{t('preferences.duePaymentsNotification.testButton')}
</>
)}
</button>
</div>
)}
{/* Info Box */}
<div className="alert alert-info mt-4 mb-0" style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)' }}>
<h6 className="text-info mb-2">
<i className="bi bi-info-circle me-2"></i>
{t('preferences.duePaymentsNotification.infoTitle')}
</h6>
<ul className="mb-0 small text-slate-300" style={{ paddingLeft: '1.2rem' }}>
<li>{t('preferences.duePaymentsNotification.info1')}</li>
<li>{t('preferences.duePaymentsNotification.info2')}</li>
<li>{t('preferences.duePaymentsNotification.info3')}</li>
<li>{t('preferences.duePaymentsNotification.info4')}</li>
</ul>
</div>
</div>
</div>
</div>
{/* Outras Preferências */}
<div className="col-lg-6">
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
<div className="card-header border-0 bg-transparent">
<h5 className="text-white mb-0">
<i className="bi bi-sliders text-primary me-2"></i>
{t('preferences.general.title')}
</h5>
</div>
<div className="card-body">
{/* Idioma */}
<div className="mb-4">
<label className="form-label text-slate-400 small">
<i className="bi bi-translate me-1"></i>
{t('preferences.general.language')}
</label>
<select
className="form-select bg-dark text-white border-secondary"
value={preferences.language}
onChange={(e) => {
handleChange('language', e.target.value);
i18n.changeLanguage(e.target.value);
}}
>
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
<option value="en">🇺🇸 English</option>
<option value="es">🇪🇸 Español</option>
</select>
</div>
{/* Moeda */}
<div className="mb-4">
<label className="form-label text-slate-400 small">
<i className="bi bi-currency-exchange me-1"></i>
{t('preferences.general.currency')}
</label>
<select
className="form-select bg-dark text-white border-secondary"
value={preferences.currency}
onChange={(e) => handleChange('currency', e.target.value)}
>
<option value="EUR"> Euro (EUR)</option>
<option value="USD">$ US Dollar (USD)</option>
<option value="BRL">R$ Real (BRL)</option>
<option value="GBP">£ Pound (GBP)</option>
</select>
</div>
{/* Fuso Horário */}
<div className="mb-4">
<label className="form-label text-slate-400 small">
<i className="bi bi-globe me-1"></i>
{t('preferences.general.timezone')}
</label>
<select
className="form-select bg-dark text-white border-secondary"
value={preferences.timezone}
onChange={(e) => handleChange('timezone', e.target.value)}
>
<option value="Europe/Madrid">Europe/Madrid (CET)</option>
<option value="Europe/London">Europe/London (GMT)</option>
<option value="America/Sao_Paulo">America/São Paulo (BRT)</option>
<option value="America/New_York">America/New York (EST)</option>
<option value="America/Los_Angeles">America/Los Angeles (PST)</option>
<option value="UTC">UTC</option>
</select>
</div>
{/* Info */}
<div className="text-slate-500 small mt-4">
<i className="bi bi-info-circle me-1"></i>
{t('preferences.general.note')}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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;