v1.27.4: Quick categorize, multi-currency dashboard, responsive sidebar, iPad Pro optimizations

This commit is contained in:
CnxiFly Dev 2025-12-13 21:12:17 +01:00
parent 6bb1adeef6
commit 84a1dbae29
16 changed files with 1227 additions and 353 deletions

View File

@ -0,0 +1,259 @@
╔═══════════════════════════════════════════════════════════════════════════════╗
║ DIRETRIZES DE DESENVOLVIMENTO - v4.0 ║
║ ║
║ ⚠️ ESTE ARQUIVO NÃO DEVE SER EDITADO APÓS QUALQUER COMMIT/PUSH ║
║ ⚠️ Representa o contrato de desenvolvimento desde a versão 1.27.2 ║
║ ⚠️ Substitui .DIRETRIZES_DESENVOLVIMENTO_v3 (v3.0) ║
║ ║
╚═══════════════════════════════════════════════════════════════════════════════╝
DATA DE CRIAÇÃO: 13 de Dezembro de 2025
VERSÃO INICIAL: 1.27.2
VERSÃO DAS DIRETRIZES: 4.0
STATUS: ATIVO E IMUTÁVEL
AMBIENTE: Windows (PowerShell)
═══════════════════════════════════════════════════════════════════════════════
REGRAS DE DESENVOLVIMENTO
═══════════════════════════════════════════════════════════════════════════════
───────────────────────────────────────────────────────────────────────────────
REGRA #1: CONTROLE DE VERSÃO SEMÂNTICO
───────────────────────────────────────────────────────────────────────────────
✓ Formato: MAJOR.MINOR.PATCH (exemplo: 1.27.2)
✓ Incrementar versão em CADA commit/push
✓ Manter sincronizado em: VERSION, CHANGELOG.md
Regra de Incremento:
- MAJOR (X.0.0): Mudanças incompatíveis, redesign completo
- MINOR (0.X.0): Novas funcionalidades
- PATCH (0.0.X): Correções de bugs, ajustes menores
───────────────────────────────────────────────────────────────────────────────
REGRA #2: VALIDAÇÃO OBRIGATÓRIA EM PRODUÇÃO
───────────────────────────────────────────────────────────────────────────────
✓ TODAS as mudanças devem ser testadas em https://webmoney.cnxifly.com
✓ Workflow obrigatório:
1. Editar código
2. Deploy para servidor (.\deploy.ps1)
3. Testar no domínio
4. Commit/push apenas após validação
5. Só então editar novamente
✗ PROIBIDO commit sem teste em produção
───────────────────────────────────────────────────────────────────────────────
REGRA #3: DOCUMENTAÇÃO ESSENCIAL
───────────────────────────────────────────────────────────────────────────────
Arquivos de documentação mantidos (apenas estes):
| Arquivo | Propósito | Atualizar quando |
|---------|-----------|------------------|
| VERSION | Número da versão | Cada commit |
| CHANGELOG.md | Histórico de mudanças | Cada commit |
| README.md | Visão geral do projeto | Mudanças significativas |
| ESTRUTURA_PROJETO.md | Estrutura técnica | Novos arquivos/endpoints |
| CREDENCIAIS_SERVIDOR.md | Acessos | Mudança de credenciais |
| .DIRETRIZES_DESENVOLVIMENTO_v4 | Este arquivo | NUNCA (criar nova versão) |
Arquivos de referência (não atualizar frequentemente):
- ESPECIFICACIONES_WEBMONEY.md (especificação original)
- APRENDIZADOS_TECNICOS.md (soluções de problemas)
- ROTEIRO_INSTALACAO_SERVIDOR.md (guia de instalação)
- DKIM_DNS_RECORD.txt (configuração DNS)
───────────────────────────────────────────────────────────────────────────────
REGRA #4: SCRIPTS DE DEPLOY (WINDOWS)
───────────────────────────────────────────────────────────────────────────────
✓ SEMPRE usar os scripts PowerShell de deploy:
Frontend: cd frontend; .\deploy.ps1
Backend: cd backend; .\deploy.ps1
✗ NUNCA enviar arquivos manualmente para diretórios errados
✓ Os scripts garantem o caminho correto:
- Frontend → /var/www/webmoney/frontend/dist
- Backend → /var/www/webmoney/backend
Requisitos Windows:
- PuTTY instalado (plink.exe, pscp.exe no PATH)
- Node.js e npm instalados
- PowerShell 5.1 ou superior
Deploy manual (se necessário):
# Frontend - Build e enviar
cd frontend
npm run build
plink -batch -pw Master9354 root@213.165.93.60 "rm -rf /var/www/webmoney/frontend/dist/*"
pscp -r -batch -pw Master9354 dist\* root@213.165.93.60:/var/www/webmoney/frontend/dist/
# Backend - Enviar e atualizar
cd backend
pscp -r -batch -pw Master9354 app root@213.165.93.60:/var/www/webmoney/backend/
plink -batch -pw Master9354 root@213.165.93.60 "cd /var/www/webmoney/backend && php artisan migrate --force"
───────────────────────────────────────────────────────────────────────────────
REGRA #5: CHECKLIST DE COMMIT
───────────────────────────────────────────────────────────────────────────────
Antes de CADA commit:
☑ VERSION atualizado
☑ CHANGELOG.md atualizado
☑ Deploy executado (.\deploy.ps1)
☑ Testado em webmoney.cnxifly.com
☑ Sem erros no console do navegador
☑ Mensagem de commit descritiva
───────────────────────────────────────────────────────────────────────────────
REGRA #6: PROIBIÇÕES EXPLÍCITAS
───────────────────────────────────────────────────────────────────────────────
✗ NÃO editar arquivos sem commit anterior
✗ NÃO criar documentação específica de versão (ex: DEPLOY_v1.9.0.md)
✗ NÃO duplicar informação em múltiplos arquivos
✗ NÃO fazer deploy manual (usar scripts)
✗ NÃO commitar sem testar em produção
═══════════════════════════════════════════════════════════════════════════════
INFRAESTRUTURA
═══════════════════════════════════════════════════════════════════════════════
───────────────────────────────────────────────────────────────────────────────
SERVIDOR DE PRODUÇÃO
───────────────────────────────────────────────────────────────────────────────
IP: 213.165.93.60
Porta SSH: 22
Usuário: root
Senha: Master9354
Acesso Windows (PowerShell):
plink -batch -pw Master9354 root@213.165.93.60 "comando"
Estrutura:
/var/www/webmoney/
├── backend/ # Laravel API
└── frontend/
└── dist/ # React build (Nginx root)
───────────────────────────────────────────────────────────────────────────────
DOMÍNIOS
───────────────────────────────────────────────────────────────────────────────
| Subdomínio | Função |
|------------|--------|
| webmoney.cnxifly.com | Aplicação principal |
| phpmyadmin.cnxifly.com | Banco de dados |
| webmail.cnxifly.com | Email |
| mail.cnxifly.com | PostfixAdmin |
───────────────────────────────────────────────────────────────────────────────
STACK TECNOLÓGICA
───────────────────────────────────────────────────────────────────────────────
| Camada | Tecnologia |
|--------|------------|
| Backend | Laravel 12 + PHP 8.4-FPM |
| Frontend | React 18 + Vite 7 + Bootstrap 5 |
| Banco | MariaDB 11.4 |
| Cache | Redis |
| Servidor | Nginx + SSL (Let's Encrypt) |
| Auth | Laravel Sanctum (Bearer Tokens) |
───────────────────────────────────────────────────────────────────────────────
AMBIENTE DE DESENVOLVIMENTO (WINDOWS)
───────────────────────────────────────────────────────────────────────────────
Requisitos:
- Windows 10/11
- PowerShell 5.1+
- Node.js 20+
- PuTTY (plink.exe, pscp.exe)
- VS Code
Ferramentas de conexão:
| Comando Linux | Equivalente Windows |
|---------------|---------------------|
| ssh user@host | plink -batch -pw SENHA user@host |
| scp file user@host:path | pscp -batch -pw SENHA file user@host:path |
| scp -r dir user@host:path | pscp -r -batch -pw SENHA dir user@host:path |
Flags importantes:
-batch : Não solicita interação (senhas, confirmações)
-pw : Fornece senha diretamente
═══════════════════════════════════════════════════════════════════════════════
SEGURANÇA
═══════════════════════════════════════════════════════════════════════════════
Implementado em v1.19.0:
| Recurso | Configuração |
|---------|--------------|
| Rate Limiting | Login: 5/min, Register: 10/hour |
| CORS | Restrito a webmoney.cnxifly.com |
| Token Expiration | 7 dias |
| Cookies | HttpOnly, Secure, SameSite=lax, Encrypt=true |
| Headers | X-XSS-Protection, X-Content-Type-Options, X-Frame-Options, CSP |
| Cookie Consent | Banner LGPD/GDPR |
═══════════════════════════════════════════════════════════════════════════════
ESTADO ATUAL
═══════════════════════════════════════════════════════════════════════════════
Versão: 1.27.2
Data: 13 de Dezembro de 2025
Status: Produção estável
Funcionalidades:
✅ Autenticação (login, registro, logout)
✅ Dashboard (gráficos, análises, widget overdue)
✅ Contas bancárias (CRUD, multi-moeda)
✅ Transações (agrupamento por semana, filtros, categorização em lote com seleção)
✅ Categorias (175 pré-configuradas, auto-classificação, keywords)
✅ Centros de custo
✅ Importação de extratos (XLSX, CSV, OFX, PDF)
✅ Detecção de duplicatas (auto-delete)
✅ Detecção de transferências
✅ Contas passivo (financiamentos)
✅ Transações recorrentes (templates, instâncias, conciliação)
✅ Multi-idioma (ES, PT-BR, EN) com detecção por país
✅ Tema dark
✅ Cookie consent (LGPD/GDPR)
✅ Segurança hardening
═══════════════════════════════════════════════════════════════════════════════
HISTÓRICO DE DIRETRIZES
═══════════════════════════════════════════════════════════════════════════════
| Versão | Data | Mudanças |
|--------|------|----------|
| v1.0 | 2025-12-07 | Criação inicial |
| v2.0 | 2025-12-08 | Adicionada REGRA #8 (ESTRUTURA_PROJETO) |
| v3.0 | 2025-12-10 | Simplificação, remoção de redundâncias |
| v4.0 | 2025-12-13 | Migração para Windows (PowerShell, PuTTY) |
Arquivos de diretrizes:
- .DIRETRIZES_DESENVOLVIMENTO (v1.0 - EXCLUÍDO)
- .DIRETRIZES_DESENVOLVIMENTO_v2 (v2.0 - arquivado)
- .DIRETRIZES_DESENVOLVIMENTO_v3 (v3.0 - arquivado)
- .DIRETRIZES_DESENVOLVIMENTO_v4 (v4.0 - ATIVO)
═══════════════════════════════════════════════════════════════════════════════
⚠️ LEMBRETE FINAL
═══════════════════════════════════════════════════════════════════════════════
ANTES de editar qualquer arquivo:
1. ✓ Último commit foi feito?
2. ✓ VERSION será incrementado?
3. ✓ CHANGELOG será atualizado?
4. ✓ Deploy será feito via script (.\deploy.ps1)?
5. ✓ Teste em produção será realizado?
Este documento é IMUTÁVEL. Qualquer mudança requer criar v5.0.
═══════════════════════════════════════════════════════════════════════════════

View File

@ -5,6 +5,55 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.27.4] - 2025-12-13
### Added
- **Categorização Rápida Individual** - Novo botão de ação no menu de transações
- Modal simplificado para categorizar transação individual
- Seleção de categoria (com subcategorias) e centro de custo
- Opção de criar keyword para futuras importações automáticas
- Traduções em ES, PT-BR e EN
### Changed
- **Dashboard Multi-Divisa** - Valores agrupados por moeda
- Saldo total, receitas e despesas mostram valores separados por divisa
- Backend agrupa transações por currency da conta
- Suporte para múltiplas moedas simultaneamente (EUR, BRL, USD, etc.)
- **Sidebar Responsivo** - Estado inicial baseado no tamanho da tela
- Mobile (< 1024px): Sidebar inicia colapsado
- Desktop (≥ 1024px): Sidebar inicia expandido
- Ajusta automaticamente ao redimensionar
- **Responsividade iPad Pro** - Otimizações para tablets
- Media queries para 12.9" (1024-1366px) e 11" (834-1024px)
- Colunas da tabela de transações com classes CSS para ocultar em tablets
- Oculta coluna "Conta" em tablets grandes, "Conta" e "Status" em menores
### Fixed
- **API Dashboard Summary** - Corrigido erro de coluna ambígua
- Erro: "Column 'user_id' in WHERE is ambiguous" ao fazer JOIN
- Prefixado todas as colunas com `transactions.` nas queries com JOIN
## [1.27.3] - 2025-12-13
### Fixed
- **Categorização em Lote** - Corrigido bug que impedia a atualização das transações
- Problema: Cache de opcodes do PHP-FPM não era limpo após deploy
- Solução: Script deploy.ps1 agora executa `php artisan optimize:clear` e reinicia PHP-FPM
- Fluxo corrigido: Seleção com checkboxes → API → UPDATE no banco funciona corretamente
### Changed
- **Script deploy.ps1 (backend)** - Melhorado para garantir limpeza completa de cache
- Adicionado `php artisan optimize:clear` antes das otimizações
- Reinício de PHP-FPM com mensagem explicativa sobre cache de opcodes
- Previne problemas de código antigo sendo servido após deploy
### Removed
- **Console.logs de debug** - Removidos logs de depuração do frontend (TransactionsByWeek.jsx)
## [1.27.2] - 2025-12-13
### Changed

