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:
parent
6149aee7ac
commit
19dcdce262
134
CHANGELOG.md
134
CHANGELOG.md
@ -5,6 +5,140 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
|
|||||||
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
|
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
|
## [1.43.26] - 2025-12-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
90
IMPLEMENTACAO_ORCAMENTOS_SUBCATEGORIA.md
Normal file
90
IMPLEMENTACAO_ORCAMENTOS_SUBCATEGORIA.md
Normal 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
|
||||||
444
backend/app/Console/Commands/SendDuePaymentsAlert.php
Normal file
444
backend/app/Console/Commands/SendDuePaymentsAlert.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,7 +23,7 @@ public function index(Request $request)
|
|||||||
$budgets = Budget::forUser(Auth::id())
|
$budgets = Budget::forUser(Auth::id())
|
||||||
->forPeriod($year, $month)
|
->forPeriod($year, $month)
|
||||||
->active()
|
->active()
|
||||||
->with('category')
|
->with(['category', 'subcategory', 'costCenter'])
|
||||||
->orderBy('amount', 'desc')
|
->orderBy('amount', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
@ -53,39 +53,84 @@ public function store(Request $request)
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'category_id' => 'nullable|exists:categories,id',
|
'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',
|
'name' => 'nullable|string|max:255',
|
||||||
'amount' => 'required|numeric|min:0.01',
|
'amount' => 'required|numeric|min:0.01',
|
||||||
'currency' => 'nullable|string|size:3',
|
'currency' => 'nullable|string|size:3',
|
||||||
'year' => 'required|integer|min:2020|max:2100',
|
'year' => 'required|integer|min:2020|max:2100',
|
||||||
'month' => 'required|integer|min:1|max:12',
|
'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',
|
'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
|
// Verificar que no exista ya
|
||||||
$exists = Budget::forUser(Auth::id())
|
$exists = Budget::forUser(Auth::id())
|
||||||
->where('category_id', $validated['category_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('year', $validated['year'])
|
||||||
->where('month', $validated['month'])
|
->where('month', $validated['month'])
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if ($exists) {
|
if ($exists) {
|
||||||
return response()->json([
|
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);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated['user_id'] = Auth::id();
|
$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
|
// Crear el presupuesto del mes actual
|
||||||
$budget = Budget::create($validated);
|
$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'];
|
$currentYear = $validated['year'];
|
||||||
$currentMonth = $validated['month'];
|
$currentMonth = $validated['month'];
|
||||||
|
|
||||||
for ($i = 1; $i <= 12; $i++) {
|
// Propagar hasta completar 12 meses (1 año)
|
||||||
$nextMonth = $currentMonth + $i;
|
for ($monthsAhead = $step; $monthsAhead < 12; $monthsAhead += $step) {
|
||||||
|
$nextMonth = $currentMonth + $monthsAhead;
|
||||||
$nextYear = $currentYear;
|
$nextYear = $currentYear;
|
||||||
|
|
||||||
if ($nextMonth > 12) {
|
if ($nextMonth > 12) {
|
||||||
@ -96,6 +141,8 @@ public function store(Request $request)
|
|||||||
// Solo crear si no existe
|
// Solo crear si no existe
|
||||||
$existsNext = Budget::forUser(Auth::id())
|
$existsNext = Budget::forUser(Auth::id())
|
||||||
->where('category_id', $validated['category_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('year', $nextYear)
|
||||||
->where('month', $nextMonth)
|
->where('month', $nextMonth)
|
||||||
->exists();
|
->exists();
|
||||||
@ -104,12 +151,15 @@ public function store(Request $request)
|
|||||||
Budget::create([
|
Budget::create([
|
||||||
'user_id' => Auth::id(),
|
'user_id' => Auth::id(),
|
||||||
'category_id' => $validated['category_id'],
|
'category_id' => $validated['category_id'],
|
||||||
|
'subcategory_id' => $validated['subcategory_id'] ?? null,
|
||||||
|
'cost_center_id' => $validated['cost_center_id'] ?? null,
|
||||||
'name' => $validated['name'] ?? null,
|
'name' => $validated['name'] ?? null,
|
||||||
'amount' => $validated['amount'],
|
'amount' => $validated['amount'],
|
||||||
'currency' => $validated['currency'] ?? null,
|
'currency' => $validated['currency'] ?? null,
|
||||||
'year' => $nextYear,
|
'year' => $nextYear,
|
||||||
'month' => $nextMonth,
|
'month' => $nextMonth,
|
||||||
'period_type' => $validated['period_type'] ?? 'monthly',
|
'period_type' => $validated['period_type'] ?? 'monthly',
|
||||||
|
'is_cumulative' => $validated['is_cumulative'] ?? false,
|
||||||
'notes' => $validated['notes'] ?? null,
|
'notes' => $validated['notes'] ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -117,7 +167,7 @@ public function store(Request $request)
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Presupuesto creado y propagado',
|
'message' => 'Presupuesto creado y propagado',
|
||||||
'data' => $budget->load('category'),
|
'data' => $budget->load(['category', 'subcategory', 'costCenter']),
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +177,7 @@ public function store(Request $request)
|
|||||||
public function show($id)
|
public function show($id)
|
||||||
{
|
{
|
||||||
$budget = Budget::forUser(Auth::id())
|
$budget = Budget::forUser(Auth::id())
|
||||||
->with('category')
|
->with(['category', 'subcategory', 'costCenter'])
|
||||||
->findOrFail($id);
|
->findOrFail($id);
|
||||||
|
|
||||||
// Obtener transacciones del período
|
// Obtener transacciones del período
|
||||||
@ -136,7 +186,12 @@ public function show($id)
|
|||||||
->whereYear('effective_date', $budget->year)
|
->whereYear('effective_date', $budget->year)
|
||||||
->whereMonth('effective_date', $budget->month);
|
->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];
|
$categoryIds = [$budget->category_id];
|
||||||
$subcategories = Category::where('parent_id', $budget->category_id)->pluck('id')->toArray();
|
$subcategories = Category::where('parent_id', $budget->category_id)->pluck('id')->toArray();
|
||||||
$categoryIds = array_merge($categoryIds, $subcategories);
|
$categoryIds = array_merge($categoryIds, $subcategories);
|
||||||
@ -183,12 +238,17 @@ public function destroy($id)
|
|||||||
$budget = Budget::forUser(Auth::id())->findOrFail($id);
|
$budget = Budget::forUser(Auth::id())->findOrFail($id);
|
||||||
|
|
||||||
$categoryId = $budget->category_id;
|
$categoryId = $budget->category_id;
|
||||||
|
$subcategoryId = $budget->subcategory_id;
|
||||||
$year = $budget->year;
|
$year = $budget->year;
|
||||||
$month = $budget->month;
|
$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())
|
Budget::forUser(Auth::id())
|
||||||
->where('category_id', $categoryId)
|
->where('category_id', $categoryId)
|
||||||
|
->where('subcategory_id', $subcategoryId)
|
||||||
|
->where('cost_center_id', $costCenterId)
|
||||||
->where(function($q) use ($year, $month) {
|
->where(function($q) use ($year, $month) {
|
||||||
$q->where('year', '>', $year)
|
$q->where('year', '>', $year)
|
||||||
->orWhere(function($q2) use ($year, $month) {
|
->orWhere(function($q2) use ($year, $month) {
|
||||||
@ -235,21 +295,48 @@ public function availableCategories(Request $request)
|
|||||||
$year = $request->get('year', now()->year);
|
$year = $request->get('year', now()->year);
|
||||||
$month = $request->get('month', now()->month);
|
$month = $request->get('month', now()->month);
|
||||||
|
|
||||||
// Obtener IDs de categorías ya usadas en el período
|
// Obtener pares (category_id, subcategory_id) já usados no período
|
||||||
$usedCategoryIds = Budget::forUser(Auth::id())
|
$usedPairs = Budget::forUser(Auth::id())
|
||||||
->forPeriod($year, $month)
|
->forPeriod($year, $month)
|
||||||
->pluck('category_id')
|
->get()
|
||||||
|
->map(fn($b) => $b->category_id . '_' . ($b->subcategory_id ?? 'null'))
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
// Categorías padre con tipo expense o both (gastos)
|
// Categorías padre con tipo expense o both (gastos)
|
||||||
$categories = Category::where('user_id', Auth::id())
|
$categories = Category::where('user_id', Auth::id())
|
||||||
->whereNull('parent_id')
|
->whereNull('parent_id')
|
||||||
->whereIn('type', ['expense', 'both'])
|
->whereIn('type', ['expense', 'both'])
|
||||||
->whereNotIn('id', $usedCategoryIds)
|
->with(['subcategories' => function($q) {
|
||||||
|
$q->orderBy('name');
|
||||||
|
}])
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1760,13 +1760,48 @@ public function overdueTransactions(Request $request)
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Recurrencias activas que deberían haber ejecutado pero no lo hicieron
|
// 3. Instancias de recorrências pendentes e vencidas
|
||||||
$overdueRecurrences = $this->getOverdueRecurrences($today);
|
$overdueRecurringInstances = DB::select("
|
||||||
foreach ($overdueRecurrences as $rec) {
|
SELECT
|
||||||
$converted = $this->convertToPrimaryCurrency($rec['amount'], $rec['currency']);
|
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;
|
$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)
|
// Ordenar por días de atraso (más atrasado primero)
|
||||||
|
|||||||
124
backend/app/Http/Controllers/Api/UserPreferenceController.php
Normal file
124
backend/app/Http/Controllers/Api/UserPreferenceController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
backend/app/Mail/DuePaymentsAlert.php
Normal file
125
backend/app/Mail/DuePaymentsAlert.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,12 +13,15 @@ class Budget extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'user_id',
|
||||||
'category_id',
|
'category_id',
|
||||||
|
'subcategory_id',
|
||||||
|
'cost_center_id',
|
||||||
'name',
|
'name',
|
||||||
'amount',
|
'amount',
|
||||||
'currency',
|
'currency',
|
||||||
'year',
|
'year',
|
||||||
'month',
|
'month',
|
||||||
'period_type',
|
'period_type',
|
||||||
|
'is_cumulative',
|
||||||
'is_active',
|
'is_active',
|
||||||
'notes',
|
'notes',
|
||||||
];
|
];
|
||||||
@ -26,6 +29,7 @@ class Budget extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'amount' => 'decimal:2',
|
'amount' => 'decimal:2',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
|
'is_cumulative' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $appends = [
|
protected $appends = [
|
||||||
@ -50,6 +54,16 @@ public function category()
|
|||||||
return $this->belongsTo(Category::class);
|
return $this->belongsTo(Category::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function subcategory()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class, 'subcategory_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function costCenter()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CostCenter::class);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Accessors
|
// Accessors
|
||||||
// ============================================
|
// ============================================
|
||||||
@ -58,15 +72,54 @@ public function getSpentAmountAttribute()
|
|||||||
{
|
{
|
||||||
// Calcular el gasto real de las transacciones
|
// Calcular el gasto real de las transacciones
|
||||||
$query = Transaction::where('user_id', $this->user_id)
|
$query = Transaction::where('user_id', $this->user_id)
|
||||||
->where('type', 'debit')
|
->where('type', 'debit');
|
||||||
->whereYear('effective_date', $this->year);
|
|
||||||
|
|
||||||
if ($this->period_type === 'monthly' && $this->month) {
|
// Definir el rango de fechas según el tipo de período
|
||||||
$query->whereMonth('effective_date', $this->month);
|
$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) {
|
// Se tem subcategoria específica, usa apenas ela
|
||||||
// Incluir subcategorías
|
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];
|
$categoryIds = [$this->category_id];
|
||||||
$subcategories = Category::where('parent_id', $this->category_id)->pluck('id')->toArray();
|
$subcategories = Category::where('parent_id', $this->category_id)->pluck('id')->toArray();
|
||||||
$categoryIds = array_merge($categoryIds, $subcategories);
|
$categoryIds = array_merge($categoryIds, $subcategories);
|
||||||
@ -74,8 +127,36 @@ public function getSpentAmountAttribute()
|
|||||||
$query->whereIn('category_id', $categoryIds);
|
$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'));
|
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()
|
public function getRemainingAmountAttribute()
|
||||||
{
|
{
|
||||||
@ -95,17 +176,26 @@ public function getIsExceededAttribute()
|
|||||||
|
|
||||||
public function getPeriodLabelAttribute()
|
public function getPeriodLabelAttribute()
|
||||||
{
|
{
|
||||||
if ($this->period_type === 'yearly') {
|
|
||||||
return $this->year;
|
|
||||||
}
|
|
||||||
|
|
||||||
$months = [
|
$months = [
|
||||||
1 => 'Enero', 2 => 'Febrero', 3 => 'Marzo', 4 => 'Abril',
|
1 => 'Enero', 2 => 'Febrero', 3 => 'Marzo', 4 => 'Abril',
|
||||||
5 => 'Mayo', 6 => 'Junio', 7 => 'Julio', 8 => 'Agosto',
|
5 => 'Mayo', 6 => 'Junio', 7 => 'Julio', 8 => 'Agosto',
|
||||||
9 => 'Septiembre', 10 => 'Octubre', 11 => 'Noviembre', 12 => 'Diciembre'
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@ -68,6 +68,14 @@ public function children(): HasMany
|
|||||||
return $this->hasMany(Category::class, 'parent_id');
|
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
|
* Relação com as sub-categorias ativas
|
||||||
*/
|
*/
|
||||||
|
|||||||
41
backend/app/Models/UserPreference.php
Normal file
41
backend/app/Models/UserPreference.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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'");
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
WEBMoney - Alerta de Pagamentos
|
||||||
|
================================
|
||||||
|
|
||||||
|
Olá, {{ $userName }}!
|
||||||
|
|
||||||
|
RESUMO FINANCEIRO
|
||||||
|
-----------------
|
||||||
|
💳 Saldo Total Disponível: {{ number_format($totalAvailable, 2, ',', '.') }} {{ $currency }}
|
||||||
|
📋 Total a Pagar: {{ number_format($totalDue, 2, ',', '.') }} {{ $currency }}
|
||||||
|
@if($shortage > 0)
|
||||||
|
⚠️ FALTA: {{ number_format($shortage, 2, ',', '.') }} {{ $currency }}
|
||||||
|
|
||||||
|
ATENÇÃO: Você não tem saldo suficiente para cobrir todos os pagamentos!
|
||||||
|
@else
|
||||||
|
✅ Situação: Saldo suficiente para cobrir os pagamentos!
|
||||||
|
@endif
|
||||||
|
|
||||||
|
SALDO DAS CONTAS
|
||||||
|
----------------
|
||||||
|
@foreach($accountBalances as $account)
|
||||||
|
• {{ $account['name'] }}: {{ number_format($account['balance'], 2, ',', '.') }} {{ $account['currency'] }}
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@if(count($overdueItems) > 0)
|
||||||
|
🔴 PAGAMENTOS VENCIDOS ({{ count($overdueItems) }})
|
||||||
|
---------------------------------------------------
|
||||||
|
@foreach($overdueItems as $item)
|
||||||
|
• {{ $item['description'] }}
|
||||||
|
Valor: {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}
|
||||||
|
Venceu em: {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }} ({{ $item['days_overdue'] }} dias de atraso)
|
||||||
|
@if($item['account_name'])Conta: {{ $item['account_name'] }}@endif
|
||||||
|
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(count($tomorrowItems) > 0)
|
||||||
|
🟡 VENCEM AMANHÃ ({{ count($tomorrowItems) }})
|
||||||
|
----------------------------------------------
|
||||||
|
@foreach($tomorrowItems as $item)
|
||||||
|
• {{ $item['description'] }}
|
||||||
|
Valor: {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}
|
||||||
|
Vence em: {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }}
|
||||||
|
@if($item['account_name'])Conta: {{ $item['account_name'] }}@endif
|
||||||
|
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(count($payableItems) > 0)
|
||||||
|
✅ PAGAMENTOS POSSÍVEIS ({{ count($payableItems) }})
|
||||||
|
----------------------------------------------------
|
||||||
|
Com base no saldo atual, você consegue pagar:
|
||||||
|
@foreach($payableItems as $item)
|
||||||
|
• {{ $item['description'] }} - {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} ✓
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(count($unpayableItems) > 0)
|
||||||
|
❌ SEM SALDO SUFICIENTE ({{ count($unpayableItems) }})
|
||||||
|
------------------------------------------------------
|
||||||
|
Não há saldo disponível para estes pagamentos:
|
||||||
|
@foreach($unpayableItems as $item)
|
||||||
|
• {{ $item['description'] }} - {{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }} ✗
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(count($transferSuggestions) > 0)
|
||||||
|
💱 SUGESTÕES DE TRANSFERÊNCIA
|
||||||
|
-----------------------------
|
||||||
|
Para cobrir os pagamentos, considere transferir entre suas contas:
|
||||||
|
@foreach($transferSuggestions as $transfer)
|
||||||
|
• De "{{ $transfer['from_account'] }}" para "{{ $transfer['to_account'] }}": {{ number_format($transfer['amount'], 2, ',', '.') }} {{ $currency }}
|
||||||
|
Motivo: {{ $transfer['reason'] }}
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@endif
|
||||||
|
|
||||||
|
--------------------------------------------------
|
||||||
|
Acesse o WEBMoney: https://webmoney.cnxifly.com
|
||||||
|
|
||||||
|
Para desativar estas notificações, acesse:
|
||||||
|
https://webmoney.cnxifly.com/preferences
|
||||||
|
|
||||||
|
© {{ date('Y') }} WEBMoney - ConneXiFly
|
||||||
375
backend/resources/views/emails/due-payments-alert.blade.php
Normal file
375
backend/resources/views/emails/due-payments-alert.blade.php
Normal 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 há 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>
|
||||||
@ -21,6 +21,7 @@
|
|||||||
use App\Http\Controllers\Api\BudgetController;
|
use App\Http\Controllers\Api\BudgetController;
|
||||||
use App\Http\Controllers\Api\ReportController;
|
use App\Http\Controllers\Api\ReportController;
|
||||||
use App\Http\Controllers\Api\FinancialHealthController;
|
use App\Http\Controllers\Api\FinancialHealthController;
|
||||||
|
use App\Http\Controllers\Api\UserPreferenceController;
|
||||||
|
|
||||||
// Public routes with rate limiting
|
// Public routes with rate limiting
|
||||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
||||||
@ -278,5 +279,12 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
Route::get('financial-health', [FinancialHealthController::class, 'index']);
|
Route::get('financial-health', [FinancialHealthController::class, 'index']);
|
||||||
Route::get('financial-health/history', [FinancialHealthController::class, 'history']);
|
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']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an 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'));
|
||||||
|
|||||||
36
deploy_budgets_subcategory.sh
Normal file
36
deploy_budgets_subcategory.sh
Normal 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
17
deploy_correto.sh
Normal 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"
|
||||||
40
deploy_subcategory_budgets.sh
Normal file
40
deploy_subcategory_budgets.sh
Normal 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"
|
||||||
@ -21,6 +21,7 @@ import FinancialHealth from './pages/FinancialHealth';
|
|||||||
import Goals from './pages/Goals';
|
import Goals from './pages/Goals';
|
||||||
import Budgets from './pages/Budgets';
|
import Budgets from './pages/Budgets';
|
||||||
import Reports from './pages/Reports';
|
import Reports from './pages/Reports';
|
||||||
|
import Preferences from './pages/Preferences';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -179,6 +180,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/preferences"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Preferences />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
|
|||||||
@ -103,6 +103,7 @@ const Layout = ({ children }) => {
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/categories', icon: 'bi-tags', label: t('nav.categories') },
|
{ path: '/categories', icon: 'bi-tags', label: t('nav.categories') },
|
||||||
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
|
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
|
||||||
|
{ path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -91,6 +91,7 @@
|
|||||||
"refunds": "Refunds",
|
"refunds": "Refunds",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"costCenters": "Cost Centers",
|
"costCenters": "Cost Centers",
|
||||||
|
"preferences": "Preferences",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"business": "Business",
|
"business": "Business",
|
||||||
@ -1698,6 +1699,8 @@
|
|||||||
"noBudgetsDescription": "Start by creating your first monthly budget",
|
"noBudgetsDescription": "Start by creating your first monthly budget",
|
||||||
"createFirst": "Create First Budget",
|
"createFirst": "Create First Budget",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
|
"subcategory": "Subcategory",
|
||||||
|
"allCategory": "All category",
|
||||||
"selectCategory": "Select a category",
|
"selectCategory": "Select a category",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
@ -1712,6 +1715,16 @@
|
|||||||
"totalSpent": "Total Spent",
|
"totalSpent": "Total Spent",
|
||||||
"allCategoriesUsed": "All categories already have budgets this month",
|
"allCategoriesUsed": "All categories already have budgets this month",
|
||||||
"autoPropagateInfo": "This budget will automatically propagate to future months",
|
"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": {
|
"alert": {
|
||||||
"exceeded": "Budget exceeded!",
|
"exceeded": "Budget exceeded!",
|
||||||
"warning": "Warning: near limit",
|
"warning": "Warning: near limit",
|
||||||
@ -1959,5 +1972,38 @@
|
|||||||
"oct": "Oct",
|
"oct": "Oct",
|
||||||
"nov": "Nov",
|
"nov": "Nov",
|
||||||
"dec": "Dec"
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -92,6 +92,7 @@
|
|||||||
"refunds": "Reembolsos",
|
"refunds": "Reembolsos",
|
||||||
"categories": "Categorías",
|
"categories": "Categorías",
|
||||||
"costCenters": "Centros de Costo",
|
"costCenters": "Centros de Costo",
|
||||||
|
"preferences": "Preferencias",
|
||||||
"reports": "Reportes",
|
"reports": "Reportes",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"business": "Negocio",
|
"business": "Negocio",
|
||||||
@ -1750,6 +1751,8 @@
|
|||||||
"noBudgetsDescription": "Crea presupuestos mensuales para controlar y limitar tus gastos por categoría.",
|
"noBudgetsDescription": "Crea presupuestos mensuales para controlar y limitar tus gastos por categoría.",
|
||||||
"createFirst": "Crear Primer Presupuesto",
|
"createFirst": "Crear Primer Presupuesto",
|
||||||
"category": "Categoría",
|
"category": "Categoría",
|
||||||
|
"subcategory": "Subcategoría",
|
||||||
|
"allCategory": "Toda la categoría",
|
||||||
"selectCategory": "Seleccionar categoría",
|
"selectCategory": "Seleccionar categoría",
|
||||||
"amount": "Monto",
|
"amount": "Monto",
|
||||||
"spent": "Gastado",
|
"spent": "Gastado",
|
||||||
@ -1765,6 +1768,16 @@
|
|||||||
"almostExceeded": "Cerca del límite (80%+)",
|
"almostExceeded": "Cerca del límite (80%+)",
|
||||||
"allCategoriesUsed": "Ya tienes presupuesto para todas las categorías este mes",
|
"allCategoriesUsed": "Ya tienes presupuesto para todas las categorías este mes",
|
||||||
"autoPropagateInfo": "Este presupuesto se propagará automáticamente a los meses siguientes",
|
"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": {
|
"summary": {
|
||||||
"totalBudget": "Presupuesto Total",
|
"totalBudget": "Presupuesto Total",
|
||||||
"totalSpent": "Gastado",
|
"totalSpent": "Gastado",
|
||||||
@ -1947,5 +1960,38 @@
|
|||||||
"oct": "Oct",
|
"oct": "Oct",
|
||||||
"nov": "Nov",
|
"nov": "Nov",
|
||||||
"dec": "Dic"
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,6 +93,7 @@
|
|||||||
"refunds": "Reembolsos",
|
"refunds": "Reembolsos",
|
||||||
"categories": "Categorias",
|
"categories": "Categorias",
|
||||||
"costCenters": "Centros de Custo",
|
"costCenters": "Centros de Custo",
|
||||||
|
"preferences": "Preferências",
|
||||||
"reports": "Relatórios",
|
"reports": "Relatórios",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"business": "Negócios",
|
"business": "Negócios",
|
||||||
@ -1704,6 +1705,8 @@
|
|||||||
"noBudgetsDescription": "Comece criando seu primeiro orçamento mensal",
|
"noBudgetsDescription": "Comece criando seu primeiro orçamento mensal",
|
||||||
"createFirst": "Criar Primeiro Orçamento",
|
"createFirst": "Criar Primeiro Orçamento",
|
||||||
"category": "Categoria",
|
"category": "Categoria",
|
||||||
|
"subcategory": "Subcategoria",
|
||||||
|
"allCategory": "Toda a categoria",
|
||||||
"selectCategory": "Selecione uma categoria",
|
"selectCategory": "Selecione uma categoria",
|
||||||
"amount": "Valor",
|
"amount": "Valor",
|
||||||
"month": "Mês",
|
"month": "Mês",
|
||||||
@ -1718,6 +1721,14 @@
|
|||||||
"totalSpent": "Total Gasto",
|
"totalSpent": "Total Gasto",
|
||||||
"allCategoriesUsed": "Todas as categorias já possuem orçamento este mês",
|
"allCategoriesUsed": "Todas as categorias já possuem orçamento este mês",
|
||||||
"autoPropagateInfo": "Este orçamento será propagado automaticamente para os meses seguintes",
|
"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": {
|
"alert": {
|
||||||
"exceeded": "Orçamento excedido!",
|
"exceeded": "Orçamento excedido!",
|
||||||
"warning": "Atenção: próximo do limite",
|
"warning": "Atenção: próximo do limite",
|
||||||
@ -1734,7 +1745,9 @@
|
|||||||
"noCategory": "Sem categoria",
|
"noCategory": "Sem categoria",
|
||||||
"exceededBy": "Excedido em",
|
"exceededBy": "Excedido em",
|
||||||
"copySuccess": "Orçamentos copiados para o próximo mês",
|
"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": {
|
"goals": {
|
||||||
"title": "Metas Financeiras",
|
"title": "Metas Financeiras",
|
||||||
@ -1965,5 +1978,38 @@
|
|||||||
"oct": "Out",
|
"oct": "Out",
|
||||||
"nov": "Nov",
|
"nov": "Nov",
|
||||||
"dec": "Dez"
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -654,6 +654,95 @@ a {
|
|||||||
color: #3b82f6;
|
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 */
|
/* Content Area */
|
||||||
.app-content {
|
.app-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -2213,111 +2302,304 @@ input[type="color"]::-webkit-color-swatch {
|
|||||||
|
|
||||||
/* Responsive adjustments */
|
/* 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 {
|
.txn-page {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-stats {
|
.txn-stats {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-stat-card {
|
.txn-stat-card {
|
||||||
padding: 0.75rem;
|
padding: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-stat-icon {
|
.txn-stat-icon {
|
||||||
width: 36px;
|
width: 38px;
|
||||||
height: 36px;
|
height: 38px;
|
||||||
font-size: 1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-stat-value {
|
.txn-stat-value {
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-filters-grid {
|
.txn-filters-grid {
|
||||||
grid-template-columns: repeat(4, 1fr) auto;
|
grid-template-columns: repeat(5, 1fr) auto; /* 5 columnas para aprovechar ancho */
|
||||||
gap: 0.5rem;
|
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 {
|
.txn-week-summary {
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-week-stat {
|
.txn-week-stat {
|
||||||
min-width: 70px;
|
min-width: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-week-stat-value {
|
/* Mantener columnas importantes */
|
||||||
font-size: 0.8rem;
|
.txn-table .col-category,
|
||||||
|
.txn-table .col-date {
|
||||||
|
display: table-cell !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-table th,
|
/* Ocultar solo las menos críticas */
|
||||||
.txn-table td {
|
|
||||||
padding: 0.5rem 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ocultar algunas columnas menos importantes en tablets */
|
|
||||||
.txn-table .col-account {
|
.txn-table .col-account {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-header-title h1 {
|
.txn-amount {
|
||||||
font-size: 1.1rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-header-actions .btn {
|
/* Modals */
|
||||||
padding: 0.3rem 0.6rem;
|
.modal-dialog {
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modals optimization for iPad */
|
|
||||||
.modal-dialog.modal-lg {
|
|
||||||
max-width: 85%;
|
max-width: 85%;
|
||||||
margin: 1rem auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
max-height: 70vh;
|
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 {
|
/* Charts */
|
||||||
grid-template-columns: repeat(3, 1fr);
|
canvas {
|
||||||
gap: 0.5rem;
|
max-height: 300px !important;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2697,6 +2979,7 @@ input[type="color"]::-webkit-color-swatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* iPhone em landscape (horizontal) */
|
/* iPhone em landscape (horizontal) */
|
||||||
|
/* También aplicable a tablets pequeños en landscape */
|
||||||
@media (max-height: 430px) and (orientation: landscape) {
|
@media (max-height: 430px) and (orientation: landscape) {
|
||||||
/* Reduz altura de elementos */
|
/* Reduz altura de elementos */
|
||||||
.card-body {
|
.card-body {
|
||||||
@ -2705,6 +2988,7 @@ input[type="color"]::-webkit-color-swatch {
|
|||||||
|
|
||||||
.modal-dialog {
|
.modal-dialog {
|
||||||
max-height: 90vh !important;
|
max-height: 90vh !important;
|
||||||
|
margin: 0.5rem auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@ -2720,6 +3004,76 @@ input[type="color"]::-webkit-color-swatch {
|
|||||||
.app-header {
|
.app-header {
|
||||||
padding: 0.5rem 1rem !important;
|
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 */
|
/* Previne problemas de input no iOS */
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { budgetService, categoryService } from '../services/api';
|
import { budgetService, categoryService, costCenterService } from '../services/api';
|
||||||
import useFormatters from '../hooks/useFormatters';
|
import useFormatters from '../hooks/useFormatters';
|
||||||
import { getCurrencyByCode } from '../config/currencies';
|
import { getCurrencyByCode } from '../config/currencies';
|
||||||
import ConfirmModal from '../components/ConfirmModal';
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
@ -13,6 +13,7 @@ const Budgets = () => {
|
|||||||
const [budgets, setBudgets] = useState([]);
|
const [budgets, setBudgets] = useState([]);
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [availableCategories, setAvailableCategories] = useState([]);
|
const [availableCategories, setAvailableCategories] = useState([]);
|
||||||
|
const [costCenters, setCostCenters] = useState([]);
|
||||||
const [year, setYear] = useState(new Date().getFullYear());
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@ -22,7 +23,11 @@ const Budgets = () => {
|
|||||||
const [primaryCurrency, setPrimaryCurrency] = useState('EUR');
|
const [primaryCurrency, setPrimaryCurrency] = useState('EUR');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
category_id: '',
|
category_id: '',
|
||||||
|
subcategory_id: '',
|
||||||
|
cost_center_id: '',
|
||||||
amount: '',
|
amount: '',
|
||||||
|
period_type: 'monthly',
|
||||||
|
is_cumulative: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Meses con i18n
|
// Meses con i18n
|
||||||
@ -50,11 +55,12 @@ const Budgets = () => {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [budgetsData, categoriesData, availableData, summaryData] = await Promise.all([
|
const [budgetsData, categoriesData, availableData, summaryData, costCentersData] = await Promise.all([
|
||||||
budgetService.getAll({ year, month }),
|
budgetService.getAll({ year, month }),
|
||||||
categoryService.getAll(),
|
categoryService.getAll(),
|
||||||
budgetService.getAvailableCategories({ year, month }),
|
budgetService.getAvailableCategories({ year, month }),
|
||||||
budgetService.getYearSummary({ year }),
|
budgetService.getYearSummary({ year }),
|
||||||
|
costCenterService.getAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Extraer datos del response si viene en formato { data, ... }
|
// Extraer datos del response si viene en formato { data, ... }
|
||||||
@ -75,6 +81,10 @@ const Budgets = () => {
|
|||||||
setAvailableCategories(available);
|
setAvailableCategories(available);
|
||||||
|
|
||||||
setYearSummary(Array.isArray(summaryData) ? summaryData : []);
|
setYearSummary(Array.isArray(summaryData) ? summaryData : []);
|
||||||
|
|
||||||
|
// Cost Centers
|
||||||
|
const centers = costCentersData?.data || costCentersData;
|
||||||
|
setCostCenters(Array.isArray(centers) ? centers : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading budgets:', error);
|
console.error('Error loading budgets:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -120,6 +130,7 @@ const Budgets = () => {
|
|||||||
setEditingBudget(budget);
|
setEditingBudget(budget);
|
||||||
setFormData({
|
setFormData({
|
||||||
category_id: budget.category_id,
|
category_id: budget.category_id,
|
||||||
|
subcategory_id: budget.subcategory_id || '',
|
||||||
amount: budget.amount,
|
amount: budget.amount,
|
||||||
});
|
});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@ -143,7 +154,11 @@ const Budgets = () => {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
category_id: '',
|
category_id: '',
|
||||||
|
subcategory_id: '',
|
||||||
|
cost_center_id: '',
|
||||||
amount: '',
|
amount: '',
|
||||||
|
period_type: 'monthly',
|
||||||
|
is_cumulative: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -381,15 +396,32 @@ const Budgets = () => {
|
|||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<div
|
<div
|
||||||
className="rounded-circle p-2 me-2"
|
className="rounded-circle p-2 me-2"
|
||||||
style={{ background: `${budget.category?.color || '#3b82f6'}20` }}
|
style={{ background: `${(budget.subcategory || budget.category)?.color || '#3b82f6'}20` }}
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
className={`bi ${budget.category?.icon || 'bi-tag'}`}
|
className={`bi ${(budget.subcategory || budget.category)?.icon || 'bi-tag'}`}
|
||||||
style={{ color: budget.category?.color || '#3b82f6' }}
|
style={{
|
||||||
|
color: (budget.subcategory || budget.category)?.color || '#3b82f6',
|
||||||
|
fontSize: '1.25rem'
|
||||||
|
}}
|
||||||
></i>
|
></i>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<div className="dropdown">
|
<div className="dropdown">
|
||||||
@ -571,7 +603,7 @@ const Budgets = () => {
|
|||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="modal-body">
|
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
<p className="text-slate-400 small mb-3">
|
<p className="text-slate-400 small mb-3">
|
||||||
<i className="bi bi-calendar3 me-1"></i>
|
<i className="bi bi-calendar3 me-1"></i>
|
||||||
{months.find(m => m.value === month)?.label} {year}
|
{months.find(m => m.value === month)?.label} {year}
|
||||||
@ -599,14 +631,14 @@ const Budgets = () => {
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
maxHeight: '300px',
|
maxHeight: '200px',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '4px'
|
padding: '4px'
|
||||||
}}>
|
}}>
|
||||||
{availableCategories.map(cat => (
|
{availableCategories.map(cat => (
|
||||||
<div
|
<div
|
||||||
key={cat.id}
|
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 ${
|
className={`p-2 rounded text-center cursor-pointer ${
|
||||||
formData.category_id == cat.id
|
formData.category_id == cat.id
|
||||||
? 'border border-primary'
|
? 'border border-primary'
|
||||||
@ -636,6 +668,109 @@ const Budgets = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Subcategory Selection - Only if category selected and has subcategories */}
|
||||||
|
{formData.category_id && !editingBudget && (() => {
|
||||||
|
const selectedCategory = availableCategories.find(c => c.id == formData.category_id);
|
||||||
|
const subcategories = selectedCategory?.subcategories || [];
|
||||||
|
|
||||||
|
if (subcategories.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">
|
||||||
|
{t('budgets.subcategory')}
|
||||||
|
<small className="text-muted ms-2">({t('common.optional')})</small>
|
||||||
|
</label>
|
||||||
|
<div className="category-grid" style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
|
gap: '8px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '4px'
|
||||||
|
}}>
|
||||||
|
{/* Option: All category (no subcategory) */}
|
||||||
|
<div
|
||||||
|
onClick={() => setFormData({...formData, subcategory_id: ''})}
|
||||||
|
className={`p-2 rounded text-center cursor-pointer ${
|
||||||
|
!formData.subcategory_id
|
||||||
|
? 'border border-primary'
|
||||||
|
: 'border border-secondary'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: !formData.subcategory_id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`bi ${selectedCategory?.icon || 'bi-tag'} d-block mb-1`}
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
color: selectedCategory?.color || '#6b7280'
|
||||||
|
}}
|
||||||
|
></i>
|
||||||
|
<small className="text-white d-block text-truncate">
|
||||||
|
{t('budgets.allCategory')}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subcategories */}
|
||||||
|
{subcategories.map(sub => (
|
||||||
|
<div
|
||||||
|
key={sub.id}
|
||||||
|
onClick={() => setFormData({...formData, subcategory_id: sub.id})}
|
||||||
|
className={`p-2 rounded text-center cursor-pointer ${
|
||||||
|
formData.subcategory_id == sub.id
|
||||||
|
? 'border border-primary'
|
||||||
|
: 'border border-secondary'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: formData.subcategory_id == sub.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`bi ${sub.icon || 'bi-tag'} d-block mb-1`}
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
color: sub.color || '#6b7280'
|
||||||
|
}}
|
||||||
|
></i>
|
||||||
|
<small className="text-white d-block text-truncate" title={sub.name}>
|
||||||
|
{sub.name}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Cost Center Selection - Only if editing or creating new */}
|
||||||
|
{!editingBudget && costCenters.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">
|
||||||
|
{t('budgets.costCenter')}
|
||||||
|
<small className="text-muted ms-2">({t('common.optional')})</small>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select bg-dark border-secondary text-white"
|
||||||
|
value={formData.cost_center_id || ''}
|
||||||
|
onChange={(e) => setFormData({...formData, cost_center_id: e.target.value})}
|
||||||
|
>
|
||||||
|
<option value="">{t('budgets.noCostCenter')}</option>
|
||||||
|
{costCenters.map(cc => (
|
||||||
|
<option key={cc.id} value={cc.id}>
|
||||||
|
{cc.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Amount */}
|
{/* Amount */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label text-slate-400">{t('budgets.amount')} *</label>
|
<label className="form-label text-slate-400">{t('budgets.amount')} *</label>
|
||||||
@ -656,6 +791,43 @@ const Budgets = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Info about auto-propagation */}
|
||||||
{!editingBudget && (
|
{!editingBudget && (
|
||||||
<div className="alert alert-info py-2 mb-0">
|
<div className="alert alert-info py-2 mb-0">
|
||||||
|
|||||||
317
frontend/src/pages/Preferences.jsx
Normal file
317
frontend/src/pages/Preferences.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
export default api;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user