From 84a1dbae29e96c3ffe4555b62789980df03ae43f Mon Sep 17 00:00:00 2001 From: CnxiFly Dev Date: Sat, 13 Dec 2025 21:12:17 +0100 Subject: [PATCH] v1.27.4: Quick categorize, multi-currency dashboard, responsive sidebar, iPad Pro optimizations --- .DIRETRIZES_DESENVOLVIMENTO_v4 | 259 +++++++++ CHANGELOG.md | 49 ++ VERSION | 2 +- .../Commands/FixBatchCategorization.php | 107 ++++ .../Controllers/Api/CategoryController.php | 94 ++-- .../Controllers/Api/DashboardController.php | 133 +++-- backend/deploy.ps1 | 156 ++---- frontend/deploy.ps1 | 45 +- frontend/src/components/Layout.jsx | 19 +- frontend/src/i18n/locales/en.json | 17 +- frontend/src/i18n/locales/es.json | 17 +- frontend/src/i18n/locales/pt-BR.json | 17 +- frontend/src/index.css | 109 ++++ frontend/src/pages/Dashboard.jsx | 50 +- frontend/src/pages/TransactionsByWeek.jsx | 503 +++++++++++++----- frontend/src/services/api.js | 3 +- 16 files changed, 1227 insertions(+), 353 deletions(-) create mode 100644 .DIRETRIZES_DESENVOLVIMENTO_v4 create mode 100644 backend/app/Console/Commands/FixBatchCategorization.php diff --git a/.DIRETRIZES_DESENVOLVIMENTO_v4 b/.DIRETRIZES_DESENVOLVIMENTO_v4 new file mode 100644 index 0000000..9825ee9 --- /dev/null +++ b/.DIRETRIZES_DESENVOLVIMENTO_v4 @@ -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. + +═══════════════════════════════════════════════════════════════════════════════ diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcf7e9..e67404e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 457f038..d620158 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.27.2 +1.27.4 diff --git a/backend/app/Console/Commands/FixBatchCategorization.php b/backend/app/Console/Commands/FixBatchCategorization.php new file mode 100644 index 0000000..952b5f0 --- /dev/null +++ b/backend/app/Console/Commands/FixBatchCategorization.php @@ -0,0 +1,107 @@ +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; + } +} diff --git a/backend/app/Http/Controllers/Api/CategoryController.php b/backend/app/Http/Controllers/Api/CategoryController.php index 4ae1d91..efad76e 100644 --- a/backend/app/Http/Controllers/Api/CategoryController.php +++ b/backend/app/Http/Controllers/Api/CategoryController.php @@ -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 diff --git a/backend/app/Http/Controllers/Api/DashboardController.php b/backend/app/Http/Controllers/Api/DashboardController.php index 3cd425c..43c8e65 100644 --- a/backend/app/Http/Controllers/Api/DashboardController.php +++ b/backend/app/Http/Controllers/Api/DashboardController.php @@ -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, ]); } diff --git a/backend/deploy.ps1 b/backend/deploy.ps1 index c2a5e95..382676b 100644 --- a/backend/deploy.ps1 +++ b/backend/deploy.ps1 @@ -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 "" diff --git a/frontend/deploy.ps1 b/frontend/deploy.ps1 index e7159f7..4ec3e43 100644 --- a/frontend/deploy.ps1 +++ b/frontend/deploy.ps1 @@ -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 } diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 34f0093..fea904b 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index da040ab..6efcdce 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index ac5b1c6..447936f 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 61ba3a1..5514839 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -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", diff --git a/frontend/src/index.css b/frontend/src/index.css index 8ded311..204fb57 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 6051ccc..8cebae4 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -107,7 +107,7 @@ const Dashboard = () => { }}>
-
+

{label}