View File

@ -1 +1 @@
1.27.2
1.27.4

View File

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Transaction;
use App\Models\Category;
use App\Models\CostCenter;
use Illuminate\Support\Facades\DB;
class FixBatchCategorization extends Command
{
protected $signature = 'fix:batch-categorization
{--dry-run : Show what would be changed without making changes}
{--show : Show current category distribution}
{--category= : Category ID to remove}
{--cost-center= : Cost Center ID to remove}
{--user=3 : User ID}';
protected $description = 'Remove incorrectly applied batch categorization';
public function handle()
{
$dryRun = $this->option('dry-run');
$show = $this->option('show');
$userId = $this->option('user');
if ($show) {
$this->info("Distribuição de categorias para user_id = {$userId}:");
$distribution = Transaction::where('user_id', $userId)
->select('category_id', 'cost_center_id', DB::raw('count(*) as total'))
->groupBy('category_id', 'cost_center_id')
->orderByDesc('total')
->limit(20)
->get();
$this->table(
['Category ID', 'Category Name', 'Cost Center ID', 'Cost Center Name', 'Total'],
$distribution->map(function($row) {
$cat = $row->category_id ? Category::find($row->category_id) : null;
$cc = $row->cost_center_id ? CostCenter::find($row->cost_center_id) : null;
return [
$row->category_id ?? 'NULL',
$cat?->name ?? '-',
$row->cost_center_id ?? 'NULL',
$cc?->name ?? '-',
$row->total
];
})
);
return 0;
}
$categoryId = $this->option('category');
$costCenterId = $this->option('cost-center');
if (!$categoryId && !$costCenterId) {
$this->error('Especifique --category e/ou --cost-center para remover');
$this->info('Use --show para ver a distribuição atual');
return 1;
}
// Construir query
$query = Transaction::where('user_id', $userId);
if ($categoryId) {
$query->where('category_id', $categoryId);
}
if ($costCenterId) {
$query->where('cost_center_id', $costCenterId);
}
$affected = $query->count();
$category = $categoryId ? Category::find($categoryId) : null;
$costCenter = $costCenterId ? CostCenter::find($costCenterId) : null;
$this->info("Categoria: " . ($categoryId ?? 'N/A') . " - " . ($category?->name ?? 'NOT FOUND'));
$this->info("Centro de Custo: " . ($costCenterId ?? 'N/A') . " - " . ($costCenter?->name ?? 'NOT FOUND'));
$this->info("Transações afetadas: {$affected}");
if ($dryRun) {
$this->warn('DRY RUN - Nenhuma alteração feita');
return 0;
}
if ($affected == 0) {
$this->info('Nenhuma transação para atualizar');
return 0;
}
// Remover categorização
$updateData = [];
if ($categoryId) {
$updateData['category_id'] = null;
}
if ($costCenterId) {
$updateData['cost_center_id'] = null;
}
$updated = $query->update($updateData);
$this->info("{$updated} transações atualizadas");
return 0;
}
}

View File

@ -554,6 +554,18 @@ public function categorizeBatchPreview(Request $request): JsonResponse
// Contar total sem categoria (com filtros)
$totalUncategorized = $allTransactions->whereNull('category_id')->count();
// Preparar lista de transações para seleção
$transactions = $allTransactions->take(100)->map(function ($t) {
return [
'id' => $t->id,
'description' => $t->description,
'amount' => $t->amount ?? $t->planned_amount,
'type' => $t->type,
'effective_date' => $t->effective_date?->format('d/m/Y'),
'planned_date' => $t->planned_date?->format('d/m/Y'),
];
})->values();
return response()->json([
'success' => true,
'data' => [
@ -564,6 +576,7 @@ public function categorizeBatchPreview(Request $request): JsonResponse
'total_keywords' => count($keywordMap),
'total_filtered' => $allTransactions->count(),
'transaction_ids' => $transactionIds,
'transactions' => $transactions,
]
]);
}
@ -584,12 +597,15 @@ public function categorizeBatchManual(Request $request): JsonResponse
'filters.end_date' => 'nullable|date',
'filters.search' => 'nullable|string|max:255',
'add_keyword' => 'nullable|boolean',
'transaction_ids' => 'nullable|array',
'transaction_ids.*' => 'integer',
]);
$categoryId = $validated['category_id'] ?? null;
$costCenterId = $validated['cost_center_id'] ?? null;
$filters = $validated['filters'] ?? [];
$addKeyword = $validated['add_keyword'] ?? false;
$transactionIds = $validated['transaction_ids'] ?? null;
// Verificar se pelo menos uma opção foi selecionada
if (!$categoryId && !$costCenterId) {
@ -625,44 +641,50 @@ public function categorizeBatchManual(Request $request): JsonResponse
}
}
// Construir query com filtros
// Construir query - usar IDs se fornecidos, senão usar filtros
$query = \App\Models\Transaction::where('user_id', Auth::id());
if (!empty($filters['account_id'])) {
$query->where('account_id', $filters['account_id']);
}
if (!empty($filters['type'])) {
$query->where('type', $filters['type']);
}
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (!empty($filters['start_date'])) {
$query->where(function ($q) use ($filters) {
$q->where('effective_date', '>=', $filters['start_date'])
->orWhere(function ($q2) use ($filters) {
$q2->whereNull('effective_date')
->where('planned_date', '>=', $filters['start_date']);
});
});
}
if (!empty($filters['end_date'])) {
$query->where(function ($q) use ($filters) {
$q->where('effective_date', '<=', $filters['end_date'])
->orWhere(function ($q2) use ($filters) {
$q2->whereNull('effective_date')
->where('planned_date', '<=', $filters['end_date']);
});
});
}
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('original_description', 'like', "%{$search}%")
->orWhere('reference', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
});
if (!empty($transactionIds)) {
// Usar IDs específicos
$query->whereIn('id', $transactionIds);
} else {
// Usar filtros
if (!empty($filters['account_id'])) {
$query->where('account_id', $filters['account_id']);
}
if (!empty($filters['type'])) {
$query->where('type', $filters['type']);
}
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (!empty($filters['start_date'])) {
$query->where(function ($q) use ($filters) {
$q->where('effective_date', '>=', $filters['start_date'])
->orWhere(function ($q2) use ($filters) {
$q2->whereNull('effective_date')
->where('planned_date', '>=', $filters['start_date']);
});
});
}
if (!empty($filters['end_date'])) {
$query->where(function ($q) use ($filters) {
$q->where('effective_date', '<=', $filters['end_date'])
->orWhere(function ($q2) use ($filters) {
$q2->whereNull('effective_date')
->where('planned_date', '<=', $filters['end_date']);
});
});
}
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('original_description', 'like', "%{$search}%")
->orWhere('reference', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
});
}
}
// Atualizar transações

