v1.27.4: Quick categorize, multi-currency dashboard, responsive sidebar, iPad Pro optimizations
This commit is contained in:
parent
6bb1adeef6
commit
84a1dbae29
259
.DIRETRIZES_DESENVOLVIMENTO_v4
Normal file
259
.DIRETRIZES_DESENVOLVIMENTO_v4
Normal 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.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
49
CHANGELOG.md
49
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
|
||||
|
||||
107
backend/app/Console/Commands/FixBatchCategorization.php
Normal file
107
backend/app/Console/Commands/FixBatchCategorization.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user