@@ -116,15 +116,15 @@ const Dashboard = () => {
) : ( -

+
{value} -

+
)} {subValue && !loading && ( {subValue} )}
-
{
- {/* Stats Cards Row */} + {/* Stats Cards Row - Multi-currency */}
= 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]) => ( +
{currency(val, curr)}
+ )) + : 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' + } />
0 + ? Object.entries(summary.current_month_by_currency.income).map(([curr, val]) => ( +
{currency(val, curr)}
+ )) + : currency(0, summary?.primary_currency || 'BRL') + } valueColor="text-success" accentColor="#22c55e" /> @@ -248,7 +272,13 @@ const Dashboard = () => { 0 + ? Object.entries(summary.current_month_by_currency.expense).map(([curr, val]) => ( +
{currency(val, curr)}
+ )) + : currency(0, summary?.primary_currency || 'BRL') + } valueColor="text-danger" accentColor="#ef4444" /> diff --git a/frontend/src/pages/TransactionsByWeek.jsx b/frontend/src/pages/TransactionsByWeek.jsx index 3006fca..b48441a 100644 --- a/frontend/src/pages/TransactionsByWeek.jsx +++ b/frontend/src/pages/TransactionsByWeek.jsx @@ -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 (
@@ -961,10 +1037,18 @@ export default function Transactions() {
- + {hasActiveFilters && ( + + )} {t('nav.import')} @@ -1220,14 +1304,32 @@ export default function Transactions() { - - - - - - - - + {hasActiveFilters && ( + + )} + + + + + + + + @@ -1235,15 +1337,25 @@ export default function Transactions() { - + )} + - - - + - - - -
{t('transactions.date')}{t('transactions.description')}{t('transactions.account')}{t('transactions.category')}{t('transactions.amount')}{t('transactions.type.label')}{t('transactions.status.label')} + 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; + }); + }} + /> + {t('transactions.date')}{t('transactions.description')}{t('transactions.account')}{t('transactions.category')}{t('transactions.amount')}{t('transactions.type.label')}{t('transactions.status.label')}
+ {hasActiveFilters && ( + + handleToggleTransaction(transaction.id)} + /> + {formatDate(transaction.effective_date || transaction.planned_date)} {transaction.is_overdue && } +
{transaction.is_transfer && } {transaction.is_reconciled && ( @@ -1262,8 +1374,8 @@ export default function Transactions() {
)}
{transaction.account?.name} + {transaction.account?.name} {transaction.category && ( )} + {transaction.type === 'credit' ? '+' : '-'} {formatCurrency(transaction.amount || transaction.planned_amount, selectedCurrency)} + {transaction.type === 'credit' ? t('transactions.type.credit') : t('transactions.type.debit')} + {t(`transactions.status.${transaction.status}`)} +
+
  • - {loadingBatch ? ( -
    -
    - Loading... + {/* Info das transações selecionadas */} +
    +
    +
    + 0 ? 'bi-check2-square' : 'bi-funnel'} text-warning`} style={{ fontSize: '2rem' }}>
    -

    {t('common.loading')}

    -
    - ) : batchPreview ? ( - <> - {/* Info das transações */} -
    -
    -
    - -
    -
    +
    + {selectedTransactionIds.size > 0 ? ( + <>
    - {batchPreview.total_uncategorized} {t('transactions.transactionsSelected')} + {selectedTransactionIds.size} {t('transactions.transactionsSelected')}
    - {batchPreview.hasFilters ? ( - <> - - {t('transactions.batchFilteredInfo', { count: batchPreview.total_uncategorized })} - - ) : ( - t('transactions.batchAllUncategorized') - )} + {t('transactions.batchWillApplySelected')} -
    -
    + + ) : ( + <> +
    + {t('transactions.batchAllFiltered')} +
    + + {t('transactions.batchWillApplyFiltered')} + + + )}
    +
    +
    - {/* Formulário de Categorização */} -
    - -
    - setBatchFormData({ ...batchFormData, category_id: e.target.value })} + style={{ background: '#0f172a', color: '#e2e8f0', border: '1px solid #334155' }} + > + + {categories.filter(c => c.parent_id === null).map(parent => ( + + {categories.filter(c => c.parent_id === parent.id).map(sub => ( + ))} - - -
    -
    + + ))} + + +
    +
    -
    - -
    - - @@ -2282,7 +2397,7 @@ export default function Transactions() {
    {/* Opção de adicionar keyword */} - {batchPreview.activeFilters?.search && ( + {filters.search && (
    {t('transactions.addAsKeyword')} - "{batchPreview.activeFilters.search}" + "{filters.search}"
    @@ -2311,12 +2426,6 @@ export default function Transactions() { {t('transactions.batchSelectRequired')}
    )} - - ) : ( -
    - {t('categories.previewError')} -
    - )}
    @@ -2776,6 +2885,108 @@ export default function Transactions() {
    + + {/* Modal de Categorização Rápida Individual */} + setShowQuickCategorizeModal(false)} + title={t('transactions.quickCategorize')} + size="md" + > +
    + {quickCategorizeData.transaction && ( +
    +
    +
    + {quickCategorizeData.transaction.description} + {quickCategorizeData.transaction.original_description && + quickCategorizeData.transaction.original_description !== quickCategorizeData.transaction.description && ( +
    + + {quickCategorizeData.transaction.original_description} +
    + )} +
    + + {quickCategorizeData.transaction.type === 'credit' ? '+' : '-'} + {formatCurrency(quickCategorizeData.transaction.amount || quickCategorizeData.transaction.planned_amount, selectedCurrency)} + +
    +
    + )} + +
    + + +
    + +
    + + +
    + + {quickCategorizeData.transaction?.original_description && ( +
    + setQuickCategorizeData(prev => ({ ...prev, add_keyword: e.target.checked }))} + /> + +
    {t('transactions.keywordHelp')}
    +
    + )} + +
    + + +
    +
    +
    ); } diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index af50385..befb620 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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; },