View File

@ -108,73 +108,136 @@ public function cashflow(Request $request): JsonResponse
/**
* Resumo geral do dashboard
* Ignora transações marcadas como transferências entre contas
* Agrupa valores por divisa para sistema multi-divisa
*/
public function summary(Request $request): JsonResponse
{
$userId = $request->user()->id;
// Saldo total das contas
$totalBalance = Account::where('user_id', $userId)
// Saldo total das contas agrupado por divisa
$balancesByCurrency = Account::where('user_id', $userId)
->where('is_active', true)
->where('include_in_total', true)
->sum('current_balance');
->select('currency', DB::raw('SUM(current_balance) as total'))
->groupBy('currency')
->get()
->mapWithKeys(function ($item) {
return [$item->currency => (float) $item->total];
})
->toArray();
// Transações do mês atual (excluindo transferências)
// Transações do mês atual agrupadas por divisa da conta (excluindo transferências)
$currentMonth = [
now()->startOfMonth()->format('Y-m-d'),
now()->endOfMonth()->format('Y-m-d')
];
$monthlyStats = Transaction::ofUser($userId)
->completed()
->where('is_transfer', false) // Ignorar transferências entre contas
->whereBetween('effective_date', $currentMonth)
$monthlyStatsByCurrency = Transaction::where('transactions.user_id', $userId)
->where('transactions.status', 'completed')
->where('transactions.is_transfer', false)
->whereBetween('transactions.effective_date', $currentMonth)
->whereNull('transactions.deleted_at')
->join('accounts', 'transactions.account_id', '=', 'accounts.id')
->select(
DB::raw("SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income"),
DB::raw("SUM(CASE WHEN type = 'debit' THEN amount ELSE 0 END) as expense"),
'accounts.currency',
DB::raw("SUM(CASE WHEN transactions.type = 'credit' THEN transactions.amount ELSE 0 END) as income"),
DB::raw("SUM(CASE WHEN transactions.type = 'debit' THEN transactions.amount ELSE 0 END) as expense"),
DB::raw("COUNT(*) as transactions_count")
)
->first();
->groupBy('accounts.currency')
->get();
// Pendentes (excluindo transferências)
$pending = Transaction::ofUser($userId)
->pending()
->where('is_transfer', false)
$incomeByCurrency = [];
$expenseByCurrency = [];
$transactionsCount = 0;
foreach ($monthlyStatsByCurrency as $stats) {
$incomeByCurrency[$stats->currency] = (float) $stats->income;
$expenseByCurrency[$stats->currency] = (float) $stats->expense;
$transactionsCount += (int) $stats->transactions_count;
}
// Pendentes agrupadas por divisa (excluindo transferências)
$pendingByCurrency = Transaction::where('transactions.user_id', $userId)
->where('transactions.status', 'pending')
->where('transactions.is_transfer', false)
->whereNull('transactions.deleted_at')
->join('accounts', 'transactions.account_id', '=', 'accounts.id')
->select(
DB::raw("SUM(CASE WHEN type = 'credit' THEN planned_amount ELSE 0 END) as income"),
DB::raw("SUM(CASE WHEN type = 'debit' THEN planned_amount ELSE 0 END) as expense"),
'accounts.currency',
DB::raw("SUM(CASE WHEN transactions.type = 'credit' THEN transactions.planned_amount ELSE 0 END) as income"),
DB::raw("SUM(CASE WHEN transactions.type = 'debit' THEN transactions.planned_amount ELSE 0 END) as expense"),
DB::raw("COUNT(*) as count")
)
->first();
->groupBy('accounts.currency')
->get();
// Atrasadas (vencidas) - excluindo transferências
$overdue = Transaction::ofUser($userId)
->pending()
->where('is_transfer', false)
->where('planned_date', '<', now()->startOfDay())
$pendingIncomeByCurrency = [];
$pendingExpenseByCurrency = [];
$pendingCount = 0;
foreach ($pendingByCurrency as $pending) {
$pendingIncomeByCurrency[$pending->currency] = (float) $pending->income;
$pendingExpenseByCurrency[$pending->currency] = (float) $pending->expense;
$pendingCount += (int) $pending->count;
}
// Atrasadas (vencidas) agrupadas por divisa - excluindo transferências
$overdueByCurrency = Transaction::where('transactions.user_id', $userId)
->where('transactions.status', 'pending')
->where('transactions.is_transfer', false)
->where('transactions.planned_date', '<', now()->startOfDay())
->whereNull('transactions.deleted_at')
->join('accounts', 'transactions.account_id', '=', 'accounts.id')
->select(
DB::raw("SUM(planned_amount) as total"),
'accounts.currency',
DB::raw("SUM(transactions.planned_amount) as total"),
DB::raw("COUNT(*) as count")
)
->first();
->groupBy('accounts.currency')
->get();
$overdueTotalByCurrency = [];
$overdueCount = 0;
foreach ($overdueByCurrency as $overdue) {
$overdueTotalByCurrency[$overdue->currency] = (float) $overdue->total;
$overdueCount += (int) $overdue->count;
}
// Determinar divisa principal (a com maior saldo ou primeira encontrada)
$primaryCurrency = !empty($balancesByCurrency) ? array_key_first($balancesByCurrency) : 'BRL';
return response()->json([
'total_balance' => (float) $totalBalance,
// Compatibilidade com versão anterior (usando primeira divisa)
'total_balance' => (float) ($balancesByCurrency[$primaryCurrency] ?? 0),
'current_month' => [
'income' => (float) ($monthlyStats->income ?? 0),
'expense' => (float) ($monthlyStats->expense ?? 0),
'balance' => (float) (($monthlyStats->income ?? 0) - ($monthlyStats->expense ?? 0)),
'transactions_count' => (int) ($monthlyStats->transactions_count ?? 0),
'income' => (float) ($incomeByCurrency[$primaryCurrency] ?? 0),
'expense' => (float) ($expenseByCurrency[$primaryCurrency] ?? 0),
'balance' => (float) (($incomeByCurrency[$primaryCurrency] ?? 0) - ($expenseByCurrency[$primaryCurrency] ?? 0)),
'transactions_count' => $transactionsCount,
],
'pending' => [
'income' => (float) ($pending->income ?? 0),
'expense' => (float) ($pending->expense ?? 0),
'count' => (int) ($pending->count ?? 0),
'income' => (float) ($pendingIncomeByCurrency[$primaryCurrency] ?? 0),
'expense' => (float) ($pendingExpenseByCurrency[$primaryCurrency] ?? 0),
'count' => $pendingCount,
],
'overdue' => [
'total' => (float) ($overdue->total ?? 0),
'count' => (int) ($overdue->count ?? 0),
'total' => (float) ($overdueTotalByCurrency[$primaryCurrency] ?? 0),
'count' => $overdueCount,
],
// Novos campos multi-divisa
'primary_currency' => $primaryCurrency,
'balances_by_currency' => $balancesByCurrency,
'current_month_by_currency' => [
'income' => $incomeByCurrency,
'expense' => $expenseByCurrency,
],
'pending_by_currency' => [
'income' => $pendingIncomeByCurrency,
'expense' => $pendingExpenseByCurrency,
],
'overdue_by_currency' => $overdueTotalByCurrency,
]);
}

View File

@ -3,11 +3,15 @@
# =============================================================================
# Este script sincroniza e deploya o backend Laravel para o servidor
# Uso: .\deploy.ps1
# Requer: PuTTY (pscp, plink) instalados no PATH
# =============================================================================
# Configurações
$ErrorActionPreference = "Stop"
# Configuracoes
$SERVER_USER = "root"
$SERVER_HOST = "213.165.93.60"
$SERVER_PASS = "Master9354"
$SERVER_PATH = "/var/www/webmoney/backend"
$LOCAL_PATH = $PSScriptRoot
@ -17,108 +21,66 @@ function Write-Color {
Write-Host $Text -ForegroundColor $Color
}
Write-Color "╔═══════════════════════════════════════════════════════╗" "Green"
Write-Color "║ WEBMoney Laravel - Deploy para Produção ║" "Green"
Write-Color "╚═══════════════════════════════════════════════════════╝" "Green"
Write-Host ""
Write-Color "========================================" "Cyan"
Write-Color " WEBMoney Backend - Deploy Script " "Cyan"
Write-Color "========================================" "Cyan"
Write-Host ""
# 1. Build local (se necessário)
Write-Color "[1/8] Verificando dependências locais..." "Yellow"
if (-not (Test-Path "$LOCAL_PATH\vendor")) {
Write-Host "Instalando dependências do Composer..."
composer install --no-dev --optimize-autoloader
}
Write-Color "✓ Dependências locais OK" "Green"
# 1. Sincronizar pastas principais
Write-Color "[1/5] Enviando pastas do backend..." "Yellow"
# 2. Preparar lista de exclusões
$excludes = @(
".git",
"node_modules",
"storage/logs/*",
"storage/framework/cache/*",
"storage/framework/sessions/*",
"storage/framework/views/*",
".env",
"database/database.sqlite"
)
$folders = @("app", "bootstrap", "config", "database", "public", "resources", "routes")
# 3. Sincronizar arquivos via scp (alternativa a rsync)
Write-Color "[2/8] Sincronizando arquivos para o servidor..." "Yellow"
# Criar arquivo tar local excluindo pastas
$tarFile = "$env:TEMP\webmoney-backend.tar.gz"
$excludeArgs = ($excludes | ForEach-Object { "--exclude='$_'" }) -join " "
# Usar tar se disponível, sino scp directo
$tarAvailable = Get-Command tar -ErrorAction SilentlyContinue
if ($tarAvailable) {
Push-Location $LOCAL_PATH
tar -czf $tarFile --exclude='.git' --exclude='node_modules' --exclude='storage/logs/*' --exclude='storage/framework/cache/*' --exclude='storage/framework/sessions/*' --exclude='storage/framework/views/*' --exclude='.env' --exclude='vendor' .
Pop-Location
# Enviar tar al servidor
scp $tarFile "${SERVER_USER}@${SERVER_HOST}:/tmp/webmoney-backend.tar.gz"
# Extraer en el servidor
ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && tar -xzf /tmp/webmoney-backend.tar.gz && rm /tmp/webmoney-backend.tar.gz"
Remove-Item $tarFile -ErrorAction SilentlyContinue
} else {
# Copiar archivos directamente (más lento pero funciona)
Write-Host "Usando SCP directo (tar no disponible)..."
scp -r "$LOCAL_PATH\app" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
scp -r "$LOCAL_PATH\bootstrap" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
scp -r "$LOCAL_PATH\config" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
scp -r "$LOCAL_PATH\database" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
scp -r "$LOCAL_PATH\public" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
scp -r "$LOCAL_PATH\resources" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
scp -r "$LOCAL_PATH\routes" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
scp "$LOCAL_PATH\artisan" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
scp "$LOCAL_PATH\composer.json" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
scp "$LOCAL_PATH\composer.lock" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
foreach ($folder in $folders) {
if (Test-Path "$LOCAL_PATH\$folder") {
Write-Host " -> $folder"
pscp -r -batch -pw $SERVER_PASS "$LOCAL_PATH\$folder" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
}
}
Write-Color "✓ Arquivos sincronizados" "Green"
# 4. Copiar .env de produção
Write-Color "[3/8] Configurando .env de produção..." "Yellow"
ssh "$SERVER_USER@$SERVER_HOST" "cp $SERVER_PATH/.env.production $SERVER_PATH/.env 2>/dev/null || true"
Write-Color "✓ .env configurado" "Green"
# 5. Instalar dependências no servidor
Write-Color "[4/8] Instalando dependências no servidor..." "Yellow"
ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && composer install --no-dev --optimize-autoloader"
Write-Color "✓ Dependências instaladas" "Green"
# 6. Ajustar permissões
Write-Color "[5/8] Ajustando permissões..." "Yellow"
ssh "$SERVER_USER@$SERVER_HOST" "chown -R www-data:www-data $SERVER_PATH/storage $SERVER_PATH/bootstrap/cache"
ssh "$SERVER_USER@$SERVER_HOST" "chmod -R 775 $SERVER_PATH/storage $SERVER_PATH/bootstrap/cache"
Write-Color "✓ Permissões ajustadas" "Green"
# 7. Executar migrações
Write-Color "[6/8] Executando migrações de banco de dados..." "Yellow"
ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && php artisan migrate --force"
Write-Color "✓ Migrações executadas" "Green"
# 8. Cache e otimizações
Write-Color "[7/8] Otimizando aplicação..." "Yellow"
ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && php artisan config:cache"
ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && php artisan route:cache"
ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && php artisan view:cache"
Write-Color "✓ Caches gerados" "Green"
# 9. Reiniciar PHP-FPM
Write-Color "[8/8] Reiniciando serviços..." "Yellow"
ssh "$SERVER_USER@$SERVER_HOST" "systemctl restart php8.4-fpm"
Write-Color "✓ PHP-FPM reiniciado" "Green"
Write-Color "Pastas enviadas" "Green"
Write-Host ""
Write-Color "╔═══════════════════════════════════════════════════════╗" "Green"
Write-Color "║ ✓ Deploy concluído com sucesso! ║" "Green"
Write-Color "╚═══════════════════════════════════════════════════════╝" "Green"
# 2. Enviar arquivos principais
Write-Color "[2/5] Enviando arquivos..." "Yellow"
$files = @("artisan", "composer.json", "composer.lock")
foreach ($file in $files) {
if (Test-Path "$LOCAL_PATH\$file") {
Write-Host " -> $file"
pscp -batch -pw $SERVER_PASS "$LOCAL_PATH\$file" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/"
}
}
Write-Color "Arquivos enviados" "Green"
Write-Host ""
Write-Host "API disponível em: " -NoNewline
Write-Color "https://webmoney.cnxifly.com/api" "Cyan"
# 3. Instalar dependencias e configurar
Write-Color "[3/5] Instalando dependencias no servidor..." "Yellow"
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH; composer install --no-dev --optimize-autoloader 2>&1 | tail -5"
Write-Color "Dependencias instaladas" "Green"
Write-Host ""
# 4. Migracoes e otimizacoes
Write-Color "[4/5] Executando migracoes e otimizacoes..." "Yellow"
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH; php artisan migrate --force; php artisan optimize:clear; php artisan config:cache; php artisan route:cache; php artisan view:cache"
Write-Color "Migracoes e caches OK" "Green"
Write-Host ""
# 5. Permissoes e reiniciar PHP-FPM (CRITICO para evitar cache de opcodes)
Write-Color "[5/5] Ajustando permissoes e reiniciando PHP-FPM..." "Yellow"
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "chown -R www-data:www-data $SERVER_PATH/storage $SERVER_PATH/bootstrap/cache; chmod -R 775 $SERVER_PATH/storage $SERVER_PATH/bootstrap/cache; systemctl restart php8.4-fpm"
Write-Color "Permissoes e PHP-FPM OK" "Green"
Write-Host ""
Write-Color "========================================" "Green"
Write-Color " Deploy concluido com sucesso! " "Green"
Write-Color "========================================" "Green"
Write-Host ""
Write-Host "API disponivel em: https://webmoney.cnxifly.com/api"
Write-Host ""

View File

@ -1,14 +1,17 @@
# =============================================================================
# WEBMoney Frontend - Script de Deploy para Windows
# =============================================================================
# Este script faz build e deploy do frontend para o servidor de produção
# Este script faz build e deploy do frontend para o servidor de producao
# Uso: .\deploy.ps1
# Requer: Node.js, npm, e clave SSH configurada (ou escribir contraseña)
# Requer: Node.js, npm, PuTTY (pscp, plink) instalados no PATH
# =============================================================================
# Configurações
$ErrorActionPreference = "Stop"
# Configuracoes
$SERVER_USER = "root"
$SERVER_HOST = "213.165.93.60"
$SERVER_PASS = "Master9354"
$REMOTE_PATH = "/var/www/webmoney/frontend/dist"
$LOCAL_DIST = ".\dist"
@ -18,6 +21,7 @@ function Write-Color {
Write-Host $Text -ForegroundColor $Color
}
Write-Host ""
Write-Color "========================================" "Cyan"
Write-Color " WEBMoney Frontend - Deploy Script " "Cyan"
Write-Color "========================================" "Cyan"
@ -33,47 +37,44 @@ if (Test-Path $LOCAL_DIST) {
npm run build
if (-not (Test-Path $LOCAL_DIST)) {
Write-Color "ERRO: Build falhou - pasta dist não encontrada" "Red"
Write-Color "ERRO: Build falhou - pasta dist nao encontrada" "Red"
exit 1
}
Write-Color "✓ Build concluído" "Green"
Write-Color "Build concluido" "Green"
Write-Host ""
# 2. Limpar diretório remoto
Write-Color "[2/4] Limpando diretório remoto..." "Yellow"
ssh "$SERVER_USER@$SERVER_HOST" "rm -rf $REMOTE_PATH/* && echo 'Diretório limpo'"
# 2. Limpar diretorio remoto
Write-Color "[2/4] Limpando diretorio remoto..." "Yellow"
plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "rm -rf $REMOTE_PATH/*"
Write-Color "✓ Diretório remoto limpo" "Green"
Write-Color "Diretorio remoto limpo" "Green"
Write-Host ""
# 3. Enviar arquivos
Write-Color "[3/4] Enviando arquivos para $REMOTE_PATH ..." "Yellow"
scp -r "$LOCAL_DIST\*" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_PATH}/"
pscp -r -batch -pw $SERVER_PASS "$LOCAL_DIST\*" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_PATH}/"
Write-Color "Arquivos enviados" "Green"
Write-Color "Arquivos enviados" "Green"
Write-Host ""
# 4. Verificar deploy
Write-Color "[4/4] Verificando deploy..." "Yellow"
$remoteFiles = ssh "$SERVER_USER@$SERVER_HOST" "ls -la $REMOTE_PATH/"
Write-Host $remoteFiles
Write-Host ""
$result = plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "ls $REMOTE_PATH/index.html 2>/dev/null"
# Verificar se index.html existe
$indexExists = ssh "$SERVER_USER@$SERVER_HOST" "test -f $REMOTE_PATH/index.html && echo 'OK'"
if ($indexExists -eq "OK") {
if ($result) {
Write-Host ""
Write-Color "========================================" "Green"
Write-Color " ✓ Deploy concluído com sucesso! " "Green"
Write-Color " Deploy concluido com sucesso! " "Green"
Write-Color "========================================" "Green"
Write-Host ""
Write-Host "Acesse: " -NoNewline
Write-Color "https://webmoney.cnxifly.com" "Cyan"
Write-Host "Acesse: https://webmoney.cnxifly.com"
Write-Host ""
Write-Host "Dica: Use Ctrl+Shift+R no navegador para limpar o cache"
Write-Host ""
} else {
Write-Color "========================================" "Red"
Write-Color " ✗ ERRO: index.html não encontrado " "Red"
Write-Color " ERRO: index.html nao encontrado " "Red"
Write-Color "========================================" "Red"
exit 1
}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
@ -12,7 +12,22 @@ const Layout = ({ children }) => {
const { user, logout } = useAuth();
const { t, i18n } = useTranslation();
const { date } = useFormatters();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Sidebar colapsado por padrão em mobile, expandido em desktop
const getInitialSidebarState = () => window.innerWidth < 1024;
const [sidebarCollapsed, setSidebarCollapsed] = useState(getInitialSidebarState);
// Atualizar estado do sidebar quando a tela for redimensionada
useEffect(() => {
const handleResize = () => {
const isMobile = window.innerWidth < 1024;
setSidebarCollapsed(isMobile);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const [expandedGroups, setExpandedGroups] = useState({
movements: true,
settings: false,

View File

@ -52,7 +52,12 @@
"difference": "Difference",
"months": "months",
"viewAll": "View all",
"today": "Today"
"today": "Today",
"selectTransactions": "Select transactions",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"applyToSelected": "Apply to Selected",
"batchNoSelection": "Select at least one transaction"
},
"auth": {
"login": "Login",
@ -521,6 +526,11 @@
"transferCreated": "Transfer completed successfully",
"quickComplete": "Quick Complete",
"quickCompleted": "Transaction completed successfully",
"quickCategorize": "Categorize",
"categorized": "Transaction categorized successfully",
"categorize": "Categorize",
"addKeywordForFuture": "Remember this categorization for future imports",
"keywordHelp": "When importing new transactions with this description, they will be categorized automatically",
"isTransfer": "Inter-account transfer",
"transfers": "Inter-account transfers",
"split": "Split",
@ -557,6 +567,10 @@
"batchFiltered": "Filtered",
"batchFilteredInfo": "Applying categorization only to the {{count}} filtered transactions",
"batchCategorizeManual": "Batch Categorize",
"batchWillApply": "The category/cost center will be applied to these transactions",
"batchWillApplySelected": "Only the checked transactions will be updated",
"batchWillApplyFiltered": "All transactions matching the filters will be updated",
"batchAllFiltered": "All filtered transactions",
"transactionsSelected": "transactions selected",
"batchAllUncategorized": "All uncategorized transactions",
"selectCategory": "Select category",
@ -564,6 +578,7 @@
"addAsKeyword": "Add search term as keyword",
"addAsKeywordHelp": "The keyword will be added to the selected category and cost center for future automatic categorization",
"batchSelectRequired": "Select at least one category or cost center",
"noTransactionsSelected": "Select transactions or apply filters to categorize",
"applyToAll": "Apply to All",
"batchUpdated": "transactions updated",
"keywordAdded": "Keyword added",

View File

@ -52,7 +52,12 @@
"difference": "Diferencia",
"months": "meses",
"viewAll": "Ver todos",
"today": "Hoy"
"today": "Hoy",
"selectTransactions": "Seleccionar transacciones",
"selectAll": "Seleccionar Todas",
"deselectAll": "Desmarcar Todas",
"applyToSelected": "Aplicar a Seleccionadas",
"batchNoSelection": "Seleccione al menos una transacción"
},
"auth": {
"login": "Iniciar Sesión",
@ -521,6 +526,11 @@
"transferCreated": "Transferencia realizada correctamente",
"quickComplete": "Completar Rápido",
"quickCompleted": "Transacción completada correctamente",
"quickCategorize": "Categorizar",
"categorized": "Transacción categorizada correctamente",
"categorize": "Categorizar",
"addKeywordForFuture": "Recordar esta categorización para futuras importaciones",
"keywordHelp": "Cuando importe nuevas transacciones con esta descripción, se categorizarán automáticamente",
"isTransfer": "Transferencia entre cuentas",
"transfers": "Transferencias entre cuentas",
"split": "Dividir",
@ -557,6 +567,10 @@
"batchFiltered": "Filtrado",
"batchFilteredInfo": "Aplicando categorización solo a las {{count}} transacciones filtradas",
"batchCategorizeManual": "Categorizar en Lote",
"batchWillApply": "La categoría/centro de costo se aplicará a estas transacciones",
"batchWillApplySelected": "Solo las transacciones marcadas serán actualizadas",
"batchWillApplyFiltered": "Todas las transacciones que coinciden con los filtros serán actualizadas",
"batchAllFiltered": "Todas las transacciones filtradas",
"transactionsSelected": "transacciones seleccionadas",
"batchAllUncategorized": "Todas las transacciones sin categorizar",
"selectCategory": "Seleccionar categoría",
@ -564,6 +578,7 @@
"addAsKeyword": "Agregar término de búsqueda como palabra clave",
"addAsKeywordHelp": "La palabra clave se agregará a la categoría y centro de costo seleccionados para futura categorización automática",
"batchSelectRequired": "Seleccione al menos una categoría o centro de costo",
"noTransactionsSelected": "Seleccione transacciones o aplique filtros para categorizar",
"applyToAll": "Aplicar a Todas",
"batchUpdated": "transacciones actualizadas",
"keywordAdded": "Palabra clave agregada",

View File

@ -53,7 +53,12 @@
"months": "meses",
"viewAll": "Ver todos",
"date": "Data",
"today": "Hoje"
"today": "Hoje",
"selectTransactions": "Selecionar transações",
"selectAll": "Selecionar Todas",
"deselectAll": "Desmarcar Todas",
"applyToSelected": "Aplicar nas Selecionadas",
"batchNoSelection": "Selecione pelo menos uma transação"
},
"auth": {
"login": "Entrar",
@ -523,6 +528,11 @@
"transferCreated": "Transferência realizada com sucesso",
"quickComplete": "Efetivação Rápida",
"quickCompleted": "Transação efetivada com sucesso",
"quickCategorize": "Categorizar",
"categorized": "Transação categorizada com sucesso",
"categorize": "Categorizar",
"addKeywordForFuture": "Lembrar esta categorização para futuras importações",
"keywordHelp": "Ao importar novas transações com esta descrição, serão categorizadas automaticamente",
"isTransfer": "Transferência entre contas",
"transfers": "Transferências entre contas",
"split": "Dividir",
@ -559,6 +569,10 @@
"batchFiltered": "Filtrado",
"batchFilteredInfo": "Aplicando categorização apenas às {{count}} transações filtradas",
"batchCategorizeManual": "Categorizar em Lote",
"batchWillApply": "A categoria/centro de custo será aplicado nestas transações",
"batchWillApplySelected": "Apenas as transações marcadas serão atualizadas",
"batchWillApplyFiltered": "Todas as transações que correspondem aos filtros serão atualizadas",
"batchAllFiltered": "Todas as transações filtradas",
"transactionsSelected": "transações selecionadas",
"batchAllUncategorized": "Todas as transações sem categoria",
"selectCategory": "Selecionar categoria",
@ -566,6 +580,7 @@
"addAsKeyword": "Adicionar termo de busca como palavra-chave",
"addAsKeywordHelp": "A palavra-chave será adicionada à categoria e centro de custo selecionados para futura categorização automática",
"batchSelectRequired": "Selecione pelo menos uma categoria ou centro de custo",
"noTransactionsSelected": "Selecione transações ou aplique filtros para categorizar",
"applyToAll": "Aplicar a Todas",
"batchUpdated": "transações atualizadas",
"keywordAdded": "Palavra-chave adicionada",

View File

@ -2108,6 +2108,115 @@ input[type="color"]::-webkit-color-swatch {
}
/* Responsive adjustments */
/* iPad Pro 12.9" landscape and large tablets (1024px - 1366px) */
@media (min-width: 1024px) and (max-width: 1366px) {
.txn-page {
padding: 1rem;
}
.txn-stats {
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.txn-stat-card {
padding: 0.75rem;
}
.txn-stat-icon {
width: 36px;
height: 36px;
font-size: 1rem;
}
.txn-stat-value {
font-size: 0.9rem;
}
.txn-filters-grid {
grid-template-columns: repeat(4, 1fr) auto;
gap: 0.5rem;
}
.txn-week-summary {
gap: 0.75rem;
}
.txn-week-stat {
min-width: 70px;
}
.txn-week-stat-value {
font-size: 0.8rem;
}
.txn-table th,
.txn-table td {
padding: 0.5rem 0.625rem;
}
/* Ocultar algunas columnas menos importantes en tablets */
.txn-table .col-account {
display: none;
}
.txn-header-title h1 {
font-size: 1.1rem;
}
.txn-header-actions .btn {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
}
/* Modals optimization for iPad */
.modal-dialog.modal-lg {
max-width: 85%;
margin: 1rem auto;
}
.modal-body {
max-height: 70vh;
overflow-y: auto;
}
}
/* iPad Pro 11" and smaller tablets (834px - 1024px) */
@media (min-width: 834px) and (max-width: 1024px) {
.txn-stats {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.txn-filters-grid {
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.txn-week-header {
padding: 0.625rem 0.75rem;
}
.txn-week-summary {
gap: 0.5rem;
flex-wrap: wrap;
}
.txn-week-stat {
min-width: 60px;
}
.txn-table .col-account,
.txn-table .col-status {
display: none;
}
.txn-amount {
font-size: 0.8rem;
}
}
@media (max-width: 1200px) {
.txn-stats {
grid-template-columns: repeat(2, 1fr);

View File

@ -107,7 +107,7 @@ const Dashboard = () => {
}}>
<div className="card-body py-3">
<div className="d-flex justify-content-between align-items-start">
<div>
<div className="flex-grow-1">
<p className="text-slate-400 mb-1 small text-uppercase" style={{ fontSize: '11px', letterSpacing: '0.5px' }}>
{label}
</p>
@ -116,15 +116,15 @@ const Dashboard = () => {
<span className="placeholder col-8" style={{ height: '28px' }}></span>
</div>
) : (
<h4 className={`mb-0 fw-bold ${valueColor}`} style={{ fontSize: '1.5rem' }}>
<div className={`mb-0 fw-bold ${valueColor}`} style={{ fontSize: '1.25rem' }}>
{value}
</h4>
</div>
)}
{subValue && !loading && (
<small className="text-slate-500">{subValue}</small>
)}
</div>
<div className="rounded-2 d-flex align-items-center justify-content-center"
<div className="rounded-2 d-flex align-items-center justify-content-center flex-shrink-0"
style={{
width: '42px',
height: '42px',
@ -224,22 +224,46 @@ const Dashboard = () => {
</div>
</div>
{/* Stats Cards Row */}
{/* Stats Cards Row - Multi-currency */}
<div className="row g-3 mb-4">
<div className="col-6 col-lg-3">
<StatCard
icon="bi-wallet2"
label={t('dashboard.totalBalance')}
value={currency(summary?.total_balance || 0, 'BRL')}
valueColor={summary?.total_balance >= 0 ? 'text-success' : 'text-danger'}
accentColor={summary?.total_balance >= 0 ? '#22c55e' : '#ef4444'}
value={
summary?.balances_by_currency && Object.keys(summary.balances_by_currency).length > 0
? Object.entries(summary.balances_by_currency).map(([curr, val]) => (
<div key={curr}>{currency(val, curr)}</div>
))
: currency(0, 'BRL')
}
valueColor={
summary?.balances_by_currency
? Object.values(summary.balances_by_currency).reduce((a, b) => a + b, 0) >= 0
? 'text-success'
: 'text-danger'
: 'text-success'
}
accentColor={
summary?.balances_by_currency
? Object.values(summary.balances_by_currency).reduce((a, b) => a + b, 0) >= 0
? '#22c55e'
: '#ef4444'
: '#22c55e'
}
/>
</div>
<div className="col-6 col-lg-3">
<StatCard
icon="bi-arrow-up-circle"
label={t('dashboard.monthIncome')}
value={currency(summary?.current_month?.income || 0, 'BRL')}
value={
summary?.current_month_by_currency?.income && Object.keys(summary.current_month_by_currency.income).length > 0
? Object.entries(summary.current_month_by_currency.income).map(([curr, val]) => (
<div key={curr}>{currency(val, curr)}</div>
))
: currency(0, summary?.primary_currency || 'BRL')
}
valueColor="text-success"
accentColor="#22c55e"
/>
@ -248,7 +272,13 @@ const Dashboard = () => {
<StatCard
icon="bi-arrow-down-circle"
label={t('dashboard.monthExpenses')}
value={currency(summary?.current_month?.expense || 0, 'BRL')}
value={
summary?.current_month_by_currency?.expense && Object.keys(summary.current_month_by_currency.expense).length > 0
? Object.entries(summary.current_month_by_currency.expense).map(([curr, val]) => (
<div key={curr}>{currency(val, curr)}</div>
))
: currency(0, summary?.primary_currency || 'BRL')
}
valueColor="text-danger"
accentColor="#ef4444"
/>

View File

@ -113,6 +113,7 @@ export default function Transactions() {
// Estados de categorização em lote
const [showBatchModal, setShowBatchModal] = useState(false);
const [batchPreview, setBatchPreview] = useState(null);
const [selectedTransactionIds, setSelectedTransactionIds] = useState(new Set());
const [loadingBatch, setLoadingBatch] = useState(false);
const [executingBatch, setExecutingBatch] = useState(false);
const [batchFormData, setBatchFormData] = useState({
@ -155,6 +156,20 @@ export default function Transactions() {
const [savingQuickItem, setSavingQuickItem] = useState(false);
const [quickCreateSource, setQuickCreateSource] = useState('form'); // 'form' ou 'batch'
// Estado para modal de categorização rápida individual
const [showQuickCategorizeModal, setShowQuickCategorizeModal] = useState(false);
const [quickCategorizeData, setQuickCategorizeData] = useState({
transaction: null,
category_id: '',
cost_center_id: '',
add_keyword: true,
});
const [savingQuickCategorize, setSavingQuickCategorize] = useState(false);
// Calcular se há filtros ativos (excluindo date_field que é sempre preenchido)
const hasActiveFilters = Object.entries(filters).some(([key, v]) => key !== 'date_field' && v !== '');
const activeFiltersCount = Object.entries(filters).filter(([key, v]) => key !== 'date_field' && v !== '').length;
// Carregar dados iniciais
const loadData = useCallback(async () => {
try {
@ -553,6 +568,53 @@ export default function Transactions() {
}
};
// ========================================
// CATEGORIZAÇÃO RÁPIDA INDIVIDUAL
// ========================================
const openQuickCategorizeModal = (transaction) => {
setQuickCategorizeData({
transaction: transaction,
category_id: transaction.category_id || '',
cost_center_id: transaction.cost_center_id || '',
add_keyword: true,
});
setShowQuickCategorizeModal(true);
};
const handleQuickCategorizeSubmit = async (e) => {
e.preventDefault();
try {
setSavingQuickCategorize(true);
const updateData = {
category_id: quickCategorizeData.category_id || null,
cost_center_id: quickCategorizeData.cost_center_id || null,
};
// Se add_keyword está ativo e há descrição original, criar keyword
if (quickCategorizeData.add_keyword && quickCategorizeData.transaction.original_description) {
// Atualizar transação e criar keyword via batch com uma única transação
await categoryService.batchCategorize({
transaction_ids: [quickCategorizeData.transaction.id],
category_id: updateData.category_id,
cost_center_id: updateData.cost_center_id,
add_keyword: true,
});
} else {
// Apenas atualizar a transação
await transactionService.update(quickCategorizeData.transaction.id, updateData);
}
showToast(t('transactions.categorized'), 'success');
setShowQuickCategorizeModal(false);
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
} finally {
setSavingQuickCategorize(false);
}
};
// ========================================
// EFETIVAÇÃO RÁPIDA
// ========================================
@ -849,44 +911,50 @@ export default function Transactions() {
return `${startDay} ${startMonth} - ${endDay} ${endMonth}`;
};
// Funções de seleção de transações
const getAllVisibleTransactionIds = () => {
const ids = [];
weeks.forEach(week => {
week.transactions?.forEach(t => ids.push(t.id));
});
return ids;
};
const handleToggleTransaction = (id) => {
setSelectedTransactionIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSelectAllVisible = () => {
const allIds = getAllVisibleTransactionIds();
if (selectedTransactionIds.size === allIds.length) {
setSelectedTransactionIds(new Set());
} else {
setSelectedTransactionIds(new Set(allIds));
}
};
// Funções de categorização em lote
const handleOpenBatchModal = async () => {
const handleOpenBatchModal = () => {
// Permitir abrir modal se há transações selecionadas OU se há filtros ativos
if (selectedTransactionIds.size === 0 && !hasActiveFilters) {
showToast(t('transactions.noTransactionsSelected'), 'warning');
return;
}
setShowBatchModal(true);
setLoadingBatch(true);
setBatchFormData({
category_id: '',
cost_center_id: '',
add_keyword: !!filters.search, // Ativar por padrão se houver busca
add_keyword: !!filters.search,
});
try {
// Preparar filtros para enviar ao backend
const activeFilters = {};
if (filters.account_id) activeFilters.account_id = filters.account_id;
if (filters.type) activeFilters.type = filters.type;
if (filters.status) activeFilters.status = filters.status;
if (filters.start_date) activeFilters.start_date = filters.start_date;
if (filters.end_date) activeFilters.end_date = filters.end_date;
if (filters.search) activeFilters.search = filters.search;
const hasFilters = Object.keys(activeFilters).length > 0;
const response = await categoryService.categorizeBatchPreview(true, 50, hasFilters ? activeFilters : null);
if (response.success) {
setBatchPreview({
...response.data,
hasFilters: hasFilters,
activeFilters: activeFilters,
});
}
} catch (error) {
showToast(t('categories.batchPreviewError'), 'error');
} finally {
setLoadingBatch(false);
}
};
const handleCloseBatchModal = () => {
setShowBatchModal(false);
setBatchPreview(null);
setBatchFormData({
category_id: '',
cost_center_id: '',
@ -900,13 +968,23 @@ export default function Transactions() {
return;
}
// Obter IDs selecionados
const selectedIds = Array.from(selectedTransactionIds);
// Verificar se há transações selecionadas ou filtros ativos
if (selectedIds.length === 0 && !hasActiveFilters) {
showToast(t('transactions.noTransactionsSelected'), 'warning');
return;
}
setExecutingBatch(true);
try {
const response = await categoryService.categorizeBatchManual(
batchFormData.category_id || null,
batchFormData.cost_center_id || null,
batchPreview?.activeFilters || null,
batchFormData.add_keyword
filters, // Enviar filtros sempre
batchFormData.add_keyword,
selectedIds.length > 0 ? selectedIds : null // Só enviar IDs se houver seleção
);
if (response.success) {
let message = `${response.data.updated} ${t('transactions.batchUpdated')}`;
@ -915,7 +993,8 @@ export default function Transactions() {
}
showToast(message, 'success');
handleCloseBatchModal();
loadWeeklyData(); // Recarregar transações
setSelectedTransactionIds(new Set());
loadWeeklyData();
}
} catch (error) {
showToast(error.response?.data?.message || t('categories.batchError'), 'error');
@ -937,9 +1016,6 @@ export default function Transactions() {
transactions: acc.transactions + (week.summary?.total_transactions || 0),
}), { credits: 0, debits: 0, pending: 0, transactions: 0 });
// Contar filtros ativos
const activeFiltersCount = Object.values(filters).filter(v => v !== '').length;
if (error) {
return (
<div className="alert alert-danger">
@ -961,10 +1037,18 @@ export default function Transactions() {
</div>
</div>
<div className="txn-header-actions">
<button className="btn btn-outline-warning btn-sm" onClick={handleOpenBatchModal}>
<i className="bi bi-lightning-charge"></i>
<span className="d-none d-md-inline ms-1">{t('categories.batchCategorize')}</span>
</button>
{hasActiveFilters && (
<button
className={`btn btn-sm ${selectedTransactionIds.size > 0 ? 'btn-warning' : 'btn-outline-warning'}`}
onClick={handleOpenBatchModal}
disabled={selectedTransactionIds.size === 0}
>
<i className="bi bi-lightning-charge"></i>
<span className="d-none d-md-inline ms-1">
{t('categories.batchCategorize')} {selectedTransactionIds.size > 0 && `(${selectedTransactionIds.size})`}
</span>
</button>
)}
<Link to="/import" className="btn btn-outline-primary btn-sm">
<i className="bi bi-upload"></i>
<span className="d-none d-md-inline ms-1">{t('nav.import')}</span>
@ -1220,14 +1304,32 @@ export default function Transactions() {
<table className="txn-table">
<thead>
<tr>
<th style={{ width: '90px' }}>{t('transactions.date')}</th>
<th>{t('transactions.description')}</th>
<th style={{ width: '120px' }}>{t('transactions.account')}</th>
<th style={{ width: '140px' }}>{t('transactions.category')}</th>
<th style={{ width: '110px' }} className="text-end">{t('transactions.amount')}</th>
<th style={{ width: '70px' }} className="text-center">{t('transactions.type.label')}</th>
<th style={{ width: '80px' }} className="text-center">{t('transactions.status.label')}</th>
<th style={{ width: '40px' }} className="text-center"></th>
{hasActiveFilters && (
<th style={{ width: '40px' }} className="text-center col-checkbox">
<input
type="checkbox"
className="form-check-input"
checked={week.transactions?.length > 0 && week.transactions.every(t => selectedTransactionIds.has(t.id))}
onChange={() => {
const weekIds = week.transactions?.map(t => t.id) || [];
const allSelected = weekIds.every(id => selectedTransactionIds.has(id));
setSelectedTransactionIds(prev => {
const next = new Set(prev);
weekIds.forEach(id => allSelected ? next.delete(id) : next.add(id));
return next;
});
}}
/>
</th>
)}
<th style={{ width: '90px' }} className="col-date">{t('transactions.date')}</th>
<th className="col-description">{t('transactions.description')}</th>
<th style={{ width: '120px' }} className="col-account">{t('transactions.account')}</th>
<th style={{ width: '140px' }} className="col-category">{t('transactions.category')}</th>
<th style={{ width: '110px' }} className="text-end col-amount">{t('transactions.amount')}</th>
<th style={{ width: '70px' }} className="text-center col-type">{t('transactions.type.label')}</th>
<th style={{ width: '80px' }} className="text-center col-status">{t('transactions.status.label')}</th>
<th style={{ width: '40px' }} className="text-center col-actions"></th>
</tr>
</thead>
<tbody>
@ -1235,15 +1337,25 @@ export default function Transactions() {
<tr
key={transaction.id}
ref={transaction.id === highlightedTransactionId ? highlightedRef : null}
className={`${transaction.is_overdue ? 'overdue' : ''} ${transaction.id === highlightedTransactionId ? 'highlighted-transaction' : ''}`}
className={`${transaction.is_overdue ? 'overdue' : ''} ${transaction.id === highlightedTransactionId ? 'highlighted-transaction' : ''} ${hasActiveFilters && selectedTransactionIds.has(transaction.id) ? 'selected-row' : ''}`}
>
<td>
{hasActiveFilters && (
<td className="text-center col-checkbox">
<input
type="checkbox"
className="form-check-input"
checked={selectedTransactionIds.has(transaction.id)}
onChange={() => handleToggleTransaction(transaction.id)}
/>
</td>
)}
<td className="col-date">
<span className="txn-date">
{formatDate(transaction.effective_date || transaction.planned_date)}
{transaction.is_overdue && <i className="bi bi-exclamation-triangle overdue-icon"></i>}
</span>
</td>
<td>
<td className="col-description">
<div className="d-flex align-items-center gap-1">
{transaction.is_transfer && <span className="txn-transfer-badge"><i className="bi bi-arrow-left-right"></i></span>}
{transaction.is_reconciled && (
@ -1262,8 +1374,8 @@ export default function Transactions() {
</div>
)}
</td>
<td><span className="txn-account">{transaction.account?.name}</span></td>
<td>
<td className="col-account"><span className="txn-account">{transaction.account?.name}</span></td>
<td className="col-category">
{transaction.category && (
<span
className="txn-category-badge"
@ -1274,23 +1386,23 @@ export default function Transactions() {
</span>
)}
</td>
<td>
<td className="col-amount">
<span className={`txn-amount ${transaction.type}`}>
{transaction.type === 'credit' ? '+' : '-'}
{formatCurrency(transaction.amount || transaction.planned_amount, selectedCurrency)}
</span>
</td>
<td className="text-center">
<td className="text-center col-type">
<span className={`txn-type-badge ${transaction.type}`}>
{transaction.type === 'credit' ? t('transactions.type.credit') : t('transactions.type.debit')}
</span>
</td>
<td className="text-center">
<td className="text-center col-status">
<span className={`txn-status-badge ${transaction.status}`}>
{t(`transactions.status.${transaction.status}`)}
</span>
</td>
<td className="text-center">
<td className="text-center col-actions">
<div className="dropdown">
<button className="txn-actions-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i className="bi bi-three-dots-vertical"></i>
@ -1329,6 +1441,12 @@ export default function Transactions() {
)}
{/* Ações Principais */}
<li>
<button className="dropdown-item" onClick={() => openQuickCategorizeModal(transaction)}>
<i className="bi bi-tags text-success me-2"></i>
{t('transactions.quickCategorize')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => openEditModal(transaction)}>
<i className="bi bi-pencil text-primary me-2"></i>
@ -2185,96 +2303,93 @@ export default function Transactions() {
<button type="button" className="btn-close btn-close-white" onClick={handleCloseBatchModal}></button>
</div>
<div className="modal-body">
{loadingBatch ? (
<div className="text-center py-4">
<div className="spinner-border text-info" role="status">
<span className="visually-hidden">Loading...</span>
{/* Info das transações selecionadas */}
<div className="alert mb-4 py-3" style={{ background: '#0f172a', border: '1px solid #334155' }}>
<div className="d-flex align-items-center">
<div className="me-3">
<i className={`bi ${selectedTransactionIds.size > 0 ? 'bi-check2-square' : 'bi-funnel'} text-warning`} style={{ fontSize: '2rem' }}></i>
</div>
<p className="text-slate-400 mt-3">{t('common.loading')}</p>
</div>
) : batchPreview ? (
<>
{/* Info das transações */}
<div className="alert mb-4 py-3" style={{ background: '#0f172a', border: '1px solid #334155' }}>
<div className="d-flex align-items-center">
<div className="me-3">
<i className="bi bi-collection text-warning" style={{ fontSize: '2rem' }}></i>
</div>
<div>
<div>
{selectedTransactionIds.size > 0 ? (
<>
<h5 className="mb-1 text-white">
{batchPreview.total_uncategorized} {t('transactions.transactionsSelected')}
{selectedTransactionIds.size} {t('transactions.transactionsSelected')}
</h5>
<small className="text-slate-400">
{batchPreview.hasFilters ? (
<>
<i className="bi bi-funnel-fill me-1 text-info"></i>
{t('transactions.batchFilteredInfo', { count: batchPreview.total_uncategorized })}
</>
) : (
t('transactions.batchAllUncategorized')
)}
{t('transactions.batchWillApplySelected')}
</small>
</div>
</div>
</>
) : (
<>
<h5 className="mb-1 text-white">
{t('transactions.batchAllFiltered')}
</h5>
<small className="text-slate-400">
{t('transactions.batchWillApplyFiltered')}
</small>
</>
)}
</div>
</div>
</div>
{/* Formulário de Categorização */}
<div className="mb-4">
<label className="form-label text-slate-300">
<i className="bi bi-folder me-1"></i>
{t('categories.category')}
</label>
<div className="input-group">
<select
className="form-select"
value={batchFormData.category_id}
onChange={(e) => setBatchFormData({ ...batchFormData, category_id: e.target.value })}
style={{ background: '#0f172a', color: '#e2e8f0', border: '1px solid #334155' }}
>
<option value="">{t('transactions.selectCategory')}</option>
{categories.filter(c => c.parent_id === null).map(parent => (
<optgroup key={parent.id} label={parent.name}>
{categories.filter(c => c.parent_id === parent.id).map(sub => (
<option key={sub.id} value={sub.id}>
{sub.name}
</option>
))}
</optgroup>
{/* Formulário de Categorização */}
<div className="mb-4">
<label className="form-label text-slate-300">
<i className="bi bi-folder me-1"></i>
{t('categories.category')}
</label>
<div className="input-group">
<select
className="form-select"
value={batchFormData.category_id}
onChange={(e) => setBatchFormData({ ...batchFormData, category_id: e.target.value })}
style={{ background: '#0f172a', color: '#e2e8f0', border: '1px solid #334155' }}
>
<option value="">{t('transactions.selectCategory')}</option>
{categories.filter(c => c.parent_id === null).map(parent => (
<optgroup key={parent.id} label={parent.name}>
{categories.filter(c => c.parent_id === parent.id).map(sub => (
<option key={sub.id} value={sub.id}>
{sub.name}
</option>
))}
</select>
<button
type="button"
className="btn btn-outline-primary"
onClick={openQuickCategoryModalForBatch}
title={t('categories.newCategory')}
>
<i className="bi bi-plus-lg"></i>
</button>
</div>
</div>
</optgroup>
))}
</select>
<button
type="button"
className="btn btn-outline-primary"
onClick={openQuickCategoryModalForBatch}
title={t('categories.newCategory')}
>
<i className="bi bi-plus-lg"></i>
</button>
</div>
</div>
<div className="mb-4">
<label className="form-label text-slate-300">
<i className="bi bi-building me-1"></i>
{t('costCenters.costCenter')}
</label>
<div className="input-group">
<select
className="form-select"
value={batchFormData.cost_center_id}
onChange={(e) => setBatchFormData({ ...batchFormData, cost_center_id: e.target.value })}
style={{ background: '#0f172a', color: '#e2e8f0', border: '1px solid #334155' }}
>
<option value="">{t('transactions.selectCostCenter')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>{cc.name}</option>
))}
</select>
<button
type="button"
className="btn btn-outline-primary"
onClick={openQuickCostCenterModalForBatch}
title={t('costCenters.newCostCenter')}
<div className="mb-4">
<label className="form-label text-slate-300">
<i className="bi bi-building me-1"></i>
{t('costCenters.costCenter')}
</label>
<div className="input-group">
<select
className="form-select"
value={batchFormData.cost_center_id}
onChange={(e) => setBatchFormData({ ...batchFormData, cost_center_id: e.target.value })}
style={{ background: '#0f172a', color: '#e2e8f0', border: '1px solid #334155' }}
>
<option value="">{t('transactions.selectCostCenter')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>{cc.name}</option>
))}
</select>
<button
type="button"
className="btn btn-outline-primary"
onClick={openQuickCostCenterModalForBatch}
title={t('costCenters.newCostCenter')}
>
<i className="bi bi-plus-lg"></i>
</button>
@ -2282,7 +2397,7 @@ export default function Transactions() {
</div>
{/* Opção de adicionar keyword */}
{batchPreview.activeFilters?.search && (
{filters.search && (
<div className="form-check mb-3 p-3 rounded" style={{ background: '#0f172a', border: '1px solid #334155' }}>
<input
type="checkbox"
@ -2295,7 +2410,7 @@ export default function Transactions() {
<i className="bi bi-key me-1 text-warning"></i>
{t('transactions.addAsKeyword')}
<span className="badge bg-warning text-dark ms-2">
"{batchPreview.activeFilters.search}"
"{filters.search}"
</span>
</label>
<div className="text-slate-500 small mt-1">
@ -2311,12 +2426,6 @@ export default function Transactions() {
<small>{t('transactions.batchSelectRequired')}</small>
</div>
)}
</>
) : (
<div className="alert alert-danger">
{t('categories.previewError')}
</div>
)}
</div>
<div className="modal-footer border-secondary">
<button type="button" className="btn btn-outline-secondary" onClick={handleCloseBatchModal}>
@ -2326,7 +2435,7 @@ export default function Transactions() {
type="button"
className="btn btn-warning"
onClick={handleExecuteBatchManual}
disabled={executingBatch || !batchPreview || batchPreview.total_uncategorized === 0 || (!batchFormData.category_id && !batchFormData.cost_center_id)}
disabled={executingBatch || (!batchFormData.category_id && !batchFormData.cost_center_id)}
>
{executingBatch ? (
<>
@ -2336,7 +2445,7 @@ export default function Transactions() {
) : (
<>
<i className="bi bi-check2-all me-2"></i>
{t('transactions.applyToAll')} ({batchPreview?.total_uncategorized || 0})
{t('common.applyToSelected')} ({selectedTransactionIds.size})
</>
)}
</button>
@ -2776,6 +2885,108 @@ export default function Transactions() {
</div>
</form>
</Modal>
{/* Modal de Categorização Rápida Individual */}
<Modal
show={showQuickCategorizeModal}
onClose={() => setShowQuickCategorizeModal(false)}
title={t('transactions.quickCategorize')}
size="md"
>
<form onSubmit={handleQuickCategorizeSubmit}>
{quickCategorizeData.transaction && (
<div className="alert alert-light mb-3">
<div className="d-flex justify-content-between align-items-center">
<div>
<strong>{quickCategorizeData.transaction.description}</strong>
{quickCategorizeData.transaction.original_description &&
quickCategorizeData.transaction.original_description !== quickCategorizeData.transaction.description && (
<div className="small text-muted mt-1">
<i className="bi bi-bank me-1"></i>
{quickCategorizeData.transaction.original_description}
</div>
)}
</div>
<span className={`badge ${quickCategorizeData.transaction.type === 'credit' ? 'bg-success' : 'bg-danger'}`}>
{quickCategorizeData.transaction.type === 'credit' ? '+' : '-'}
{formatCurrency(quickCategorizeData.transaction.amount || quickCategorizeData.transaction.planned_amount, selectedCurrency)}
</span>
</div>
</div>
)}
<div className="mb-3">
<label className="form-label">{t('transactions.category')}</label>
<select
className="form-select"
value={quickCategorizeData.category_id}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, category_id: e.target.value }))}
>
<option value="">{t('transactions.selectCategory')}</option>
{(() => {
// Agrupar categorias por parent
const parentCategories = categories.filter(c => !c.parent_id);
const childCategories = categories.filter(c => c.parent_id);
return parentCategories.map(parent => (
<optgroup key={parent.id} label={parent.name}>
<option value={parent.id}>{parent.name}</option>
{childCategories
.filter(child => child.parent_id === parent.id)
.map(child => (
<option key={child.id} value={child.id}> {child.name}</option>
))
}
</optgroup>
));
})()}
</select>
</div>
<div className="mb-3">
<label className="form-label">{t('transactions.costCenter')}</label>
<select
className="form-select"
value={quickCategorizeData.cost_center_id}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, cost_center_id: e.target.value }))}
>
<option value="">{t('transactions.selectCostCenter')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>{cc.name}</option>
))}
</select>
</div>
{quickCategorizeData.transaction?.original_description && (
<div className="form-check mb-3">
<input
type="checkbox"
className="form-check-input"
id="quickCategorizeAddKeyword"
checked={quickCategorizeData.add_keyword}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, add_keyword: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="quickCategorizeAddKeyword">
{t('transactions.addKeywordForFuture')}
</label>
<div className="form-text">{t('transactions.keywordHelp')}</div>
</div>
)}
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-secondary" onClick={() => setShowQuickCategorizeModal(false)}>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-success" disabled={savingQuickCategorize}>
{savingQuickCategorize ? (
<><span className="spinner-border spinner-border-sm me-1"></span>{t('common.saving')}</>
) : (
<><i className="bi bi-tags me-1"></i>{t('transactions.categorize')}</>
)}
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@ -290,12 +290,13 @@ export const categoryService = {
},
// Categorização em lote manual - aplicar categoria/centro de custo selecionados
categorizeBatchManual: async (categoryId, costCenterId, filters, addKeyword = false) => {
categorizeBatchManual: async (categoryId, costCenterId, filters, addKeyword = false, transactionIds = null) => {
const response = await api.post('/categories/categorize-batch/manual', {
category_id: categoryId,
cost_center_id: costCenterId,
filters: filters,
add_keyword: addKeyword,
transaction_ids: transactionIds,
});
return response.data;
},